Compare commits

..
157 Commits
Author SHA1 Message Date
Omar Amin acda765964 Fix conditional check for DISABLE_PERIPHERAL_API 2026-07-27 13:37:22 +03:00
Omar Amin 3077e7230b Add optional peripheral API configuration options
Added optional configuration settings for peripheral API in .env.example.
2026-07-27 13:35:36 +03:00
dependabot[bot] ea991e026f Bump docker/login-action from 4.5.0 to 4.5.1 (#377)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.0 to 4.5.1.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/06fb636fac595d6fb4b28a5dfcb21a6f5091859c...abd2ef45e78c5afb21d64d4ca52ee8550d9572c7)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 18:46:17 +02:00
SpiliosDmk 69a4fd3098 docs: add realtime scheduling tuning for constrained hardware (#376)
The core problem in #164 (config fully overriding pipewire.conf) was
already fixed by switching to pipewire.conf.d. This adds the
remaining performance-tuning recommendation (libpipewire-module-rt)
from the issue thread that never made it into the docs, to reduce
audio dropouts on hardware like the Raspberry Pi 3B.

Closes #164
2026-07-26 16:01:33 +02:00
dependabot[bot] 126bdf1862 Bump docker/login-action from 4.4.0 to 4.5.0 (#375)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.4.0 to 4.5.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/af1e73f918a031802d376d3c8bbc3fe56130a9b0...06fb636fac595d6fb4b28a5dfcb21a6f5091859c)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 17:41:26 +02:00
Florian Asche 3b33730105 feat(ai): integrate Kilo agent framework and coding instructions (#366)
* feat(ai): integrate Kilo agent framework and coding instructions

Add configuration files, agent definitions, and skill sets to enable
AI-driven development workflows and standardized agent behavior.
2026-07-24 17:35:34 +02:00
dependabot[bot] 0f57ca989c Bump actions/checkout from 4 to 7 (#370)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:12:30 +02:00
litinoveweedleandOmar Amin ba7d834d1f fix race condition between LVA registration to the HA and peripheral registering tot the LVA (#373)
Co-authored-by: Omar Amin <65174210+omaramin-2000@users.noreply.github.com>
2026-07-22 01:10:10 +02:00
aryanhasgithubandFlorian Asche 2eb29470f9 Colored Debug Logging and move Voice Event to Info level in logger (#271)
* Added Colored Debug logging and arhument and changes voice event log to info

---------

Co-authored-by: Florian Asche <github@florian-asche.de>
2026-07-22 01:09:13 +02:00
dependabot[bot] 2378187197 Bump docker/setup-buildx-action from 3 to 4 (#371)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 19:40:21 +02:00
maximilianovermeyer d3874f6a2f Update install.md - Fixed log path (#372)
Fixed a typo in the path of the log dir.
2026-07-21 19:39:52 +02:00
litinoveweedleandFlorian Asche 201896196f fix Respeaker USB Mic Array v2 LEDs (#357)
Fix ReSpeaker USB LED control and suppress stale peripheral disconnect replay

---------

Co-authored-by: Florian Asche <github@florian-asche.de>
2026-07-21 19:16:10 +03:00
Florian Asche 8f788eeff0 chore(ci): update github actions and docker dependencies (#368)
- Update `actions/checkout` from v3 to v4 in workflows
- Enable `allow-unsafe-pr-checkout` in `docker-build-pr.yml` to support
  PR builds with updated checkout version
2026-07-20 18:48:13 +02:00
dependabot[bot] 305b787dd6 Bump docker/login-action from 3.1.0 to 4.4.0 (#360)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.1.0 to 4.4.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/e92390c5fb421da1463c202d546fed0ec5c39f20...af1e73f918a031802d376d3c8bbc3fe56130a9b0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 18:40:20 +02:00
dependabot[bot] e14844a113 Bump docker/setup-qemu-action from 3 to 4 (#361)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 18:40:11 +02:00
dependabot[bot] 9ed97cd8f4 Bump docker/metadata-action from 5.5.1 to 6.2.0 (#362)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.5.1 to 6.2.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/8e5442c4ef9f78752691e2d8f8d19755c6f78e81...dc802804100637a589fabce1cb79ff13a1411302)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 18:39:57 +02:00
dependabot[bot] e8042ee19d Bump actions/attest-build-provenance from 1 to 4 (#363)
Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 1 to 4.
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/v1...v4)

---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 18:39:47 +02:00
dependabot[bot] 802908cb8f Bump docker/build-push-action from 5.3.0 to 7.3.0 (#364)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5.3.0 to 7.3.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/2cdde995de11925a030ce8070c3d77a52ffcf1c0...53b7df96c91f9c12dcc8a07bcb9ccacbed38856a)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 18:39:37 +02:00
Florian Asche c37b482b87 docs(github): establish repository governance and automation workflows (#359)
Introduce essential GitHub repository files to standardize community
contributions and automate maintenance tasks.

- Add AI usage policy and contribution guidelines
- Implement issue and pull request templates for consistent reporting
- Configure Dependabot for automated dependency updates
- Add workflow for automated PR labeling
- Include funding information and repository metadata
2026-07-20 17:41:57 +02:00
Florian Asche 486ff4bb53 build(dev): enhance local development environment and setup workflow (#358)
Improve the developer experience by providing structured setup scripts,
VS Code workspace configurations, and detailed documentation.

- Add VS Code workspace configuration and recommended settings
- Update README.md with system requirements and VS Code setup instructions
- Refactor `script/setup` to include Python 3.11+ validation and system dependency checks
- Add option to skip PulseAudio wait in `docker-entrypoint.sh` for local development
- Update `.gitignore` to ensure consistent version file handling
2026-07-20 17:38:52 +02:00
Willian WangandFlorian Asche a66c0e4f28 feat: add --listen-during-wake-sound argument (#273)
* feat: add --listen-during-wake-sound argument

---------

Co-authored-by: Florian Asche <github@florian-asche.de>
2026-07-18 19:16:11 +02:00
aryanhasgithub 9b2a8b4b30 Add Tests For LVA (#312)
* Add Tests
2026-07-18 18:22:25 +02:00
Omar Amin 4a0695ab57 Switch between MWW/OWW models from the UI (#348)
This PR adds the ability to select microWakeWord/openWakeWord models using the wake word dropdown selection via the HA device page at any time without the need for restarting the app/container to change the wake word path.
2026-07-16 03:57:51 +03:00
Omar Amin 2c9c1d2b8a Fix lint_mypy (#352)
* Add type ignore for stop_word attribute assignment

Added type ignore comment for stop_word attribute.

* Refactor exception handling in disconnect process

Updated exception handling to use pylint directive for broad exceptions.

* Fix callback lambda for broadcasting state
2026-07-16 03:38:40 +03:00
d811ae75d4 Broadcast entity state to all API clients; don't desync on a second connection (#329)
The ESPHome API server delivered asynchronous entity-state changes to a single
connection (the entity's `server`), which every new connection reassigns. A
second API client — a diagnostic tool, a second dashboard, or Home Assistant's
own overlapping reconnect — therefore stole state delivery, leaving Home
Assistant stuck on a stale state (e.g. `media_player` showing `playing` after
playback had finished). `connection_lost` also stopped the shared music/TTS
players for *any* dropped connection.

- models.py: add a per-server connection registry and a `broadcast()` helper.
- satellite.py: register/deregister connections in connection_made/_lost; only
  stop shared playback when the last client disconnects; handle the standard
  `SubscribeStatesRequest` by replaying current entity state to the subscriber.
- entity.py: push playback-completion state via broadcast to all clients.

Co-authored-by: Omar Amin <65174210+omaramin-2000@users.noreply.github.com>
Co-authored-by: Florian Asche <github@florian-asche.de>
2026-07-15 19:11:54 +02:00
Florian Asche 2ccb1fb597 perf(player): limit cache to 32 MiB (#309)
* Update libmpv initialization to use demuxer_max_bytes and cache_secs
instead of the deprecated cache-size property. This ensures more
predictable memory usage and better buffering behavior during
playback.
2026-07-15 18:21:58 +02:00
Omar Amin 2e6c726bd8 Modify USB control transfer settings in respeaker_usb_mic_array.py (#349)
Updated USB control transfer parameters for compatibility.
2026-07-14 13:01:42 +03:00
Omar Amin 2d460b6728 Leds and buttons events (#266)
Use events and commands to control custom onboard leds and buttons. Can be used to communicate with an external Python script via a WebSocket API running in a container alongside LVA.
2026-07-03 19:58:45 +03:00
Omar Amin e1770d071c Update README.md 2026-07-01 02:55:40 +03:00
Omar Amin 49b0937a13 Document continue-conversation-delay 2026-07-01 02:48:25 +03:00
Omar Amin 19c6322c28 Add audio input channels env 2026-06-24 22:57:31 +03:00
Omar Amin eb2d3b21b1 Allow using python versions above 3.11 (#345) 2026-06-22 21:53:18 +03:00
Omar Amin 479c1d1a97 Update webrtc to the latest version 2026-06-22 20:20:51 +03:00
Omar Amin a9f0af1287 Upgrade to Python 3.13 2026-06-22 20:19:35 +03:00
florian-asche fc66bfbb06 Update changelog for v1.1.12
This commit was created by changelog-from-release in 'Generate changelog' CI workflow
2026-06-22 15:04:45 +00:00
Omar Amin 1d84fa0898 Add delay to continue conversation before listening (#342)
Add a configurable amount of delay (defaulting to 0.5s) after the TTS playback is finished to avoid the mic to capture the end of response during start/continue conversation.

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

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

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

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

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

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

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

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

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

---------

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

---------

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

---------

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

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

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

No files matched your search

+2
View File
@@ -8,3 +8,5 @@
!/sounds/*
!/wakewords/*
!/wakewords/openWakeWord/*
!/version.txt
!/version_githash.txt
+60 -7
View File
@@ -1,9 +1,16 @@
# Linux-Voice-Assistant - Docker Environment Configuration
# Copy this file to .env and customize for your setup by 'cp .env.example .env'
### Specify another docker image (optional):
# LVA_IMAGE="ghcr.io/ohf-voice/linux-voice-assistant"
# LVA_IMAGE_TAG="latest"
### Enable debug mode (optional):
# ENABLE_DEBUG="1"
### Enable colored debug mode (optional):
# ENABLE_COLORED_DEBUG="1"
### List audio devices (optional):
# if enabled normal startup is disabled
# LIST_DEVICES="1"
@@ -14,13 +21,14 @@ LVA_USER_ID="1000"
LVA_USER_GROUP="1000"
### Name for the client (optional):
# CLIENT_NAME="speaker_livvingroom"
# CLIENT_NAME="speaker_livingroom"
### PulseAudio socket path on the host:
# PulseAudio Server: /run/user/1000/pulse
# 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_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie"
### Path to the preferences file (optional):
# PREFERENCES_FILE="/app/configuration/preferences.json"
@@ -40,21 +48,66 @@ LVA_XDG_RUNTIME_DIR="/run/user/${LVA_USER_ID}"
### Audio output device (optional):
# AUDIO_OUTPUT_DEVICE="default"
# Enable thinking sound (optional):
### Microphone volume level (optional):
# MIC_VOLUME="100"
### Mic Gain (optional) passed as an argument to LVA will overide the value in the preferences file on boot but can be changed at runtime in Home Assistant:
# MIC_AUTO_GAIN="0"
### Mic Noise Suppression (optional) passed as an argument to LVA will overide the value in the preferences file on boot but can be changed at runtime in Home Assistant:
# MIC_NOISE_SUPPRESSION="0"
### Number of mic channels to stream
# AUDIO_INPUT_CHANNELS=2
### Enable thinking sound (optional):
# ENABLE_THINKING_SOUND="1"
# Wake model (optional):
### Start listening during wake sound (optional):
# Start listening immediately after wake word detection, without waiting for the wake sound to finish
# LISTEN_DURING_WAKE_SOUND="1"
### Perpheral api host (optional):
# Bind address for the peripheral WebSocket API
# PERIPHERAL_HOST="0.0.0.0"
### Perpheral api port (optional):
# Port for the peripheral WebSocket API
# PERIPHERAL_PORT="6055"
### Peripheral api volume step (optional):
# Volume change for peripheral api per button press, 0.0–1.0
# PERIPHERAL_VOLUME_STEP="%(default)s"
### Disable the peripheral WebSocket API entirely
# DISABLE_PERIPHERAL_API="1"
### 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"
# Stop model (optional):
### Stop model (optional):
# 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"
# Sound files (optional):
### Delay before mic opens for continued conversation (optional):
# CONTINUE_CONVERSATION_DELAY="0.5"
### Sound files (optional):
# path for custom files in docker is for example "sounds/custom/your_soundfile.flac"
# WAKEUP_SOUND="sounds/wake_word_triggered.flac"
# TIMER_FINISHED_SOUND="sounds/timer_finished.flac"
# PROCESSING_SOUND="sounds/processing.wav"
# MUTE_SOUND="sounds/mute_switch_on.flac"
# UNMUTE_SOUND="sounds/mute_switch_off.flac"
### Enable output-only mode (optional):
# ENABLE_OUTPUT_ONLY="1"
+39
View File
@@ -0,0 +1,39 @@
# Linux Voice Assistant - AI Policy
We support using AI (i.e., LLMs) as tools when contributing to Linux Voice Assistant. However, you are responsible for any contributions you submit, and we are responsible for any contributions we merge and release. We hold a high bar for all contributions to our projects.
Our maintainers dedicate their time and expertise to reviewing contributions. Submitting AI-generated content that you have not personally reviewed and understood wastes that time and will not be accepted.
## Autonomous agents
We do not allow autonomous agents to be used for contributing to our projects. We will close any pull requests or issues that we believe were created autonomously, and may mark automated comments as spam. This includes contributions that bypass the provided issue or pull request templates.
## Communication on issues, pull requests, and code reviews
We don't mind if you use AI tools to help you write. However, do not have tools post unreviewed content on your behalf. Keep responses to the minimum needed to communicate your intent. We may hide any comments that we believe are unreviewed AI output.
If you are opening a pull request, we expect you to be able to explain the proposed changes in your own words. This includes the pull request description and responses to questions. If you use AI to help generate the pull request summary, you must review it for technical accuracy.
**Do not use AI to generate answers to questions from maintainers.** You should understand and be able to explain your own work. Using AI to improve grammar or clarity is fine, but the substance of your responses must be your own.
If you wish to include context from an interaction with AI in your comments, it must be in a quote block (e.g., using `>`) and disclosed as such. It must be accompanied by your own commentary explaining the relevance and implications of the context. Do not share long snippets.
## Non-native English speakers
We understand that AI is useful when communicating as a non-native English speaker. If you are using AI to translate your comments, please ensure the translation accurately reflects your intent. Including your original text in a details block shows the effort behind your contribution, helps maintainers verify the translation if needed, and keeps the conversation readable.
## Code and documentation contributions
AI can be a helpful tool for writing code and documentation. However, due to the foundational open source nature of our projects, we require a human in the loop who understands the work produced by AI.
All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review.
## Our use of AI
Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct.
If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer.
## Enforcement
Contributions that do not follow this policy will be closed. Repeated violations may result in being blocked from contributing to LVA projects.
+22
View File
@@ -0,0 +1,22 @@
# Contributing to Linux Voice Assistant
Everybody is invited and welcome to contribute to Linux Voice Assistant. Even if you are not a developer, perhaps you would like to help with the documentation? If you are a developer and have a feature that isn't supported by LVA yet, why not spend some time and help to integrate it?
The process is straight-forward.
- Fork the LVA repository.
- Write the code for your feature or fix.
- Ensure tests pass and linting succeeds (`./script/lint` and `./script/tests`).
- Create a Pull Request against the `main` branch.
## Feature suggestions
If you want to suggest a new feature for LVA, please open a discussion on GitHub.
## Issue Tracker
If you want to report an issue, please create an issue on the repository.
## AI Policy
Linux Voice Assistant is an [Open Home Foundation](https://www.openhomefoundation.org/) project and follows the [OHF AI Policy](https://github.com/music-assistant/.github/blob/main/AI_POLICY.md). Please read it before submitting AI-assisted contributions. The TLDR: AI tools are allowed, but a human is responsible and must review and understand every change. Autonomous agents and unreviewed AI output will be rejected.
+1
View File
@@ -0,0 +1 @@
custom: https://www.openhomefoundation.org
+14
View File
@@ -0,0 +1,14 @@
blank_issues_enabled: false
contact_links:
- name: "🐛 Report a bug"
url: https://github.com/OHF-Voice/linux-voice-assistant/issues/new?template=bug_report.md
about: Report bugs in the Linux Voice Assistant.
- name: "💬 Discord community & support"
url: https://discord.com/channels/330944238910963714/1464387232201703547
about: Chat with the community and get help from other users.
- name: "❓ Questions & how-to (Q&A)"
url: https://github.com/OHF-Voice/linux-voice-assistant/discussions
about: Ask how-to questions and search previous answers.
- name: "💡 Feature requests & ideas"
url: https://github.com/OHF-Voice/linux-voice-assistant/discussions
about: Suggest and vote on new features and enhancements.
+29
View File
@@ -0,0 +1,29 @@
# What does this implement/fix?
<!-- Quick description and explanation of changes. -->
**Related issue (if applicable):**
- Fixes #<issue number>
## Types of changes
<!--
Tick exactly one box. CI derives the label from the ticked box.
-->
- [ ] Bugfix (non-breaking change which fixes an issue) — `bugfix`
- [ ] New feature (non-breaking change which adds functionality) — `new-feature`
- [ ] Enhancement to an existing feature — `enhancement`
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — `breaking-change`
- [ ] Refactor (no behaviour change) — `refactor`
- [ ] Documentation only — `documentation`
- [ ] Maintenance / chore — `maintenance`
- [ ] CI / workflow change — `ci`
- [ ] Dependencies bump — `dependencies`
## Checklist
- [ ] The code change is tested and works locally.
- [ ] `./script/lint` passes.
- [ ] `./script/tests` passes, and tests have been added/updated under `tests/` where applicable.
+14
View File
@@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: daily
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: weekly
groups:
dependencies:
patterns:
- "*"
+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-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
@@ -40,17 +40,16 @@ Automatically builds nightly images from the main branch.
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:**
- Push to all branches except `main`
- No tags
- Pull request opened, synchronized, or reopened
**Image Tag:**
```
ghcr.io/ohf-voice/linux-voice-assistant:<branch-name>
ghcr.io/ohf-voice/linux-voice-assistant:pr-<pr-number>
```
## Multi-Architecture Support
-64
View File
@@ -1,64 +0,0 @@
name: Build branch
on:
push:
branches-ignore:
- "main"
tags-ignore:
- "**"
env:
REGISTRY: ghcr.io
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Extract clean branch name
id: extract_branch
run: |
echo "CLEAN_BRANCH_NAME=$(echo ${{ github.head_ref || github.ref_name }} | sed 's|.*/||')" >> $GITHUB_ENV
echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Log in to the Container registry
uses: docker/login-action@v3.1.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ github.token }}
# Build Docker Images (amd64 and arm64)
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v5.3.0
with:
context: .
platforms: linux/amd64,linux/aarch64
push: true
tags: |
${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}:${{ env.CLEAN_BRANCH_NAME }}
labels: |
branch=${{ env.CLEAN_BRANCH_NAME }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1.4.4
with:
subject-name: ${{ env.REGISTRY }}/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
+14 -6
View File
@@ -20,25 +20,33 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set lowercase registry and repository
run: |
echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Log in to the Container registry
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ github.token }}
# Build Docker Images (amd64 and arm64)
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Create version files
run: |
echo "nightly" > version.txt
echo "${{ github.sha }}" > version_githash.txt
ls -ltr
- name: Build and push Docker image
id: push
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64,linux/aarch64
@@ -46,7 +54,7 @@ jobs:
tags: ${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}:nightly
labels: "nightly"
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1.4.4
uses: actions/attest-build-provenance@v4
with:
subject-name: ${{ env.REGISTRY }}/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
+67
View File
@@ -0,0 +1,67 @@
---
name: Build PR
on:
pull_request_target:
types: [opened, synchronize, reopened]
env:
REGISTRY: ghcr.io
jobs:
build-and-push-image:
runs-on: ubuntu-latest
environment:
name: pr-build
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Checkout PR code
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true
- name: Set variables
run: |
echo "PR_NUMBER=${{ github.event.pull_request.number || github.run_number }}" >> $GITHUB_ENV
echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Log in to the Container registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ github.token }}
# Build Docker Images (amd64 and arm64)
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- 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
id: push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64,linux/aarch64
push: true
tags: "${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}-pr:${{ env.PR_NUMBER }}"
labels: "${{ env.PR_NUMBER }}"
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v4
with:
subject-name: ${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}-pr
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
+14 -7
View File
@@ -21,21 +21,21 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Log in to the Container registry
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ github.token }}
# Build Docker Images (amd64 and arm64)
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}
# E.g. tag v1.2.0 produces 3 tags: '1.2.0', '1.2', and 'latest'
@@ -45,9 +45,16 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
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
id: push
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64,linux/aarch64
@@ -55,7 +62,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
uses: actions/attest-build-provenance@v4
with:
subject-name: ${{ env.REGISTRY }}/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
+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@v7
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@v7
- 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@v7
- 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@v7
- 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@v7
- 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@v7
- 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
+87
View File
@@ -0,0 +1,87 @@
name: PR Labels
on:
pull_request_target:
types:
- opened
- edited
- synchronize
- labeled
- unlabeled
- reopened
- ready_for_review
branches:
- main
permissions:
pull-requests: write
jobs:
apply_label:
name: Apply
runs-on: ubuntu-latest
if: github.event.pull_request.user.type != 'Bot'
steps:
- name: 🏷 Apply label from PR description checkbox
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3
with:
script: |
const known = [
'breaking-change', 'bugfix', 'refactor',
'new-feature', 'enhancement', 'maintenance',
'ci', 'dependencies', 'documentation',
];
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
const current = (context.payload.pull_request.labels || [])
.map(l => l.name);
const alreadyLabelled = current.filter(n => known.includes(n));
if (alreadyLabelled.length === 1) {
core.info(
`Skipping: PR already labelled '${alreadyLabelled[0]}'.`
);
return;
}
const body = context.payload.pull_request.body || '';
const re = /^\s*-\s*\[x\][^`\n]*`([^`]+)`/gim;
const ticked = [];
for (const m of body.matchAll(re)) {
const name = m[1];
if (known.includes(name) && !ticked.includes(name)) {
ticked.push(name);
}
}
if (ticked.length === 0) {
core.setFailed(
'No "Types of changes" checkbox is ticked in the PR ' +
'description. Edit the description and tick exactly ' +
'one box so this PR can be release-noted.'
);
return;
}
if (ticked.length > 1) {
core.setFailed(
`Multiple "Types of changes" checkboxes are ticked ` +
`(${ticked.join(', ')}). Tick exactly one.`
);
return;
}
const desired = ticked[0];
for (const name of current) {
if (known.includes(name) && name !== desired) {
await github.rest.issues.removeLabel({
owner, repo, issue_number, name,
}).catch(() => {});
}
}
if (!current.includes(desired)) {
await github.rest.issues.addLabels({
owner, repo, issue_number, labels: [desired],
});
}
+37
View File
@@ -0,0 +1,37 @@
name: Unit Tests
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:
pytest:
name: Pytest Unit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libmpv-dev mpv
- name: Install dependencies
run: |
python -m pip install --upgrade pip
./script/setup --dev
- name: Run unit tests
run: ./script/tests
+3
View File
@@ -21,3 +21,6 @@ venv/
# Docker
.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
+8
View File
@@ -0,0 +1,8 @@
node_modules
package.json
package-lock.json
pnpm-lock.yaml
bun.lock
yarn.lock
worktrees
agent-manager.json
+14
View File
@@ -0,0 +1,14 @@
# Lint Command
Runs all linting checks for the project (black, isort, flake8, mypy, pylint).
## Usage
```
/lint # Run all linting checks
/lint --auto # Auto-fix formatting issues (black, isort)
```
## Command
./script/lint
+14
View File
@@ -0,0 +1,14 @@
# Run Command
Runs the Linux Voice Assistant application.
## Usage
```
/run # Run the application
/run --help # Show CLI help
```
## Command
./script/run
+14
View File
@@ -0,0 +1,14 @@
# Setup Command
Installs project dependencies in a virtual environment.
## Usage
```
/setup # Install dependencies
/setup --dev # Install dev dependencies (linting, testing tools)
```
## Command
./script/setup
+15
View File
@@ -0,0 +1,15 @@
# Test Command
Runs the pytest unit tests for the project.
## Usage
```
/tests # Run all tests
/tests <path> # Run tests in specific path
/tests -k <pattern> # Run tests matching pattern
```
## Command
./script/tests
+70
View File
@@ -0,0 +1,70 @@
# PR Review Standards
## Review Philosophy
- Only comment when confident an issue exists
- Be concise: one sentence per comment when possible
- Focus on actionable feedback, not observations
- When reviewing text, only comment on clarity issues if the text is genuinely confusing or could lead to errors
## What to Analyze
Review all code changes for:
- Code quality and style consistency with the existing codebase
- Potential bugs or issues
- Performance implications
- Blocking IO in async code
- Security concerns
- Test coverage
- Documentation updates if needed
## Project Standards
Respect the project standards as outlined in AGENTS.md. Any deviations must be raised as `[PROBLEM]`.
Key standards for LVA:
- Python 3.11, 3.12, 3.13 supported
- Black formatting with 200 char line length
- isort import sorting with black profile
- mypy type checking with strict settings
- pylint code quality checks (many checks disabled in pyproject.toml)
## Architecture-Specific Checks
- `linux_voice_assistant/wake_word.py` - Wake word model loading is hardware-dependent, verify no assumptions about specific hardware
- `linux_voice_assistant/mpv_player.py` - Audio output requires `libmpv-dev`, check for proper error handling
- `linux_voice_assistant/webrtc.py` - Noise suppression/gain algorithms need careful review for audio quality
- `linux_voice_assistant/satellite.py` - ESPHome API protocol handling must be robust
## CI Context
Lint runs on Python 3.13, tests run on Python 3.12 with libmpv-dev.
### What CI Checks
- **Lint**: `.github/workflows/lint.yml` runs all linting checks
- **Tests**: `.github/workflows/tests.yml` runs pytest unit tests
## Skip These (Low Value)
Do not comment on:
- Style/formatting (pre-commit handles this)
- Test failures (CI catches this)
- Missing dependencies (CI catches this)
- Minor naming suggestions
- Suggestions to add comments
- Logging suggestions unless security-related
## Issue Categories
Categorize every issue as:
- `[CRITICAL]` — must fix before merging (bugs, security issues, broken functionality)
- `[PROBLEM]` — should fix (code quality, bad patterns, missing tests)
- `[SUGGESTION]` — optional improvement (style, minor refactors, nice-to-haves)
## PR Title
The PR title must be a functional description of the change. It must NOT contain conventional commit prefixes such as `feat:`, `fix:`, `refactor:`, `chore:`, etc. Labels categorize PRs, not the title. Flag as `[PROBLEM]` if the title uses such prefixes.
+44
View File
@@ -0,0 +1,44 @@
name: review-pr
description: Use when asked to review a GitHub pull request, PR link is shared, or user says /review-pr
# Review GitHub Pull Request
Review the GitHub pull request: $ARGUMENTS.
## Steps
1. Run `gh pr view` to get PR details and description.
2. Run `gh pr diff` to see all changes.
3. Compare the local commit hash with the remote PR head commit.
- Make sure to do your work in a git worktree to avoid affecting the checked out branch.
- If they don't match, run `gh pr checkout` to check out the PR locally.
- **HARD STOP**: If `gh pr checkout` fails for ANY reason, STOP immediately. Do NOT attempt workarounds. Alert about the failure and wait for instructions.
- After checkout, verify the local commit hash matches the remote one. If they still don't match, STOP.
4. Run `gh pr checks` to see CI status.
5. Apply the review standards defined in `REVIEW_STANDARDS.md` (same directory).
6. Output review feedback to the CONSOLE only. DO NOT act on GitHub (no posting comments, no approving, no requesting changes).
## Output Format
List specific comments per file/line that need attention. Do not list things that are already correct.
Each comment:
1. State the severity ([CRITICAL], [PROBLEM], [SUGGESTION])
2. State the problem (1 sentence)
3. Why it matters (1 sentence, if needed)
4. Suggested fix (snippet or specific action)
End with an overall assessment and summary:
```
Overall assessment: <approve | request changes | comment>
- [CRITICAL] <issue>
- [PROBLEM] <issue>
- [SUGGESTION] <improvement>
```
## Constraints
- DO NOT make any changes to the code.
- No need to run tests or linters, just review the code changes.
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"ms-python.python",
"ms-python.vscode-pylance",
"kilo.kilocode"
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Linux Voice Assistant",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/linux_voice_assistant/__main__.py",
"console": "integratedTerminal",
"justMyCode": true,
"envFile": "${workspaceFolder}/.env"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"python.defaultInterpreterPath": "./.venv/bin/python",
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": ["tests"],
"python.testing.pytestAutoInclude": false,
"python.linting.enabled": true,
"python.linting.flake8Enabled": true,
"python.linting.mypyEnabled": true,
"python.formatting.provider": "none",
"python.terminal.activateEnvironment": true,
"python.terminal.executeInShell": true,
"python.pythonPath": "./.venv/bin/python"
}
+62
View File
@@ -0,0 +1,62 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Setup (incl. dev dependencies)",
"type": "shell",
"command": "./script/setup --dev",
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new",
"clear": true
},
"problemMatcher": []
},
{
"label": "Run Linter",
"type": "shell",
"command": "./script/lint",
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Run Tests",
"type": "shell",
"command": "./script/tests",
"group": "test",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Run Application (Docker)",
"type": "shell",
"command": "docker-compose",
"args": ["-f", "docker-compose.dev.yml", "up"],
"group": "none",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new"
},
"problemMatcher": [],
"options": {
"envFile": "${workspaceFolder}/.env"
}
}
]
}
+129
View File
@@ -0,0 +1,129 @@
# Project Context for AI Agents
## Behaviour
- NEVER automatically reply on Github (PRs or Discussions) without explicit consent from the developer.
- Always run `./script/lint` and `./script/tests` after code changes to ensure standards are met.
## Project Overview
**Linux Voice Assistant (LVA)** - A Linux-based voice satellite software for Home Assistant using the ESPHome protocol.
### Purpose
- Provides voice control and interaction for Home Assistant via the Assist integration
- Runs on x64/ARM64 Linux hardware with PulseAudio/PipeWire audio support
- Performs local wake word detection (OpenWakeWord/MicroWakeWord) and on-device processing
### Architecture
- **Protocol**: ESPHome API via `aioesphomeapi` for Home Assistant integration
- **Wake Words**: Supports both OpenWakeWord and MicroWakeWord engines
- **Audio**: Uses `soundcard` library for input, `python-mpv` for output
- **Peripherals**: WebSocket API for LEDs, buttons, HAT boards
- **Discovery**: mDNS/ZeroConf for auto-discovery by Home Assistant
### Key Components
- `linux_voice_assistant/__main__.py` - Main entry point with CLI argument parsing
- `linux_voice_assistant/satellite.py` - VoiceSatelliteProtocol implementation (ESPHome API)
- `linux_voice_assistant/wake_word.py` - Wake word model loading and detection
- `linux_voice_assistant/webrtc.py` - WebRTC noise gain/NS processing
- `linux_voice_assistant/mpv_player.py` - MPV-based media player
- `linux_voice_assistant/entity.py` - Entity definitions (MediaPlayer, MuteSwitch, etc.)
- `linux_voice_assistant/api_server.py` - Peripheral WebSocket API server
- `linux_voice_assistant/models.py` - Preferences and ServerState dataclasses
## Development Commands
### Setup
```bash
./script/setup --dev # Install dev dependencies in virtual environment
```
### Linting
```bash
./script/lint # Run all linting checks
./script/lint_black # Black formatting check (add --auto to fix)
./script/lint_isort # isort import sorting check (add --auto to fix)
./script/lint_flake8 # flake8 style checks
./script/lint_mypy # mypy type checking
./script/lint_pylint # pylint code quality checks
```
### Testing
```bash
./script/tests # Run pytest unit tests
```
## Code Quality Standards
- **Python**: 3.11, 3.12, 3.13 supported
- **Formatting**: Black (200 char line length, py312/py312 target)
- **Import Sorting**: isort with black profile
- **Type Checking**: mypy with strict settings
- **Linting**: pylint (many checks disabled in pyproject.toml for practical reasons)
## Testing Strategy
- Unit tests in `tests/unit/` using pytest with asyncio mode
- Test helper `make_state()` in `tests/unit/conftest.py` for creating state fixtures
- Mocks used heavily for hardware dependencies (audio devices, MPV, wake words)
## CI/CD
- GitHub Actions for lint checks and unit tests
- Tests run on Python 3.12 with libmpv-dev system dependency
- Lint runs on Python 3.13
## Branching and PRs
- All PRs target `main` (primary development branch). `stable` is for production releases.
- PRs labeled `bugfix` + `backport-to-stable` are automatically backported to `stable` — use only for bugs also present in `stable`.
## Agent Commands
When making code changes, run these commands in order:
1. **Format code**: `./script/lint_black --auto` and `./script/lint_isort --auto`
2. **Run linting**: `./script/lint`
3. **Run tests**: `./script/tests`
## Code Style
### Comments
Only use comments to explain complex, multi-line blocks of code. Do not comment obvious operations. Inline comments are there to explain code parts that need explaining - keep them when editing code.
### Docstring Format
Use Sphinx-style docstrings with `:param:` syntax. For simple functions, a single-line docstring is fine. The docstring should provide clarity to the caller, not explain how it works technically.
```python
def my_function(param1: str, param2: int, param3: bool = False) -> str:
"""
Brief one-line description of the function.
:param param1: Description of what param1 is used for.
:param param2: Description of what param2 is used for.
:param param3: Description of what param3 is used for.
"""
```
Do **not** use Google-style (`Args:`) or bullet-style (`- param:`) docstrings.
### File structure
Private methods should be at the bottom of the file, public at the top.
## Verification Checklist
Before claiming completion:
- [ ] Ran `./script/lint` - all checks passed
- [ ] Ran `./script/tests` - all tests passed
- [ ] For audio-related changes: Note that hardware testing (microphone/speaker) was NOT performed
- [ ] Did NOT claim hardware behavior is verified unless actually exercised
- [ ] Changes are focused on one feature or fix per change
## Areas Requiring Extra Care
- `linux_voice_assistant/wake_word.py` - Wake word model loading is hardware-dependent
- `linux_voice_assistant/mpv_player.py` - Audio output requires `libmpv-dev`
- `linux_voice_assistant/webrtc.py` - Noise suppression/gain algorithms
- `linux_voice_assistant/satellite.py` - ESPHome API protocol handling
+313 -14
View File
@@ -1,18 +1,317 @@
# Changelog
<a id="v1.1.12"></a>
# [v1.1.12](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.12) - 2026-06-22
## Unreleased
<!-- Release notes generated using configuration in .github/release.yml at main -->
- Add support for custom/external wake words
- Add `--download-dir <DIR>` to store downloaded wake word models/configs
- Switch to `soundcard` instead of `sounddevice`
- Add `--list-input-devices` and `--list-output-devices`
- Use `pymicro-wakeword` for microWakeWord
- Add zeroconf/mDNS discovery
- Support openWakeWord with `pyopen-wakeword`
- Support multiple wake words
- Save active wake words to preferences JSON file
- Refactor main into separate files
## What's Changed
### Exciting New Features 🎉
* Enable dual audio input channel by [@omaramin-2000](https://github.com/omaramin-2000) in [#334](https://github.com/OHF-Voice/linux-voice-assistant/pull/334)
* Add delay to continue conversation before listening by [@omaramin-2000](https://github.com/omaramin-2000) in [#342](https://github.com/OHF-Voice/linux-voice-assistant/pull/342)
### Other Changes
* Revise Assist Satellite installation instructions by [@Hedda](https://github.com/Hedda) in [#338](https://github.com/OHF-Voice/linux-voice-assistant/pull/338)
## 1.0.0
- Initial release
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.11...v1.1.12
[Changes][v1.1.12]
<a id="v1.1.11"></a>
# [v1.1.11](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.11) - 2026-06-11
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Other Changes
* docs(readme): restructure requirements and hardware section by [@florian-asche](https://github.com/florian-asche) in [#321](https://github.com/OHF-Voice/linux-voice-assistant/pull/321)
* App for HAOS by [@omaramin-2000](https://github.com/omaramin-2000) in [#325](https://github.com/OHF-Voice/linux-voice-assistant/pull/325)
* Add volume arg to parser by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#335](https://github.com/OHF-Voice/linux-voice-assistant/pull/335)
* Enhance wake word documentation by [@omaramin-2000](https://github.com/omaramin-2000) in [#336](https://github.com/OHF-Voice/linux-voice-assistant/pull/336)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.10...v1.1.11
[Changes][v1.1.11]
<a id="v1.1.10"></a>
# [v1.1.10](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.10) - 2026-04-23
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Exciting New Features 🎉
* Add support for WebRTC Auto Gain and Noise Suppression by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#250](https://github.com/OHF-Voice/linux-voice-assistant/pull/250)
* Add Instructions for enabling AEC by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#254](https://github.com/OHF-Voice/linux-voice-assistant/pull/254)
* Discover project version from Git tags by [@mweinelt](https://github.com/mweinelt) in [#270](https://github.com/OHF-Voice/linux-voice-assistant/pull/270)
* Output only satellite by [@jaminh](https://github.com/jaminh) in [#182](https://github.com/OHF-Voice/linux-voice-assistant/pull/182)
* Wake word sensitivity by [@florian-asche](https://github.com/florian-asche) in [#207](https://github.com/OHF-Voice/linux-voice-assistant/pull/207)
### Other Changes
* Revise intro to Linux-Voice-Assistant in README by [@Hedda](https://github.com/Hedda) in [#223](https://github.com/OHF-Voice/linux-voice-assistant/pull/223)
* small typo inside the .env file by [@nielsnl68](https://github.com/nielsnl68) in [#235](https://github.com/OHF-Voice/linux-voice-assistant/pull/235)
* Move examples by [@jaminh](https://github.com/jaminh) in [#193](https://github.com/OHF-Voice/linux-voice-assistant/pull/193)
* Hotfix/build pipeline by [@florian-asche](https://github.com/florian-asche) in [#253](https://github.com/OHF-Voice/linux-voice-assistant/pull/253)
* Cleanup mac address by [@nielsnl68](https://github.com/nielsnl68) in [#249](https://github.com/OHF-Voice/linux-voice-assistant/pull/249)
* Fix double _tts_finished() call when stop word interrupts TTS by [@interkelstar](https://github.com/interkelstar) in [#256](https://github.com/OHF-Voice/linux-voice-assistant/pull/256)
* Add debug logging for stop word detection by [@interkelstar](https://github.com/interkelstar) in [#257](https://github.com/OHF-Voice/linux-voice-assistant/pull/257)
* Add maximum seconds for ringing timer by [@omaramin-2000](https://github.com/omaramin-2000) in [#261](https://github.com/OHF-Voice/linux-voice-assistant/pull/261)
* Fix lint formatting by [@omaramin-2000](https://github.com/omaramin-2000) in [#262](https://github.com/OHF-Voice/linux-voice-assistant/pull/262)
* Update README removing colon signs section headers by [@Hedda](https://github.com/Hedda) in [#265](https://github.com/OHF-Voice/linux-voice-assistant/pull/265)
* Update package inclusion to include subpackages: Python Module Hotfix by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#245](https://github.com/OHF-Voice/linux-voice-assistant/pull/245)
* Fixed PipeWire setup by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#258](https://github.com/OHF-Voice/linux-voice-assistant/pull/258)
* Expose linux-voice-assistant console script by [@mweinelt](https://github.com/mweinelt) in [#268](https://github.com/OHF-Voice/linux-voice-assistant/pull/268)
* Start listening after stopping ringing timer using wake word by [@omaramin-2000](https://github.com/omaramin-2000) in [#275](https://github.com/OHF-Voice/linux-voice-assistant/pull/275)
* fix(docker): simplify PulseAudio cookie file creation by [@florian-asche](https://github.com/florian-asche) in [#276](https://github.com/OHF-Voice/linux-voice-assistant/pull/276)
* docs(config): document timer max ring seconds configuration by [@florian-asche](https://github.com/florian-asche) in [#277](https://github.com/OHF-Voice/linux-voice-assistant/pull/277)
* Output only satellite - part 2 by [@florian-asche](https://github.com/florian-asche) in [#278](https://github.com/OHF-Voice/linux-voice-assistant/pull/278)
* Add the ability to control microphone volume by [@omaramin-2000](https://github.com/omaramin-2000) in [#287](https://github.com/OHF-Voice/linux-voice-assistant/pull/287)
* Fix audio cuts and playback issues by [@omaramin-2000](https://github.com/omaramin-2000) in [#296](https://github.com/OHF-Voice/linux-voice-assistant/pull/296)
* docs(install): clarify linger is required on headless systems by [@genericJE](https://github.com/genericJE) in [#299](https://github.com/OHF-Voice/linux-voice-assistant/pull/299)
* Fix README.md typo by [@AlecJDavidson](https://github.com/AlecJDavidson) in [#303](https://github.com/OHF-Voice/linux-voice-assistant/pull/303)
* Allow option to have docker use nightly build by [@lowlyocean](https://github.com/lowlyocean) in [#302](https://github.com/OHF-Voice/linux-voice-assistant/pull/302)
* feat(docker): add custom image and tag env configuration by [@florian-asche](https://github.com/florian-asche) in [#306](https://github.com/OHF-Voice/linux-voice-assistant/pull/306)
* Downgrade to Python 3.12 for quicker webrtc builds by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#305](https://github.com/OHF-Voice/linux-voice-assistant/pull/305)
## New Contributors
* [@nielsnl68](https://github.com/nielsnl68) made their first contribution in [#235](https://github.com/OHF-Voice/linux-voice-assistant/pull/235)
* [@aryanhasgithub](https://github.com/aryanhasgithub) made their first contribution in [#254](https://github.com/OHF-Voice/linux-voice-assistant/pull/254)
* [@interkelstar](https://github.com/interkelstar) made their first contribution in [#256](https://github.com/OHF-Voice/linux-voice-assistant/pull/256)
* [@mweinelt](https://github.com/mweinelt) made their first contribution in [#270](https://github.com/OHF-Voice/linux-voice-assistant/pull/270)
* [@genericJE](https://github.com/genericJE) made their first contribution in [#299](https://github.com/OHF-Voice/linux-voice-assistant/pull/299)
* [@AlecJDavidson](https://github.com/AlecJDavidson) made their first contribution in [#303](https://github.com/OHF-Voice/linux-voice-assistant/pull/303)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.9...v1.1.10
[Changes][v1.1.10]
<a id="v1.1.9"></a>
# [v1.1.9](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.9) - 2026-03-11
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Other Changes
* Cookie file and other default parameters by [@florian-asche](https://github.com/florian-asche) in [#231](https://github.com/OHF-Voice/linux-voice-assistant/pull/231)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.8...v1.1.9
[Changes][v1.1.9]
<a id="v1.1.8"></a>
# [v1.1.8](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.8) - 2026-03-11
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Other Changes
* Add changelog generator based on release information by [@florian-asche](https://github.com/florian-asche) in [#226](https://github.com/OHF-Voice/linux-voice-assistant/pull/226)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.7...v1.1.8
[Changes][v1.1.8]
<a id="v1.1.7"></a>
# [v1.1.7](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.7) - 2026-03-11
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Exciting New Features 🎉
* Add linting scripts and pipeline by [@florian-asche](https://github.com/florian-asche) in [#201](https://github.com/OHF-Voice/linux-voice-assistant/pull/201)
### Other Changes
* add build flag option for low end devices by [@florian-asche](https://github.com/florian-asche) in [#189](https://github.com/OHF-Voice/linux-voice-assistant/pull/189)
* replaced branch build by PR build only by [@florian-asche](https://github.com/florian-asche) in [#203](https://github.com/OHF-Voice/linux-voice-assistant/pull/203)
* Change when lint pipeline will start by [@florian-asche](https://github.com/florian-asche) in [#204](https://github.com/OHF-Voice/linux-voice-assistant/pull/204)
* fix(script): use os.environ instead of subprocess.environ in setup script by [@florian-asche](https://github.com/florian-asche) in [#205](https://github.com/OHF-Voice/linux-voice-assistant/pull/205)
* docs: add documentation for low power device setup and improve setup script by [@florian-asche](https://github.com/florian-asche) in [#206](https://github.com/OHF-Voice/linux-voice-assistant/pull/206)
* fix: cookie error ([#127](https://github.com/OHF-Voice/linux-voice-assistant/issues/127)) by [@florian-asche](https://github.com/florian-asche) in [#210](https://github.com/OHF-Voice/linux-voice-assistant/pull/210)
* fix(config): remove unix: prefix from default pulseaudio socket path … by [@florian-asche](https://github.com/florian-asche) in [#221](https://github.com/OHF-Voice/linux-voice-assistant/pull/221)
* Update the changelog file automatically on each release by [@omaramin-2000](https://github.com/omaramin-2000) in [#224](https://github.com/OHF-Voice/linux-voice-assistant/pull/224)
* Enhance hardware compatibility details in README by [@Hedda](https://github.com/Hedda) in [#222](https://github.com/OHF-Voice/linux-voice-assistant/pull/222)
* add changelog based on release notes by [@florian-asche](https://github.com/florian-asche) in [#225](https://github.com/OHF-Voice/linux-voice-assistant/pull/225)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.6...v1.1.7
[Changes][v1.1.7]
<a id="v1.1.6"></a>
# [v1.1.6](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.6) - 2026-03-04
## What's Changed
* Bugfix: start listening after wakeup sound by [@florian-asche](https://github.com/florian-asche) in [#185](https://github.com/OHF-Voice/linux-voice-assistant/pull/185)
* added default sample rate to pulse documentation by [@florian-asche](https://github.com/florian-asche) in [#190](https://github.com/OHF-Voice/linux-voice-assistant/pull/190)
* Bugfix: Prevent wake word from interrupting active voice pipeline by [@ekiczek](https://github.com/ekiczek) in [#151](https://github.com/OHF-Voice/linux-voice-assistant/pull/151)
* Bugfix: ensure wake word state is set after the wake sound by [@florian-asche](https://github.com/florian-asche) in [#198](https://github.com/OHF-Voice/linux-voice-assistant/pull/198)
* Improvement: script/run error handling and return code consistency by [@florian-asche](https://github.com/florian-asche) in [#186](https://github.com/OHF-Voice/linux-voice-assistant/pull/186)
* Improvement: move portcheck into python application by [@florian-asche](https://github.com/florian-asche) in [#187](https://github.com/OHF-Voice/linux-voice-assistant/pull/187)
* Improvement: help output on application parameters by [@florian-asche](https://github.com/florian-asche) in [#188](https://github.com/OHF-Voice/linux-voice-assistant/pull/188)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.5...v1.1.6
[Changes][v1.1.6]
<a id="v1.1.5"></a>
# [v1.1.5](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.5) - 2026-02-27
## What's Changed
* Improve documentation by [@florian-asche](https://github.com/florian-asche) in [#166](https://github.com/OHF-Voice/linux-voice-assistant/pull/166)
* Add version and hash to docker container by [@florian-asche](https://github.com/florian-asche) in [#167](https://github.com/OHF-Voice/linux-voice-assistant/pull/167)
* Fix timer finished sound not looping by [@ekiczek](https://github.com/ekiczek) in [#159](https://github.com/OHF-Voice/linux-voice-assistant/pull/159)
* Volume persistence across restarts by [@omaramin-2000](https://github.com/omaramin-2000) in [#168](https://github.com/OHF-Voice/linux-voice-assistant/pull/168)
* docs: add info about reboot by [@florian-asche](https://github.com/florian-asche) in [#175](https://github.com/OHF-Voice/linux-voice-assistant/pull/175)
* Enhance play method for playlist support by [@omaramin-2000](https://github.com/omaramin-2000) in [#172](https://github.com/OHF-Voice/linux-voice-assistant/pull/172)
* Allow portainer stack to point directly to docker-compose.yml by [@lowlyocean](https://github.com/lowlyocean) in [#177](https://github.com/OHF-Voice/linux-voice-assistant/pull/177)
* Fix: Suppress spurious end-file event when starting playback by [@florian-asche](https://github.com/florian-asche) in [#179](https://github.com/OHF-Voice/linux-voice-assistant/pull/179)
* Fix: tts.speak and announce by [@florian-asche](https://github.com/florian-asche) in [#179](https://github.com/OHF-Voice/linux-voice-assistant/pull/179)
* Fix: duck unduck on timers by [@florian-asche](https://github.com/florian-asche) in [#179](https://github.com/OHF-Voice/linux-voice-assistant/pull/179)
## New Contributors
* [@ekiczek](https://github.com/ekiczek) made their first contribution in [#159](https://github.com/OHF-Voice/linux-voice-assistant/pull/159)
* [@lowlyocean](https://github.com/lowlyocean) made their first contribution in [#177](https://github.com/OHF-Voice/linux-voice-assistant/pull/177)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.4...v1.1.5
[Changes][v1.1.5]
<a id="v1.1.4"></a>
# [v1.1.4](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.4) - 2026-02-20
## What's Changed
* Documentation: Correct LVA_USER_ID variable name where wrong by [@giobber](https://github.com/giobber) in [#155](https://github.com/OHF-Voice/linux-voice-assistant/pull/155)
* Documentation by [@florian-asche](https://github.com/florian-asche) in [#162](https://github.com/OHF-Voice/linux-voice-assistant/pull/162)
* Added custom paths to docker for wakeword and sounds by [@florian-asche](https://github.com/florian-asche) in [#165](https://github.com/OHF-Voice/linux-voice-assistant/pull/165)
* Changed default pipewire configuration by [@florian-asche](https://github.com/florian-asche) in [#165](https://github.com/OHF-Voice/linux-voice-assistant/pull/165)
* Added wake-word-dir variable by [@florian-asche](https://github.com/florian-asche) in [#165](https://github.com/OHF-Voice/linux-voice-assistant/pull/165)
* Updated documentation regarding wakeword and sounds and some other things. by [@florian-asche](https://github.com/florian-asche) in [#165](https://github.com/OHF-Voice/linux-voice-assistant/pull/165)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.3...v1.1.4
[Changes][v1.1.4]
<a id="v1.1.3"></a>
# [v1.1.3](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.3) - 2026-02-14
## Hotfix:
Fixed a bug where the application crashes when the new network-interface parameter is used.
## What's Changed
* Update documentation, correcting typo by [@giobber](https://github.com/giobber) in [#144](https://github.com/OHF-Voice/linux-voice-assistant/pull/144)
* Hotfix: Typo in network-interface output order by [@florian-asche](https://github.com/florian-asche) in [#146](https://github.com/OHF-Voice/linux-voice-assistant/pull/146)
## New Contributors
* [@giobber](https://github.com/giobber) made their first contribution in [#144](https://github.com/OHF-Voice/linux-voice-assistant/pull/144)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.2...v1.1.3
[Changes][v1.1.3]
<a id="v1.1.2"></a>
# [v1.1.2](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.2) - 2026-02-13
## Breaking Change
* A previous bug in the MAC address detection could have resulted in a device being created with an incorrect address. After installing the new version, it may be necessary to remove and re-add the device in Home Assistant.
* The docker-compose.yml and .env.example files were updated. Please review and adapt your configuration if required.
## What's Changed
* Add open home foundation badge to readme by [@omaramin-2000](https://github.com/omaramin-2000) in [#138](https://github.com/OHF-Voice/linux-voice-assistant/pull/138)
* Update README.md by [@Hedda](https://github.com/Hedda) in [#31](https://github.com/OHF-Voice/linux-voice-assistant/pull/31)
* Changes EXTRA_ARGS variable to bash array to better handle variable expansion by [@Ziris85](https://github.com/Ziris85) in [#136](https://github.com/OHF-Voice/linux-voice-assistant/pull/136)
* change client-name validation (no spaces allowed) and fix example configuration by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* add first version of docker-compose for developers by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* add variable for network-interface with auto-detection enabled by default by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* add mac-address autodetection based on detected or specified network-interface by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* switch from listening on all ip-addresses to binding to the ip-address of the default interface by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* add missing variables and fix variable names by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* fix non-working variables (e.g. sound-files) by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* move auto-name creation from shell to python (name is now optional and auto-generated if not provided) by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* Change download link for docker-compose setup to latest version by [@florian-asche](https://github.com/florian-asche) in [#141](https://github.com/OHF-Voice/linux-voice-assistant/pull/141)
## New Contributors
* [@Hedda](https://github.com/Hedda) made their first contribution in [#31](https://github.com/OHF-Voice/linux-voice-assistant/pull/31)
* [@Ziris85](https://github.com/Ziris85) made their first contribution in [#136](https://github.com/OHF-Voice/linux-voice-assistant/pull/136)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.1...v1.1.2
[Changes][v1.1.2]
<a id="v1.1.1"></a>
# [v1.1.1](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.1) - 2026-02-10
## What's Changed
* media_player: handle STOP command correctly (fixes [#128](https://github.com/OHF-Voice/linux-voice-assistant/issues/128)) by [@pgrafe](https://github.com/pgrafe) in [#129](https://github.com/OHF-Voice/linux-voice-assistant/pull/129)
* changed github workflow naming by [@florian-asche](https://github.com/florian-asche) in [#132](https://github.com/OHF-Voice/linux-voice-assistant/pull/132)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.0...v1.1.1
[Changes][v1.1.1]
<a id="v1.1.0"></a>
# [v1.1.0](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.0) - 2026-02-10
## What's Changed
* Fix PEP 508 dependency operator: `=>` -> `>=` by [@ms1design](https://github.com/ms1design) in [#14](https://github.com/OHF-Voice/linux-voice-assistant/pull/14)
* Support custom/external wake words by [@synesthesiam](https://github.com/synesthesiam) in [#55](https://github.com/OHF-Voice/linux-voice-assistant/pull/55)
* Add an option to play thinking sound by [@omaramin-2000](https://github.com/omaramin-2000) in [#30](https://github.com/OHF-Voice/linux-voice-assistant/pull/30)
* Add mute/unmute sounds by [@omaramin-2000](https://github.com/omaramin-2000) in [#82](https://github.com/OHF-Voice/linux-voice-assistant/pull/82)
* Fix issues [#98](https://github.com/OHF-Voice/linux-voice-assistant/issues/98) and [#87](https://github.com/OHF-Voice/linux-voice-assistant/issues/87) by [@omaramin-2000](https://github.com/omaramin-2000) in [#100](https://github.com/OHF-Voice/linux-voice-assistant/pull/100)
* Merge beta changes by [@omaramin-2000](https://github.com/omaramin-2000) in [#112](https://github.com/OHF-Voice/linux-voice-assistant/pull/112)
* Add sound customization by [@omaramin-2000](https://github.com/omaramin-2000) in [#116](https://github.com/OHF-Voice/linux-voice-assistant/pull/116)
* Fix some format and linting issues by [@jaminh](https://github.com/jaminh) in [#120](https://github.com/OHF-Voice/linux-voice-assistant/pull/120)
* removed disconnected log message in process_packet function by [@florian-asche](https://github.com/florian-asche) in [#124](https://github.com/OHF-Voice/linux-voice-assistant/pull/124)
* Handle announcement separately from the media player by [@omaramin-2000](https://github.com/omaramin-2000) in [#125](https://github.com/OHF-Voice/linux-voice-assistant/pull/125)
* Don't require name for list commands by [@jaminh](https://github.com/jaminh) in [#67](https://github.com/OHF-Voice/linux-voice-assistant/pull/67)
* Fix mpv pause state when loading new media by [@pgrafe](https://github.com/pgrafe) in [#123](https://github.com/OHF-Voice/linux-voice-assistant/pull/123)
* Add docker build pipeline and compose files by [@florian-asche](https://github.com/florian-asche) in [#118](https://github.com/OHF-Voice/linux-voice-assistant/pull/118)
## New Contributors
* [@ms1design](https://github.com/ms1design) made their first contribution in [#14](https://github.com/OHF-Voice/linux-voice-assistant/pull/14)
* [@synesthesiam](https://github.com/synesthesiam) made their first contribution in [#55](https://github.com/OHF-Voice/linux-voice-assistant/pull/55)
* [@omaramin-2000](https://github.com/omaramin-2000) made their first contribution in [#30](https://github.com/OHF-Voice/linux-voice-assistant/pull/30)
* [@jaminh](https://github.com/jaminh) made their first contribution in [#120](https://github.com/OHF-Voice/linux-voice-assistant/pull/120)
* [@florian-asche](https://github.com/florian-asche) made their first contribution in [#124](https://github.com/OHF-Voice/linux-voice-assistant/pull/124)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.0.0...v1.1.0
[Changes][v1.1.0]
<a id="v1.0.0"></a>
# [v1.0.0](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.0.0) - 2025-09-16
Initial release
[Changes][v1.0.0]
[v1.1.12]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.11...v1.1.12
[v1.1.11]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.10...v1.1.11
[v1.1.10]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.9...v1.1.10
[v1.1.9]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.8...v1.1.9
[v1.1.8]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.7...v1.1.8
[v1.1.7]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.6...v1.1.7
[v1.1.6]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.5...v1.1.6
[v1.1.5]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.4...v1.1.5
[v1.1.4]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.3...v1.1.4
[v1.1.3]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.2...v1.1.3
[v1.1.2]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.1...v1.1.2
[v1.1.1]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.0...v1.1.1
[v1.1.0]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.0.0...v1.1.0
[v1.0.0]: https://github.com/OHF-Voice/linux-voice-assistant/tree/v1.0.0
<!-- Generated by https://github.com/rhysd/changelog-from-release v3.9.1 -->
+3 -1
View File
@@ -27,7 +27,7 @@ LABEL \
# - iproute2: For ss command in entrypoint (port check)
# - procps: For pgrep in healthcheck
RUN apt-get update && \
apt-get install --yes --no-install-recommends \
apt-get install --yes --no-install-recommends \
avahi-utils \
pulseaudio-utils \
alsa-utils \
@@ -54,6 +54,8 @@ COPY sounds/ ./sounds/
COPY ../wakewords/ ./wakewords/
COPY linux_voice_assistant/ ./linux_voice_assistant/
COPY docker-entrypoint.sh ./
COPY version.txt ./
COPY version_githash.txt ./
### Run installation:
RUN chmod +x docker-entrypoint.sh
+191 -41
View File
@@ -2,69 +2,123 @@
[![CI](https://github.com/OHF-Voice/linux-voice-assistant/actions/workflows/docker-build-release.yml/badge.svg)](https://github.com/OHF-Voice/linux-voice-assistant/actions/workflows/docker-build-release.yml) [![GitHub Package Version](https://img.shields.io/github/v/tag/OHF-Voice/linux-voice-assistant?label=version)](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant) [![GitHub License](https://img.shields.io/github/license/OHF-Voice/linux-voice-assistant)](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/LICENSE.md) [![GitHub last commit](https://img.shields.io/github/last-commit/OHF-Voice/linux-voice-assistant)](https://github.com/OHF-Voice/linux-voice-assistant/commits) [![GitHub Container Registry](https://img.shields.io/badge/Container%20Registry-GHCR-blue)](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant)
Experimental Linux-Voice-Assistant for [Home Assistant](https://www.home-assistant.io/) that uses the [ESPHome](https://esphome.io/) protocol/API (via [aioesphomeapi](https://github.com/esphome/aioesphomeapi)).
An experimental voice satellite software for [Home Assistant](https://www.home-assistant.io/) remote voice control and interaction.
This project enables you to build a Linux-based voice assistant designed to use [Assist](https://www.home-assistant.io/voice_control/) for Home Assistant. It allows you to create your own smart speaker that runs on any x64 or ARM64 hardware capable of handling local audio processing (using PulseAudio).
Unlike simpler voice satellites that run on microcontrollers with very limited compute power, this setup can perform local wake word detection (OWW/MWW) and process some data on-device.
Because it runs on a full Linux system and offers access significantly more local computing resources for additional features and other integrations on the same satellite, this approach also provides greater flexibility for customization (such as for example experiment with using PipeWire).
[![A project from the Open Home Foundation](https://www.openhomefoundation.org/badges/ohf-project.png)](https://www.openhomefoundation.org/)
## Features:
## Features
- Works with [Home Assistant](https://www.home-assistant.io/integrations/esphome/)
- Local wake word detection using integrated [OpenWakeWord](https://github.com/dscripka/openWakeWord) or [MicroWakeWord](https://github.com/kahrendt/microWakeWord)
- Works with [Home Assistant](https://www.home-assistant.io/integrations/esphome/) using the [ESPHome](https://esphome.io/) protocol/API (via [aioesphomeapi](https://github.com/esphome/aioesphomeapi))
- Feature local on-device wake word detection using integrated [OpenWakeWord](https://github.com/dscripka/openWakeWord) or [MicroWakeWord](https://github.com/kahrendt/microWakeWord)
- Supports multiple wake words and languages
- Supports multiple architectures (linux/amd64 and linux/aarch64)
- Automated builds with artifact attestation for security
- Supports multiple wake words and languages
- Supports announcments, start/continue conversation, and timers
- Tested with Python 3.13 and Python 3.11.
- Tested and works with Python 3.11 and Python 3.13.
- 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)
- Supports [Websocket API](./docs/peripheral_api.md) for peripherals (e.g. buttons, LEDs, etc.) to integrate with the voice assistant
## Requirements
## Usage:
- **Microphone:** Device must support 16kHz mono audio
- **CPU:** 1Ghz
- **Memory:** min. 512MB
- **Storage:** The OS and software is around 4GB
- **OS:** linux/amd64 or linux/aarch64
### 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 setups today is:
### Software:
- use a Raspberry Pi Zero 2W (Single Board Computer with built-in WiFi) in combination with the [Satellite1 Hat Board](https://futureproofhomes.net/products/satellite1-top-microphone-board)
- use at least a Raspberry Pi 3 with the [Respeaker Lite](https://wiki.seeedstudio.com/reSpeaker_usb_v3/). The Respeaker Lite currently has a problem with the Zero 2W.
#### Installation:
Those mic-boards have microphone-array designed for far-field voice capture with the added benefit of using an onboard XMOS DSP microcontroller with custom firmware which does advanced audio pre-processing for microphone cleanup that result in very good voice recognition capabilities (as it runs algorithms for Noise Suppression, Acoustic Echo Cancellation, Interference Cancellation, and Automatic Gain Control).
We have different installation methods available (Docker, systemd), each with its own dedicated instructions.
Alternatively if on a lower budget then suggest could use other microphone-array boards like for example the [reSpeaker 2-Mics Pi HAT V2.0](https://wiki.seeedstudio.com/ReSpeaker_2_Mics_Pi_HAT/) (which uses a much more basic audio codec chip).
See [Linux-Voice-Assistant - Installation](docs/install.md).
## Usage
#### Parameter overview:
### Installation
``` 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]
**Assist Satellite app for Home Assistant OS**
For HA OS, we provide a finished [Assist Satellite](https://github.com/OHF-Voice/apps/tree/main/assist_satellite) app (formerly add-on), which uses the Linux Voice Assistant runtime to turn your HA host into a voice satellite.
> [!NOTE]
> For now you first have to add the [OHF-Voice apps](https://github.com/OHF-Voice/apps) repo manually to the App Store repositroy inside Home Assistant before you can install it.
Later you will be able to install it directly from the official add-on repository (but it is not yet published publicly there):
[![Add repository to your Home Assistant instance.](https://my.home-assistant.io/badges/supervisor_addon.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https://github.com/home-assistant/addons)
Once installed, the satellite is automatically discovered by Home Assistant via the ESPHome integration.
**Raspberry Pi prebuilt image**
For Raspberry Pi users, we provide a prebuild image that can be flashed to a SD card. See [PiCompose](https://github.com/florian-asche/PiCompose).
**Docker / bare metal**
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
usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE] [--list-input-devices] [--audio-input-block-size AUDIO_INPUT_BLOCK_SIZE] [--audio-output-device AUDIO_OUTPUT_DEVICE] [--list-output-devices] [--wake-word-dir WAKE_WORD_DIR] [--mic-auto-gain] [--mic-noise-suppression]
[--wake-model WAKE_MODEL] [--stop-model STOP_MODEL] [--download-dir DOWNLOAD_DIR] [--refractory-seconds REFRACTORY_SECONDS] [--wakeup-sound WAKEUP_SOUND] [--timer-finished-sound TIMER_FINISHED_SOUND] [--processing-sound PROCESSING_SOUND]
[--mute-sound MUTE_SOUND] [--unmute-sound UNMUTE_SOUND] [--preferences-file PREFERENCES_FILE] [--host HOST] [--network-interface NETWORK_INTERFACE] [--port PORT] [--enable-thinking-sound] [--debug]
[--mute-sound MUTE_SOUND] [--unmute-sound UNMUTE_SOUND] [--preferences-file PREFERENCES_FILE] [--host HOST] [--network-interface NETWORK_INTERFACE] [--port PORT] [--enable-thinking-sound] [--listen-during-wake-sound] [--debug]
```
| Parameter | Description | Default |
|-----------|-------------|---------|
| `--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-block-size` | Audio input block size in samples | 1024 |
| `--audio-output-device` | mpv name for output device | System default speaker |
| `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` |
| `--wake-model` | ID of active wake word model | `okay_nabu` |
| `--stop-model` | ID of stop model | `stop` |
| `--download-dir` | Directory to download custom wake word models, etc. | `local/` |
| `--refractory-seconds` | Seconds before wake word can be activated again | 2.0 |
| `--wakeup-sound` | Sound file played when wake word is detected | `sounds/wake_word_triggered.flac` |
| `--timer-finished-sound` | Sound file played when timer finishes | `sounds/timer_finished.flac` |
| `--processing-sound` | Sound played while assistant is processing | `sounds/processing.wav` |
| `--mute-sound` | Sound played when muting the assistant | `sounds/mute_switch_on.flac` |
| `--unmute-sound` | Sound played when unmuting the assistant | `sounds/mute_switch_off.flac` |
| `--preferences-file` | Path to preferences JSON file | `preferences.json` |
| `--host` | IP-Address for ESPHome server, use 0.0.0.0 for all | Autodetected |
| `--network-interface` | Network interface for ESPHome server | Autodetected |
| `--port` | Port for ESPHome server | 6053 |
| `--enable-thinking-sound` | Enable thinking sound on startup | False |
| `--debug` | Print DEBUG messages to console | False |
| Parameter | Description | Default |
| ------------------------------- | ------------------------------------------------------------- | ------------------------------------ |
| `--name` | Name of the voice assistant device (required) | Autogenerated (`lva-MAC-ADDRESS`) |
| `--audio-input-device` | Soundcard name for input device | Autodetected |
| `--audio-input-block-size` | Audio input block size in samples | 1024 |
| `--audio-output-device` | mpv name for output device | Autodetected |
| `--mic-volume` | Control microphone volume | 100 |
| `--mic-auto-gain` | Add WebRTC Gain to Mic | 0 |
| `--mic-noise-suppression` | Add WebRTC Noise Suppression to Mic | 0 |
| `--audio-input-channels` | Number of microphone audio channels to stream | 2 |
| `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` |
| `--wake-model` | ID of active wake word model | `okay_nabu` |
| `--stop-model` | ID of stop model | `stop` |
| `--download-dir` | Directory to download custom wake word models, etc. | `local/` |
| `--refractory-seconds` | Seconds before wake word can be activated again | 2.0 |
| `--continue-conversation-delay` | Delay before mic opens for continued conversation | 0.5 |
| `--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` |
| `--start-listening-sound` | Sound file played when button is pressed to start listening | `sounds/start_listening_button.flac` |
| `--timer-finished-sound` | Sound file played when timer finishes | `sounds/timer_finished.flac` |
| `--processing-sound` | Sound played while assistant is processing | `sounds/processing.wav` |
| `--mute-sound` | Sound played when muting the assistant | `sounds/mute_switch_on.flac` |
| `--unmute-sound` | Sound played when unmuting the assistant | `sounds/mute_switch_off.flac` |
| `--preferences-file` | Path to preferences JSON file | `preferences.json` |
| `--host` | IP-Address for ESPHome server, use 0.0.0.0 for all | Autodetected |
| `--network-interface` | Network interface for ESPHome server | Autodetected |
| `--port` | Port for ESPHome server | 6053 |
| `--enable-thinking-sound` | Enable thinking sound on startup | False |
| `--listen-during-wake-sound` | Start listening while the wake sound is still playing | False |
| `--peripheral-host` | Bind address for the peripheral WebSocket API | 0.0.0.0 |
| `--peripheral-port` | Port for the peripheral WebSocket API | 6055 |
| `--peripheral-volume-step` | Volume change per button press, 0.0–1.0 | %(default)s |
| `--disable-peripheral-api` | Disable the peripheral WebSocket API entirely | False |
| `--debug` | Print DEBUG messages to console | False |
| `--colored-debug` | Print colored 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.
@@ -75,3 +129,99 @@ The Docker images are built using GitHub Actions, which provides:
- Regular updates and maintenance
The documentation for the build process can be found in the [GitHub Actions Workflows](.github/workflow.md) file.
## Development
### System Requirements
**System packages (Linux):**
- libmpv-dev (`sudo apt install libmpv-dev`)
- PulseAudio/PipeWire (`sudo apt install pulseaudio pipewire`)
- ALSA utils (`sudo apt install alsa-utils`)
**Python:**
- Python 3.11+ must be installed on your system (`python3 --version` should show 3.11+)
- On Ubuntu/Debian: `sudo apt install python3.11 python3.11-venv python3.11-dev`
### VS Code Setup
VS Code development uses a local Python virtual environment (`.venv/`):
1. Open workspace in VS Code
2. Accept Workspace Trust (bottom-left status bar)
3. Install recommended extensions when prompted:
- `ms-python.python` - Python language support
- `ms-python.vscode-pylance` - Python language server
- `kilo.kilocode` - Kilo AI assistant
4. **Terminal** → **New Terminal** (or `` Ctrl+` ``) - Opens integrated terminal (uses .venv when available)
5. Run `./script/setup --dev` to create `.venv` and install dev dependencies
6. VS Code automatically detects `.venv/bin/python` as the Python interpreter
**Available VS Code Tasks:**
- `Ctrl+Shift+B` - Run Setup or Linter
- `Ctrl+Shift+T` - Run Tests
- `Ctrl+Shift+P` → "Tasks: Run Task" - Show all tasks (Setup, Linter, Tests, Run App)
### 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/tests
```
## 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:
# Fix permissions for wakeword_data and configuration volume
fix-permissions:
image: "ghcr.io/ohf-voice/linux-voice-assistant:latest"
image: "${LVA_IMAGE:-ghcr.io/ohf-voice/linux-voice-assistant-pr}:${LVA_IMAGE_TAG:-nightly}"
entrypoint: []
command: "chown -R ${LVA_USER_ID}:${LVA_USER_GROUP} /app/local /app/configuration"
env_file: .env
command: "chown -R ${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000} /app/local /app/configuration /app/wakewords/custom /app/sounds/custom"
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:
- audio
volumes:
# Wakeword data and configuration
- wakeword_data:/app/local
- wakeword_custom:/app/wakewords/custom
- configuration:/app/configuration
- sounds_custom:/app/sounds/custom
restart: "no"
# Linux Voice Assistant
linux-voice-assistant:
container_name: linux-voice-assistant
image: "ghcr.io/ohf-voice/linux-voice-assistant:latest"
image: "${LVA_IMAGE:-ghcr.io/ohf-voice/linux-voice-assistant-pr}:${LVA_IMAGE_TAG:-nightly}"
restart: unless-stopped
network_mode: "host"
user: "${LVA_USER_ID}:${LVA_USER_GROUP}"
user: "${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000}"
environment:
# Pulseaudio
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR}
- PULSE_SERVER=${LVA_PULSE_SERVER}
env_file: .env
- 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:
- audio
# Priority for audio process
@@ -34,15 +42,21 @@ services:
volumes:
# Wakeword data and configuration
- wakeword_data:/app/local
- wakeword_custom:/app/wakewords/custom
- configuration:/app/configuration
- sounds_custom:/app/sounds/custom
# Time and zone
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
# Pulseaudio
# /run/user/1000:/run/user/1000
- ${LVA_XDG_RUNTIME_DIR}:${LVA_XDG_RUNTIME_DIR}
- ${LVA_XDG_RUNTIME_DIR:-/run/user/1000}:${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
# Mount source code for development
- ./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:
- fix-permissions
# Health check for process
@@ -52,11 +66,15 @@ services:
timeout: 5s
retries: 3
start_period: 90s
#command:
command:
# - "--mic-noise-suppression"
# - "1"
# - --additional-parameters-here
# - --but-use-env-if-possible
volumes:
wakeword_data:
wakeword_custom:
configuration:
sounds_custom:
+21 -9
View File
@@ -2,30 +2,38 @@
services:
# Fix permissions for wakeword_data and configuration volume
fix-permissions:
image: "ghcr.io/ohf-voice/linux-voice-assistant:latest"
image: "${LVA_IMAGE:-ghcr.io/ohf-voice/linux-voice-assistant}:${LVA_IMAGE_TAG:-latest}"
entrypoint: []
command: "chown -R ${LVA_USER_ID}:${LVA_USER_GROUP} /app/local /app/configuration"
env_file: .env
command: "chown -R ${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000} /app/local /app/configuration /app/wakewords/custom /app/sounds/custom"
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:
- audio
volumes:
# Wakeword data and configuration
- wakeword_data:/app/local
- wakeword_custom:/app/wakewords/custom
- configuration:/app/configuration
- sounds_custom:/app/sounds/custom
restart: "no"
# Linux Voice Assistant
linux-voice-assistant:
container_name: linux-voice-assistant
image: "ghcr.io/ohf-voice/linux-voice-assistant:latest"
image: "${LVA_IMAGE:-ghcr.io/ohf-voice/linux-voice-assistant}:${LVA_IMAGE_TAG:-latest}"
restart: unless-stopped
network_mode: "host"
user: "${LVA_USER_ID}:${LVA_USER_GROUP}"
user: "${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000}"
environment:
# Pulseaudio
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR}
- PULSE_SERVER=${LVA_PULSE_SERVER}
env_file: .env
- 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:
- audio
# Priority for audio process
@@ -34,13 +42,15 @@ services:
volumes:
# Wakeword data and configuration
- wakeword_data:/app/local
- wakeword_custom:/app/wakewords/custom
- configuration:/app/configuration
- sounds_custom:/app/sounds/custom
# Time and zone
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
# Pulseaudio
# /run/user/1000:/run/user/1000
- ${LVA_XDG_RUNTIME_DIR}:${LVA_XDG_RUNTIME_DIR}
- ${LVA_XDG_RUNTIME_DIR:-/run/user/1000}:${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
depends_on:
- fix-permissions
# Health check for process
@@ -57,4 +67,6 @@ services:
volumes:
wakeword_data:
wakeword_custom:
configuration:
sounds_custom:
+90 -39
View File
@@ -9,6 +9,10 @@ if [ "$ENABLE_DEBUG" = "1" ]; then
EXTRA_ARGS+=( "--debug" )
fi
if [ "$ENABLE_COLORED_DEBUG" = "1" ]; then
EXTRA_ARGS+=( "--colored-debug" )
fi
if [ -n "${CLIENT_NAME}" ]; then
EXTRA_ARGS+=( "--name" "$CLIENT_NAME" )
fi
@@ -40,10 +44,34 @@ if [ -n "${AUDIO_OUTPUT_DEVICE}" ]; then
EXTRA_ARGS+=( "--audio-output-device" "$AUDIO_OUTPUT_DEVICE" )
fi
if [ -n "${MIC_VOLUME}" ]; then
EXTRA_ARGS+=( "--mic-volume" "$MIC_VOLUME" )
fi
if [ -n "${MIC_AUTO_GAIN}" ]; then
EXTRA_ARGS+=( "--mic-auto-gain" "$MIC_AUTO_GAIN" )
fi
if [ -n "${MIC_NOISE_SUPPRESSION}" ]; then
EXTRA_ARGS+=( "--mic-noise-suppression" "$MIC_NOISE_SUPPRESSION" )
fi
if [ -n "${AUDIO_INPUT_CHANNELS}" ]; then
EXTRA_ARGS+=( "--audio-input-channels" "$AUDIO_INPUT_CHANNELS" )
fi
if [ "$ENABLE_THINKING_SOUND" = "1" ]; then
EXTRA_ARGS+=( "--enable-thinking-sound" )
fi
if [ "$LISTEN_DURING_WAKE_SOUND" = "1" ]; then
EXTRA_ARGS+=( "--listen-during-wake-sound" )
fi
if [ -n "${WAKE_WORD_DIR}" ]; then
EXTRA_ARGS+=( "--wake-word-dir" "$WAKE_WORD_DIR" )
fi
if [ -n "${WAKE_MODEL}" ]; then
EXTRA_ARGS+=( "--wake-model" "$WAKE_MODEL" )
fi
@@ -56,10 +84,18 @@ if [ -n "${REFACTORY_SECONDS}" ]; then
EXTRA_ARGS+=( "--refractory-seconds" "$REFACTORY_SECONDS" )
fi
if [ -n "${CONTINUE_CONVERSATION_DELAY}" ]; then
EXTRA_ARGS+=( "--continue-conversation-delay" "$CONTINUE_CONVERSATION_DELAY" )
fi
if [ -n "${WAKEUP_SOUND}" ]; then
EXTRA_ARGS+=( "--wakeup-sound" "$WAKEUP_SOUND" )
fi
if [ -n "${START_LISTENING_SOUND}" ]; then
EXTRA_ARGS+=( "--start-listening-sound" "$START_LISTENING_SOUND" )
fi
if [ -n "${TIMER_FINISHED_SOUND}" ]; then
EXTRA_ARGS+=( "--timer-finished-sound" "$TIMER_FINISHED_SOUND" )
fi
@@ -76,50 +112,65 @@ if [ -n "${UNMUTE_SOUND}" ]; then
EXTRA_ARGS+=( "--unmute-sound" "$UNMUTE_SOUND" )
fi
if [ -n "${TIMER_MAX_RING_SECONDS}" ]; then
EXTRA_ARGS+=( "--timer-max-ring-seconds" "$TIMER_MAX_RING_SECONDS" )
fi
if [ -n "${PERIPHERAL_HOST}" ]; then
EXTRA_ARGS+=( "--peripheral-host" "$PERIPHERAL_HOST" )
fi
if [ -n "${PERIPHERAL_PORT}" ]; then
EXTRA_ARGS+=( "--peripheral-port" "$PERIPHERAL_PORT" )
fi
if [ -n "${PERIPHERAL_VOLUME_STEP}" ]; then
EXTRA_ARGS+=( "--peripheral-volume-step" "$PERIPHERAL_VOLUME_STEP" )
fi
if [ -n "$DISABLE_PERIPHERAL_API" = "1" ]; then
EXTRA_ARGS+=( "--disable-peripheral-api" "$DISABLE_PERIPHERAL_API" )
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 to be available before starting the application
CP_MAX_RETRIES=30
CP_RETRY_DELAY=1
### while maybe besser?
echo "Checking port $PORT..."
for i in $(seq 1 $CP_MAX_RETRIES); do
# Check if PulseAudio is running
if pactl info >/dev/null 2>&1; then
echo "✅ PulseAudio is running"
break
fi
if [ $i -eq $CP_MAX_RETRIES ]; then
echo "❌ PulseAudio did not start after $CP_MAX_RETRIES seconds"
exit 2
fi
echo "⏳ PulseAudio not running yet, retrying in $CP_RETRY_DELAY s..."
sleep $CP_RETRY_DELAY
done
### Check port availability
# PORT variable is used from env
PA_MAX_RETRIES=30
PA_RETRY_DELAY=2
echo "Checking port $PORT..."
for i in $(seq 1 $PA_MAX_RETRIES); do
# Wait for port to be free (in case of rapid restarts)
if ! ss -tln | grep -q ":${PORT} "; then
echo "Port $PORT is available"
# Skip wait if SKIP_PULSE_AUDIO_WAIT is set (for local development)
if [ "$SKIP_PULSE_AUDIO_WAIT" != "1" ]; then
CP_MAX_RETRIES=30
CP_RETRY_DELAY=1
### while maybe besser?
echo "Checking PulseAudio service status..."
for i in $(seq 1 $CP_MAX_RETRIES); do
# Check if PulseAudio is running
if pactl info >/dev/null 2>&1; then
echo "✅ PulseAudio is running"
break
fi
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
if [ $i -eq $CP_MAX_RETRIES ]; then
echo "❌ PulseAudio did not start after $CP_MAX_RETRIES seconds"
exit 2
fi
echo "Attempt $i/$PA_MAX_RETRIES: Port $PORT in use, waiting ${PA_RETRY_DELAY}s..."
sleep $PA_RETRY_DELAY
done
echo "⏳ PulseAudio not running yet, retrying in $CP_RETRY_DELAY s..."
sleep $CP_RETRY_DELAY
done
fi
### Start application
-36
View File
@@ -1,36 +0,0 @@
# Additional Audio Settings
This document is still in an early stage and will be updated soon.
## Pipewire:
tbd
## Pulseaudio:
### Acoustic Echo Cancellation:
Enable the echo cancel PulseAudio module:
``` sh
pactl load-module module-echo-cancel \
aec_method=webrtc \
aec_args="analog_gain_control=0 digital_gain_control=1 noise_suppression=1"
```
Verify that the `echo-cancel-source` and `echo-cancel-sink` devices are present:
``` sh
pactl list short sources
pactl list short sinks
```
Use the new devices:
``` sh
# The device names may be different on your system.
# Double check with --list-input-devices and --list-output-devices
python3 -m linux_voice_assistant ... \
--audio-input-device 'Echo-Cancel Source' \
--audio-output-device 'pipewire/echo-cancel-sink'
```
+73
View File
@@ -0,0 +1,73 @@
# Audio Options: Gain, Noise Suppression, and Wake Word Sensitivity
## What do Gain and Noise Suppression do
### Gain
When you change gain, LVA(using the [webrtc](https://github.com/OHF-Voice/webrtc-noise-gain) library) automatically adjusts the microphone input volume to keep it at a consistent level. If you're speaking quietly it boosts the signal, if you're speaking loudly it reduces it. Useful in the following ways:-
- Microphones that are too quiet by default
- Environments where you move around relative to the mic
- Keeping wake word detection consistent regardless of speaking volume
### Noise Suppression
When you change noise suppression, LVA(using the [webrtc](https://github.com/OHF-Voice/webrtc-noise-gain) library) filters out constant background noise from the audio signal. It works by learning what "silence" sounds like in your environment and subtracting that from the audio. Useful in the following ways:
- Noisy environments
- Improving STT accuracy by sending cleaner audio to Home Assistant
- Reducing false wake word triggers from background noise
## Using Gain and Noise Suppression with LVA
LVA implements gain and Noise Suppression in two ways:
- [CLI Argument/ENV file](#cli-argumentenv-file)
- [Home Assistant Entity](#home-assistant-entity)
### CLI Argument/ENV file
#### CLI Argument
Using docker-entrypoint.sh the following flags can be used to set gain and noise suppression:
- `--mic-auto-gain`(ranges from 0-31)
- `--mic-noise-suppression`(ranges from 0-4)
#### ENV file
You can add/edit these variables in the .env file to set gain and noise suppression:
- `MIC_AUTO_GAIN="0"`
- `MIC_NOISE_SUPPRESSION="0"`
### Home Assistant Entity
LVA exposes two slider entities to change these gain and noise suppression from which you can edit the gain and noise suppression at runtime.
💡 **Note:** Setting the flag and ENV values to 0 turns them off and are not used.
💡 **Note:** Keep in mind that when the flags are set they will overwrite the previous value in the preferences file and also they will be overridden if the value is changed in Home Assistant(Also applies to the ENV file but the ENV file will always overwrite the last value on startup).
## Wake Word Sensitivity
LVA exposes three numeric controls in the Home Assistant device page for fine-grained sensitivity tuning. These let you dial in the exact probability threshold that best matches your microphone quality, room acoustics, and false-activation tolerance.
| Entity | Description | Default |
|--------|-------------|---------|
| **Wake Word 1 Sensitivity** | Probability cutoff for the primary wake word | From model manifest |
| **Wake Word 2 Sensitivity** | Probability cutoff for the secondary wake word (if active) | From model manifest |
| **Stop Word Sensitivity** | Probability cutoff for the stop word | From model manifest |
Values range from `0.0` to `1.0`:
- **Higher value** (e.g. `0.95`) → more selective, fewer false activations, may miss quieter or accented speech
- **Lower value** (e.g. `0.50`) → more responsive, but more likely to trigger on similar-sounding words
The defaults are read directly from each model's `.json` manifest file (the `probability_cutoff` field), so bundled models like `okay_nabu` (0.85), `hey_jarvis` (0.97), and custom downloaded models all start at their author-tested baseline. Changes made in the Home Assistant UI are persisted in `preferences.json` and survive restarts.
**Tuning guide:**
- If the wake word **rarely activates** (misses your voice): lower the value slightly, e.g. by `0.05` steps
- If the wake word **activates too often** (false triggers from TV, music, or similar words): raise the value slightly
- Start from the model's default and make small adjustments — a change of `0.05–0.10` is usually enough to notice a difference
- Far-field microphones with noise cancellation (e.g. ReSpeaker, Satellite1) generally work well at the default values; basic USB microphones may need a lower threshold
+83
View File
@@ -0,0 +1,83 @@
# Enabling Acoustic Echo Cancellation
Acoustic Echo Cancellation (AEC) is a type of sound processing used to cancel out the noise coming out of your speaker and going into your mic. In LVA this functionality can be useful to allow LVA to listen to wake words even when audio is playing, particularly when a timer is playing. PulseAudio and PipeWire already provide built-in modules for AEC.
## Enabling AEC
### PulseAudio
```sh
pactl load-module module-echo-cancel source_name=aec_mic aec_method=webrtc
```
To make permanent, add to `/etc/pulse/default.pa`:
```
load-module module-echo-cancel source_name=aec_mic aec_method=webrtc
```
### PipeWire
```sh
pw-cli load-module libpipewire-module-echo-cancel '{ aec.method=webrtc source.props={ node.name=aec_mic } }'
```
To make permanent, add the following to the `context.modules` section of `/etc/pipewire/pipewire.conf`:
```
{ name = libpipewire-module-echo-cancel
args = {
aec.method = webrtc
source.props = { node.name = aec_mic }
}
}
```
Or if you are using a standard PipeWire installation without a custom `pipewire.conf`, create a new file inside `pipewire.conf.d/`:
```
context.modules = [
{ name = libpipewire-module-echo-cancel
args = {
aec.method = webrtc
source.props = { node.name = aec_mic }
}
}
]
```
## Using the AEC Input
In the `.env` file:
```
AUDIO_INPUT_DEVICE="aec_mic"
```
## Changing Source and Sink for AEC
### PulseAudio
```sh
pactl load-module module-echo-cancel source_name=aec_mic aec_method=webrtc source_master=<mic-source> sink_master=<sink>
```
### PipeWire
```sh
pw-cli load-module libpipewire-module-echo-cancel '{ aec.method=webrtc source.props={ node.name=aec_mic node.description="Echo Cancelled Mic" } sink.props={ node.name=aec_sink } capture.props={ node.name=<your-mic-node-name> } playback.props={ node.name=<your-speaker-node-name> } }'
```
To make permanent, add to `context.modules` in `/etc/pipewire/pipewire.conf` or a file in `pipewire.conf.d/`:
```
{ name = libpipewire-module-echo-cancel
args = {
aec.method = webrtc
source.props = {
node.name = aec_mic
node.description = "Echo Cancelled Mic"
}
sink.props = {
node.name = aec_sink
}
capture.props = {
node.name = <your-mic-node-name>
}
playback.props = {
node.name = <your-speaker-node-name>
}
}
}
```
+1 -1
View File
@@ -77,7 +77,7 @@ The easyiest way to get started is to use the prebuilt image. This image comes w
- Download ready-to-use image from [PiCompose](https://github.com/florian-asche/PiCompose)
- Change configuration in /compose/ directory if you need to.
- You may need to reboot the board like 3 times for everything to be installed. Especially for the 2MicHat drivers. And give docker-compose some time to download the images. You can watch the logs in /var/logs/picompose.log
- You may need to reboot the board like 3 times for everything to be installed. Especially for the 2MicHat drivers. And give docker-compose some time to download the images. You can watch the logs in /var/log/picompose.log
- More information within the PiCompose project.
### Option B) Install on your own:
+95 -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
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
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.
You can change the used version in the docker-compose.yml.
### Docker Image Tags
| Tag | Description | Example |
@@ -59,44 +61,7 @@ Edit the .env file and change the values to your needs:
vim .env
```
```ini
# Linux-Voice-Assistant - Docker Environment Configuration
# Copy this file to .env and customize for your setup by 'cp .env.example .env'
### Enable debug mode (optional):
# ENABLE_DEBUG="1"
### List audio devices (optional):
# if enabled normal startup is disabled
# LIST_DEVICES="1"
### User ID:
# This is used to set the correct permissions for the accessing the audio device and accessing the PulseAudio socket
USERLVA_USER_ID00"
LVA_USER_GROUP="1000"
### Name for the client (optional):
# by default it uses the HOSTNAME variable from the piCompose environment which includes the MAC from the network card
# CLIENT_NAME="My Voice Assistant Speaker"
### PulseAudio socket path on the host:
# PulseAudio Server: /run/user/1000/pulse
# Pipewire Server: /run/user/1000/pulse/native
LVA_PULSE_SERVER="unix:/run/user/${USERLVA_USER_IDlse/native"
LVA_XDG_RUNTIME_DIR="/run/user/${USERLVA_USER_ID
### Path to the preferences file (optional):
# PREFERENCES_FILE="/app/configuration/preferences.json"
### Port for the api (optional):
# PORT="6053"
### Audio input device (optional):
[...]
```
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.
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.
💡 **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
```
💡 **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:
``` sh
@@ -188,29 +163,42 @@ Wants=network-online.target
Type=simple
User=pi
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PATH=/home/pi/linux-voice-assistant/.venv/bin:/usr/bin:/bin
# Environment=ENABLE_DEBUG="1"
# Environment=LIST_DEVICES="1"
Environment=LVA_USER_ID="1000"
Environment=LVA_USER_GROUP="1000"
# Environment=CLIENT_NAME="My Voice Assistant Speaker"
Environment=LVA_PULSE_SERVER="unix:/run/user/${LVA_USER_ID}/pulse/native"
Environment=LVA_XDG_RUNTIME_DIR="/run/user/${LVA_USER_ID}"
Environment=PULSE_SERVER="/run/user/<replace_with_your_user_id>/pulse/native"
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=NETWORK_INTERFACE="eth0"
# Environment=HOST="0.0.0.0"
# Environment=PORT="6053"
# Environment=AUDIO_INPUT_DEVICE="default"
# Environment=AUDIO_OUTPUT_DEVICE="default"
# Environment=MIC_VOLUME="1.0"
# Environment=MIC_AUTO_GAIN="0"
# Environment=MIC_NOISE_SUPPRESSION="0"
# Environment=AUDIO_INPUT_CHANNELS=2
# Environment=ENABLE_THINKING_SOUND="1"
# Environment=WAKE_WORD_DIR="app/wakewords"
# Environment=WAKE-MODEL="okay_nabu"
# Environment=STOP_MODEL="stop"
# Environment=TIMER_MAX_RING_SECONDS="900"
# Environment=REFACTORY_SECONDS="2"
# Environment=CONTINUE_CONVERSATION_DELAY="0.5"
# Environment=WAKEUP_SOUND="sounds/wake_word_triggered.flac"
# Environment=START_LISTENING_SOUND="sounds/start_listening_button.flac"
# Environment=TIMER_FINISHED_SOUND="sounds/timer_finished.flac"
# Environment=PROCESSING_SOUND="sounds/processing.wav"
# Environment=LISTEN_DURING_WAKE_SOUND="0"
# Environment=MUTE_SOUND="sounds/mute_switch_on.flac"
# Environment=UNMUTE_SOUND="sounds/mute_switch_off.flac"
# Environment=PERIPHERAL_HOST="0.0.0.0"
# Environment=PERIPHERAL_PORT="6055"
# Environment=PERIPHERAL_VOLUME_STEP="%(default)s"
# Environment=DISABLE_PERIPHERAL_API="1"
# Environment=ENABLE_OUTPUT_ONLY="1"
ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh
# ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh --additional-parameter-if-you-want
Restart=always
@@ -222,7 +210,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:** Replace `LVA_USER_ID` with 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:
@@ -264,49 +252,99 @@ The following variables can be configured in the `.env` or in the service file:
| Variable | Default | Description |
|----------|---------|-------------|
| `USERLVA_USER_ID` | `1000` | User ID for the container (usually 1000 for the first user) |
| `GROUP_ID` | `1000` | GROUP ID for the container (usually 1000 for the first users group) |
| `CLIENT_NAME` | (optional) | Custom name for this voice assistant instance |
| `LVA_PULSE_SERVER` | `unix:/run/user/${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 |
| `LIST_DEVICES` | (optional) | Set to "1" to list audio devices instead of starting |
| `LVA_USER_ID` | `1000` | User ID for the container (usually 1000 for the first user) |
| `LVA_USER_GROUP` | `1000` | GROUP ID for the container (usually 1000 for the first users group) |
| `CLIENT_NAME` | (optional) | Custom name for this voice assistant instance |
| `LVA_PULSE_SERVER` | `/run/user/${LVA_USER_ID}/pulse/native` | Path to the PulseAudio/PipeWire socket (In some cases a `:unix`infront of the path is needed) |
| `LVA_XDG_RUNTIME_DIR` | `/run/user/${LVA_USER_ID}` | XDG runtime directory |
| `LVA_PULSE_COOKIE` | `/app/configuration/tmp_pulse_cookie` | Cookie file for PulseAudio if you use encryption. By default disabled. We use a tmp file to avoid errors if the file is not found |
| `ENABLE_DEBUG` | (optional) | Set to "1" to enable debug mode |
| `ENABLE_COLORED_DEBUG` | (optional) | Set to "1" to enable colored debug mode |
| `LIST_DEVICES` | (optional) | Set to "1" to list audio devices instead of starting |
| `PREFERENCES_FILE` | (optional) | Path to a custom preferences JSON file |
| `NETWORK_INTERFACE` | Autodetected | network card for server |
| `HOST` | Autodetected | API server IP-Address, can be 0.0.0.0 for all interfaces, but only one network card works for MAC-ADDRESS and ESP protocol |
| `PORT` | `6053` | API server port |
| `AUDIO_INPUT_DEVICE` | `default` | Audio input device name |
| `AUDIO_OUTPUT_DEVICE` | `default` | Audio output device name |
| `AUDIO_INPUT_DEVICE` | Autodetected | Audio input device name |
| `AUDIO_OUTPUT_DEVICE` | Autodetected | Audio output device name |
| `MIC_VOLUME` | Control microphone volume | 100 |
| `MIC_AUTO_GAIN` | Add WebRTC Gain to Mic | 0 |
| `MIC_NOISE_SUPPRESSION` | Add WebRTC Noise Suppresion to Mic | 0 |
| `AUDIO_INPUT_CHANNELS` | Number of audio input channels | 2 |
| `ENABLE_THINKING_SOUND` | false | Set to "1" to enable thinking sound |
| `WAKE_WORD_DIR` | `app/wakewords` | Path to the wake word directory |
| `WAKE_MODEL` | `okay_nabu` | Wake word model to use |
| `STOP_MODEL` | `stop` | Stop model to use |
| `TIMER_MAX_RING_SECONDS` | `900` | Seconds after which the timer stops ringing |
| `REFACTORY_SECONDS` | `2` | Refractory period in seconds after wake word |
| `CONTINUE_CONVERSATION_DELAY` | `0.5` | Delay before mic opens for continued conversation |
| `WAKEUP_SOUND` | `sounds/wake_word_triggered.flac` | Sound file for wake word triggered |
| `START_LISTENING_SOUND` | `sounds/start_listening_button.flac` | Sound file for pressing button to talk |
| `TIMER_FINISHED_SOUND` | `sounds/timer_finished.flac` | Sound file for timer finished |
| `PROCESSING_SOUND` | `sounds/processing.wav` | Sound file for processing state |
| `LISTEN_DURING_WAKE_SOUND` | false | Set to "1" to start listening immediately after wake word detection, without waiting for the wake sound to finish |
| `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
| `PERIPHERAL_HOST` | 0.0.0.0 | Host for the peripheral WebSocket API |
| `PERIPHERAL_PORT` | 6055 | Port for the peripheral WebSocket API |
| `PERIPHERAL_VOLUME_STEP` | %(default)s | Volume change per button press |
| `DISABLE_PERIPHERAL_API` | false | Disable the peripheral WebSocket API |
| `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.
### Feature: Listen During Wake Sound
By default, LVA waits until the wake sound has finished playing before it starts listening for your spoken command. This can force a short pause between saying the wake word and the rest of your request. For example: `Okay Nabu <pause until wake sound finishes> turn on the kitchen lights`.
Enable `LISTEN_DURING_WAKE_SOUND="1"` to let LVA begin listening immediately after the wake word is detected, even while the wake sound is still playing. This allows you to say the wake word and the full command in one go.
This feature is especially useful when you want a more natural interaction flow and do not want to wait to confirm that the wake word was detected before speaking the command.
Keep in mind that listening while the wake sound is playing can also make the microphone pick up some of that wake sound as echo, depending on your speaker and microphone setup. This echo can interfere with speech-to-text and make commands less accurate. If you run into this, see [Enabling Acoustic Echo Cancellation (AEC)](enabling_aec.md).
### 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:
The following wake word models are available:
**microWakeWord:**
- `okay_nabu` - Default wake word
- `alexa` - Alexa wake word
- `hey_jarvis` - Jarvis wake word
- `hey_mycroft` - Mycroft wake word
- `hey_morgan` - Morgan wake word
- `hey_luna` - Luna wake word
- `hey_home_assistant` - Home Assistant wake word
- `stop` - Stop wake word
- `okay_computer` - Okay Computer wake word
- `choo_choo_homie` - Choo Choo Homie wake word
**openWakeWord:**
- `ok_nabu` - Default wake word
- `alexa` - Alexa wake word
- `hey_jarvis` - Jarvis wake word
- `hey_mycroft` - Mycroft wake word
- `hey_rhasspy` - Rhasspy wake word
### Wake Word:
💡 **Note:** The default value for this parameter is the `wakewords` directory in the repository.
### Custom Wake Word:
Change the default wake word with `--wake-model <id>` where `<id>` is the name of a model in the `wakewords` directory. For example, `--wake-model hey_jarvis` will load `wakewords/hey_jarvis.tflite` by default.
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.
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.
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 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:
@@ -318,7 +356,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:
+89 -43
View File
@@ -1,116 +1,158 @@
# 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):
PipeWire is a multimedia server that provides low-latency audio/video handling. Install it with the following commands:
``` sh
```sh
# Update package database
sudo apt update
# Install PipeWire and related packages
sudo apt install -y pipewire wireplumber pipewire-audio-client-libraries libspa-0.2-bluetooth pipewire-audio pipewire-pulse dfu-util
sudo apt install -y pipewire wireplumber pipewire-audio-client-libraries libspa-0.2-bluetooth pipewire-audio pipewire-pulse dfu-util pulseaudio-utils
```
Link the PipeWire configuration for ALSA applications:
``` sh
```sh
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 touch /var/lib/systemd/linger/$USER
```
💡 **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
sudo mkdir -p "/etc/pipewire"
```sh
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
# Daemon config file for PipeWire
💡 **Note:** If you are not on a desktop system, which is already configured for PipeWire, you can configure it manually.
💡 **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
```
💡 **Note:** On constrained hardware like a Raspberry Pi 3B, audio
processing can be interrupted by other system activity, causing
audible glitches or dropped wake-word detections. Loading the
`libpipewire-module-rt` module raises PipeWire's scheduling priority
(`rt.prio`) above normal processes, which reduces these interruptions.
The `flags = [ ifexists nofail ]` ensures this configuration is
silently skipped rather than causing a startup failure on systems
where realtime scheduling isn't available (e.g. some containers or
restricted kernels).
Add the following content:
```
context.properties = {
link.max-buffers = 16
mem.warn-mlock = true
log.level = 3
context.num-data-loops = 1
core.daemon = true
core.name = pipewire-0
default.clock.rate = 16000
}
context.modules = [
{ name = libpipewire-module-rt
{ name = libpipewire-module-rt
args = {
nice.level = -11
rt.prio = 88
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:
Make sure that you only run Pulseaudio and there is no Pipewire installed.
``` sh
```sh
sudo apt remove --purge pipewire pipewire-pulse wireplumber
sudo apt autoremove
```
Install Pulseaudio
``` sh
```sh
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
``` sh
```sh
systemctl --user enable pulseaudio
systemctl --user start pulseaudio
```
Check if Pulseaudio is running
``` sh
```sh
pulseaudio --check
pactl info
```
Reboot the system:
In some cases to get the audio hardware working you may need to restart your system.
## 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:
If your driver or audiodevice is loaded and you can see the device with `aplay -L` then
@@ -135,3 +177,7 @@ sudo alsamixer
```
💡 **Note:** Replace `$LVA_USER_ID` with your actual user id that you want to run the voice assistant.
## Adding Acoustic Echo Cancellation
Acoustic Echo Cancellation (AEC) is a type of sound processing used to cancel out the noise coming out of your speaker and going into your mic. In LVA this functionality can be useful to allow LVA to listen to wake words even when audio is playing, particularly when a timer is playing. PulseAudio and PipeWire already provide built-in modules for AEC. To enable AEC, see [Linux-Voice-Assistant - Enabling AEC](enabling_aec.md).
+342
View File
@@ -0,0 +1,342 @@
# Peripheral WebSocket API
Linux Voice Assistant exposes a WebSocket API on port 6055 that lets external scripts and containers integrate with LVA in real time. This is how LED rings, button boards, displays, and other peripherals communicate with the voice assistant without modifying LVA itself.
The peripheral API is what allows hardware like the Satellite1 HAT Board or the ReSpeaker series to light up when you say the wake word, show a thinking animation while the assistant processes your request, and let physical buttons start a conversation or dismiss a timer — all from a completely separate process or container.
---
## How it works
LVA acts as the WebSocket **server**. Your peripheral script or container connects as a **client**. Once connected, LVA immediately sends a snapshot of its current state, and from that point on all communication is bidirectional:
- **Events** flow from LVA to your client, describing what the assistant is doing right now (listening, thinking, speaking, etc.).
- **Commands** flow from your client to LVA, telling it what to do (start listening, adjust volume, fire a button press event to Home Assistant, etc.).
All messages are JSON objects sent over a plain WebSocket connection. No authentication is required.
```
Your peripheral script LVA
│ │
│──── WebSocket connect ──────────►│
│◄─── snapshot (current state) ────│
│ │
│◄─── event: wake_word_detected ───│ (user said the wake word)
│◄─── event: listening ────────────│ (STT streaming started)
│◄─── event: thinking ─────────────│ (HA is processing)
│◄─── event: tts_speaking ─────────│ (assistant is replying)
│◄─── event: tts_finished ─────────│
│◄─── event: idle ─────────────────│
│ │
│──── command: volume_up ─────────►│ (user pressed a button)
│──── command: start_listening ───►│ (user pressed action button)
```
### Connection
```
ws://<lva-host>:6055
```
By default LVA binds to `0.0.0.0:6055`. You can change the host and port with `--peripheral-host` and `--peripheral-port`, or disable the API entirely with `--disable-peripheral-api`.
### Message format
Events from LVA:
```json
{"event": "<event_name>", "data": {"key": "value"}}
```
Commands to LVA:
```json
{"command": "<command_name>", "data": {"key": "value"}}
```
The `data` field is omitted when there is no payload.
### Snapshot on connect
Immediately after connecting, LVA sends a snapshot so your client can synchronise its state without waiting for the next event:
```json
{
"event": "snapshot",
"data": {
"muted": false,
"volume": 0.8,
"ha_connected": true,
"last_stt_text": "set a timer for five minutes",
"last_tts_text": "Sure, I've set a timer for five minutes."
}
}
```
`last_stt_text` and `last_tts_text` are the most recent conversation exchange, or `null` if no conversation has happened yet since startup. Both are cleared when a new pipeline run starts.
---
## Python quick-start
```python
import asyncio
import json
import websockets
async def main():
async with websockets.connect("ws://localhost:6055") as ws:
async for raw in ws:
msg = json.loads(raw)
event = msg.get("event")
data = msg.get("data", {})
print(f"Event: {event} data={data}")
# Example: send a command in response to an event
if event == "idle":
await ws.send(json.dumps({"command": "volume_up"}))
asyncio.run(main())
```
---
## Events (LVA → your script)
These are all the events LVA emits. Your peripheral script receives them and reacts — for example by changing LED colours, updating a display, or playing a sound.
### Voice pipeline events
| Event | Data | Description |
|-------|------|-------------|
| `wake_word_detected` | — | The configured wake word was detected. The wakeup chime is now playing. Start your "wake" animation here. |
| `listening` | — | The wakeup chime has finished and LVA is now streaming audio to Home Assistant for speech-to-text. Show a "listening" animation. |
| `stt_text` | `{"text": str}` | Home Assistant returned the recognised speech transcript. Use this to show what the user said on a display or LED ticker. |
| `thinking` | — | LVA has stopped streaming audio and Home Assistant is processing the intent. Show a "thinking" animation. |
| `tts_text` | `{"text": str}` | Home Assistant returned the assistant's text response, just before TTS audio begins playing. Use this to display the reply on a screen. |
| `tts_speaking` | — | The TTS audio response is now playing. Show a "speaking" animation. |
| `tts_finished` | — | TTS playback has finished. Transition back to idle, unless a new pipeline run is about to start. |
| `idle` | — | The assistant is idle and ready for the next wake word. Turn off active animations. |
### Error and connection events
| Event | Data | Description |
|-------|------|-------------|
| `pipeline_error` | `{"reason": str}` | The voice pipeline failed — for example STT failure or an intent error. Show a brief red error animation (3 flashes, then off). NOT emitted when HA disconnects; see `disconnected` below. |
| `disconnected` | — | The TCP connection to Home Assistant was lost. Show a "no connection" animation and keep it until you see `zeroconf` with `status: connected`. Note: if LVA itself is not running, your client will see a WebSocket connection failure instead — treat that the same way. |
| `muted` | `{"muted": bool}` | The microphone mute state changed. `true` = muted (show a muted indicator on your LEDs, e.g. red at mic positions), `false` = unmuted. Emitted on every transition in both directions, so peripherals can track mute state without inferring it from `idle`. |
| `zeroconf` | `{"status": "getting_started" \| "connected"}` | Reports LVA's connection lifecycle. `getting_started` is emitted at startup before HA connects; `connected` is emitted once the HA TCP handshake completes. Use `connected` to clear a "no connection" animation. |
### Timer events
| Event | Data | Description |
|-------|------|-------------|
| `timer_ticking` | `{"id": str, "name": str, "total_seconds": int, "seconds_left": int}` | A timer has been set and is counting down. Show a countdown animation proportional to `seconds_left / total_seconds`. |
| `timer_updated` | `{"id": str, "name": str, "total_seconds": int, "seconds_left": int}` | A running timer was adjusted. Update your countdown display. |
| `timer_ringing` | `{"id": str, "name": str, "total_seconds": int, "seconds_left": int}` | The timer has expired and the alarm sound is playing. Show a repeating alert animation. Send `stop_timer_ringing` to dismiss. |
### Media and volume events
| Event | Data | Description |
|-------|------|-------------|
| `media_player_playing` | — | HA started streaming music or media to the background music player (non-announcement). Show a "media playing" indicator. Not emitted for TTS or announcements — those use `tts_speaking`. |
| `volume_changed` | `{"volume": float}` | The speaker volume changed (0.0–1.0). Update any volume display or indicator ring. |
| `volume_muted` | `{"muted": bool}` | The media player mute state changed. Distinct from microphone mute (`muted` event). |
### HA driven entity events
These events fire when a user changes a peripheral registered HA entity from Home Assistant. Use them to apply the user's preferences to your hardware in real time.
| Event | Data | Description |
|-------|------|-------------|
| `light_command` | `{"object_id": str, "state": bool, "brightness": float, "red": float, "green": float, "blue": float, "effect": str}` | An HA Light entity that a peripheral registered via `register_light` was changed. The event is broadcast to every connected peripheral, so filter on `object_id` to route it to the right hardware. `brightness` and RGB values are 0.0 to 1.0. `effect` is one of the strings declared when the Light was registered. |
---
## Commands (your script → LVA)
Send these to control LVA from your peripheral hardware.
### Entity registration
A peripheral that wants Home Assistant to control its hardware declares its entities at connect time. LVA materialises matching ESPHome entities, HA enumerates and shows them as it would any other ESPHome device, and user changes flow back as events.
For HA to see your entity, your peripheral must register before HA enumerates the LVA API. LVA waits briefly at startup (see `--peripheral-startup-wait`, default 2 seconds) so peripherals have a window to connect and register. Peripherals that connect later still work, but the new entities only appear in HA after the integration is reloaded.
| Command | Data | Description |
|---------|------|-------------|
| `register_light` | `{"name": str, "object_id": str, "effects": [str], "supports_rgb": bool, "supports_brightness": bool}` | Register a Light entity for an LED strip, ring, or single LED. HA exposes it as `light.<satellite>_<object_id>` with on/off, brightness, RGB, and a selectable effect from the declared list. Subsequent HA changes are delivered as `light_command` events. Send once after connecting; repeat registrations for the same `object_id` are idempotent (no-op). Example: `{"command": "register_light", "data": {"name": "LEDs", "object_id": "leds", "effects": ["Voice Assistant"], "supports_rgb": true, "supports_brightness": true}}` |
| `register_button` | `{"name": Button Press, "button_press_event": str}` | Register a Button entity for a physical button on your peripheral. When the user presses the button in HA, LVA emits a `button_press` event to all connected peripherals. Send once after connecting; repeat registrations for the same `object_id` are idempotent (no-op). Example: `{"command": "register_button"}` |
### Voice pipeline
| Command | Data | Description |
|---------|------|-------------|
| `start_listening` | — | Play the start-listening chime and begin a voice pipeline run, as if the user pressed the action button. No-op if already muted or pipeline already active. |
| `stop_pipeline` | — | Abort the active voice pipeline at any phase (listening, thinking, or TTS speaking). Cleans up STT streaming, sends `AnnounceFinished` to HA, unducking music, and emits `idle`. |
### Microphone
| Command | Data | Description |
|---------|------|-------------|
| `mute_mic` | — | Mute the microphone. Stops any active pipeline, plays the mute sound, and emits `muted` with `{"muted": true}` to all clients. No-op if already muted. |
| `unmute_mic` | — | Unmute the microphone. Plays the unmute sound and emits `muted` with `{"muted": false}` followed by `idle`. No-op if already unmuted. |
### Volume
| Command | Data | Description |
|---------|------|-------------|
| `volume_up` | — | Increase speaker volume by one step (default 5 %, configurable with `--peripheral-volume-step`). Updates HA, persists the value, and emits `volume_changed`. |
| `volume_down` | — | Decrease speaker volume by one step. |
| `set_volume` | `{"volume": float}` | Set the speaker volume to an exact value between 0.0 and 1.0. Values outside this range are clamped. Updates HA, persists, and emits `volume_changed`. Example: `{"command": "set_volume", "data": {"volume": 0.6}}` |
### Timer
| Command | Data | Description |
|---------|------|-------------|
| `stop_timer_ringing` | — | Dismiss a ringing timer. Stops the alarm sound, clears the timer state, unducking music, and emits `idle`. No-op if no timer is ringing. |
### Media player
| Command | Data | Description |
|---------|------|-------------|
| `stop_media_player` | — | Stop background music / media playback. Updates the HA media player entity state to Idle. |
| `pause_media_player` | — | Pause background music. Updates HA state to Paused. |
| `resume_media_player` | — | Resume paused music. Updates HA state to Playing. |
### Button events (sent to Home Assistant)
These commands fire event entities on the LVA device page in Home Assistant, which you can use in automations. They are intended for any of the buttons on your hardware that are available for these multipress functions and don't handle other context-sensitive commands. **The button must be registered with `register_button` command to appear as a HA sensor and be able to send these events.**
| Command | Data | Description |
|---------|------|-------------|
| `button_single_press` | — | Fire a `single_press` event to HA. No sound is played. |
| `button_double_press` | — | Fire a `double_press` event to HA. Plays the configured double-press sound. |
| `button_triple_press` | — | Fire a `triple_press` event to HA. Plays the configured triple-press sound. |
| `button_long_press` | — | Fire a `long_press` event to HA. Plays the configured long-press sound. |
> **Important:** `button_single_press` must be sent from a **different physical button** than the button that trigger other commands. Like the action button's single press that's reserved for triggering the voice pipeline (`start_listening` on first press, then context-sensitive commands on subsequent presses). Using the same button for both would create ambiguous behavior.
The press type is determined by your peripheral script using timing logic that matches the Home Assistant Voice PE centre button behaviour:
```
Single press : one press, then >250 ms silence
Double press : press → <250 ms gap → press → >250 ms silence
Triple press : press → <250 ms gap → press → <250 ms gap → press → >250 ms silence
Long press : button held for ≥1 s
```
Your script is responsible for detecting these patterns and sending the appropriate command. See the board-specific examples below for reference implementations.
---
## Minimal example script
The following self-contained script connects to LVA, prints every event with a timestamp, and maps a single GPIO button (Raspberry Pi GPIO 17) to context-sensitive commands:
```python
#!/usr/bin/env python3
"""Minimal LVA peripheral — prints events, GPIO button → context command."""
import asyncio
import json
import logging
import websockets
LVA_URI = "ws://localhost:6055"
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
assist_state = "idle"
muted = False
async def main():
global assist_state, muted
command_queue: asyncio.Queue = asyncio.Queue()
# Optional: wire up a GPIO button with gpiozero
try:
from gpiozero import Button
loop = asyncio.get_running_loop()
btn = Button(17, pull_up=True, bounce_time=0.15)
btn.when_pressed = lambda: asyncio.run_coroutine_threadsafe(
command_queue.put(_context_command()), loop
)
except ImportError:
pass # Run without GPIO on non-Pi hardware
while True:
try:
async with websockets.connect(LVA_URI) as ws:
recv = asyncio.create_task(_recv(ws, command_queue))
send = asyncio.create_task(_send(ws, command_queue))
done, pending = await asyncio.wait([recv, send], return_when=asyncio.FIRST_EXCEPTION)
for t in pending:
t.cancel()
except Exception as exc:
logging.warning("Disconnected: %s — retrying in 3s", exc)
await asyncio.sleep(3)
def _context_command() -> str:
if assist_state == "timer_ringing":
return "stop_timer_ringing"
if assist_state in ("wake_word_detected", "listening", "thinking", "tts_speaking"):
return "stop_pipeline"
if assist_state == "media_player_playing":
return "stop_media_player"
return "start_listening"
async def _recv(ws, queue):
global assist_state, muted
async for raw in ws:
msg = json.loads(raw)
event = msg.get("event", "")
data = msg.get("data", {})
logging.info("← %s %s", event, data)
if event in ("snapshot", "idle", "tts_finished", "wake_word_detected",
"listening", "thinking", "tts_speaking", "muted",
"timer_ticking", "timer_ringing", "media_player_playing"):
if event in ("snapshot", "muted"):
# Both carry the mute state; "muted" defaults to true when
# sent without data.
muted = data.get("muted", event == "muted")
assist_state = "muted" if muted else "idle"
else:
assist_state = event
async def _send(ws, queue):
while True:
command = await queue.get()
logging.info("→ %s", command)
await ws.send(json.dumps({"command": command}))
asyncio.run(main())
```
---
## Pre-included examples
The repository ships with ready-to-use peripheral controllers for popular hardware boards. Each example is a standalone Docker container that connects to LVA over this API.
- [Peripheral Web Console](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/Peripheral%20web%20console/DOCS.md) — browser-based real-time dashboard showing all events and a command palette; useful for debugging your setup or testing automations without any hardware
- [Satellite1 HAT Board](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/Satellite1%20HAT%20Board/DOCS.md) — 12-LED SK6812 ring + 4 buttons; animations mirror the Home Assistant Voice PE firmware exactly
- [ReSpeaker 2-Mic Pi HAT](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/ReSpeaker%202mic%20HAT/DOCS.md) — 3 APA102 LEDs + 1 onboard button; compact single-button controller
- [ReSpeaker 4-Mic Array HAT](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/ReSpeaker%204mic%20HAT/DOCS.md) — 12 APA102 LEDs + external GPIO buttons; four-microphone circular array
- [ReSpeaker Mic Array v2.0 (USB)](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/ReSpeaker%20Mic%20Array%20v2.0%20(USB)/DOCS.md) — 12 APA102 LEDs driven over USB HID; plug-and-play, no GPIO required
- [Jabra Speak 410](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/examples/Jabra%20Speak%20410/DOCS.md) — USB speakerphone with hardware button integration
---
## Adding your own example
Have you built a peripheral controller for a board not listed above? Contributions are very welcome. Open a pull request at [github.com/OHF-Voice/linux-voice-assistant/pulls](https://github.com/OHF-Voice/linux-voice-assistant/pulls) with:
- A folder under `examples/<Your Board Name>/`
- The controller script (Python or other language)
- A `Dockerfile` and `compose.yml` so others can run it with one command
- A `DOCS.md` following the same structure as the existing examples (hardware layout, GPIO mapping, LED animations, installation steps, troubleshooting)
Please make sure your example handles reconnection gracefully (retry on WebSocket failure) and shows a "not ready" effect when LVA is not reachable or HA is disconnected.
+225
View File
@@ -0,0 +1,225 @@
# Jabra Speak 410
<img src="image.png" width="300">
Peripheral controller for the [Jabra Speak 410](https://www.jabra.com/business/speakerphones/jabra-speak-series/jabra-speak-410) USB speakerphone, running alongside the Linux Voice Assistant (LVA) container.
The controller connects to the Jabra Speak 410 via USB HID, drives its LED ring to reflect the current assistant state, and maps its hardware buttons to LVA commands. It also optionally integrates with PipeWire for volume control and hardware mute detection.
**Credit:** This integration was written by [@machineonamission](https://github.com/machineonamission). Source repository: [machineonamission/lva-jabra-speak-410](https://github.com/machineonamission/lva-jabra-speak-410).
> **This board connects over USB** — no GPIO, no SPI, no HAT required. It works on any Linux host.
[Demo Video](./demo.mp4)
---
## Hardware
| Component | Details |
|---|---|
| Connection | USB (Vendor `0x0b0e`) |
| Microphone | 360° omni, enumerates as USB audio |
| Speaker | Built-in 360° speaker |
| LED ring | Integrated ring (state-driven via USB HID) |
| Buttons | Call / hang-up, mute, volume up, volume down |
---
## LED states
The Jabra Speak 410 has an internal telephony state machine that maps LED patterns to button behaviour. The controller uses this to mirror the assistant state:
| LVA state | LED pattern | Description |
|---|---|---|
| Idle | Off | Default state |
| Wake word detected | Flashing | Ring flashes |
| Listening | 3 green | Ring solid green |
| Thinking | Flashing | Ring flashes |
| TTS speaking | Partial flash | Ring partially flashing |
| TTS finished | Off | Returns to default |
| Muted | All red | Entire ring solid red |
| Pipeline error | Red flash (×4) | Ring rapidly flashes red then returns to off |
| Timer ringing | Flashing | Ring flashes |
| No HA connection | Flashing | Ring flashes |
> **Note:** The Jabra Speak 410 is a telephony device with a fixed firmware state machine. The LED patterns are constrained by its HID protocol — custom per-pixel colours are not possible.
---
## Button behaviour
### Call button
Context action — starts listening when idle; dismissed by hang-up gesture during active pipeline.
### Hang-up / flash button
| Current state | Command sent |
|---|---|
| Timer ringing | `stop_timer_ringing` |
| Wake word / listening / thinking / TTS speaking | `stop_pipeline` |
| Music / media playing | `stop_media_player` |
### Volume Up/Down buttons
Controlled by `VOLUME_CONTROL` setting (see configuration). The controller can either adjust the PipeWire sink volume directly, or send `volume_up` / `volume_down` commands to LVA.
### Mute button
Togges microphone mute. Sends `mute_mic` when unmuted, `unmute_mic` when muted.
> **Mute button caveat:** The Jabra Speak 410 does not expose the mute button state over HID when not in an active call. The controller detects mute by reading the raw audio stream from PipeWire — all-zero audio indicates hardware mute has engaged. This requires PipeWire to be exposed to the container (see installation).
---
## Installation
### Step 1 — Add udev rule
Run once on the **host**:
```bash
sudo tee /etc/udev/rules.d/99-jabra.rules << 'EOF'
SUBSYSTEM=="usb", ATTR{idVendor}=="0b0e", MODE="0666", GROUP="plugdev"
EOF
sudo udevadm control --reload-rules
sudo udevadm trigger
```
Verify the device is visible:
```bash
lsusb | grep 0b0e
# Example: Bus 001 Device 007: ID 0b0e:0412 GN Netcom ...
```
### Step 2 — Expose PipeWire to the container
The mute button detection requires PipeWire. Follow the [LVA audio server documentation](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/docs/install_audioserver.md) to expose the PipeWire socket.
Find your runtime directory:
```bash
echo $XDG_RUNTIME_DIR
# Typically: /run/user/1000
```
Update the volume mounts in `docker-compose.yml` if your path is different from `/run/user/1000`.
### Step 3 — Configure LVA to use the Jabra microphone
The Jabra Speak 410 enumerates as a standard USB audio device. Pass the device to LVA:
```bash
--audio-input-device "Jabra SPEAK 410"
```
Or in LVA's compose environment:
```yaml
environment:
- AUDIO_INPUT_DEVICE=Jabra SPEAK 410
```
### Step 4 — File structure
```
Jabra Speak 410/
├── docker-compose.yml
└── (no build required — uses pre-built image)
```
### Step 5 — Start
```bash
cd jabra_speak_410
```
#### Option A — Run with Docker Compose (recommended)
```bash
docker compose up -d
```
Check logs:
```bash
docker compose logs -f
```
#### Option B — Run directly with Python
```bash
pip install hidapi websocket-client pycaw pycaw[pipewire]
export LVA_WS_URL=ws://localhost:6055
export XDG_RUNTIME_DIR=/run/user/1000
export PULSE_SERVER=unix:/run/user/1000/pulse/native
python main.py
```
---
## Configuration
All options are set via environment variables in `docker-compose.yml`:
| Variable | Default | Description |
|---|---|---|
| `LVA_WS_URL` | `ws://localhost:6055` | LVA peripheral API WebSocket URL |
| `XDG_RUNTIME_DIR` | `/run/user/1000` | PipeWire runtime directory (must match volume mount) |
| `PULSE_SERVER` | `unix:/run/user/1000/pulse/native` | PulseAudio / PipeWire-Pulse socket path |
| `PW_SINK` | `@DEFAULT_SINK@` | PipeWire sink for volume control |
| `VOLUME_CONTROL` | *(unset)* | Volume button mode — see below |
### Volume control modes
| Value | Behaviour |
|---|---|
| `pipewire` | Adjusts PipeWire sink volume directly via `wpctl` on each button press |
| `lva` | Sends `volume_up` / `volume_down` commands to LVA |
| *(unset)* | Volume button presses are ignored |
> **Warning:** Do not use `VOLUME_CONTROL=lva` if your host OS is also processing HID volume events from the Jabra — this will result in double volume changes.
---
## Drivers summary
| Component | Driver needed | How |
|---|---|---|
| Jabra HID (LEDs + buttons) | udev rule | `99-jabra.rules` on host |
| Microphone (USB audio) | **None** | Enumerates as USB audio device automatically |
| Mute detection | PipeWire socket | Expose `$XDG_RUNTIME_DIR` to container |
---
## Known limitations
**Mute button detection** requires PipeWire to be exposed to the container. Without it, the mute button will not work. This is a firmware limitation of the Jabra Speak 410 — the device does not report the mute button state over HID unless it believes it is in an active call.
**Volume button exclusivity** — `hidapi` takes full ownership of the USB HID interface. External tools that rely on HID volume events (such as `alsa_volume_from_usb_hid`) will not function while this container is running.
**LED pattern constraints** — The Jabra Speak 410 firmware enforces a telephony state machine. LED patterns are limited to what the device's HID protocol supports; custom animations or per-pixel colour control are not possible.
---
## Troubleshooting
### Device not found at startup
1. Confirm `lsusb | grep 0b0e` shows the device.
2. Check the udev rule is applied: `ls -la /dev/bus/usb/<bus>/<device>` should show group `plugdev` with mode `0666`.
3. Restart the container after plugging in the device.
### Mute button has no effect
1. Confirm the PipeWire socket volume mount is correct and the path exists on the host: `ls $XDG_RUNTIME_DIR/pipewire-0`.
2. Run with debug logging and look for `pw-record not found` — this means `wireplumber` is not installed in the container. The pre-built image includes it.
3. Check that `XDG_RUNTIME_DIR` in the container environment matches the volume mount path.
### Double volume changes
Set `VOLUME_CONTROL` to `pipewire` instead of `lva`, or leave it unset. The `lva` mode conflicts with OS-level HID volume processing.
### LVA not reachable
1. Confirm LVA is running and port 6055 is open: `nc -zv localhost 6055`.
2. With `network_mode: host`, `localhost` resolves to the host itself.
3. If LVA is on a different machine, change `LVA_WS_URL` to its IP address.
+13
View File
@@ -0,0 +1,13 @@
FROM ghcr.io/astral-sh/uv:python3.14-bookworm
ENV UV_COMPILE_BYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y wireplumber libhidapi-dev python3-hidapi python3-hid wireplumber
COPY . /app
WORKDIR /app
RUN uv sync --locked
CMD uv run main.py
+144
View File
@@ -0,0 +1,144 @@
---
# Jabra Speak 410 – peripheral controller
#
# Runs alongside the linux-voice-assistant container.
# Controls the Jabra Speak 410's LED ring and buttons via USB HID,
# and optionally integrates with PipeWire for volume control and mute detection.
#
# Credit: https://github.com/machineonamission/lva-jabra-speak-410
#
# See hardware notes at the bottom of this file before first run.
services:
jabra-peripheral:
image: ghcr.io/machineonamission/lva-jabra-speak-410:main
container_name: jabra-peripheral
restart: unless-stopped
# -----------------------------------------------------------------------
# USB device access — required for HID LED and button control
# -----------------------------------------------------------------------
devices:
- /dev/bus/usb:/dev/bus/usb
# -----------------------------------------------------------------------
# PipeWire socket — required for mute detection and optional volume control
#
# The mute detect bodge reads from the microphone stream via pw-record to
# detect when the hardware mute engages (all-zero audio = hardware muted).
# Without this, mute/unmute from the Jabra button will not work.
#
# Replace /run/user/1000 with your actual XDG_RUNTIME_DIR if different.
# For the correct path run: echo $XDG_RUNTIME_DIR
# -----------------------------------------------------------------------
volumes:
- /run/user/1000/pipewire-0:/run/user/1000/pipewire-0:ro
- /run/user/1000/pulse:/run/user/1000/pulse:ro
group_add:
- audio
# -----------------------------------------------------------------------
# Environment variables
# -----------------------------------------------------------------------
environment:
- PYTHONUNBUFFERED=1
# LVA peripheral API WebSocket URL.
# If LVA runs on the same host with network_mode: host this default works.
- LVA_WS_URL=ws://localhost:6055
# PipeWire runtime directory — must match the volume mount path above.
- XDG_RUNTIME_DIR=/run/user/1000
- PULSE_SERVER=unix:/run/user/1000/pulse/native
# PipeWire sink for volume control.
# Leave as @DEFAULT_SINK@ or set to a specific sink name.
# Find sink names with: wpctl status
- PW_SINK=@DEFAULT_SINK@
# Volume control mode. Options:
# pipewire – adjust PipeWire volume directly on button press
# (recommended with LVA pipewire volume control PR #272)
# lva – send volume_up / volume_down commands to LVA
# (WARNING: may double-trigger if system also handles HID)
# (unset) – ignore volume button presses entirely
# - VOLUME_CONTROL=pipewire
# -----------------------------------------------------------------------
# Network — host mode so "localhost" reaches the LVA container directly
# -----------------------------------------------------------------------
network_mode: host
healthcheck:
test: ["CMD", "pgrep", "-f", "main.py"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# =============================================================================
# HARDWARE NOTES
# =============================================================================
#
# 1. USB ACCESS — udev rule (recommended)
#
# Add a udev rule on the HOST so the container can access the Jabra device
# without needing additional privileges:
#
# sudo tee /etc/udev/rules.d/99-jabra.rules << 'EOF'
# SUBSYSTEM=="usb", ATTR{idVendor}=="0b0e", MODE="0666", GROUP="plugdev"
# EOF
# sudo udevadm control --reload-rules
# sudo udevadm trigger
#
# Verify the device is visible:
# lsusb | grep 0b0e
#
# 2. PIPEWIRE — required for mute detection
#
# The Jabra Speak 410 does not expose its mute button state over HID when
# not in an active call. As a workaround, the controller reads raw audio
# from PipeWire and detects hardware mute by observing all-zero audio chunks
# (digitally muted audio is impossible in real conditions).
#
# Expose the PipeWire socket from the host to the container as shown in the
# volumes section above. See the LVA audio server documentation for the
# full procedure:
# https://github.com/OHF-Voice/linux-voice-assistant/blob/main/docs/install_audioserver.md
#
# If pw-record is not available inside the container, install wireplumber:
# apt-get install wireplumber
# (already included in the pre-built image)
#
# 3. VOLUME CONTROL
#
# The hidapi library takes full control of the USB HID interface, which
# means external tools like alsa_volume_from_usb_hid will stop working
# while this container is running.
#
# Set VOLUME_CONTROL=pipewire to handle volume buttons via wpctl.
# Set VOLUME_CONTROL=lva to send volume commands to LVA instead.
# Leave unset to ignore volume buttons entirely.
#
# Avoid VOLUME_CONTROL=lva if your system is also processing HID volume
# events at the OS level — this will result in double volume changes.
#
# 4. MICROPHONE INPUT
#
# The Jabra Speak 410 enumerates as a standard USB audio device.
# No driver install is required. Pass it to LVA via:
#
# --audio-input-device "Jabra SPEAK 410"
#
# Or in LVA's compose environment:
# AUDIO_INPUT_DEVICE=Jabra SPEAK 410
#
# =============================================================================
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

+17
View File
@@ -0,0 +1,17 @@
[Container]
# container stuff
ContainerName=jabra
Image=ghcr.io/machineonamission/lva-jabra-speak-410:main
# auto update
AutoUpdate=registry
Pull=newer
# usb communication
AddDevice=/dev/bus/usb
# pipewire (see LVA docs)
Volume=/run/pipewire:/run/pipewire:ro
SecurityLabelDisable=true
GroupAdd=audio
Environment=XDG_RUNTIME_DIR=/run/pipewire
Environment=PULSE_SERVER=unix:/run/pipewire/pulse/native
+476
View File
@@ -0,0 +1,476 @@
import asyncio
import re
import shutil
import time
from enum import IntFlag, Enum
import websockets
import json
import hid
import os
from websockets import ClientConnection
# --- CONFIGURATION ---
# Default to localhost if running on host network, or use LVA container name/IP
LVA_WS_URL = os.getenv("LVA_WS_URL", "ws://0.0.0.0:6055")
JABRA_VENDOR = 0x0b0e
# JABRA_PRODUCT = 0x0412
starttime = time.time()
pw_sink = os.getenv("PW_SINK", "@DEFAULT_SINK@")
vol_ctrl = os.getenv("VOLUME_CONTROL", None)
if vol_ctrl not in ["pipewire", "lva"]:
print("invalid volume control")
vol_ctrl = None
print(hid.enumerate())
# USAGE_PAGE = 11
devices = []
for device in hid.enumerate(JABRA_VENDOR):
serial = device['serial_number']
print(f"Found {device['product_string']} serial number: {serial}")
devices.append(device['path'])
if len(devices) == 0:
# print("NO JABRA ALERT WAAAAA")
raise Exception("no jabra speak 410 found!")
class Telephony(IntFlag):
hook_switch = 1 << 0
line_busy_tone = 1 << 1
speaker_phone = 1 << 2
mute = 1 << 3
flash = 1 << 4
redial = 1 << 5
speed_dial = 1 << 6
phone_key_bit_0 = 1 << 7
phone_key_bit_1 = 1 << 8
phone_key_bit_2 = 1 << 9
phone_key_bit_3 = 1 << 10
# no clue
button_7 = 1 << 11
class LEDs(IntFlag):
off_hook = 1 << 0
speaker = 1 << 1
mute = 1 << 2
ring = 1 << 3
hold = 1 << 4
microphone = 1 << 5
# Marked telephony and not LED, probably why it's a dupe of ring
ringer = 1 << 6
class Volume(IntFlag):
vol_down = 1 << 0
vol_up = 1 << 1
# Does this ever trigger? might be a host -> device only thing?
mute = 1 << 2
class LEDState(IntFlag, Enum):
default = 0
three_green = LEDs.off_hook
all_red = LEDs.mute | LEDs.off_hook
ringing_and_flashing = LEDs.ring
flashing = LEDs.ring | LEDs.off_hook
partial_flash = LEDs.hold
class LVAEvent(str, Enum):
"""Events broadcast from LVA to peripheral clients."""
WAKE_WORD_DETECTED = "wake_word_detected"
LISTENING = "listening"
THINKING = "thinking"
TTS_SPEAKING = "tts_speaking"
TTS_FINISHED = "tts_finished"
PIPELINE_ERROR = "pipeline_error"
IDLE = "idle"
MUTED = "muted"
TIMER_TICKING = "timer_ticking"
TIMER_UPDATED = "timer_updated"
TIMER_RINGING = "timer_ringing"
MEDIA_PLAYER_PLAYING = "media_player_playing"
VOLUME_CHANGED = "volume_changed"
VOLUME_MUTED = "volume_muted"
ZEROCONF = "zeroconf"
DISCONNECTED = "disconnected"
class LVACommand(str, Enum):
"""Commands accepted from peripheral clients."""
START_LISTENING = "start_listening"
MUTE_MIC = "mute_mic"
UNMUTE_MIC = "unmute_mic"
VOLUME_UP = "volume_up"
VOLUME_DOWN = "volume_down"
STOP_TIMER_RINGING = "stop_timer_ringing"
STOP_MEDIA_PLAYER = "stop_media_player"
STOP_PIPELINE = "stop_pipeline"
class JabraSpeak:
def __init__(self, path):
self.path = path
self.device = hid.device()
self.device.open_path(self.path)
# we can do thread fuckery to make this work
self.device.set_nonblocking(True)
async def read(self):
while True:
try:
read_info = self.device.read(8)
if read_info:
match read_info[0]:
case 0x03:
return Telephony(read_info[1] | read_info[2] << 8)
case 0x01:
return Volume(read_info[1])
case _:
print(f"{round(time.time() - starttime, 1)}s: Packet {bytelist(read_info)} of unknown type")
await asyncio.sleep(0.1)
except asyncio.CancelledError:
raise
except Exception as e:
print("fatal error in jabra loop: ", e)
await asyncio.sleep(1)
async def write(self, button_state: LEDs):
return await asyncio.to_thread(self.device.write, [0x03, button_state & 0xff, (button_state & 0xff00) >> 8])
async def readloop(self):
global last_mute, muted
while True:
event = await self.read()
print(f"from jabra: {event.__class__.__name__} {event.name} 0b{int(event):b}")
# print(f"last event: {last_jabra_write.name}")
if isinstance(event, Telephony):
if (
# Hangup pressed during TTS playback
((event & Telephony.flash) and last_jabra_write == LEDState.partial_flash)
# Hangup pressed during active listening
or (event is None and last_jabra_write == LEDState.three_green)
# button_7 (bit 11, undocumented in HID spec) fires when hangup is pressed
# while LEDs are flashing — covers wake word detected, thinking, and timer
# ringing states; acts as a catch-all hangup for active pipeline phases
or (event & Telephony.button_7)
):
print("jabra to lva: hangup detected")
if current_state == LVAEvent.MEDIA_PLAYER_PLAYING:
await write_to_lva(LVACommand.STOP_MEDIA_PLAYER)
else:
await write_to_lva(LVACommand.STOP_TIMER_RINGING)
await write_to_lva(LVACommand.STOP_PIPELINE)
# Mute switch
elif event & Telephony.mute:
print("jabra to lva: mute toggle detected")
global muted
if muted:
await set_mute(False)
else:
await set_mute(True)
# Call button pressed while LEDs are in default (idle) state.
# Guard against the device's known hardware quirk: it spuriously fires a
# hook_switch event immediately after unmuting, so off_mute_cooldown()
# gates the action on at least 1 second having elapsed since the last
# mute toggle before treating this as a genuine button press.
elif event & Telephony.hook_switch and last_jabra_write == LEDState.default:
if off_mute_cooldown():
print("jabra to lva: call button detected")
# If lva is glitched and i dont update the state machine, it will absolutely crap out
await write_to_jabra(LEDState.flashing)
await write_to_lva(LVACommand.START_LISTENING)
await asyncio.create_task(listening_bodge())
elif isinstance(event, Volume):
if not vol_ctrl:
print("ignoring volume command, env not set")
continue
if event & Volume.vol_up:
print("jabra to lva: volume up detected")
match vol_ctrl:
case "lva":
await write_to_lva(LVACommand.VOLUME_UP)
case "pipewire":
await wpctl_vol("10%+")
elif event & Volume.vol_down:
print("jabra to lva: volume down detected")
match vol_ctrl:
case "lva":
await write_to_lva(LVACommand.VOLUME_DOWN)
case "pipewire":
await wpctl_vol("10%-")
elif event & Volume.mute:
print("jabra to lva: consumer control mute toggle detected")
if muted:
await set_mute(False)
print("unmute cooldown")
else:
await set_mute(True)
# Fixes a bug where it can get stuck on wakeword detected
async def listening_bodge():
await asyncio.sleep(0.5)
if current_state == LVAEvent.WAKE_WORD_DETECTED:
await write_to_lva(LVACommand.STOP_PIPELINE)
await write_to_jabra(LEDState.three_green)
last_jabra_write: LEDs | LEDState = LEDState.default
last_lva_write: LVACommand | None = None
devices = [JabraSpeak(d) for d in devices]
last_mute = 0
def bytelist(l: list[int]):
return "[" + ', '.join(f"0x{b:02x}" for b in l) + "]"
async def write_to_jabra(state: LEDs | LEDState):
print(f"to jabra: {state.name} {int(state):b}")
global last_jabra_write
last_jabra_write = state
return await asyncio.gather(*[d.write(state) for d in devices])
async def write_to_lva(command: LVACommand, data: dict = None):
if lva_sock:
global last_lva_write
last_lva_write = command
message = json.dumps({"command": command} | ({"data": data} if data else {}))
print(f"to lva: {message}")
await lva_sock.send(message)
else:
print("to lva: failed, no websocket")
lva_sock: None | ClientConnection = None
async def set_mute(m: bool, write_lva: bool = True):
global muted, last_mute
muted = m
mute_cooldown()
if m:
commands = ([write_to_jabra(LEDState.all_red)] +
([write_to_lva(LVACommand.MUTE_MIC)] if write_lva else []))
await asyncio.gather(*commands)
else:
commands = ([write_to_jabra(LEDState.default)] +
([write_to_lva(LVACommand.UNMUTE_MIC)] if write_lva else []))
await asyncio.gather(*commands)
async def pipeline_error():
for _ in range(4):
await write_to_jabra(LEDState.all_red)
await asyncio.sleep(0.1)
await write_to_jabra(LEDState.default)
await asyncio.sleep(0.1)
async def ha_disconnected():
"""All surround leds blink repeatedly with red color to indicate disconnected state from LVA."""
while True:
await write_to_jabra(LEDState.all_red)
await asyncio.sleep(0.5)
await write_to_jabra(LEDState.default)
await asyncio.sleep(0.5)
current_state: None | LVAEvent = None
muted: bool = False
async def run_cmd(cmd: list[str]):
print("run_cmd ", cmd)
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL,
)
stdout, stderr = await proc.communicate()
try:
stdout = stdout.decode().strip()
stderr = stderr.decode().strip()
except UnicodeDecodeError:
stdout = stdout.decode("ascii", 'ignore').strip()
stderr = stderr.decode("ascii", 'ignore').strip()
if proc.returncode != 0:
raise Exception("wpctl failed ", cmd, stdout, stderr)
else:
return stdout, stderr
async def wpctl_vol(vol_mod: str):
await run_cmd(["wpctl", "set-volume", "-l", "1.0", pw_sink, vol_mod])
vol = await pw_vol()
if vol <= 0:
await run_cmd(["wpctl", "set-mute", pw_sink, "1"])
else:
await run_cmd(["wpctl", "set-mute", pw_sink, "0"])
async def wsloop():
global lva_sock
while True:
try:
async with websockets.connect(LVA_WS_URL) as websocket:
print(f"Connected to LVA at {LVA_WS_URL}")
lva_sock = websocket
while True:
data = await websocket.recv()
print(f"from lva: {data}")
json_data = json.loads(data)
if json_data["event"] == "snapshot":
await set_mute(json_data["data"]["muted"], write_lva=False)
global current_state
try:
current_state = LVAEvent(json_data["event"])
except ValueError:
print(f"current state is not a valid event: {json_data['event']}")
match current_state:
case LVAEvent.WAKE_WORD_DETECTED:
await write_to_jabra(LEDState.flashing)
case LVAEvent.LISTENING:
await write_to_jabra(LEDState.three_green)
case LVAEvent.THINKING:
await write_to_jabra(LEDState.flashing)
case LVAEvent.TTS_SPEAKING:
await write_to_jabra(LEDState.partial_flash)
case LVAEvent.TTS_FINISHED:
await write_to_jabra(LEDState.default)
case LVAEvent.PIPELINE_ERROR:
asyncio.create_task(pipeline_error())
case LVAEvent.IDLE:
await set_mute(False, write_lva=False)
case LVAEvent.MUTED:
await set_mute(True, write_lva=False)
case LVAEvent.TIMER_TICKING:
pass
case LVAEvent.TIMER_UPDATED:
pass
case LVAEvent.TIMER_RINGING:
await write_to_jabra(LEDState.flashing)
case LVAEvent.MEDIA_PLAYER_PLAYING:
pass
case LVAEvent.VOLUME_CHANGED:
pass
case LVAEvent.VOLUME_MUTED:
pass
case LVAEvent.ZEROCONF:
pass
case LVAEvent.DISCONNECTED:
await write_to_jabra(ha_disconnected())
case event:
print(f"Unknown event: {event}")
except asyncio.CancelledError:
raise
except Exception as e:
print("fatal error in lva loop: ", e)
lva_sock = None
await asyncio.sleep(1)
async def pw_vol():
stdout, stderr = await run_cmd(["wpctl", "get-volume", pw_sink])
# Extract the float value
match = re.search(r'Volume:\s*([0-9.]+)', stdout)
return float(match.group(1)) # Returns exactly 0.5
def off_mute_cooldown():
global last_mute
ret = time.perf_counter() - last_mute >= 1
print("off mute cooldown: ", ret)
return ret
def mute_cooldown():
global last_mute
print("set mute cooldown")
last_mute = time.perf_counter()
async def mute_detect_bodge():
if shutil.which("pw-record") is None:
print("pw-record not found. make sure wireplumber is installed.")
return
while True:
proc = None
try:
proc = await asyncio.create_subprocess_exec(
"pw-record", "--rate", "16000", "--channels", "1", "--format", "s16", "-",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL
)
while True:
rate = 16_000
read_dur = 0.2
chunk = await proc.stdout.readexactly(int(rate * read_dur))
global muted, last_mute
# all zeroes, mic has been muted
if not any(chunk) and not muted and off_mute_cooldown():
await set_mute(True)
if any(chunk) and muted and off_mute_cooldown():
await set_mute(False)
except asyncio.CancelledError:
raise
except asyncio.IncompleteReadError as e:
# This happens when pw-record is killed while waiting for data. Just exit the loop and respawn it.
print("pw-record process ended ", e)
print(await proc.communicate())
print(proc.returncode)
except Exception as e:
if proc:
try:
proc.kill()
except ProcessLookupError:
pass
print("fatal error in mute_detect_bodge: ", e)
await asyncio.sleep(1)
async def main():
async with asyncio.TaskGroup() as tg:
# Spawn your infinite loops here
tg.create_task(wsloop())
tg.create_task(mute_detect_bodge())
# tg.create_task(block_test())
for d in devices:
tg.create_task(d.readloop())
# await asyncio.sleep(0.5)
# await write_to_jabra(LEDs.microphone | LEDs.speaker)
asyncio.run(main())
+9
View File
@@ -0,0 +1,9 @@
[project]
name = "lva-jabra-speak-410"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.14"
dependencies = [
"hidapi>=0.15.0",
"websockets>=16.0",
]
+73
View File
@@ -0,0 +1,73 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "hidapi"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/f6/caad9ed701fbb9223eb9e0b41a5514390769b4cb3084a2704ab69e9df0fe/hidapi-0.15.0.tar.gz", hash = "sha256:ecbc265cbe8b7b88755f421e0ba25f084091ec550c2b90ff9e8ddd4fcd540311", size = 184995, upload-time = "2025-12-09T09:48:54.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/65/e9c70deb396f5baea92cd2ea7803914a55d455c3982367bae4390483e6cc/hidapi-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a6bb079bcd5152dd8968893aecdcbde3954b849896078f54df2254c0d1f301e", size = 69532, upload-time = "2025-12-09T09:45:55.269Z" },
{ url = "https://files.pythonhosted.org/packages/05/eb/d006a7e28ec63b5db76340e4aca6ff077ad336f8fbe64ac804b968927011/hidapi-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d80c745d285dfc9889e856226daa8eee6f9e83025ef2cb97e5fe0b1396f7818a", size = 69350, upload-time = "2025-12-09T09:45:56.695Z" },
{ url = "https://files.pythonhosted.org/packages/52/2b/95e7595eb61982c651ed2b3ecd9bae05a0298b668c801a992e4e814b22f2/hidapi-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0f4411fd479345ca86742d9dda77200476a902749d8e47ed438f31ec98af418", size = 1065210, upload-time = "2025-12-09T09:46:02.484Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a5/0344a7c223c3610510ec7f91f7220b0e5852c92d52aff23377f1c2888880/hidapi-0.15.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d93f7173dcbec61b928f4cf800bf26535f81ef3b7bd89199237207f6ca7e4f43", size = 1610362, upload-time = "2025-12-09T09:45:58.533Z" },
{ url = "https://files.pythonhosted.org/packages/d9/96/781212cf852705bc1db26edd4340b01b9ce04cadd5d327c00b64f7324978/hidapi-0.15.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:dacf0b9e5d776fde69b92d68c09ab878a09486629e1fe8e6ae851252b0f33ce5", size = 1680942, upload-time = "2025-12-09T09:46:00.971Z" },
{ url = "https://files.pythonhosted.org/packages/d1/48/6d51a37d3a81355740a82cd265d4fc8485a0a279e4b545cbf790bd106f51/hidapi-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda6ce04a77662e77a4b6b2598211935ca281e1cf663248d54bce75bd684fed8", size = 707550, upload-time = "2025-12-09T09:46:03.993Z" },
{ url = "https://files.pythonhosted.org/packages/86/77/175efa84ecea161c70948dc67b412b7444c2a196e4def8b305cb756077c6/hidapi-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:74900b5c7e884e0e4316d4deb746e5dd294b5aaaf90eac056c4f9f19cf2c8097", size = 689865, upload-time = "2025-12-09T09:46:05.976Z" },
{ url = "https://files.pythonhosted.org/packages/7e/29/0e513390a8d686448672fabd30385f9cda6e01663765851d0cef88e6f0b1/hidapi-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:76236468509dfcb256fa0fbd5a73708c598b3819695b7d08499ae489c82116b6", size = 706809, upload-time = "2025-12-09T09:46:07.309Z" },
{ url = "https://files.pythonhosted.org/packages/a2/d6/c0b9d8568fdb38eebb9caaa56643a81d92cccb5d59a5b44e0d975a0f3808/hidapi-0.15.0-cp314-cp314-win32.whl", hash = "sha256:901c03817306eac7edd589f09e0e07467871b45f665949cc4caa645b54abc97b", size = 61089, upload-time = "2025-12-09T09:46:09.59Z" },
{ url = "https://files.pythonhosted.org/packages/cb/14/3b27516b2867e53545164ebd8ef45d0c36857d2a062036e788e56e2e34ce/hidapi-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:df0dd435562654e27ad0c7d045074eb1e1e016b09167dd48a258dec1ce4b9127", size = 67710, upload-time = "2025-12-09T09:46:08.677Z" },
{ url = "https://files.pythonhosted.org/packages/97/8f/ff034661faf99acaabe8954fb7db87c5242c79bf763dd972b23b1be5f104/hidapi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2f16e9ea8a1d24ac7a6bf316905823eafd58f78ec815a93118d4710faf95a224", size = 70893, upload-time = "2025-12-09T09:46:10.625Z" },
{ url = "https://files.pythonhosted.org/packages/d8/44/617666a0919d06521d732ef07998306b8ddcbd96038e19348c831acd76e8/hidapi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:77f0965ca81a9be44694e84aa18e501d5cda49e372202a38a52d22acc38deadd", size = 71430, upload-time = "2025-12-09T09:46:11.648Z" },
{ url = "https://files.pythonhosted.org/packages/64/64/129aed49fa45b6a5e2fd955618eebe93ff7e3889f05237da6b45051c77bb/hidapi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:541c85b8225ce19b38ceacd404283ca1857345ccfa7ca12f54131203ff2d7f75", size = 1094257, upload-time = "2025-12-09T09:46:17.529Z" },
{ url = "https://files.pythonhosted.org/packages/17/ca/266ca521ff643957823a7f04a67030f6d2917e86e31c60c3d4878cd6b913/hidapi-0.15.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:89f909a6783307c2f067e8e7948f38ec43386f15d04759d11e5b32e3b2fdf3d8", size = 1663756, upload-time = "2025-12-09T09:46:13.403Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/eb9d0fe6d9ccd2645e993930cc7fdeb6e12ddcdec086f3ef7dec947286ca/hidapi-0.15.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:fd32f311633fad21a52e750775cd4ea23da912199c63d1331f4d8ae00ab0874b", size = 1710998, upload-time = "2025-12-09T09:46:15.68Z" },
{ url = "https://files.pythonhosted.org/packages/07/43/e5c0cd80df4a65a468b7a9fc6891ea77435a75e64bb273104c37f12e05b3/hidapi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8f7ca38699557ab057333f1628a346073ae6088c09897b2cd9fbc68f4896780c", size = 751383, upload-time = "2025-12-09T09:46:19.183Z" },
{ url = "https://files.pythonhosted.org/packages/8c/8f/033e19857bcc3ad1b9880b5c0dfbaa7c945e577b7dee18919c1b2478484d/hidapi-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1c4e707c82629637a311fa1a7adc1ae1386de7b539e84b4e37125e52e449318d", size = 722837, upload-time = "2025-12-09T09:46:20.707Z" },
{ url = "https://files.pythonhosted.org/packages/ff/f0/2b4e7ff42cdc6a6bf7def0591dfd3854af81038a1925ac1ed7a96578e834/hidapi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc99bb16b359023190bf857297f6492a1c8441ccacff66dac881c9e278f5c262", size = 750923, upload-time = "2025-12-09T09:46:23.161Z" },
{ url = "https://files.pythonhosted.org/packages/bd/eb/c0676ff1f7e9ec9d99eaa428c66745cdab5b1742808f0c16061c85bd0b18/hidapi-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:005c84c74c940a314b211674edc763a931ace0b63685ace88148496f563e25f9", size = 66288, upload-time = "2025-12-09T09:46:25.246Z" },
{ url = "https://files.pythonhosted.org/packages/cd/5b/f22dbf886824559d3e66d84710c5fb48c09856e6816885da97f2f51700eb/hidapi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e384430363d400c4ffb33fe65f22a61c9f288a870158dc5a9f5c9d917b7250c7", size = 74723, upload-time = "2025-12-09T09:46:24.36Z" },
]
[[package]]
name = "lva-jabra-speak-410"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "hidapi" },
{ name = "websockets" },
]
[package.metadata]
requires-dist = [
{ name = "hidapi", specifier = ">=0.15.0" },
{ name = "websockets", specifier = ">=16.0" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
+223
View File
@@ -0,0 +1,223 @@
# Peripheral Web Console
The Peripheral Web Console is a web-based interface for testing and interacting with the Linux Voice Assistant WebSocket API. It allows you to monitor LVA state and send commands to control the voice assistant from a browser.
## Overview
This HTML console provides:
- Real-time connection status to the LVA WebSocket server
- Live event feed showing all LVA events (wake word detected, listening, TTS, timers, etc.)
- Control buttons to send commands to LVA (mute/unmute, volume control, start listening, etc.)
- Visual indicators for volume, mute state, and Home Assistant connection status
- Audio level visualization
## Getting Started
### Step 1: Download the HTML File
Download [peripheral_web_console.html](peripheral_web_console.html)
### Step 2: Open the HTML File in Your Browser
Open the file using any modern web browser (Chrome, Firefox, Safari, Edge):
<img src="image.png" width="300">
**Option A: Direct File Open**
- Double-click the `peripheral_web_console.html` file
- Or drag it into your browser window
- The file will open as a local file URL (e.g., `file:///path/to/peripheral_web_console.html`)
**Option B: Using a Local Web Server**
```bash
# Using Python 3
python -m http.server 8000
# Using Python 2
python -m SimpleHTTPServer 8000
# Then navigate to: http://localhost:8000/peripheral_web_console.html
```
### Step 3: Configure the WebSocket Connection URL
When the page loads, you'll see the **CONNECTION & STATUS** section at the top with:
- Current WebSocket URL (default: `ws://localhost:6055`)
- A "connect" button
**To modify the connection URL:**
1. Look at the input field showing the WebSocket address
2. By default, it points to `ws://localhost:6055`
3. Change this if your LVA server is running on a different host or port:
- Local development: `ws://localhost:6055`
- Remote server: `ws://your-server-ip:6055`
- Docker container: `ws://lva-container:6055` (if using Docker networking)
### Step 4: Connect to LVA
<img src="image_2.png" width="300">
1. Once you've configured the URL, click the **"connect"** button
2. The page will attempt to establish a WebSocket connection to the LVA server
3. On successful connection:
- The status indicator changes from "disconnected" (gray dot) to "connected" (green dot)
- You'll receive a "snapshot" event showing the current state (volume, mute status, last recognized text, etc.)
- Event feed will show: `connecting to ws://...`, `connected`, `snapshot`
4. If connection fails:
- Ensure LVA is running and the WebSocket server is active
- Verify the URL is correct (check host and port)
- Check browser console (F12 → Console tab) for error messages
- Verify network connectivity and firewall rules
## Using the Web Console
### Connection & Status Section
Displays real-time information:
- **Status Indicator**: Gray (disconnected) or Green (connected)
- **WebSocket URL**: The connection endpoint
- **STATE**: Current LVA state (idle, listening, thinking, etc.)
- **VOLUME**: Current volume level (0-100%)
- **MUTED**: Microphone mute status (yes/no)
- **HA CONNECTED**: Home Assistant connection status
- **AUDIO LEVEL**: Visual bar showing real-time audio input level
### Event Feed
Shows a chronological log of all events received from LVA:
**Example events:**
- `connecting to ws://localhost:6055`
- `connected`
- `snapshot` - Initial state when connected
- `wake_word_detected` - Wake word detected
- `listening` - Microphone is recording
- `stt_text: "set a timer for 10 seconds"` - Recognized speech
- `thinking` - Processing the command
- `tts_text: "OK, timer set."` - Assistant's response
- `tts_speaking` - Playing the response audio
- `idle` - Returned to waiting state
- `timer_ticking`, `timer_ringing` - Timer events
- `volume_changed`, `volume_muted` - Volume events
- `disconnected` - Connection lost
Click "clear" to remove all events from the feed.
### Commands Section
Send commands to LVA by clicking buttons:
**Listening & Pipeline:**
- **Start listening** - Manually trigger listening (as if wake word was detected)
- **Stop pipeline** - Abort the current voice pipeline (stops listening, thinking, speaking)
**Audio Control:**
- **Volume up** / **Volume down** - Adjust volume by small increments
- **Set volume** - Set exact volume level using slider (0.0-1.0)
- **Mute microphone** / **Unmute microphone** - Toggle microphone mute
**Media & Timers:**
- **Pause media player** / **Resume media player** / **Stop media player** - Control music playback
- **Stop timer ringing** - Silence an active timer alarm
**Button Presses:**
- **Single press**, **Double press**, **Triple press**, **Long press** - Simulate physical button inputs
- Useful for testing button-based interactions if LVA is connected to hardware buttons
## Example Workflow
1. **Start LVA server** with WebSocket peripheral API enabled
2. **Open** `peripheral_web_console.html` in browser
3. **Click "connect"** to establish WebSocket connection
4. **Observe events** as you interact with LVA via voice (e.g., "Hey Home Assistant, what time is it?")
5. **Send commands** from the console (e.g., click "Mute microphone" to test mute functionality)
6. **Watch feedback** in the event feed and status indicators
## Troubleshooting
### Connection Won't Establish
- **Problem**: "disconnected" status persists
- **Solutions**:
- Verify LVA is running: `docker ps` (if using Docker) or check if the service started
- Check the WebSocket URL is correct
- Ensure firewall allows traffic on port 6055
- Check browser console (F12) for detailed error messages
### No Events Appearing
- **Problem**: Connected but no events in feed
- **Solutions**:
- Trigger an event manually (speak a wake word, click a button)
- Check that the LVA peripheral API server is properly initialized
- Verify the WebSocket connection is truly active (status should show "connected")
### Volume/State Not Updating
- **Problem**: Controls don't seem to work
- **Solutions**:
- Confirm "connected" status is shown
- Check that LVA is not muted at the system level
- Try stopping and reconnecting
- Check LVA logs for errors
### CORS or Mixed Content Errors
- **Problem**: Console shows security errors
- **Solutions**:
- Use `http://` with `ws://` (not `https://` with `wss://`)
- Serve the HTML over HTTP if testing locally
- Ensure your LVA server is configured for WebSocket connections
## Protocol Details
The console communicates with LVA using JSON over WebSocket:
**Messages from LVA (Events):**
```json
{"event": "event_name", "data": {...}}
```
**Messages to LVA (Commands):**
```json
{"command": "command_name", "data": {...}}
```
**Initial Snapshot (on connect):**
```json
{
"event": "snapshot",
"data": {
"muted": false,
"volume": 1.0,
"ha_connected": true,
"last_stt_text": "...",
"last_tts_text": "..."
}
}
```
For detailed protocol documentation, see `peripheral_api.py` in the Linux Voice Assistant repository.
## Advanced Usage
### Accessing Remotely
To access the console from another computer:
1. **Expose LVA server** to your network (modify firewall/port forwarding)
2. **Open console** from remote machine
3. **Update WebSocket URL** to your LVA server's network address:
```
ws://your-lva-server-ip:6055
```
### Integration with Peripherals
This console mirrors the interface used by physical peripherals (LED boards, buttons, etc.):
- Hardware peripherals use the same WebSocket API
- Testing here helps debug peripheral integrations
- Both console and hardware can be connected simultaneously
### Development & Debugging
Use browser Developer Tools (F12) to:
- Monitor WebSocket messages in Network tab
- Check console for JavaScript errors
- Inspect HTML structure and styling
- Test custom commands before deploying to hardware
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

@@ -0,0 +1,433 @@
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-mono, monospace); font-size: 13px; background: var(--color-background-secondary, #f5f5f5); }
.root { display: flex; flex-direction: column; gap: 12px; padding: 1rem; max-width: 1200px; margin: 0 auto; }
.row { display: flex; gap: 12px; align-items: stretch; flex-wrap: wrap; }
.card { background: var(--color-background-primary); border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-lg); padding: 1.2rem; flex: 1; min-width: 260px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.card-title { font-size: 11px; font-weight: 600; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 12px; }
/* Connection section */
.conn-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.conn-row input { flex: 1; font-family: var(--font-mono); font-size: 12px; padding: 6px 8px; border: 0.5px solid var(--color-border-secondary); border-radius: 4px; }
.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; transition: background 0.2s; }
.dot.off { background: #888780; }
.dot.on { background: #1D9E75; }
.dot.err { background: #E24B4A; }
.conn-label { font-size: 12px; color: var(--color-text-secondary); min-width: 70px; font-weight: 500; }
/* State display */
.state-section { display: flex; gap: 12px; align-items: center; margin: 12px 0; padding: 10px; background: var(--color-background-secondary); border-radius: 6px; }
.state-display { display: flex; gap: 8px; align-items: center; flex: 1; }
.state-badge { display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; background: var(--color-background-secondary); color: var(--color-text-primary); border: 1px solid var(--color-border-tertiary); transition: all 0.2s; min-width: 80px; text-align: center; }
.state-badge.wake_word_detected { background: #EEEDFE; color: #3C3489; border-color: #AFA9EC; }
.state-badge.listening { background: #E1F5EE; color: #085041; border-color: #5DCAA5; }
.state-badge.thinking { background: #FAEEDA; color: #633806; border-color: #EF9F27; }
.state-badge.tts_speaking { background: #E6F1FB; color: #0C447C; border-color: #85B7EB; }
.state-badge.muted { background: #FCEBEB; color: #791F1F; border-color: #F09595; }
.state-badge.pipeline_error { background: #FCEBEB; color: #A32D2D; border-color: #E24B4A; animation: pulse 0.5s ease-out; }
.state-badge.timer_ringing { background: #FAECE7; color: #712B13; border-color: #F0997B; }
.state-badge.media_player_playing { background: #EAF3DE; color: #27500A; border-color: #97C459; }
@keyframes pulse { 0% { transform: scale(1.05); } 100% { transform: scale(1); } }
/* Meta info grid */
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-top: 10px; }
.meta-item { display: flex; flex-direction: column; }
.meta-label { font-size: 10px; font-weight: 600; color: var(--color-text-tertiary); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
.meta-value { font-size: 13px; font-weight: 500; color: var(--color-text-primary); font-family: var(--font-mono); }
/* Timer display - prominent */
.timer-box { background: linear-gradient(135deg, #0C447C 0%, #085041 100%); color: white; padding: 12px; border-radius: 8px; text-align: center; margin-top: 10px; }
.timer-box.hidden { display: none; }
.timer-name { font-size: 11px; font-weight: 500; opacity: 0.9; }
.timer-countdown { font-size: 24px; font-weight: 700; font-family: var(--font-mono); letter-spacing: 2px; }
/* Volume bar */
.vol-section { margin-top: 10px; }
.vol-label { font-size: 10px; font-weight: 600; color: var(--color-text-tertiary); text-transform: uppercase; margin-bottom: 4px; }
.vol-bar-wrap { height: 8px; background: var(--color-background-secondary); border-radius: 4px; overflow: hidden; }
.vol-bar { height: 100%; background: #1D9E75; border-radius: 4px; transition: width 0.3s; }
/* Feed */
.feed { height: 280px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
.feed-item { display: flex; gap: 8px; align-items: flex-start; padding: 6px 8px; border-radius: 4px; font-size: 11px; }
.feed-item:hover { background: var(--color-background-secondary); }
.feed-ts { color: var(--color-text-tertiary); font-size: 10px; flex-shrink: 0; min-width: 55px; font-weight: 500; }
.feed-event { color: var(--color-text-primary); font-weight: 500; }
.feed-data { color: var(--color-text-secondary); font-size: 10px; }
.feed-item.sent .feed-event { color: #185FA5; }
.feed-item.recv .feed-event { color: #3B6D11; }
.feed-item.system .feed-event { color: #888780; font-style: italic; }
.feed-item.err-item .feed-event { color: #A32D2D; }
/* Last conversation context */
.context-box { background: #f9f9f9; border-left: 3px solid #85B7EB; padding: 8px 10px; margin-top: 8px; border-radius: 4px; font-size: 11px; }
.context-label { color: var(--color-text-tertiary); font-weight: 600; text-transform: uppercase; font-size: 9px; letter-spacing: 0.05em; }
.context-text { color: var(--color-text-primary); margin-top: 4px; font-style: italic; }
.context-box.hidden { display: none; }
/* Commands */
.cmd-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 6px; }
.cmd-btn { font-family: var(--font-mono); font-size: 11px; padding: 8px 10px; text-align: center; cursor: pointer; border-radius: 4px; background: var(--color-background-primary); border: 0.5px solid var(--color-border-secondary); color: var(--color-text-primary); transition: all 0.1s; font-weight: 500; }
.cmd-btn:hover { background: var(--color-background-secondary); }
.cmd-btn:active { transform: scale(0.97); }
.cmd-btn.danger { border-color: #F09595; color: #A32D2D; }
.cmd-btn.danger:hover { background: #FCEBEB; }
.cmd-btn.primary { border-color: #85B7EB; color: #0C447C; }
.cmd-btn.primary:hover { background: #E6F1FB; }
/* Volume input row */
.vol-input-row { display: flex; gap: 6px; margin-top: 8px; }
#volume-input { flex: 1; padding: 6px 8px; font-family: var(--font-mono); font-size: 11px; border: 0.5px solid var(--color-border-secondary); border-radius: 4px; }
.clear-btn { font-size: 10px; padding: 4px 8px; cursor: pointer; background: none; border: 0.5px solid var(--color-border-tertiary); border-radius: 3px; color: var(--color-text-secondary); float: right; margin-top: -2px; transition: all 0.1s; }
.clear-btn:hover { background: var(--color-background-secondary); }
</style>
<div class="root">
<h2 class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;">LVA peripheral API web console — connect to a running LVA instance to view events and send commands</h2>
<!-- Connection & Status Card -->
<div class="card">
<div class="card-title">Connection & Status</div>
<div class="conn-row">
<input type="text" id="url" value="ws://localhost:6055" placeholder="ws://host:6055" />
<button id="conn-btn" class="cmd-btn primary" style="min-width:75px;">connect</button>
</div>
<div class="state-section">
<div class="dot off" id="dot"></div>
<span class="conn-label" id="conn-label">disconnected</span>
<span style="margin-left:auto; font-size:10px; color:var(--color-text-secondary); text-transform: uppercase; font-weight: 600;">state</span>
<span class="state-badge" id="state-badge">idle</span>
</div>
<!-- Meta info -->
<div class="meta-grid">
<div class="meta-item">
<span class="meta-label">Volume</span>
<span class="meta-value" id="meta-vol">—</span>
</div>
<div class="meta-item">
<span class="meta-label">Muted</span>
<span class="meta-value" id="meta-muted">—</span>
</div>
<div class="meta-item">
<span class="meta-label">HA Connected</span>
<span class="meta-value" id="meta-ha">—</span>
</div>
</div>
<!-- Volume bar -->
<div class="vol-section">
<div class="vol-label">Audio Level</div>
<div class="vol-bar-wrap"><div class="vol-bar" id="vol-bar" style="width:0%"></div></div>
</div>
<!-- Timer display (prominent) -->
<div class="timer-box hidden" id="timer-box">
<div class="timer-name" id="timer-name"></div>
<div class="timer-countdown" id="timer-countdown">0:00</div>
</div>
<!-- Last conversation context -->
<div class="context-box hidden" id="stt-context">
<div class="context-label">You said:</div>
<div class="context-text" id="stt-text"></div>
</div>
<div class="context-box hidden" id="tts-context">
<div class="context-label">Assistant said:</div>
<div class="context-text" id="tts-text"></div>
</div>
</div>
<!-- Main layout: Feed + Commands -->
<div class="row">
<div class="card" style="flex:2; min-width:340px;">
<div class="card-title">Event Feed <button class="clear-btn" id="clear-btn">clear</button></div>
<div class="feed" id="feed"></div>
</div>
<div class="card" style="flex:1; min-width:280px;">
<div class="card-title">Commands</div>
<div class="cmd-grid">
<button class="cmd-btn primary" onclick="send('start_listening')">Start listening</button>
<button class="cmd-btn danger" onclick="send('stop_pipeline')">■ Stop pipeline</button>
<button class="cmd-btn danger" onclick="send('stop_timer_ringing')">■ Stop timer ringing</button>
<button class="cmd-btn" onclick="send('mute_mic')">Mute microphone</button>
<button class="cmd-btn" onclick="send('unmute_mic')">Unmute microphone</button>
<button class="cmd-btn" onclick="send('volume_up')">Volume up</button>
<button class="cmd-btn" onclick="send('volume_down')">Volume down</button>
<button class="cmd-btn" onclick="send('pause_media_player')">⏸ Pause media player</button>
<button class="cmd-btn" onclick="send('resume_media_player')">▶ Resume media player</button>
<button class="cmd-btn" onclick="send('stop_media_player')">■ Stop media player</button>
<button class="cmd-btn" onclick="send('button_single_press')">◉ Single press</button>
<button class="cmd-btn" onclick="send('button_double_press')">◉ Double press</button>
<button class="cmd-btn" onclick="send('button_triple_press')">◉ Triple press</button>
<button class="cmd-btn" onclick="send('button_long_press')">◉ Long press</button>
</div>
<!-- Volume input -->
<div class="vol-input-row">
<button class="cmd-btn primary" style="flex: 0 0 auto;" onclick="sendSetVolume()">Set volume</button>
<input type="text" id="volume-input" placeholder="0.0–1.0" />
</div>
</div>
</div>
</div>
<script>
let ws = null;
let timerInterval = null;
let errorTimeout = null;
let currentTimer = null;
let previousState = null;
const feed = document.getElementById('feed');
const dot = document.getElementById('dot');
const label = document.getElementById('conn-label');
const badge = document.getElementById('state-badge');
const btn = document.getElementById('conn-btn');
const volBar = document.getElementById('vol-bar');
const metaVol = document.getElementById('meta-vol');
const metaMuted = document.getElementById('meta-muted');
const metaHa = document.getElementById('meta-ha');
const timerBox = document.getElementById('timer-box');
const timerName = document.getElementById('timer-name');
const timerCountdown = document.getElementById('timer-countdown');
const sttContext = document.getElementById('stt-context');
const sttText = document.getElementById('stt-text');
const ttsContext = document.getElementById('tts-context');
const ttsText = document.getElementById('tts-text');
const STATES = ['wake_word_detected','listening','thinking','tts_speaking','muted','pipeline_error','timer_ringing','media_player_playing'];
// LED Light entity registration constants
const LIGHT_NAME = 'Web Console LEDs';
const LIGHT_OBJECT_ID = 'web_console_leds';
const EFFECT_VOICE_ASSISTANT = 'Voice Assistant';
const EFFECT_LOOP = 'Loop';
const EFFECT_NONE = 'None';
function ts() {
const d = new Date();
return d.getHours().toString().padStart(2,'0') + ':' +
d.getMinutes().toString().padStart(2,'0') + ':' +
d.getSeconds().toString().padStart(2,'0');
}
function log(cls, eventStr, dataStr) {
const item = document.createElement('div');
item.className = 'feed-item ' + cls;
item.innerHTML =
'<span class="feed-ts">' + ts() + '</span>' +
'<div><div class="feed-event">' + eventStr + '</div>' +
(dataStr ? '<div class="feed-data">' + dataStr + '</div>' : '') +
'</div>';
feed.appendChild(item);
feed.scrollTop = feed.scrollHeight;
}
function setState(ev) {
// Temporary error display: show for 4 seconds then clear
if (ev === 'pipeline_error') {
badge.textContent = ev;
badge.className = 'state-badge ' + ev;
clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => {
badge.textContent = previousState || 'idle';
badge.className = 'state-badge ' + (previousState || 'idle');
}, 3000);
} else {
previousState = ev;
badge.textContent = ev;
badge.className = 'state-badge ' + (STATES.includes(ev) ? ev : '');
}
}
function setConnected(ok, err) {
dot.className = 'dot ' + (ok ? 'on' : err ? 'err' : 'off');
label.textContent = ok ? 'connected' : err ? 'error' : 'disconnected';
btn.textContent = ok ? 'disconnect' : 'connect';
}
function updateTimerDisplay() {
if (!currentTimer) {
clearInterval(timerInterval);
return;
}
const remaining = currentTimer.seconds_left;
const m = Math.floor(remaining / 60);
const s = remaining % 60;
timerCountdown.textContent = m + ':' + s.toString().padStart(2, '0');
timerName.textContent = currentTimer.name || 'timer';
timerBox.classList.remove('hidden');
// Only decrement if still counting (not ringing)
if (!currentTimer.ringing) {
currentTimer.seconds_left -= 1;
}
}
function startTimerCountdown() {
clearInterval(timerInterval);
timerInterval = setInterval(updateTimerDisplay, 1000);
updateTimerDisplay(); // Immediate update
}
function connect() {
const url = document.getElementById('url').value.trim();
log('system', '→ connecting to ' + url);
ws = new WebSocket(url);
ws.onopen = () => {
setConnected(true, false);
log('system', '→ connected');
// Register the LED Light entity with LVA so HA can control it.
// LVA treats repeat registrations for the same object_id as a
// no-op, so it's safe to send this on every connect.
ws.send(JSON.stringify({
command: 'register_light',
data: {
name: LIGHT_NAME,
object_id: LIGHT_OBJECT_ID,
effects: [EFFECT_VOICE_ASSISTANT, EFFECT_LOOP, EFFECT_NONE],
supports_rgb: true,
supports_brightness: true,
},
}));
log('sent', '↑ register_light', JSON.stringify({
name: LIGHT_NAME,
object_id: LIGHT_OBJECT_ID,
effects: [EFFECT_VOICE_ASSISTANT],
}));
// Register the Button entity with LVA so HA can receive button events.
ws.send(JSON.stringify({command: 'register_button'}));
log('sent', '↑ register_button');
};
ws.onclose = () => {
setConnected(false, false);
setState('idle');
log('system', '→ disconnected');
ws = null;
};
ws.onerror = () => {
setConnected(false, true);
log('err-item', '→ connection error — check host/port');
ws = null;
};
ws.onmessage = (e) => {
let msg;
try { msg = JSON.parse(e.data); } catch { log('recv', e.data); return; }
const ev = msg.event || '?';
const data = msg.data;
let dataStr = data ? JSON.stringify(data) : '';
if (ev === 'snapshot') {
metaVol.textContent = data.volume !== undefined ? (data.volume * 100).toFixed(0) + '%' : '—';
metaMuted.textContent = data.muted !== undefined ? (data.muted ? 'yes' : 'no') : '—';
metaHa.textContent = data.ha_connected !== undefined ? (data.ha_connected ? 'yes' : 'no') : '—';
volBar.style.width = (data.volume !== undefined ? data.volume * 100 : 0).toFixed(0) + '%';
// Restore last conversation context
if (data.last_stt_text) {
sttText.textContent = data.last_stt_text;
sttContext.classList.remove('hidden');
}
if (data.last_tts_text) {
ttsText.textContent = data.last_tts_text;
ttsContext.classList.remove('hidden');
}
log('recv', 'snapshot', dataStr);
return;
}
if (ev === 'volume_changed' && data) {
const pct = (data.volume * 100).toFixed(0);
metaVol.textContent = pct + '%';
volBar.style.width = pct + '%';
}
if (ev === 'volume_muted' && data) {
metaMuted.textContent = data.muted ? 'yes' : 'no';
}
if (ev === 'zeroconf' && data) {
metaHa.textContent = data.status === 'connected' ? 'yes' : 'connecting…';
}
if (ev === 'stt_text' && data) {
sttText.textContent = data.text;
sttContext.classList.remove('hidden');
}
if (ev === 'tts_text' && data) {
ttsText.textContent = data.text;
ttsContext.classList.remove('hidden');
}
if ((ev === 'timer_ticking' || ev === 'timer_updated') && data) {
currentTimer = {
id: data.id,
name: data.name,
seconds_left: data.seconds_left,
ringing: false
};
startTimerCountdown();
}
if (ev === 'timer_ringing' && data) {
if (currentTimer) {
currentTimer.ringing = true;
timerName.textContent = (data && data.name) ? data.name + ' 🔔' : 'ringing';
timerCountdown.textContent = '0:00';
timerBox.classList.remove('hidden');
}
}
setState(ev);
log('recv', ev, dataStr);
};
}
function disconnect() {
if (ws) { ws.close(); ws = null; }
}
btn.onclick = () => ws ? disconnect() : connect();
document.getElementById('clear-btn').onclick = () => { feed.innerHTML = ''; };
function send(command, data) {
if (!ws || ws.readyState !== 1) {
log('err-item', '✗ not connected — connect first');
return;
}
const msg = { command };
if (data) msg.data = data;
ws.send(JSON.stringify(msg));
log('sent', '↑ ' + command, data ? JSON.stringify(data) : '');
}
function sendSetVolume() {
const volumeInput = document.getElementById('volume-input');
const volume = parseFloat(volumeInput.value);
if (isNaN(volume)) {
log('err-item', '✗ invalid volume — enter 0.0–1.0');
return;
}
const clamped = Math.max(0.0, Math.min(1.0, volume));
send('set_volume', { volume: clamped });
volumeInput.value = ''; // Clear after sending
}
</script>
+332
View File
@@ -0,0 +1,332 @@
# ReSpeaker 2-Mic Pi HAT
<img src="image.png" width="300">
Peripheral controller for the [ReSpeaker 2-Mic Pi HAT](https://wiki.seeedstudio.com/ReSpeaker_2_Mics_Pi_HAT/) by Seeed Studio, running alongside the Linux Voice Assistant (LVA) container.
The controller runs as a separate Docker container on the same Raspberry Pi. It connects to LVA's peripheral WebSocket API, drives the 3 APA102 RGB LEDs, and maps the single onboard button to context-aware LVA commands with multipress support.
---
## Hardware
| Component | Details |
|---|---|
| Microphone array | 2 × MEMS mics — MIC_L (left), MIC_R (right) — WM8960 codec, I2S |
| LED strip | 3 × APA102 RGB LEDs in a row |
| Speaker output | JST 2.0 connector + 3.5 mm audio jack (WM8960 codec) |
| Button | 1 × onboard tactile button (GPIO 17) |
| Interface | Raspberry Pi 40-pin HAT connector |
### Compatible hardware
| Board | Notes |
|---|---|
| Raspberry Pi Zero 2 W | Recommended for compact builds |
| Raspberry Pi 3 B / B+ | Fully supported |
| Raspberry Pi 4 B | Fully supported |
| Raspberry Pi 5 | Requires seeed-voicecard driver compatible with kernel 6.6+ — see Step 1 |
---
## GPIO pin mapping
### LED strip (APA102)
| Signal | GPIO (BCM) | Notes |
|---|---|---|
| MOSI (data) | GPIO 10 | SPI0 MOSI |
| SCLK (clock) | GPIO 11 | SPI0 SCLK |
| CE | GPIO 8 (CE0) | /dev/spidev0.0 |
### Button
| Function | GPIO (BCM) | Notes |
|---|---|---|
| Context action | GPIO 17 | Active low, internal pull-up enabled |
---
## LED positions
```
┌──────────────────────────────────────┐
│ [LED 0] [LED 1] [LED 2] │
│ MIC_L centre MIC_R │
└──────────────────────────────────────┘
```
LED 0 sits above MIC_L and LED 2 sits above MIC_R. When the microphone is muted, these two LEDs turn red to visually indicate the mic positions are disabled. The centre LED (1) is used for general status animations.
---
## LED animations
| LVA state | Animation | LEDs |
|---|---|---|
| Not ready / no HA connection | Dim red pulse | All 3 |
| Idle | Solid user color when the LED light is on, else off | All 3 |
| Wake word detected | Flash (×2) in user color | All 3 |
| Listening | Chase (bouncing left ↔ right) in user color | 1 at a time |
| Thinking | Yellow pulse | All 3 |
| TTS speaking | Green breathe (slow sine) | All 3 |
| **Muted** | **Solid red** | **LEDs 0 & 2 only (mic positions)** |
| Error | Red flash (×3), then off | All 3 |
| **Timer ringing** | **Blue flash (repeating)** | **All 3** |
| Timer ticking | Dim cyan, brightness ∝ time left | All 3 |
| Media playing | Dim green steady | All 3 |
"User color" comes from the Home Assistant Light entity described below (default: HAVPE-style blue). HA brightness scales every animation in this table. Semantic colors (Thinking yellow, Speaking green, Muted red, Timer cyan/blue) are hardcoded so they remain recognisable across user customisation.
---
## Home Assistant Light entity
On connect the controller registers a Light entity with LVA, which appears in Home Assistant as `light.<satellite>_leds`. It defaults off, matching the Voice PE LED Ring; turn it on for a solid idle glow and set its RGB color and brightness from the device page.
### Effects
| Effect | Behaviour |
|---|---|
| `Voice Assistant` (default) | Run the pipeline animations from the table above. Wake word and Listening are tinted with the HA color; brightness scales every animation. |
Like the Home Assistant Voice PE, this example exposes a single Voice Assistant effect: the pipeline animations always run and can't be switched off from HA. The peripheral protocol itself accepts any number of effects, so your own integration is free to declare more (a color loop, a static accent, and so on) — see the [peripheral API docs](../../docs/peripheral_api.md).
### Brightness, on/off, and color
Matching the HA Voice PE LED Ring, the Light defaults off, so the LEDs stay dark while idle until you turn it on; once on, idle shows the solid color, and turning it off again just removes that idle glow. The voice animations always run either way (on/off only gates the idle glow, it does not disable them), so the effect can't be switched off completely. Brightness scales linearly across every animation, and RGB color is the solid idle color and tints the Wake word / Listening animations.
---
## Button behaviour
The single onboard button supports both single-press context actions and multi-press gestures:
### Single press (< 1000 ms hold time)
Context-aware command based on current state, mirroring the Home Assistant Voice PE centre button priority:
| Current state | Command sent |
|---|---|
| Timer ringing | `stop_timer_ringing` |
| Wake word / listening / thinking / TTS speaking | `stop_pipeline` |
| Music / media playing | `stop_media_player` |
| Any other (idle) | `start_listening` |
### Multi-press gestures
Detected via press timing within a detection window (250 ms between releases, matching the HAVPE centre button):
| Gesture | Timing | Command sent |
|---|---|---|
| **Double press** | 2 presses < 250 ms apart | `button_double_press` |
| **Triple press** | 3 presses < 250 ms apart | `button_triple_press` |
| **Long press** | Single press held > 1000 ms | `button_long_press` |
Each gesture plays its own short confirmation sound on the satellite speaker so the user knows it was detected. Sound paths are configurable via `--button-double-press-sound`, `--button-triple-press-sound`, and `--button-long-press-sound`.
Multi-press commands are useful for triggering custom Home Assistant automations. For example:
- Double press → start a specific routine or mode
- Triple press → access a menu or configuration option
- Long press → toggle do-not-disturb or night mode
These commands are exposed as button press events to Home Assistant (via the registered button sensor), allowing you to create custom automations via `button_press_event` triggers.
---
## Installation
### Step 1 — Install the seeed-voicecard audio driver
> **This must be done on the host Raspberry Pi, not inside Docker.**
```bash
git clone https://github.com/respeaker/seeed-voicecard
cd seeed-voicecard
sudo ./install.sh
sudo reboot
```
After rebooting, verify the microphone and speaker appear:
```bash
arecord -l
# Should list: seeded-2mic-voicecard
aplay -l
# Should list: seeded-2mic-voicecard
```
> **Note:** The seeed-voicecard installer also enables SPI automatically. Check `/boot/firmware/config.txt` after running `install.sh` before proceeding to step 2.
> **Pi 5 note:** The standard seeed-voicecard repository may not support the Pi 5 kernel (6.6+). Check the [seeed-voicecard GitHub issues](https://github.com/respeaker/seeed-voicecard/issues) for a compatible branch or fork before proceeding.
### Step 2 — Enable SPI in config.txt
If `dtparam=spi=on` is not already present in `/boot/firmware/config.txt`, add it:
```ini
dtparam=spi=on
# Also disable onboard audio if you see conflicts:
# dtparam=audio=on ← comment this out
```
Reboot and verify:
```bash
ls /dev/spidev*
# Should show: /dev/spidev0.0
```
### Step 3 — Add user to GPIO and SPI groups
```bash
sudo usermod -aG gpio,spi $USER
```
Log out and back in. Check your UID:
```bash
id -u $USER
```
If it is not `1000`, update the `user:` field in `compose.yml` to match.
> **Pi 5 note:** On Pi 5, GPIO is exposed as `/dev/gpiochip4` not `/dev/gpiochip0`. Update the `devices` mapping in `compose.yml`:
> ```yaml
> devices:
> - /dev/spidev0.0:/dev/spidev0.0
> - /dev/gpiochip4:/dev/gpiochip4
> ```
### Step 4 — File structure
```
ReSpeaker 2mic HAT/
├── Dockerfile
├── compose.yml
├── requirements.txt
└── respeaker_2mic_hat.py
```
### Step 5 — Build and start
#### Option A — Run with Docker Compose (recommended)
```bash
docker compose up -d
```
Check logs:
```bash
docker compose logs -f
```
#### Option B — Run directly with Python
```bash
pip install -r requirements.txt
python respeaker_2mic_hat.py --host localhost --port 6055
```
---
## Using the speaker output with LVA
The WM8960 codec on the HAT also drives the speaker. After installing seeed-voicecard, pass the device name to LVA:
```bash
--audio-output-device "seeed-2mic-voicecard"
```
Or set the environment variable in LVA's compose file:
```yaml
environment:
- AUDIO_OUTPUT_DEVICE=seeed-2mic-voicecard
```
---
## Configuration
All configuration is at the top of `respeaker_2mic_hat.py`:
```python
# LVA connection
DEFAULT_LVA_HOST = "localhost"
DEFAULT_LVA_PORT = 6055
# APA102 SPI
SPI_BUS = 0
SPI_DEVICE = 0 # /dev/spidev0.0
SPI_SPEED_HZ = 8_000_000
LED_COUNT = 3
LED_BRIGHTNESS = 0.6 # 0.0–1.0
# GPIO button
BTN_ACTION = 17
BTN_DEBOUNCE_MS = 150
# Button multipress timing
MULTIPRESS_TIMEOUT_MS = 500 # Time window between presses (ms)
LONG_PRESS_MS = 1000 # Duration to detect long press (ms)
```
### Command-line arguments
| Argument | Default | Description |
|---|---|---|
| `--host` | `localhost` | LVA container hostname or IP |
| `--port` | `6055` | LVA peripheral API port |
| `--debug` | off | Enable verbose debug logging |
---
## Drivers summary
| Component | Driver needed | Where to install | How |
|---|---|---|---|
| Microphones + speaker (WM8960) | **seeed-voicecard** | **Host Pi** | `git clone` + `sudo ./install.sh`, then reboot |
| LED strip (APA102) | SPI overlay | **Host Pi** | `dtparam=spi=on` in `config.txt`, then reboot |
| Button | None | — | `gpiozero` + `lgpio` installed inside container via pip |
---
## Troubleshooting
### Microphone or speaker not detected by LVA
1. Confirm `arecord -l` and `aplay -l` show `seeed-2mic-voicecard` on the host.
2. If not, re-run `sudo ./install.sh` and reboot.
3. On Raspberry Pi 5 with kernel 6.6+, check the [seeed-voicecard GitHub issues](https://github.com/respeaker/seeed-voicecard/issues) for a compatible branch.
### LEDs do not light up
1. Confirm `dtparam=spi=on` is in `/boot/firmware/config.txt` and the Pi has been rebooted.
2. Check `/dev/spidev0.0` exists: `ls /dev/spidev*`.
3. Confirm the container user is in the `spi` group: `groups $USER`.
4. Run with `--debug` and look for `APA102 SPI driver opened` in the logs. If `spidev not found` appears, the Python package failed to install — rebuild the image.
### Button does not respond
1. Confirm `/dev/gpiochip0` (or `/dev/gpiochip4` on Pi 5) is mapped in the compose `devices` section.
2. Run with `--debug` — each button press logs `Button → <command>`.
### Pi 5 — button not working
On Pi 5, GPIO is on `/dev/gpiochip4` not `/dev/gpiochip0`. Update `compose.yml`:
```yaml
devices:
- /dev/spidev0.0:/dev/spidev0.0
- /dev/gpiochip4:/dev/gpiochip4
```
### LVA not reachable
1. Confirm LVA is running and `--disable-peripheral-api` was not passed.
2. With `network_mode: host`, `localhost` resolves to the Pi itself.
3. Check port 6055 is not blocked: `nc -zv localhost 6055`.
+65
View File
@@ -0,0 +1,65 @@
FROM python:3.13-slim-trixie
ENV LANG=C.UTF-8
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
LABEL \
org.opencontainers.image.title="ReSpeaker 2-Mic Pi HAT Peripheral Controller" \
org.opencontainers.image.description="LED and button controller for ReSpeaker 2-Mic Pi HAT, communicating with Linux Voice Assistant" \
org.opencontainers.image.source="https://github.com/OHF-Voice/linux-voice-assistant"
# ----------------------------------------------------------------------------
# System packages
#
# The lgpio Python package wraps the system liblgpio C library, which is
# not in Debian's repos (Raspberry Pi OS ships it, upstream Debian does
# not). On aarch64 with no prebuilt wheel we compile the C library from
# source and then build the Python bindings against it.
#
# build-essential compiles the lgpio C library, spidev, and the
# Python lgpio extension
# python3-dev Python C headers for the Python lgpio extension
# swig generates the Python lgpio bindings at build time
# git clones the lgpio C library source
# ----------------------------------------------------------------------------
RUN apt-get update && \
apt-get install --yes --no-install-recommends \
build-essential \
python3-dev \
swig \
git \
ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Build and install the lgpio C library so the lgpio Python package can
# link against it. Pinned to a tagged release (v0.2.2) for reproducibility.
RUN git clone --depth 1 --branch v0.2.2 https://github.com/joan2937/lg.git /tmp/lg && \
cd /tmp/lg && make && make install && ldconfig && \
rm -rf /tmp/lg
WORKDIR /app
# ----------------------------------------------------------------------------
# Python dependencies
#
# websockets – WebSocket client for LVA peripheral API
# spidev – low-level SPI access for APA102 LEDs
# gpiozero – GPIO button input (Pi 3/4/5 compatible)
# lgpio – gpiozero backend; works on Pi 3/4/5 including Pi 5
# ----------------------------------------------------------------------------
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY respeaker_2mic_hat.py ./
# Make /app writable by the runtime user. lgpio creates notification
# files (.lgd-nfy-N) in the process working directory at startup, and
# compose runs this container as a non root user for device access.
# The default root owned /app blocks lgpio from initialising, which
# makes gpiozero fall through every pin factory.
RUN chmod a+w /app
ENTRYPOINT ["python3", "respeaker_2mic_hat.py"]
CMD []
+133
View File
@@ -0,0 +1,133 @@
---
# ReSpeaker 2-Mic Pi HAT – peripheral controller
#
# Runs alongside the linux-voice-assistant container.
# Connects to LVA's peripheral WebSocket API (port 6055) and drives the
# 3 APA102 RGB LEDs and the single onboard button on the ReSpeaker 2-Mic HAT.
#
# See hardware notes at the bottom of this file before first run.
services:
respeaker2mic-peripheral:
build: .
image: ohf/respeaker2mic-peripheral:local
container_name: respeaker2mic-peripheral
restart: unless-stopped
# -----------------------------------------------------------------------
# Device access
#
# /dev/spidev0.0 – SPI0 CE0, used by spidev for the APA102 LED strip
# /dev/gpiomem – allows RPi.GPIO to access the button pin without root
# -----------------------------------------------------------------------
devices:
- /dev/spidev0.0:/dev/spidev0.0
- /dev/gpiomem:/dev/gpiomem
# -----------------------------------------------------------------------
# Run as normal user — no privileged mode required.
# Update UID:GID if your host user is not 1000:1000.
# -----------------------------------------------------------------------
user: "1000:1000"
# -----------------------------------------------------------------------
# SPI / GPIO group membership
#
# docker compose's group_add resolves names against /etc/group inside
# the container, but the python:slim base does not define gpio or
# spi. Pass numeric GIDs instead so they match the host's groups.
# The defaults below match a stock Raspberry Pi OS install. Check
# yours with "getent group gpio spi" and override in .env if they
# differ.
# -----------------------------------------------------------------------
group_add:
- "${GPIO_GID:-986}"
- "${SPI_GID:-989}"
environment:
- PYTHONUNBUFFERED=1
# -----------------------------------------------------------------------
# Host network so "localhost" reaches the LVA container directly.
# Change --host if LVA is on a different machine.
# -----------------------------------------------------------------------
network_mode: host
command:
- "--host"
- "localhost"
- "--port"
- "6055"
# Uncomment for verbose logging during setup:
# - "--debug"
healthcheck:
test: ["CMD", "pgrep", "-f", "respeaker_2mic_hat.py"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# =============================================================================
# HARDWARE NOTES
# =============================================================================
#
# 1. AUDIO DRIVER — seeed-voicecard (REQUIRED for microphone input)
#
# The ReSpeaker 2-Mic HAT uses a WM8960 I2S codec for both the
# microphones and the speaker output. Install the driver on the HOST Pi:
#
# git clone https://github.com/respeaker/seeed-voicecard
# cd seeed-voicecard
# sudo ./install.sh
# sudo reboot
#
# Verify after reboot:
# arecord -l
# # Should list: seeed-2mic-voicecard
#
# The seeed-voicecard installer also enables SPI automatically.
# Check /boot/firmware/config.txt before proceeding to step 2.
#
# 2. SPI OVERLAY (REQUIRED for LED ring)
#
# The APA102 LEDs use SPI0 CE0 (/dev/spidev0.0).
# If not already added by seeed-voicecard, add to /boot/firmware/config.txt:
#
# dtparam=spi=on
#
# Then reboot and verify:
# ls /dev/spidev* # Should show /dev/spidev0.0
#
# 3. GPIO GROUP (REQUIRED for button input)
#
# Add the host user to the gpio and spi groups:
#
# sudo usermod -aG gpio,spi $USER
#
# Then log out and back in. Check your UID:
# id -u $USER # Update user: field above if not 1000
#
# 4. SPEAKER OUTPUT
#
# The WM8960 codec also drives the JST 2.0 speaker connector and the
# 3.5 mm audio jack. After installing seeed-voicecard, the speaker
# appears as an ALSA playback device. Pass it to LVA via:
#
# --audio-output-device "seeed-2mic-voicecard"
#
# 5. ONBOARD AUDIO CONFLICT
#
# The ReSpeaker 2-Mic HAT replaces the Pi's onboard audio.
# Disable it in /boot/firmware/config.txt if you see conflicts:
#
# # dtparam=audio=on ← comment this out
#
# =============================================================================
Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

@@ -0,0 +1,4 @@
websockets>=12.0
spidev>=3.6
gpiozero>=2.0
lgpio>=0.2
File diff suppressed because it is too large. Load diff
+238
View File
@@ -0,0 +1,238 @@
# ReSpeaker 4-Mic Array HAT
<img src="image.png" width="300">
Peripheral controller for the [ReSpeaker 4-Mic Array](https://wiki.seeedstudio.com/ReSpeaker_4_Mic_Array_for_Raspberry_Pi/) by Seeed Studio, running alongside the Linux Voice Assistant (LVA) container.
The controller runs as a separate Docker container on the same Raspberry Pi. It connects to LVA's peripheral WebSocket API and drives the 12-LED APA102 ring with animations that mirror the [Home Assistant Voice PE](https://www.home-assistant.io/voice_control/voice_remote_local_assistant/) LED behaviour.
---
## Hardware
| Component | Details |
|---|---|
| Microphone array | 4 × MEMS mics at board corners (AC108 codec, I2S) |
| LED ring | 12 × APA102 RGB LEDs |
| Interface | Raspberry Pi 40-pin HAT connector |
### Compatible hardware
| Board | Notes |
|---|---|
| Raspberry Pi Zero 2 W | Recommended for compact builds |
| Raspberry Pi 3 B / B+ | Fully supported |
| Raspberry Pi 4 B | Fully supported |
| Raspberry Pi 5 | Requires seeed-voicecard driver compatible with kernel 6.6+ — see Step 1 |
---
## GPIO pin mapping
### LED ring (APA102)
| Signal | GPIO (BCM) | Notes |
|---|---|---|
| MOSI (data) | GPIO 10 | SPI0 MOSI |
| SCLK (clock) | GPIO 11 | SPI0 SCLK |
| CE (bus select) | GPIO 7 (CE1) | /dev/spidev0.1 |
---
## LED ring animations
All animations mirror the Home Assistant Voice PE ESPHome firmware exactly.
| LVA state | Animation | Description |
|---|---|---|
| No HA connection / not ready | Red twinkle | Random red sparkle across all LEDs |
| Idle | Off | All LEDs off |
| Wake word detected | Slow clockwise spin | Two trailing arcs at opposing positions |
| Listening | Fast clockwise spin | Same dual-arc pattern at 50 ms interval |
| Thinking | Pulsing pair | Two opposing LEDs fade in and out |
| TTS speaking | Anticlockwise spin | Dual-arc spin in reverse direction |
| Muted | Solid ring + red indicators | Full ring on; red at LEDs 1, 4, 7, 10 (mic corners) |
| Error | Red pulse | All LEDs red, pulsing |
| Timer ticking | Countdown arc | Arc proportional to `seconds_left / total_seconds` |
| Timer ringing | Pulse + optional red | Full ring pulsing; red at corners if muted |
"User color" comes from the Home Assistant Light entity described below (default: HAVPE-style blue). HA brightness scales every animation in this table. Semantic colors (Waiting/Listening cyan spin, Thinking pulse, Replying anticlockwise, Muted solid + red, Timer pulse/arc, Error red pulse) are hardcoded so they remain recognisable across user customisation.
### Mic indicator positions
The four mics sit at the corners of the square board. On the 12-LED ring (30° per step), the corners land at 45°, 135°, 225°, 315° → **LEDs 1, 4, 7, 10**.
---
## Home Assistant Light entity
On connect the controller registers a Light entity with LVA, which appears in Home Assistant as `light.leds`. It defaults off, matching the Voice PE LED Ring; turn it on for a solid idle glow and set its RGB color and brightness from the device page.
### Effects
| Effect | Behaviour |
|---|---|
| `Voice Assistant` (default) | Run the pipeline animations from the table above. Waiting/Listening animations are tinted with the HA color; brightness scales every animation. |
Like the Home Assistant Voice PE, this example exposes a single Voice Assistant effect: the pipeline animations always run and can't be switched off from HA. The peripheral protocol itself accepts any number of effects, so your own integration is free to declare more (a color loop, a static accent, and so on) — see the [peripheral API docs](../../docs/peripheral_api.md).
### Brightness, on/off, and color
Matching the HA Voice PE LED Ring, the Light defaults off, so the LEDs stay dark while idle until you turn it on; once on, idle shows the solid color, and turning it off again just removes that idle glow. The voice animations always run either way (on/off only gates the idle glow, it does not disable them), so the effect can't be switched off completely. Brightness scales linearly across every animation, and RGB color is the solid idle color and tints the Waiting/Listening animations.
---
## Installation
### Step 1 — Install the seeed-voicecard audio driver
> **This must be done on the host Raspberry Pi, not inside Docker.**
```bash
git clone https://github.com/respeaker/seeed-voicecard
cd seeed-voicecard
sudo ./install.sh
sudo reboot
```
After rebooting, verify the microphone appears:
```bash
arecord -l
# Expected: card X: seeed4micvoicec [seeed-4mic-voicecard]
```
> **Note:** The seeed-voicecard installer modifies `/boot/firmware/config.txt` automatically. Check the file after running `install.sh` — SPI may already be enabled.
> **Pi 5 note:** The standard seeed-voicecard repository may not support the Pi 5 kernel (6.6+). Check the [seeed-voicecard GitHub issues](https://github.com/respeaker/seeed-voicecard/issues) for a compatible branch or fork before proceeding.
### Step 2 — Enable SPI in config.txt
If the seeed-voicecard installer did not already enable SPI, add to `/boot/firmware/config.txt`:
```ini
dtparam=spi=on
```
Reboot and verify:
```bash
ls /dev/spidev*
# Should show: /dev/spidev0.0 /dev/spidev0.1
```
### Step 3 — Add user to GPIO and SPI groups
```bash
sudo usermod -aG gpio,spi $USER
```
Log out and back in. Check your UID:
```bash
id -u $USER
```
If it is not `1000`, update the `user:` field in `compose.yml` to match.
> **Pi 5 note:** On Pi 5, GPIO is exposed as `/dev/gpiochip4` not `/dev/gpiochip0`. Update the `devices` mapping in `compose.yml`:
> ```yaml
> devices:
> - /dev/spidev0.1:/dev/spidev0.1
> - /dev/gpiochip4:/dev/gpiochip4
> ```
### Step 4 — File structure
```
ReSpeaker 4mic HAT/
├── Dockerfile
├── compose.yml
├── requirements.txt
└── respeaker_4mic_hat.py
```
### Step 5 — Build and start
#### Option A — Run with Docker Compose (recommended)
```bash
docker compose up -d
```
Check logs:
```bash
docker compose logs -f
```
#### Option B — Run directly with Python
```bash
pip install -r requirements.txt
python respeaker_4mic_hat.py --host localhost --port 6055
```
---
## Configuration
All configuration is at the top of `respeaker_4mic_hat.py`:
```python
# LVA connection
DEFAULT_LVA_HOST = "localhost"
DEFAULT_LVA_PORT = 6055
# APA102 LED ring
LED_COUNT = 12
SPI_BUS = 0
SPI_DEVICE = 1 # 1 = CE1 (/dev/spidev0.1), 0 = CE0 (/dev/spidev0.0)
LED_BRIGHTNESS = 10 # 0–31 (APA102 global brightness register)
# Default ring colour (R, G, B) — matches HA Voice PE default
DEFAULT_R, DEFAULT_G, DEFAULT_B = 24, 187, 242
```
### Command-line arguments
| Argument | Default | Description |
|---|---|---|
| `--host` | `localhost` | LVA container hostname or IP |
| `--port` | `6055` | LVA peripheral API port |
| `--debug` | off | Enable verbose debug logging |
---
## Drivers summary
| Component | Driver needed | Where to install | How |
|---|---|---|---|
| Microphone array (AC108) | **seeed-voicecard** | **Host Pi** | `git clone` + `sudo ./install.sh`, then reboot |
| LED ring (APA102) | SPI overlay | **Host Pi** | `dtparam=spi=on` in `config.txt`, then reboot |
---
## Troubleshooting
### Microphone not detected by LVA
1. Confirm `arecord -l` shows `seeed-4mic-voicecard` on the host.
2. If not, re-run `sudo ./install.sh` from the seeed-voicecard repository and reboot.
3. On Raspberry Pi 5 with kernel 6.6+, check the [seeed-voicecard GitHub issues](https://github.com/respeaker/seeed-voicecard/issues) for a compatible branch.
### LEDs do not light up
1. Confirm `dtparam=spi=on` is in `/boot/firmware/config.txt` and the Pi has been rebooted.
2. Check `/dev/spidev0.1` exists: `ls /dev/spidev*`. If only `spidev0.0` exists, change `SPI_DEVICE = 0` in the script and update the device mapping in `compose.yml` to `/dev/spidev0.0`.
3. Confirm the container user is in the `spi` group: `groups $USER`.
4. Run with `--debug` and look for `APA102 LED ring initialised` in the logs.
### SPI and seeed-voicecard conflict
The seeed-voicecard driver uses I2S — it does **not** use SPI — so there is no conflict with the APA102 LED ring. Both can run simultaneously without any special configuration.
### LVA not reachable
1. Confirm LVA is running and port 6055 is open: `nc -zv localhost 6055`.
2. With `network_mode: host`, `localhost` resolves to the Pi itself.
3. If LVA runs in a separate Docker network, use its container IP or service name as `--host`.
+45
View File
@@ -0,0 +1,45 @@
FROM python:3.13-slim-bookworm
ENV LANG=C.UTF-8
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
LABEL \
org.opencontainers.image.title="ReSpeaker 4-Mic Array Peripheral Controller" \
org.opencontainers.image.description="LED ring and button controller for ReSpeaker 4-Mic Array HAT, communicating with Linux Voice Assistant" \
org.opencontainers.image.source="https://github.com/OHF-Voice/linux-voice-assistant"
# ----------------------------------------------------------------------------
# System packages
#
# build-essential – compiles spidev / apa102-pi C extensions
# python3-dev – Python C headers
# git – some pip packages pull from git during install
# ----------------------------------------------------------------------------
RUN apt-get update && \
apt-get install --yes --no-install-recommends \
build-essential \
python3-dev \
git \
ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# ----------------------------------------------------------------------------
# Python dependencies
#
# websockets – WebSocket client for LVA peripheral API
# apa102-pi – APA102 LED ring driver (SPI)
# spidev – low-level SPI access (used by apa102-pi)
# gpiozero – GPIO button input (Pi 3/4/5 compatible)
# lgpio – gpiozero backend; works on Pi 3/4/5 including Pi 5
# ----------------------------------------------------------------------------
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY respeaker_4mic_hat.py ./
ENTRYPOINT ["python3", "respeaker_4mic_hat.py"]
CMD []
+142
View File
@@ -0,0 +1,142 @@
---
# ReSpeaker 4-Mic Array HAT – peripheral controller
#
# Runs alongside the linux-voice-assistant container.
# Connects to LVA's peripheral WebSocket API (port 6055) and drives the
#
# Key differences from Satellite 1 HAT:
# - LEDs use SPI (not PWM/DMA), so no privileged mode is required
# - Audio uses I2S via the seeed-voicecard driver — see hardware notes
services:
respeaker4mic-peripheral:
build: .
image: lva/respeaker-4mic-hat:local
container_name: respeaker-4mic-hat
restart: unless-stopped
# -----------------------------------------------------------------------
# Device access
#
# SPI access for APA102 LEDs:
# /dev/spidev0.1 – SPI0 CE1 (used by apa102-pi for the LED ring)
# Change to /dev/spidev0.0 if you wired CE0 instead.
# -----------------------------------------------------------------------
devices:
- /dev/spidev0.1:/dev/spidev0.1
- /dev/gpiomem:/dev/gpiomem
# -----------------------------------------------------------------------
# Run as the gpio/spi group member — no root required.
# The host user must be in the gpio and spi groups.
# (see hardware notes below)
# -----------------------------------------------------------------------
user: "1000:1000"
group_add:
- gpio
- spi
# -----------------------------------------------------------------------
# Environment
# -----------------------------------------------------------------------
environment:
- PYTHONUNBUFFERED=1
# -----------------------------------------------------------------------
# Network — host mode so "localhost" reaches the LVA container directly
# -----------------------------------------------------------------------
network_mode: host
# -----------------------------------------------------------------------
# Command-line arguments
# -----------------------------------------------------------------------
command:
- "--host"
- "localhost" # Change to LVA container IP if on a different host
- "--port"
- "6055"
# Uncomment for verbose logging during setup / troubleshooting:
# - "--debug"
# -----------------------------------------------------------------------
# Health check
# -----------------------------------------------------------------------
healthcheck:
test: ["CMD", "pgrep", "-f", "respeaker4mic_peripheral.py"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# =============================================================================
# HARDWARE NOTES
# =============================================================================
#
# 1. AUDIO DRIVER — seeed-voicecard (REQUIRED for microphone input)
#
# The ReSpeaker 4-Mic Array uses an AC108 I2S codec for the microphones.
# The standard Raspberry Pi kernel does not include this driver.
# Install it on the HOST Raspberry Pi (not inside Docker):
#
# git clone https://github.com/respeaker/seeed-voicecard
# cd seeed-voicecard
# sudo ./install.sh
# sudo reboot
#
# After reboot, the microphone array should appear as an ALSA device.
# LVA will automatically detect it via soundcard. Verify with:
#
# arecord -l
# # Should list: seeed-4mic-voicecard
#
# Note: the seeed-voicecard installer adds its own dtoverlay to
# /boot/firmware/config.txt. Do not add it manually.
#
# 2. SPI OVERLAY (REQUIRED for LED ring)
#
# The APA102 LED ring uses SPI. Enable it in /boot/firmware/config.txt:
#
# dtparam=spi=on
#
# Then reboot. Verify SPI devices are present:
#
# ls /dev/spidev*
# # Should show: /dev/spidev0.0 /dev/spidev0.1
#
# The seeed-voicecard installer sometimes enables SPI automatically.
# Check /boot/firmware/config.txt after running install.sh before
# adding it manually.
#
# 3. GPIO GROUP (REQUIRED for button input)
#
# Add the host user to the gpio and spi groups:
#
# sudo usermod -aG gpio,spi $USER
#
# Log out and back in. Also update the user field in this compose file
# if your UID is not 1000:
#
# id -u $USER # check your UID
#
# 4. SPI CONFLICT WITH seeed-voicecard
#
# The seeed-voicecard driver uses I2S (not SPI), so there is no conflict
# with the APA102 SPI LED ring. Both can run simultaneously.
#
# 5. SPI CE SELECTION
#
# The APA102 ring does not use a chip enable (CE) line electrically, but
# the apa102-pi library requires CE0 or CE1 to open the SPI bus.
# This compose file maps /dev/spidev0.1 (CE1). If you prefer CE0, change:
# - device: /dev/spidev0.0:/dev/spidev0.0
# - SPI_DEVICE = 0 in respeaker_4mic_hat.py
#
# =============================================================================
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

@@ -0,0 +1,5 @@
websockets>=12.0
apa102-pi>=2.0.0
spidev>=3.6
gpiozero>=2.0
lgpio>=0.2
@@ -0,0 +1,796 @@
#!/usr/bin/env python3
"""
ReSpeaker 4-Mic Array HAT – Linux Voice Assistant peripheral controller.
Mirrors the Home Assistant Voice PE LED ring animations and maps four
external GPIO buttons to LVA peripheral API commands.
Hardware layout (ReSpeaker 4-Mic HAT on Raspberry Pi)
------------------------------------------------------
LED ring : 12 × APA102 RGB LEDs → SPI0 (MOSI GPIO 10, SCLK GPIO 11)
Microphones: 4 × MEMS mics → I2S (AC108 codec, seeed-voicecard driver)
The ReSpeaker 4-Mic HAT has no onboard buttons. Connect your own momentary
tactile switches between the GPIO pins above and GND.
Install dependencies
---------------------
pip install websockets apa102-pi gpiozero lgpio
Note: gpiozero with the lgpio backend works on Pi 3/4/5 without RPi.GPIO.
Requires /dev/gpiochip0 (Pi 1–4) or /dev/gpiochip4 (Pi 5).
Enable SPI on the Pi (required for APA102 LEDs):
Add dtparam=spi=on to /boot/firmware/config.txt and reboot.
Run
---
python3 respeaker_4mic_hat.py
python3 respeaker_4mic_hat.py --host 127.0.0.1 --port 6055 --debug
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import math
import random
import signal
import sys
import threading
import time
from enum import Enum
from typing import Optional, Tuple
# ---------------------------------------------------------------------------
# Hardware dependencies — gracefully stubbed when not on real Pi hardware
# ---------------------------------------------------------------------------
try:
from gpiozero import Button as GPIOButton # type: ignore[import]
_HAS_GPIO = True
except ImportError:
_HAS_GPIO = False
logging.warning("gpiozero not found – button input disabled")
try:
from apa102_pi.driver import apa102 as _apa102_driver # type: ignore[import]
_HAS_APA102 = True
except ImportError:
_HAS_APA102 = False
logging.warning("apa102-pi not found – LED output disabled")
try:
import websockets # type: ignore[import]
except ImportError:
sys.exit("websockets not installed. Run: pip install websockets")
# ===========================================================================
# Configuration – edit these to match your wiring
# ===========================================================================
DEFAULT_LVA_HOST = "localhost"
DEFAULT_LVA_PORT = 6055
# HA Light entity registered with LVA via register_light. The same object_id
# is used to route incoming light_command events back to this script.
LIGHT_OBJECT_ID = "led_ring"
LIGHT_NAME = "LED Ring"
LIGHT_ICON = "mdi:circle-outline"
EFFECT_VOICE_ASSISTANT = "Voice Assistant"
BTN_DEBOUNCE_MS = 150 # Button debounce in milliseconds
# APA102 LED ring
LED_COUNT = 12
# SPI bus and device (SPI0, CE1 — CE0 is also valid if CE1 conflicts)
SPI_BUS = 0
SPI_DEVICE = 1
# Global brightness: 0–31. APA102 has a separate 5-bit brightness register.
# 10 ≈ 32 % (gentle default), 31 = maximum.
LED_BRIGHTNESS = 10
# Default ring colour (R, G, B) — matches HA Voice PE default
DEFAULT_R, DEFAULT_G, DEFAULT_B = 24, 187, 242
# Reconnect delay when WebSocket connection to LVA is lost
RECONNECT_DELAY_S = 3.0
# ===========================================================================
# Logging
# ===========================================================================
_LOGGER = logging.getLogger("respeaker4mic")
# ===========================================================================
# Assistant state
# ===========================================================================
class AssistState(str, Enum):
NOT_READY = "not_ready"
IDLE = "idle"
WAKE_WORD = "wake_word_detected"
LISTENING = "listening"
THINKING = "thinking"
SPEAKING = "tts_speaking"
TTS_FINISHED = "tts_finished"
ERROR = "error"
MUTED = "muted"
TIMER_TICKING = "timer_ticking"
TIMER_RINGING = "timer_ringing"
MEDIA_PLAYING = "media_player_playing"
# ---------------------------------------------------------------------------
# Shared state (written by WS thread, read by LED thread + button callbacks)
# ---------------------------------------------------------------------------
class SharedState:
def __init__(self) -> None:
self._lock = threading.Lock()
self.assist_state: AssistState = AssistState.NOT_READY
self.ha_connected: bool = False
self.muted: bool = False
self.volume: float = 1.0
self.timer_total_seconds: int = 0
self.timer_seconds_left: int = 0
# Light entity state, driven by HA via light_command events.
# Defaults match the LEDLightEntity in LVA core so the script
# behaves sensibly before the first light_command arrives: off by
# default, so idle stays dark until the user turns the light on.
self.light_is_on: bool = False
self.light_brightness: float = 0.66
self.light_red: float = 0.094
self.light_green: float = 0.733
self.light_blue: float = 0.949
def update(self, **kwargs) -> None:
with self._lock:
for key, val in kwargs.items():
setattr(self, key, val)
@property
def snapshot(self) -> dict:
with self._lock:
return {
"assist_state": self.assist_state,
"ha_connected": self.ha_connected,
"muted": self.muted,
"volume": self.volume,
"timer_total_seconds": self.timer_total_seconds,
"timer_seconds_left": self.timer_seconds_left,
"light_is_on": self.light_is_on,
"light_brightness": self.light_brightness,
"light_red": self.light_red,
"light_green": self.light_green,
"light_blue": self.light_blue,
}
# ===========================================================================
# LED ring controller — APA102 via SPI
#
# Mirrors every animation from the ESPHome home-assistant-voice.yaml exactly.
# Key difference from SK6812: APA102 uses SPI (MOSI + SCLK) instead of PWM.
# ===========================================================================
RGB = Tuple[int, int, int]
BLACK: RGB = (0, 0, 0)
RED: RGB = (255, 0, 0)
def _scale(color: RGB, factor: float) -> RGB:
f = max(0.0, min(1.0, factor))
return (int(color[0] * f), int(color[1] * f), int(color[2] * f))
class LEDRing:
"""
Manages the 12-LED APA102 ring via the apa102-pi SPI driver.
All animations run in a dedicated daemon thread. Calling
``set_animation()`` switches cleanly to the new pattern.
"""
ANIM_OFF = "off"
ANIM_IDLE = "idle"
ANIM_TWINKLE = "twinkle"
ANIM_TWINKLE_BLUE = "twinkle_blue"
ANIM_WAIT_CMD = "waiting"
ANIM_LISTENING = "listening"
ANIM_THINKING = "thinking"
ANIM_REPLYING = "replying"
ANIM_MUTED = "muted"
ANIM_ERROR = "error"
ANIM_TIMER_TICK = "timer_tick"
ANIM_TIMER_RING = "timer_ring"
def __init__(self, state: SharedState) -> None:
self._state = state
self._pixels: list[RGB] = [BLACK] * LED_COUNT
self._strip = None
self._animation = self.ANIM_OFF
self._anim_lock = threading.Lock()
self._stop_event = threading.Event()
self._thread = threading.Thread(
target=self._loop, daemon=True, name="led-ring"
)
self._index: int = 0
self._brightness_step: int = 0
self._brightness_dec: bool = True
self._twinkle_state: list[float] = [0.0] * LED_COUNT
if _HAS_APA102:
self._strip = _apa102_driver.APA102(
num_led=LED_COUNT,
global_brightness=LED_BRIGHTNESS,
mosi=10,
sclk=11,
ce=SPI_DEVICE,
)
_LOGGER.info(
"APA102 LED ring initialised (%d LEDs, SPI0 CE%d)",
LED_COUNT, SPI_DEVICE,
)
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
self._thread.join(timeout=2)
self._all_off()
if _HAS_APA102 and self._strip is not None:
self._strip.cleanup()
def set_animation(self, name: str) -> None:
with self._anim_lock:
if self._animation != name:
_LOGGER.debug("LED animation → %s", name)
self._animation = name
self._index = 0
self._brightness_step = 0
self._brightness_dec = True
self._twinkle_state = [random.random() for _ in range(LED_COUNT)]
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _write(self) -> None:
if not _HAS_APA102 or self._strip is None:
return
for i, (r, g, b) in enumerate(self._pixels):
self._strip.set_pixel(i, r, g, b)
self._strip.show()
def _all_off(self) -> None:
self._pixels = [BLACK] * LED_COUNT
self._write()
def _set(self, i: int, color: RGB) -> None:
self._pixels[i % LED_COUNT] = color
def _color(self) -> RGB:
"""Return the current user-configured ring colour from light entity state."""
snap = self._state.snapshot
r = int(max(0.0, min(1.0, snap["light_red"])) * 255)
g = int(max(0.0, min(1.0, snap["light_green"])) * 255)
b = int(max(0.0, min(1.0, snap["light_blue"])) * 255)
return (r, g, b)
def _pulse_step(self, steps: int = 10) -> float:
factor = (steps - self._brightness_step) / steps
if self._brightness_dec:
self._brightness_step += 1
else:
self._brightness_step -= 1
if self._brightness_step <= 0 or self._brightness_step >= steps:
self._brightness_dec = not self._brightness_dec
return factor
def _apply_mic_indicators(self) -> None:
"""
Mark all 4 mic positions red on the already-rendered frame.
ReSpeaker 4-Mic Array has mics at the four corners of the board.
On a 12-LED ring (30° per step) the corners land at 45°, 135°,
225°, 315° → LEDs 1, 4, 7, 10.
Neighbours are blanked so each indicator reads as a distinct dot.
"""
self._set(0, BLACK); self._set(1, RED); self._set(2, BLACK)
self._set(3, BLACK); self._set(4, RED); self._set(5, BLACK)
self._set(6, BLACK); self._set(7, RED); self._set(8, BLACK)
self._set(9, BLACK); self._set(10, RED); self._set(11, BLACK)
def _apply_mic_indicators_pulsed(self, factor: float) -> None:
"""Pulsed version for timer_ring — keeps indicators in sync with ring brightness."""
self._set(1, _scale(RED, factor))
self._set(4, _scale(RED, factor))
self._set(7, _scale(RED, factor))
self._set(10, _scale(RED, factor))
# ------------------------------------------------------------------
# Animation implementations
# ------------------------------------------------------------------
def _anim_off(self) -> float:
self._all_off()
return 0.1
def _anim_idle(self) -> float:
"""Resting state with user color when light is on, else dark.
Matches the HA Voice PE LED Ring behavior where the light defaults
off, so idle stays dark until the user turns the light on in HA.
Pipeline animations always run regardless.
"""
snap = self._state.snapshot
if snap["light_is_on"]:
color = self._color()
brightness = snap["light_brightness"]
for i in range(LED_COUNT):
self._set(i, _scale(color, brightness))
self._write()
else:
self._all_off()
return 0.1
def _anim_twinkle(self, color: RGB) -> float:
FADE = 0.85
SPARK_PROB = 0.15
for i in range(LED_COUNT):
if random.random() < SPARK_PROB:
self._twinkle_state[i] = 1.0
else:
self._twinkle_state[i] *= FADE
self._set(i, _scale(color, self._twinkle_state[i]))
self._write()
return 0.05
def _anim_spin(self, color: RGB, interval: float, reverse: bool = False) -> float:
"""
Shared spin for Waiting / Listening / Replying.
Mirrors ESPHome "Waiting for Command", "Listening For Command",
and "Replying" effects.
"""
if reverse:
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
offsets = [(0, 1.0), (1, 192/255), (2, 128/255),
(6, 1.0), (7, 192/255), (8, 128/255)]
else:
offsets = [(0, 1.0), (11, 192/255), (10, 128/255),
(6, 1.0), (5, 192/255), (4, 128/255)]
for i in range(LED_COUNT):
self._set(i, BLACK)
for offset, brightness in offsets:
self._set((self._index + offset) % LED_COUNT, _scale(color, brightness))
if not reverse:
self._index = (self._index + 1) % LED_COUNT
self._write()
return interval
def _anim_thinking(self, color: RGB) -> float:
"""Two opposing LEDs pulsing. Mirrors ESPHome "Thinking" effect."""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
if i == self._index % LED_COUNT or i == (self._index + 6) % LED_COUNT:
self._set(i, _scale(color, factor))
else:
self._set(i, BLACK)
self._write()
return 0.01
def _anim_muted(self, color: RGB) -> float:
"""
Solid ring with red at all 4 mic positions.
ReSpeaker mics are at the board corners → LEDs 1, 4, 7, 10
(45°, 135°, 225°, 315° on the 12-LED ring).
Mirrors ESPHome "Muted or Silent" effect.
"""
for i in range(LED_COUNT):
self._set(i, color)
self._apply_mic_indicators()
self._write()
return 0.016
def _anim_error(self) -> float:
"""All LEDs red, pulsing. Mirrors ESPHome "Error" effect."""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
self._set(i, _scale(RED, factor))
self._write()
return 0.01
def _anim_timer_ring(self, color: RGB, muted: bool) -> float:
"""
Full ring pulsing; red at all 4 mic positions if muted.
Mirrors ESPHome "Timer Ring" effect.
"""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
self._set(i, _scale(color, factor))
if muted:
self._apply_mic_indicators_pulsed(factor)
self._write()
return 0.01
def _anim_timer_tick(
self, color: RGB, muted: bool,
seconds_left: int, total_seconds: int,
) -> float:
"""
Arc proportional to remaining time with sweep marker dip.
Mirrors ESPHome "Timer Tick" effect exactly.
"""
total = max(total_seconds, 1)
timer_ratio = LED_COUNT * seconds_left / total
last_led_on = max(0, math.ceil(timer_ratio) - 1)
for i in range(LED_COUNT):
brightness_dip = (
0.9 if (i == self._index % LED_COUNT and i != last_led_on) else 1.0
)
if i <= timer_ratio:
brightness = min(brightness_dip * (timer_ratio - i), brightness_dip)
self._set(i, _scale(color, brightness))
else:
self._set(i, BLACK)
if muted:
self._apply_mic_indicators()
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
self._write()
return 0.1
# ------------------------------------------------------------------
# Animation loop
# ------------------------------------------------------------------
def _loop(self) -> None:
while not self._stop_event.is_set():
with self._anim_lock:
anim = self._animation
snap = self._state.snapshot
color = self._color()
muted = snap["muted"]
t_total = snap["timer_total_seconds"]
t_left = snap["timer_seconds_left"]
if anim == self.ANIM_OFF:
sleep = self._anim_off()
elif anim == self.ANIM_IDLE:
sleep = self._anim_idle()
elif anim == self.ANIM_TWINKLE:
sleep = self._anim_twinkle(RED)
elif anim == self.ANIM_TWINKLE_BLUE:
sleep = self._anim_twinkle(color)
elif anim == self.ANIM_WAIT_CMD:
sleep = self._anim_spin(color, interval=0.1, reverse=False)
elif anim == self.ANIM_LISTENING:
sleep = self._anim_spin(color, interval=0.05, reverse=False)
elif anim == self.ANIM_THINKING:
sleep = self._anim_thinking(color)
elif anim == self.ANIM_REPLYING:
sleep = self._anim_spin(color, interval=0.05, reverse=True)
elif anim == self.ANIM_MUTED:
sleep = self._anim_muted(color)
elif anim == self.ANIM_ERROR:
sleep = self._anim_error()
elif anim == self.ANIM_TIMER_RING:
sleep = self._anim_timer_ring(color, muted)
elif anim == self.ANIM_TIMER_TICK:
sleep = self._anim_timer_tick(color, muted, t_left, t_total)
else:
sleep = self._anim_off()
time.sleep(sleep)
# ===========================================================================
# State → animation mapping
# ===========================================================================
def state_to_animation(
state: AssistState, ha_connected: bool, muted: bool
) -> str:
if not ha_connected:
return LEDRing.ANIM_TWINKLE
if state == AssistState.NOT_READY:
return LEDRing.ANIM_TWINKLE
if state == AssistState.TIMER_RINGING:
return LEDRing.ANIM_TIMER_RING
if state == AssistState.WAKE_WORD:
return LEDRing.ANIM_WAIT_CMD
if state == AssistState.LISTENING:
return LEDRing.ANIM_LISTENING
if state == AssistState.THINKING:
return LEDRing.ANIM_THINKING
if state == AssistState.SPEAKING:
return LEDRing.ANIM_REPLYING
if state == AssistState.ERROR:
return LEDRing.ANIM_ERROR
if state == AssistState.MUTED or muted:
return LEDRing.ANIM_MUTED
if state == AssistState.TIMER_TICKING:
return LEDRing.ANIM_TIMER_TICK
return LEDRing.ANIM_IDLE
# ===========================================================================
# WebSocket client
# ===========================================================================
class LVAClient:
def __init__(
self,
host: str,
port: int,
state: SharedState,
leds: LEDRing,
command_queue: asyncio.Queue,
) -> None:
self._uri = f"ws://{host}:{port}"
self._state = state
self._leds = leds
self._queue = command_queue
async def run_forever(self) -> None:
while True:
try:
await self._connect()
except Exception as exc: # pylint: disable=broad-except
_LOGGER.warning(
"LVA connection lost (%s) – retrying in %.0fs",
exc, RECONNECT_DELAY_S,
)
self._state.update(
ha_connected=False,
assist_state=AssistState.NOT_READY,
)
self._leds.set_animation(LEDRing.ANIM_TWINKLE)
await asyncio.sleep(RECONNECT_DELAY_S)
async def _connect(self) -> None:
_LOGGER.info("Connecting to LVA at %s …", self._uri)
async with websockets.connect(self._uri) as ws:
_LOGGER.info("Connected to LVA peripheral API")
# Register the LED Light entity with LVA so HA can control it.
# LVA treats repeat registrations for the same object_id as a
# no-op, so it's safe to send this on every connect.
await ws.send(json.dumps({
"command": "register_light",
"data": {
"name": LIGHT_NAME,
"object_id": LIGHT_OBJECT_ID,
"icon": LIGHT_ICON,
"effects": [EFFECT_VOICE_ASSISTANT],
"supports_rgb": True,
"supports_brightness": True,
},
}))
recv_task = asyncio.create_task(self._recv_loop(ws))
send_task = asyncio.create_task(self._send_loop(ws))
done, pending = await asyncio.wait(
[recv_task, send_task],
return_when=asyncio.FIRST_EXCEPTION,
)
for task in pending:
task.cancel()
for task in done:
if task.exception():
raise task.exception()
async def _recv_loop(self, ws) -> None:
async for raw in ws:
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
await self._handle_event(msg)
async def _send_loop(self, ws) -> None:
while True:
command = await self._queue.get()
try:
await ws.send(json.dumps({"command": command}))
except Exception as exc: # pylint: disable=broad-except
_LOGGER.warning("Failed to send command %s: %s", command, exc)
self._queue.put_nowait(command)
raise
async def _handle_event(self, msg: dict) -> None:
event = msg.get("event", "")
data = msg.get("data") or {}
_LOGGER.debug("Event: %s data=%s", event, data)
if event == "snapshot":
self._state.update(
muted=data.get("muted", False),
volume=data.get("volume", 1.0),
ha_connected=data.get("ha_connected", False),
)
elif event == "wake_word_detected":
self._state.update(assist_state=AssistState.WAKE_WORD)
elif event == "listening":
self._state.update(assist_state=AssistState.LISTENING)
elif event == "thinking":
self._state.update(assist_state=AssistState.THINKING)
elif event == "tts_speaking":
self._state.update(assist_state=AssistState.SPEAKING)
elif event in ("tts_finished", "idle"):
self._state.update(assist_state=AssistState.IDLE)
elif event == "muted":
self._state.update(assist_state=AssistState.MUTED, muted=True)
elif event == "pipeline_error":
_LOGGER.warning("LVA pipeline error: %s", data.get("reason", ""))
self._state.update(assist_state=AssistState.ERROR)
elif event == "disconnected":
_LOGGER.warning("Home Assistant disconnected")
self._state.update(
ha_connected=False,
assist_state=AssistState.NOT_READY,
)
elif event == "timer_ticking":
self._state.update(
assist_state=AssistState.TIMER_TICKING,
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
elif event == "timer_updated":
self._state.update(
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
elif event == "timer_ringing":
self._state.update(
assist_state=AssistState.TIMER_RINGING,
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
elif event == "media_player_playing":
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
elif event == "volume_changed":
self._state.update(volume=data.get("volume", 1.0))
elif event == "volume_muted":
self._state.update(muted=data.get("muted", False))
elif event == "zeroconf":
status = data.get("status", "")
if status == "connected":
self._state.update(ha_connected=True)
_LOGGER.info("Home Assistant connected")
elif status == "getting_started":
_LOGGER.info("LVA starting up, waiting for HA …")
# --- Light command events ------------------------------------------
elif event == "light_command":
# LVA broadcasts to every connected peripheral; only act on
# commands targeting our registered Light. The Light exposes a
# single "Voice Assistant" effect, so there is no effect to
# switch on: we apply on/off, brightness, and color and let the
# pipeline animations run.
if data.get("object_id") != LIGHT_OBJECT_ID:
return
self._state.update(
light_is_on=bool(data.get("state", True)),
light_brightness=float(data.get("brightness", 0.66)),
light_red=float(data.get("red", 0.094)),
light_green=float(data.get("green", 0.733)),
light_blue=float(data.get("blue", 0.949)),
)
# Force re-render animation with new light state
snap = self._state.snapshot
anim = state_to_animation(
snap["assist_state"], snap["ha_connected"], snap["muted"]
)
self._leds.set_animation(anim)
return
# Recompute LED animation after every event
snap = self._state.snapshot
anim = state_to_animation(
snap["assist_state"], snap["ha_connected"], snap["muted"]
)
self._leds.set_animation(anim)
# ===========================================================================
# Main
# ===========================================================================
async def async_main(host: str, port: int) -> None:
state = SharedState()
command_queue: asyncio.Queue = asyncio.Queue()
loop = asyncio.get_running_loop()
leds = LEDRing(state)
client = LVAClient(host, port, state, leds, command_queue)
leds.start()
leds.set_animation(LEDRing.ANIM_TWINKLE)
def _shutdown(signum, frame) -> None:
_LOGGER.info("Shutdown requested")
leds.stop()
sys.exit(0)
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
_LOGGER.info(
"ReSpeaker 4-Mic controller started – connecting to ws://%s:%d",
host, port,
)
try:
await client.run_forever()
finally:
leds.stop()
def main() -> None:
parser = argparse.ArgumentParser(
description="ReSpeaker 4-Mic Array HAT controller for Linux Voice Assistant"
)
parser.add_argument(
"--host", default=DEFAULT_LVA_HOST,
help=f"LVA container hostname or IP (default: {DEFAULT_LVA_HOST})",
)
parser.add_argument(
"--port", type=int, default=DEFAULT_LVA_PORT,
help=f"LVA peripheral API port (default: {DEFAULT_LVA_PORT})",
)
parser.add_argument(
"--debug", action="store_true",
help="Enable DEBUG logging",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
)
asyncio.run(async_main(args.host, args.port))
if __name__ == "__main__":
main()
@@ -0,0 +1,220 @@
# ReSpeaker Mic Array v2.0 (USB)
<img src="image.png" width="300">
Peripheral controller for the [ReSpeaker Mic Array v2.0](https://wiki.seeedstudio.com/ReSpeaker_Mic_Array_v2.0/) by Seeed Studio, running alongside the Linux Voice Assistant (LVA) container.
The controller runs as a separate Docker container and connects to LVA's peripheral WebSocket API. It drives the 12-LED APA102 ring via a USB HID vendor command to the device's onboard XMOS XVF3000 firmware.
> **This board connects over USB** — no GPIO, no SPI, no Raspberry Pi HAT required. It works on any Linux host including Raspberry Pi, desktop PCs, and mini-PCs.
---
## Hardware
| Component | Details |
|---|---|
| Microphone array | 4 × MEMS mics at DOA 0°, 90°, 180°, 270° (XMOS XVF3000) |
| LED ring | 12 × APA102 RGB LEDs |
| Connection | USB (Vendor `0x2886`, Product `0x0018`) |
---
## LED ring animations
All animations mirror the Home Assistant Voice PE ESPHome firmware exactly.
| LVA state | Animation | Description |
|---|---|---|
| No HA connection / not ready | Red twinkle | Random red sparkle across all LEDs |
| Idle | Solid user color (if light on) or off | All LEDs show user color and brightness when light is on, else dark |
| Wake word detected | Slow clockwise spin | Two trailing arcs at opposing positions |
| Listening | Fast clockwise spin | Same dual-arc pattern at 50 ms interval |
| Thinking | Pulsing pair | Two opposing LEDs fade in and out |
| TTS speaking | Anticlockwise spin | Dual-arc spin in reverse direction |
| Muted | Red mic indicators | LEDs 0, 3, 6, 9 red; neighbours blanked (4 mic cardinal positions) |
| Error | Red pulse | All LEDs red, pulsing |
| Timer ticking | Countdown arc | Arc proportional to `seconds_left / total_seconds` |
| Timer ringing | Pulse + optional red | Full ring pulsing; red at mic positions if muted |
"User color" comes from the Home Assistant Light entity described below (default: HAVPE-style blue). HA brightness scales every animation in this table. Semantic colors (Waiting/Listening cyan spin, Thinking pulse, Replying anticlockwise, Muted solid + red, Timer pulse/arc, Error red pulse) are hardcoded so they remain recognisable across user customisation.
### Mic indicator positions
The v2.0 has 4 microphones at DOA 0°, 90°, 180°, 270°. On the 12-LED ring (30° per step) this maps to **LEDs 0, 3, 6, 9** — the four cardinal points. When muted, these four LEDs turn red and their immediate neighbours are blanked so each indicator stands out as a distinct dot.
```
LED 3 ← MIC (DOA 90°)
2 · · 4
1 · · 5
0 · · 6
↑MIC MIC↑
(DOA 0°) (DOA 180°)
11 · · 7
10 · · 8
LED 9 ← MIC (DOA 270°)
Mic LEDs: 0, 3, 6, 9
```
---
## Home Assistant Light entity
On connect the controller registers a Light entity with LVA, which appears in Home Assistant as `light.leds`. It defaults off, matching the Voice PE LED Ring; turn it on for a solid idle glow and set its RGB color and brightness from the device page.
### Effects
| Effect | Behaviour |
|---|---|
| `Voice Assistant` (default) | Run the pipeline animations from the table above. Waiting/Listening animations are tinted with the HA color; brightness scales every animation. |
Like the Home Assistant Voice PE, this example exposes a single Voice Assistant effect: the pipeline animations always run and can't be switched off from HA. The peripheral protocol itself accepts any number of effects, so your own integration is free to declare more (a color loop, a static accent, and so on) — see the [peripheral API docs](../../docs/peripheral_api.md).
### Brightness, on/off, and color
Matching the HA Voice PE LED Ring, the Light defaults off, so the LEDs stay dark while idle until you turn it on; once on, idle shows the solid color, and turning it off again just removes that idle glow. The voice animations always run either way (on/off only gates the idle glow, it does not disable them), so the effect can't be switched off completely. Brightness scales linearly across every animation, and RGB color is the solid idle color and tints the Waiting/Listening animations.
---
## Installation
### Step 1 — Install udev rule (required for non-root USB access)
Run once on the **host**:
```bash
sudo tee /etc/udev/rules.d/99-respeaker.rules << 'EOF'
SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="0018", \
MODE="0666", GROUP="plugdev", SYMLINK+="respeaker"
EOF
sudo udevadm control --reload-rules
sudo udevadm trigger
```
This creates a stable symlink at `/dev/respeaker` and makes the device accessible to the `plugdev` group. After this you can tighten Docker's device access — see the note in `docker-compose.yml`.
Verify the device is visible:
```bash
lsusb | grep 2886:0018
# Example: Bus 001 Device 005: ID 2886:0018 SEEED Technology...
```
### Step 2 — Verify microphone
The device enumerates as a standard USB audio device — no driver install needed:
```bash
arecord -l
# Should list: SEEED ReSpeaker Mic Array v2.0
```
Pass the device to LVA:
```bash
--audio-input-device "SEEED ReSpeaker Mic Array v2.0"
```
Or in LVA's compose environment:
```yaml
environment:
- AUDIO_INPUT_DEVICE=SEEED ReSpeaker Mic Array v2.0
```
### Step 3 — File structure
```
ReSpeaker Mic Array v2.0 (USB)/
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── respeaker_usb_mic_array.py
```
### Step 4 — Build and start
#### Option A — Run with Docker Compose (recommended)
```bash
cd respeaker_usb
docker compose up -d
```
Check logs:
```bash
docker compose logs -f
```
#### Option B — Run directly with Python
```bash
pip install -r requirements.txt
python respeaker_usb_mic_array.py --host localhost --port 6055
```
---
## Configuration
All configuration is at the top of `respeaker_usb_mic_array.py`:
```python
# LVA connection
DEFAULT_LVA_HOST = "localhost"
DEFAULT_LVA_PORT = 6055
# USB device
USB_VENDOR_ID = 0x2886
USB_PRODUCT_ID = 0x0018
# LED ring
LED_COUNT = 12
LED_BRIGHTNESS = 10 # 0–31 (APA102 5-bit global brightness register)
```
### Command-line arguments
| Argument | Default | Description |
|---|---|---|
| `--host` | `localhost` | LVA container hostname or IP |
| `--port` | `6055` | LVA peripheral API port |
| `--debug` | off | Enable verbose debug logging |
---
## Drivers summary
| Component | Driver needed | How |
|---|---|---|
| Microphone array (XMOS XVF3000) | **None** | Enumerates as USB audio automatically |
| LED ring (APA102 via USB HID) | udev rule | `99-respeaker.rules` on host |
---
## Troubleshooting
### Microphone not detected by LVA
1. Confirm `arecord -l` lists the device on the host.
2. Check the USB cable — the device uses micro-USB and requires a data cable, not a charge-only cable.
3. Try a different USB port. The v2.0 draws up to 900 mA; use a powered USB hub or a USB 3.0 port on Pi 4/5.
### LEDs do not light up
1. Confirm the device appears in `lsusb | grep 2886:0018`.
2. Check the udev rule is applied: `ls -la /dev/respeaker` should show the symlink.
3. Run with `--debug` and look for `ReSpeaker Mic Array v2.0 found` in the logs. If you see `not found`, check the USB connection and udev rule.
4. If you see a USB permission error, confirm `privileged: true` is set in `docker-compose.yml`, or that the udev rule is active and the container's user is in the `plugdev` group.
### USB error after reconnect
The script automatically attempts to re-find the device after a USB error (e.g. cable unplug/replug). Watch the logs for `Attempting to reconnect to ReSpeaker USB device`. If the device does not re-enumerate within a few seconds, restart the container.
### LVA not reachable
1. Confirm LVA is running and port 6055 is open: `nc -zv localhost 6055`.
2. With `network_mode: host`, `localhost` resolves to the host itself regardless of where the LVA container runs.
3. If LVA is on a different machine, use its IP address as `--host`.
@@ -0,0 +1,38 @@
FROM python:3.13-slim-bookworm
ENV LANG=C.UTF-8
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
LABEL \
org.opencontainers.image.title="ReSpeaker Mic Array v2.0 Peripheral Controller" \
org.opencontainers.image.description="LED controller for ReSpeaker Mic Array v2.0 (USB), communicating with Linux Voice Assistant" \
org.opencontainers.image.source="https://github.com/OHF-Voice/linux-voice-assistant"
# ----------------------------------------------------------------------------
# System packages
#
# libusb-1.0-0 – required by pyusb for USB device access
# ----------------------------------------------------------------------------
RUN apt-get update && \
apt-get install --yes --no-install-recommends \
libusb-1.0-0 \
ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# ----------------------------------------------------------------------------
# Python dependencies
#
# websockets – WebSocket client for LVA peripheral API
# pyusb – USB vendor control transfer for LED control
# ----------------------------------------------------------------------------
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY respeaker_usb_mic_array.py ./
ENTRYPOINT ["python3", "respeaker_usb_mic_array.py"]
CMD []
@@ -0,0 +1,139 @@
---
# ReSpeaker Mic Array v2.0 (USB) – peripheral controller
#
# Runs alongside the linux-voice-assistant container.
# Connects to LVA's peripheral WebSocket API (port 6055) and drives the
# 12-LED APA102 ring on the ReSpeaker Mic Array v2.0 via USB HID.
#
# This board connects over USB — no GPIO, no SPI, no HAT header required.
# It works on any Linux host (Raspberry Pi, desktop, mini-PC, etc.)
#
# See hardware notes at the bottom of this file before first run.
services:
respeaker-usb-peripheral:
build: .
image: ohf/respeaker-usb-peripheral:local
container_name: respeaker-usb-peripheral
restart: unless-stopped
# -----------------------------------------------------------------------
# USB device access
#
# Option A (recommended): pass only the ReSpeaker device.
#
# Find the bus and device numbers on the host:
# lsusb | grep 2886:0018
# # Example output: Bus 001 Device 005: ID 2886:0018 ...
# Then set the path below:
# - /dev/bus/usb/001/005:/dev/bus/usb/001/005
#
# NOTE: the device number changes on every reconnect. The udev rule
# in the hardware notes section creates a stable symlink at
# /dev/respeaker that you can use instead:
# - /dev/respeaker:/dev/respeaker
#
# Option B (simpler but broader): pass the entire USB bus.
# - /dev/bus/usb:/dev/bus/usb
# -----------------------------------------------------------------------
devices:
- /dev/bus/usb:/dev/bus/usb # Option B — change to specific device after setup
# -----------------------------------------------------------------------
# Privileges
#
# USB vendor control transfers require elevated access.
# The udev rule (hardware notes step 1) makes the device accessible to
# the plugdev group; if that rule is in place you can remove privileged
# and add group_add: [plugdev] instead.
# -----------------------------------------------------------------------
privileged: true
environment:
- PYTHONUNBUFFERED=1
# -----------------------------------------------------------------------
# Network — host mode so "localhost" reaches the LVA container directly
# -----------------------------------------------------------------------
network_mode: host
command:
- "--host"
- "localhost"
- "--port"
- "6055"
# Uncomment for verbose logging during setup:
# - "--debug"
healthcheck:
test: ["CMD", "pgrep", "-f", "respeaker_usb_mic_array.py"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# =============================================================================
# HARDWARE NOTES
# =============================================================================
#
# 1. UDEV RULE (REQUIRED for non-root USB access)
#
# Create the udev rule on the HOST so the container can access the device
# without needing privileged mode long-term:
#
# sudo tee /etc/udev/rules.d/99-respeaker.rules << 'EOF'
# SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="0018", \
# MODE="0666", GROUP="plugdev", SYMLINK+="respeaker"
# EOF
# sudo udevadm control --reload-rules
# sudo udevadm trigger
#
# After this, /dev/respeaker is a stable symlink to the device.
# You can then replace the broad /dev/bus/usb mount with:
#
# devices:
# - /dev/respeaker:/dev/respeaker
#
# And replace privileged: true with:
#
# privileged: false
# group_add:
# - plugdev
#
# 2. MICROPHONE — NO DRIVER NEEDED
#
# The ReSpeaker Mic Array v2.0 runs a XMOS XVF3000 firmware and enumerates
# as a standard USB audio device. No kernel driver or seeed-voicecard
# install is required.
#
# Verify on the host:
# arecord -l
# # Should list: SEEED ReSpeaker Mic Array v2.0
#
# LVA detects it automatically, or specify it explicitly:
# --audio-input-device "SEEED ReSpeaker Mic Array v2.0"
#
# Or in LVA's compose environment:
# AUDIO_INPUT_DEVICE=SEEED ReSpeaker Mic Array v2.0
#
# 3. LED CONTROL — USB HID VENDOR COMMAND
#
# The LEDs are controlled via a USB vendor control transfer to the XMOS
# firmware (bmRequestType=0x40, bRequest=0, wValue=0x0001, wIndex=0x8001).
# This is the same protocol used by Seeed's pixel_ring library.
# No SPI, GPIO, or kernel overlay is required.
#
# 4. POWER
#
# The device draws up to 900 mA at 5 V when all LEDs are on at full
# brightness. Use a powered USB hub or a Pi 4/5 with a quality 5 V/3 A
# power supply to avoid brown-outs.
#
# =============================================================================
Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

@@ -0,0 +1,2 @@
websockets>=12.0
pyusb>=1.2.1
@@ -0,0 +1,894 @@
#!/usr/bin/env python3
"""
ReSpeaker Mic Array v2.0 (USB) – Linux Voice Assistant peripheral controller.
Connects to the device via USB HID and drives its 12 APA102 LEDs with
animations that mirror the Home Assistant Voice PE LED ring behaviour.
Hardware
--------
Connection : USB (Vendor 0x2886, Product 0x0018)
LED ring : 12 × APA102 RGB LEDs (controlled via USB HID)
Microphones: 4 × MEMS mics at DOA 0°, 90°, 180°, 270° (XMOS XVF3000, USB audio)
LED animations mirror the ESPHome home-assistant-voice.yaml effects exactly.
Muted indicator: LEDs at the 4 mic positions light red.
The v2.0 has 4 mics at DOA 0°, 90°, 180°, 270° which on a 12-LED ring
(30° per step) maps to LEDs 0, 3, 6, 9 — the four cardinal points.
Install dependencies
--------------------
pip install websockets pyusb pixel-ring
Host udev rule (run once on the Pi/host, then reboot or run udevadm trigger):
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="0018", \
MODE="0666", GROUP="plugdev"' | sudo tee /etc/udev/rules.d/99-respeaker.rules
sudo udevadm trigger
Run
---
python3 respeaker_usb_mic_array.py
python3 respeaker_usb_mic_array.py --host 127.0.0.1 --port 6055 --debug
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import math
import random
import signal
import sys
import threading
import time
from enum import Enum
from typing import Optional, Tuple
# ---------------------------------------------------------------------------
# Hardware dependencies — gracefully stubbed when not on real hardware
# ---------------------------------------------------------------------------
try:
import usb.core # type: ignore[import]
import usb.util # type: ignore[import]
_HAS_USB = True
except ImportError:
_HAS_USB = False
logging.warning("pyusb not found – LED output will be simulated")
try:
import websockets # type: ignore[import]
except ImportError:
sys.exit("websockets not installed. Run: pip install websockets")
# ===========================================================================
# Configuration
# ===========================================================================
DEFAULT_LVA_HOST = "localhost"
DEFAULT_LVA_PORT = 6055
# HA Light entity registered with LVA via register_light. The same object_id
# is used to route incoming light_command events back to this script.
LIGHT_OBJECT_ID = "led_ring"
LIGHT_NAME = "LED Ring"
LIGHT_ICON = "mdi:circle-outline"
EFFECT_VOICE_ASSISTANT = "Voice Assistant"
# USB device identifiers
USB_VENDOR_ID = 0x2886
USB_PRODUCT_ID = 0x0018
LED_COUNT = 12
LED_BRIGHTNESS = 10 # 0–31 (APA102 5-bit global brightness register)
RECONNECT_DELAY_S = 3.0
# ===========================================================================
# Logging
# ===========================================================================
_LOGGER = logging.getLogger("respeaker-usb")
# ===========================================================================
# Assistant state
# ===========================================================================
class AssistState(str, Enum):
NOT_READY = "not_ready"
IDLE = "idle"
WAKE_WORD = "wake_word_detected"
LISTENING = "listening"
THINKING = "thinking"
SPEAKING = "tts_speaking"
TTS_FINISHED = "tts_finished"
ERROR = "error"
MUTED = "muted"
TIMER_TICKING = "timer_ticking"
TIMER_RINGING = "timer_ringing"
MEDIA_PLAYING = "media_player_playing"
# ---------------------------------------------------------------------------
# Shared state
# ---------------------------------------------------------------------------
class SharedState:
def __init__(self) -> None:
self._lock = threading.Lock()
self.assist_state: AssistState = AssistState.NOT_READY
self.ha_connected: bool = False
self.muted: bool = False
self.volume: float = 1.0
self.timer_total_seconds: int = 0
self.timer_seconds_left: int = 0
# Light entity state, driven by HA via light_command events.
# Defaults match the LEDLightEntity in LVA core so the script
# behaves sensibly before the first light_command arrives: off by
# default, so idle stays dark until the user turns the light on.
self.light_is_on: bool = False
self.light_brightness: float = 0.66
self.light_red: float = 0.094
self.light_green: float = 0.733
self.light_blue: float = 0.949
def update(self, **kwargs) -> None:
with self._lock:
for key, val in kwargs.items():
setattr(self, key, val)
@property
def snapshot(self) -> dict:
with self._lock:
return {
"assist_state": self.assist_state,
"ha_connected": self.ha_connected,
"muted": self.muted,
"volume": self.volume,
"timer_total_seconds": self.timer_total_seconds,
"timer_seconds_left": self.timer_seconds_left,
"light_is_on": self.light_is_on,
"light_brightness": self.light_brightness,
"light_red": self.light_red,
"light_green": self.light_green,
"light_blue": self.light_blue,
}
# ===========================================================================
# USB LED driver
#
# The ReSpeaker Mic Array v2.0 LED firmware exposes a vendor-command USB
# interface. The copied pixel_ring reference uses command 0x06 for custom LED
# frames and command 0x20 for brightness, both addressed to wIndex 0x1C.
# ===========================================================================
RGB = Tuple[int, int, int]
BLACK: RGB = (0, 0, 0)
RED: RGB = (255, 0, 0)
BLUE: RGB = (0, 0, 255)
CYAN: RGB = (0, 200, 200)
GREEN: RGB = (0, 200, 50)
YELLOW: RGB = (220, 180, 0)
def _scale(color: RGB, factor: float) -> RGB:
f = max(0.0, min(1.0, factor))
return (int(color[0] * f), int(color[1] * f), int(color[2] * f))
class USBLEDRing:
"""
Controls the 12 LEDs on the ReSpeaker Mic Array v2.0 over USB.
Uses the same vendor-command protocol as Seeed's pixel_ring library.
That protocol talks to the device recipient directly and does not require
detaching the kernel audio driver.
"""
_CMD_SHOW = 0x06
_CMD_BRIGHTNESS = 0x20
_CTRL_TIMEOUT = 8000 # ms
_CTRL_REQUEST = 0
_CTRL_INDEX = 0x1C
_CTRL_TYPE_OUT = 0x40 # vendor, device, host-to-device
def __init__(self) -> None:
self._pixels: list[RGB] = [BLACK] * LED_COUNT
self._dev = None
self._dev_lock = threading.Lock()
self._last_brightness: Optional[int] = None
if _HAS_USB:
self._find_device()
def _find_device(self) -> None:
dev = usb.core.find(idVendor=USB_VENDOR_ID, idProduct=USB_PRODUCT_ID)
if dev is None:
_LOGGER.error(
"ReSpeaker Mic Array v2.0 not found (USB %04x:%04x). "
"Check connection and udev rules.",
USB_VENDOR_ID, USB_PRODUCT_ID,
)
return
self._dev = dev
self._last_brightness = None
_LOGGER.info(
"ReSpeaker Mic Array v2.0 found (USB %04x:%04x, bus %d, addr %d)",
USB_VENDOR_ID, USB_PRODUCT_ID, dev.bus, dev.address,
)
def set(self, index: int, color: RGB) -> None:
self._pixels[index % LED_COUNT] = color
def set_all(self, color: RGB) -> None:
self._pixels = [color] * LED_COUNT
def _write(self, command: int, data: list[int]) -> None:
self._dev.ctrl_transfer(
self._CTRL_TYPE_OUT,
self._CTRL_REQUEST,
command,
self._CTRL_INDEX,
data,
self._CTRL_TIMEOUT,
)
def show(self, brightness: float = 1.0) -> None:
"""
Push the current pixel buffer to the device.
The USB firmware expects command 0x06 with 4 bytes per LED in RGB0
order, plus command 0x20 for device brightness. Sending raw APA102
frames here causes the firmware to misinterpret the payload.
"""
bright5 = max(0, min(31, int(brightness * LED_BRIGHTNESS)))
frame: list[int] = []
for r, g, b in self._pixels:
br = max(0, min(255, int(r * brightness)))
bg = max(0, min(255, int(g * brightness)))
bb = max(0, min(255, int(b * brightness)))
frame += [br, bg, bb, 0]
with self._dev_lock:
if self._dev is None:
pixels = ", ".join(f"rgb{p}" for p in self._pixels)
_LOGGER.debug("LEDs [simulated]: %s", pixels)
return
try:
if self._last_brightness != bright5:
self._write(self._CMD_BRIGHTNESS, [bright5])
self._last_brightness = bright5
self._write(self._CMD_SHOW, frame)
except usb.core.USBError as exc:
_LOGGER.warning("USB write error: %s – attempting reconnect", exc)
self._dev = None
self._last_brightness = None
# Attempt to re-find the device on next show() call
threading.Thread(
target=self._reconnect, daemon=True
).start()
def _reconnect(self) -> None:
time.sleep(2)
_LOGGER.info("Attempting to reconnect to ReSpeaker USB device …")
self._find_device()
def off(self) -> None:
self._pixels = [BLACK] * LED_COUNT
self.show(0)
def close(self) -> None:
self.off()
with self._dev_lock:
if self._dev is not None:
usb.util.dispose_resources(self._dev)
self._dev = None
# ===========================================================================
# LED ring animator
#
# All animations mirror the ESPHome home-assistant-voice.yaml effects.
# Muted indicator uses LEDs 0, 2, 4, 6, 8, 10 — the 6 outer mic positions
# on the v2.0 (0°, 60°, 120°, 180°, 240°, 300° on the ring).
# ===========================================================================
# Mic LED positions — 4 mics at DOA 0°, 90°, 180°, 270°
# On a 12-LED ring (30° per step): DOA 0° → LED 0, 90° → LED 3,
# 180° → LED 6, 270° → LED 9
MIC_LEDS = (0, 3, 6, 9)
class LEDRing:
"""
Manages the 12-LED ring animations for the ReSpeaker Mic Array v2.0.
All animations run in a dedicated daemon thread.
"""
ANIM_OFF = "off"
ANIM_IDLE = "idle"
ANIM_TWINKLE = "twinkle"
ANIM_TWINKLE_BLUE = "twinkle_blue"
ANIM_WAIT_CMD = "waiting"
ANIM_LISTENING = "listening"
ANIM_THINKING = "thinking"
ANIM_REPLYING = "replying"
ANIM_MUTED = "muted"
ANIM_ERROR = "error"
ANIM_TIMER_TICK = "timer_tick"
ANIM_TIMER_RING = "timer_ring"
def __init__(self, state: SharedState) -> None:
self._state = state
self._leds = USBLEDRing()
self._animation = self.ANIM_OFF
self._anim_lock = threading.Lock()
self._stop_event = threading.Event()
self._thread = threading.Thread(
target=self._loop, daemon=True, name="led-ring"
)
self._index: int = 0
self._brightness_step: int = 0
self._brightness_dec: bool = True
self._twinkle_state: list[float] = [0.0] * LED_COUNT
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
self._thread.join(timeout=2)
self._leds.off()
self._leds.close()
def set_animation(self, name: str) -> None:
with self._anim_lock:
if self._animation != name:
_LOGGER.debug("LED animation → %s", name)
self._animation = name
self._index = 0
self._brightness_step = 0
self._brightness_dec = True
self._twinkle_state = [random.random() for _ in range(LED_COUNT)]
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _color(self) -> RGB:
"""Return the current user-configured ring colour from light entity state."""
snap = self._state.snapshot
r = int(max(0.0, min(1.0, snap["light_red"])) * 255)
g = int(max(0.0, min(1.0, snap["light_green"])) * 255)
b = int(max(0.0, min(1.0, snap["light_blue"])) * 255)
return (r, g, b)
def _pulse_step(self, steps: int = 10) -> float:
factor = (steps - self._brightness_step) / steps
if self._brightness_dec:
self._brightness_step += 1
else:
self._brightness_step -= 1
if self._brightness_step <= 0 or self._brightness_step >= steps:
self._brightness_dec = not self._brightness_dec
return factor
def _apply_mic_indicators(self) -> None:
"""
Mark all 4 mic positions red on the already-rendered frame.
ReSpeaker Mic Array v2.0 has 4 mics at DOA 0°, 90°, 180°, 270°.
On the 12-LED ring (30° per step):
DOA 0° → LED 0 (right)
DOA 90° → LED 3 (top)
DOA 180° → LED 6 (left)
DOA 270° → LED 9 (bottom)
Neighbours are blanked so each indicator reads as a distinct dot.
"""
self._leds.set(11, BLACK); self._leds.set(0, RED); self._leds.set(1, BLACK)
self._leds.set(2, BLACK); self._leds.set(3, RED); self._leds.set(4, BLACK)
self._leds.set(5, BLACK); self._leds.set(6, RED); self._leds.set(7, BLACK)
self._leds.set(8, BLACK); self._leds.set(9, RED); self._leds.set(10, BLACK)
def _apply_mic_indicators_pulsed(self, factor: float) -> None:
"""Pulsed version for timer_ring — keeps indicators in sync with ring brightness."""
self._leds.set(0, _scale(RED, factor))
self._leds.set(3, _scale(RED, factor))
self._leds.set(6, _scale(RED, factor))
self._leds.set(9, _scale(RED, factor))
# ------------------------------------------------------------------
# Animations — each returns sleep time in seconds
# ------------------------------------------------------------------
def _anim_off(self) -> float:
self._leds.off()
return 0.1
def _anim_idle(self) -> float:
"""Resting state with user color when light is on, else dark.
Matches the HA Voice PE LED Ring behavior where the light defaults
off, so idle stays dark until the user turns the light on in HA.
Pipeline animations always run regardless.
"""
snap = self._state.snapshot
if snap["light_is_on"]:
color = self._color()
brightness = snap["light_brightness"]
for i in range(LED_COUNT):
self._leds.set(i, _scale(color, brightness))
self._leds.show()
else:
self._leds.off()
return 0.1
def _anim_twinkle(self, color: RGB) -> float:
FADE = 0.85
SPARK_PROB = 0.15
for i in range(LED_COUNT):
if random.random() < SPARK_PROB:
self._twinkle_state[i] = 1.0
else:
self._twinkle_state[i] *= FADE
self._leds.set(i, _scale(color, self._twinkle_state[i]))
self._leds.show()
return 0.05
def _anim_spin(self, color: RGB, interval: float, reverse: bool = False) -> float:
"""
Shared spin for Waiting / Listening / Replying.
Mirrors ESPHome "Waiting for Command", "Listening For Command",
and "Replying" effects.
"""
if reverse:
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
offsets = [(0, 1.0), (1, 192/255), (2, 128/255),
(6, 1.0), (7, 192/255), (8, 128/255)]
else:
offsets = [(0, 1.0), (11, 192/255), (10, 128/255),
(6, 1.0), (5, 192/255), (4, 128/255)]
for i in range(LED_COUNT):
self._leds.set(i, BLACK)
for offset, brightness in offsets:
self._leds.set((self._index + offset) % LED_COUNT, _scale(color, brightness))
if not reverse:
self._index = (self._index + 1) % LED_COUNT
self._leds.show()
return interval
def _anim_thinking(self, color: RGB) -> float:
"""Two opposing LEDs pulsing. Mirrors ESPHome "Thinking" effect."""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
if i == self._index % LED_COUNT or i == (self._index + 6) % LED_COUNT:
self._leds.set(i, _scale(color, factor))
else:
self._leds.set(i, BLACK)
self._leds.show()
return 0.01
def _anim_muted(self, color: RGB) -> float:
"""
Solid ring with red at all 4 mic positions (cardinal points).
DOA 0°/90°/180°/270° → LEDs 0, 3, 6, 9.
Neighbours are blanked so each indicator stands out clearly.
Mirrors ESPHome "Muted or Silent" effect.
"""
for i in range(LED_COUNT):
self._leds.set(i, color)
self._apply_mic_indicators()
self._leds.show()
return 0.016
def _anim_error(self) -> float:
"""All LEDs red, pulsing. Mirrors ESPHome "Error" effect."""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
self._leds.set(i, _scale(RED, factor))
self._leds.show()
return 0.01
def _anim_timer_ring(self, color: RGB, muted: bool) -> float:
"""
Full ring pulsing; red at all 6 mic positions if muted.
Mirrors ESPHome "Timer Ring" effect.
"""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
self._leds.set(i, _scale(color, factor))
if muted:
self._apply_mic_indicators_pulsed(factor)
self._leds.show()
return 0.01
def _anim_timer_tick(
self, color: RGB, muted: bool,
seconds_left: int, total_seconds: int,
) -> float:
"""
Arc proportional to remaining time with sweep marker dip.
Mirrors ESPHome "Timer Tick" effect exactly.
"""
total = max(total_seconds, 1)
timer_ratio = LED_COUNT * seconds_left / total
last_led_on = max(0, math.ceil(timer_ratio) - 1)
for i in range(LED_COUNT):
brightness_dip = (
0.9 if (i == self._index % LED_COUNT and i != last_led_on) else 1.0
)
if i <= timer_ratio:
brightness = min(brightness_dip * (timer_ratio - i), brightness_dip)
self._leds.set(i, _scale(color, brightness))
else:
self._leds.set(i, BLACK)
if muted:
self._apply_mic_indicators()
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
self._leds.show()
return 0.1
# ------------------------------------------------------------------
# Animation loop
# ------------------------------------------------------------------
def _loop(self) -> None:
while not self._stop_event.is_set():
with self._anim_lock:
anim = self._animation
snap = self._state.snapshot
color = self._color()
muted = snap["muted"]
t_total = snap["timer_total_seconds"]
t_left = snap["timer_seconds_left"]
if anim == self.ANIM_OFF:
sleep = self._anim_off()
elif anim == self.ANIM_IDLE:
sleep = self._anim_idle()
elif anim == self.ANIM_TWINKLE:
sleep = self._anim_twinkle(RED)
elif anim == self.ANIM_TWINKLE_BLUE:
sleep = self._anim_twinkle(color)
elif anim == self.ANIM_WAIT_CMD:
sleep = self._anim_spin(color, interval=0.1, reverse=False)
elif anim == self.ANIM_LISTENING:
sleep = self._anim_spin(color, interval=0.05, reverse=False)
elif anim == self.ANIM_THINKING:
sleep = self._anim_thinking(color)
elif anim == self.ANIM_REPLYING:
sleep = self._anim_spin(color, interval=0.05, reverse=True)
elif anim == self.ANIM_MUTED:
sleep = self._anim_muted(color)
elif anim == self.ANIM_ERROR:
sleep = self._anim_error()
elif anim == self.ANIM_TIMER_RING:
sleep = self._anim_timer_ring(color, muted)
elif anim == self.ANIM_TIMER_TICK:
sleep = self._anim_timer_tick(color, muted, t_left, t_total)
else:
sleep = self._anim_off()
time.sleep(sleep)
# ===========================================================================
# State → animation mapping (identical to 4-Mic HAT)
# ===========================================================================
def state_to_animation(
state: AssistState, ha_connected: bool, muted: bool
) -> str:
if not ha_connected:
return LEDRing.ANIM_TWINKLE
if state == AssistState.NOT_READY:
return LEDRing.ANIM_TWINKLE
if state == AssistState.TIMER_RINGING:
return LEDRing.ANIM_TIMER_RING
if state == AssistState.WAKE_WORD:
return LEDRing.ANIM_WAIT_CMD
if state == AssistState.LISTENING:
return LEDRing.ANIM_LISTENING
if state == AssistState.THINKING:
return LEDRing.ANIM_THINKING
if state == AssistState.SPEAKING:
return LEDRing.ANIM_REPLYING
if state == AssistState.ERROR:
return LEDRing.ANIM_ERROR
if state == AssistState.MUTED or muted:
return LEDRing.ANIM_MUTED
if state == AssistState.TIMER_TICKING:
return LEDRing.ANIM_TIMER_TICK
return LEDRing.ANIM_IDLE
# ===========================================================================
# WebSocket client
# ===========================================================================
class LVAClient:
def __init__(
self,
host: str,
port: int,
state: SharedState,
leds: LEDRing,
command_queue: asyncio.Queue,
) -> None:
self._uri = f"ws://{host}:{port}"
self._state = state
self._leds = leds
self._queue = command_queue
async def run_forever(self) -> None:
while True:
try:
await self._connect()
except Exception as exc: # pylint: disable=broad-except
_LOGGER.warning(
"LVA connection lost (%s) – retrying in %.0fs",
exc, RECONNECT_DELAY_S,
)
self._state.update(
ha_connected=False,
assist_state=AssistState.NOT_READY,
)
self._leds.set_animation(LEDRing.ANIM_TWINKLE)
await asyncio.sleep(RECONNECT_DELAY_S)
async def _connect(self) -> None:
_LOGGER.info("Connecting to LVA at %s …", self._uri)
async with websockets.connect(self._uri) as ws:
_LOGGER.info("Connected to LVA peripheral API")
# Register the LED Light entity with LVA so HA can control it.
# LVA treats repeat registrations for the same object_id as a
# no-op, so it's safe to send this on every connect.
await ws.send(json.dumps({
"command": "register_light",
"data": {
"name": LIGHT_NAME,
"object_id": LIGHT_OBJECT_ID,
"icon": LIGHT_ICON,
"effects": [EFFECT_VOICE_ASSISTANT],
"supports_rgb": True,
"supports_brightness": True,
},
}))
recv_task = asyncio.create_task(self._recv_loop(ws))
send_task = asyncio.create_task(self._send_loop(ws))
done, pending = await asyncio.wait(
[recv_task, send_task],
return_when=asyncio.FIRST_EXCEPTION,
)
for task in pending:
task.cancel()
for task in done:
if task.exception():
raise task.exception()
async def _recv_loop(self, ws) -> None:
async for raw in ws:
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
await self._handle_event(msg)
async def _send_loop(self, ws) -> None:
while True:
command = await self._queue.get()
try:
await ws.send(json.dumps({"command": command}))
except Exception as exc: # pylint: disable=broad-except
_LOGGER.warning("Failed to send command %s: %s", command, exc)
self._queue.put_nowait(command)
raise
async def _handle_event(self, msg: dict) -> None:
event = msg.get("event", "")
data = msg.get("data") or {}
_LOGGER.debug("Event: %s data=%s", event, data)
if event == "snapshot":
muted = data.get("muted", False)
ha_connected = data.get("ha_connected", False)
self._state.update(
muted=muted,
volume=data.get("volume", 1.0),
ha_connected=ha_connected,
)
if muted:
self._state.update(assist_state=AssistState.MUTED)
elif ha_connected:
self._state.update(assist_state=AssistState.IDLE)
else:
self._state.update(assist_state=AssistState.NOT_READY)
elif event == "wake_word_detected":
self._state.update(assist_state=AssistState.WAKE_WORD)
elif event == "listening":
self._state.update(assist_state=AssistState.LISTENING)
elif event == "thinking":
self._state.update(assist_state=AssistState.THINKING)
elif event == "tts_speaking":
self._state.update(assist_state=AssistState.SPEAKING)
elif event in ("tts_finished", "idle"):
self._state.update(assist_state=AssistState.IDLE)
elif event == "muted":
muted = data.get("muted", True)
self._state.update(muted=muted)
if muted:
self._state.update(assist_state=AssistState.MUTED)
elif self._state.assist_state == AssistState.MUTED:
self._state.update(assist_state=AssistState.IDLE)
elif event == "pipeline_error":
_LOGGER.warning("LVA pipeline error: %s", data.get("reason", ""))
self._state.update(assist_state=AssistState.ERROR)
elif event == "disconnected":
_LOGGER.warning("Home Assistant disconnected")
self._state.update(
ha_connected=False,
assist_state=AssistState.NOT_READY,
)
elif event == "timer_ticking":
self._state.update(
assist_state=AssistState.TIMER_TICKING,
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
elif event == "timer_updated":
self._state.update(
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
elif event == "timer_ringing":
self._state.update(
assist_state=AssistState.TIMER_RINGING,
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
elif event == "media_player_playing":
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
elif event == "volume_changed":
self._state.update(volume=data.get("volume", 1.0))
elif event == "volume_muted":
self._state.update(muted=data.get("muted", False))
elif event == "zeroconf":
status = data.get("status", "")
if status == "connected":
self._state.update(ha_connected=True)
if self._state.assist_state == AssistState.NOT_READY:
self._state.update(
assist_state=(
AssistState.MUTED if self._state.muted else AssistState.IDLE
),
)
_LOGGER.info("Home Assistant connected")
elif status == "getting_started":
_LOGGER.info("LVA starting up, waiting for HA …")
# --- Light command events ------------------------------------------
elif event == "light_command":
# LVA broadcasts to every connected peripheral; only act on
# commands targeting our registered Light. The Light exposes a
# single "Voice Assistant" effect, so there is no effect to
# switch on: we apply on/off, brightness, and color and let the
# pipeline animations run.
if data.get("object_id") != LIGHT_OBJECT_ID:
return
self._state.update(
light_is_on=bool(data.get("state", True)),
light_brightness=float(data.get("brightness", 0.66)),
light_red=float(data.get("red", 0.094)),
light_green=float(data.get("green", 0.733)),
light_blue=float(data.get("blue", 0.949)),
)
# Force re-render animation with new light state
snap = self._state.snapshot
anim = state_to_animation(
snap["assist_state"], snap["ha_connected"], snap["muted"]
)
self._leds.set_animation(anim)
return
# Recompute LED animation after every event
snap = self._state.snapshot
anim = state_to_animation(
snap["assist_state"], snap["ha_connected"], snap["muted"]
)
self._leds.set_animation(anim)
# ===========================================================================
# Main
# ===========================================================================
async def async_main(host: str, port: int) -> None:
state = SharedState()
command_queue: asyncio.Queue = asyncio.Queue()
leds = LEDRing(state)
client = LVAClient(host, port, state, leds, command_queue)
leds.start()
leds.set_animation(LEDRing.ANIM_TWINKLE)
def _shutdown(signum, frame) -> None:
_LOGGER.info("Shutdown requested")
leds.stop()
sys.exit(0)
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
_LOGGER.info(
"ReSpeaker Mic Array v2.0 controller started – connecting to ws://%s:%d",
host, port,
)
try:
await client.run_forever()
finally:
leds.stop()
def main() -> None:
parser = argparse.ArgumentParser(
description="ReSpeaker Mic Array v2.0 (USB) controller for Linux Voice Assistant"
)
parser.add_argument(
"--host", default=DEFAULT_LVA_HOST,
help=f"LVA container hostname or IP (default: {DEFAULT_LVA_HOST})",
)
parser.add_argument(
"--port", type=int, default=DEFAULT_LVA_PORT,
help=f"LVA peripheral API port (default: {DEFAULT_LVA_PORT})",
)
parser.add_argument(
"--debug", action="store_true",
help="Enable DEBUG logging",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
)
asyncio.run(async_main(args.host, args.port))
if __name__ == "__main__":
main()
+440
View File
@@ -0,0 +1,440 @@
# Satellite 1 HAT Board
<img src="image.png" width="400" height="200">
Peripheral controller for the [Satellite1 HAT Board](https://futureproofhomes.net/products/satellite1-top-microphone-board) by FutureProofHomes, running alongside the Linux Voice Assistant (LVA) container.
The controller runs as a separate Docker container on the same Raspberry Pi. It connects to LVA's peripheral WebSocket API, drives the 12-LED SK6812 ring with animations that mirror the [Home Assistant Voice PE](https://www.home-assistant.io/voice-pe/) LED behaviour, and maps the four hardware buttons to LVA commands with multipress support on the context action button.
---
## Hardware
| Component | Details |
|---|---|
| Microphone array | XMOS XVF3800 (USB, far-field, onboard DSP) |
| LED ring | 12 × SK6812 RGBW NeoPixels |
| Buttons | 4 × tactile (top, bottom, left, right) |
| Audio output | I2S line-out (optional) |
| Interface | Raspberry Pi 40-pin HAT connector |
### Compatible hardware
The Satellite 1 HAT works on any Raspberry Pi with a 40-pin GPIO header. Tested targets:
- Raspberry Pi Zero 2 W
- Raspberry Pi 3 B / B+
- Raspberry Pi 4 B
- Raspberry Pi 5
---
## GPIO pin mapping
| Function | GPIO (BCM) | Notes |
|---|---|---|
| LED ring data | 12 | PWM0, channel 0 |
| Right button (Volume Up) | 17 | Active low, internal pull-up |
| Left button (Volume Down) | 27 | Active low, internal pull-up |
| Top button (Mute / Unmute) | 22 | Active low, internal pull-up |
| Bottom button (Context action) | 23 | Active low, internal pull-up | — **supports multipress** |
---
## Home Assistant Light Entity
The Satellite1 peripheral controller automatically registers an LED light entity with Home Assistant via LVA. This allows you to control the LED ring color and brightness directly from HA.
### Light entity details
| Property | Value |
|---|---|
| Entity ID | `light.<satellite>_leds` |
| Supported features | On/Off, RGB color, brightness, effect |
| Default effect | "Voice Assistant" |
| RGB default | Cyan (9.4% R, 73.3% G, 94.9% B) |
| Brightness default | 66% |
| On/Off default | **Off** (idle stays dark until activated) |
### Controlling from Home Assistant
In Home Assistant, you can:
1. **Turn the light on/off** — toggles the idle LED glow. When off, only active pipeline animations show (listening, thinking, speaking, etc.). When on, the ring displays your chosen color during idle/standby.
2. **Set color** — pick any RGB color. All pipeline animations are tinted by your chosen color (matching Home Assistant Voice PE behavior):
- Wake word → Flash in your color
- Listening → Chase in your color
- Thinking → Pulse in your color
- Speaking → Reverse spin in your color
- Muted → Solid ring in your color + red at mic positions
- Timer → Countdown arc in your color
3. **Set brightness** — scales all animations by the chosen brightness (0–100%). Useful for adapting to ambient lighting.
4. **Use automations** — e.g., dim the LEDs at night, change color based on assistant state, or flash for alerts.
### Service calls example (YAML)
```yaml
# Turn on and set to purple
service: light.turn_on
target:
entity_id: light.satellite1_leds
data:
rgb_color: [200, 0, 255]
brightness: 200
# Dim for night mode
service: light.turn_on
target:
entity_id: light.satellite1_leds
data:
brightness: 80
# Turn off (hides idle glow, shows only pipeline animations)
service: light.turn_off
target:
entity_id: light.satellite1_leds
```
---
## Button behaviour
### Right button — Volume Up
Sends `volume_up` to LVA. Each press increases volume by one step.
### Left button — Volume Down
Sends `volume_down` to LVA. Each press decreases volume by one step.
### Top button — Mute / Unmute
Toggles microphone mute. Sends `mute_mic` when unmuted, `unmute_mic` when muted. The LED ring switches to the muted indicator pattern immediately.
### Bottom button — Context action (with multipress and color change support)
This button supports single-press context actions, multi-press gestures, **and color changing via the HSV hue wheel** (matching Home Assistant Voice PE):
#### Single press (< 100 ms hold time)
Context-aware command based on current assistant state, mirroring the Home Assistant Voice PE centre button priority:
| Current state | Command sent |
|---|---|
| Timer ringing | `stop_timer_ringing` |
| Wake word / listening / thinking / TTS speaking | `stop_pipeline` |
| Music / media playing | `stop_media_player` |
| Any other (idle) | `start_listening` |
#### Multi-press gestures
When the action button is released quickly (not held), it detects multiple presses:
| Gesture | Timing | Command sent | Use case |
|---|---|---|---|
| **Double press** | 2 presses < 250 ms apart | `button_double_press` | Trigger custom HA automations, mode toggles, etc. |
| **Triple press** | 3 presses < 250 ms apart | `button_triple_press` | Access menu, configuration, advanced features |
| **Long press** | Single press held > 1000 ms | `button_long_press` | Scene activation, do-not-disturb, night mode, etc. |
**Examples:**
- Double press → start a specific routine or mode
- Triple press → access a menu or configuration option
- Long press → toggle do-not-disturb or night mode
These commands are exposed as button press events to Home Assistant (via the registered button sensor), allowing you to create custom automations via `button_press_event` triggers.
**Note:** The color change mode (hold + volume) activates after ~100 ms of holding, allowing quick single presses to still trigger context actions.
#### Hold + Volume buttons — Color wheel rotation
Hold down the action button and use the volume buttons to rotate through the HSV color wheel (matching Voice PE's rotary dial):
| Action | Effect |
|---|---|
| **Hold + Volume Up** | Rotate hue +10° (clockwise around color wheel) |
| **Hold + Volume Down** | Rotate hue -10° (counter-clockwise around color wheel) |
The color wheel provides **36 distinct color stops** (0°–360° in 10° increments):
- **0°** – Red
- **60°** – Yellow
- **120°** – Green
- **180°** – Cyan
- **240°** – Blue
- **300°** – Magenta
Each press changes the hue by 10°, allowing smooth navigation around the full color spectrum. The LED ring updates immediately to show the new color during idle/standby (when the light is on).
**Example workflow:**
1. Press and hold the action button
2. Press volume up repeatedly to cycle through warm colors (red → orange → yellow)
3. Release action button to confirm and exit color mode
4. The new color persists in Home Assistant
---
## LED ring animations
All animations mirror the Home Assistant Voice PE ESPHome firmware exactly. The ring color is driven by the HA Light entity (see [Home Assistant Light Entity](#home-assistant-light-entity) above).
| LVA state | Animation | Description |
|---|---|---|
| No HA connection / error | Red twinkle | Random red sparkle across all LEDs |
| Idle | User color (when light on) | Ring shows your HA light color at configured brightness, or off if light is disabled |
| Wake word detected | Slow clockwise spin | Two trailing arcs at opposing positions, tinted by your color |
| Listening | Fast clockwise spin | Same dual-arc pattern at 50 ms interval, tinted by your color |
| Thinking | Pulsing pair | Two opposing LEDs fade in and out, tinted by your color |
| TTS speaking | Anticlockwise spin | Dual-arc spin in reverse direction, tinted by your color |
| Muted | Solid ring + red indicators | Full ring tinted by your color; red at positions 0, 3, 6 & 9 (all 4 mic locations) |
| Error | Red pulse | All LEDs red, pulsing |
| Timer ticking | Countdown arc | Arc length proportional to `seconds_left / total_seconds`, tinted by your color |
| Timer ringing | Pulse + optional red | Full ring pulsing in your color; red at 3 & 9 if muted |
**Note:** All animations except twinkle, off, and error animations use the color from the HA Light entity. This means you can customize the entire LED experience from Home Assistant by adjusting the light's RGB color and brightness.
---
## Installation
### Step 1 — Host kernel configuration
> **This must be done on the host Raspberry Pi, not inside Docker.**
Edit `/boot/firmware/config.txt` (or `/boot/config.txt` on older Raspberry Pi OS):
```ini
# Enable PWM on GPIO 12 for the SK6812 LED ring
dtoverlay=pwm,pin=12,func=4
# Disable onboard audio — it shares PWM0 with GPIO 12
# dtparam=audio=on ← comment this out
# Optional: I2S line-out if using the HAT's audio output
# dtoverlay=hifiberry-dac
# Optional: increase GPU memory on Pi Zero 2 W
# gpu_mem=64
```
Reboot after saving:
```bash
sudo reboot
```
> **Why disable onboard audio?** The Raspberry Pi's 3.5 mm headphone jack uses the same PWM0 hardware peripheral as GPIO 12. They cannot run simultaneously. The Satellite 1 HAT's XMOS microphone array connects over USB and is unaffected.
> **Pi 5 note:** On the Raspberry Pi 5, GPIO is exposed as `/dev/gpiochip4` instead of `/dev/gpiochip0`. Update the `devices` mapping in `compose.yml`:
> ```yaml
> devices:
> - /dev/gpiochip4:/dev/gpiochip4
> - /dev/mem:/dev/mem
> - /dev/vcio:/dev/vcio
> ```
### Step 2 — Add user to GPIO group
```bash
sudo usermod -aG gpio,spi $USER
```
Log out and back in for the group change to take effect.
### Step 3 — File structure
Place all files in the same directory:
```
Satellite1 HAT Board/
├── Dockerfile
├── compose.yml
├── requirements.txt
└── Satellite1_HAT_Board.py
```
### Step 4 — Build and start
#### Option A — Run with Docker Compose (recommended)
```bash
docker compose up -d
```
Check logs:
```bash
docker compose logs -f
```
#### Option B — Run directly with Python
```bash
pip install -r requirements.txt
python Satellite1_HAT_Board.py --host localhost --port 6055
```
---
## Configuration
All configuration is at the top of `Satellite1_HAT_Board.py`:
```python
# LVA connection
DEFAULT_LVA_HOST = "localhost"
DEFAULT_LVA_PORT = 6055
# GPIO pins (BCM numbering)
LED_GPIO_PIN = 12
BTN_VOLUME_UP = 17
BTN_VOLUME_DOWN = 27
BTN_MUTE = 22
BTN_ACTION = 23
BTN_DEBOUNCE_MS = 150 # Button debounce in milliseconds
# Button multipress timing
MULTIPRESS_TIMEOUT_MS = 500 # Time window between presses (ms)
LONG_PRESS_MS = 1000 # Duration to detect long press (ms)
# LED ring
LED_COUNT = 12
LED_BRIGHTNESS = 168 # 0–255, default is 66 % (168)
# HA Light entity registration
LIGHT_OBJECT_ID = "leds"
LIGHT_NAME = "LEDs"
EFFECT_VOICE_ASSISTANT = "Voice Assistant"
# Default ring colour — matches HA Voice PE default (cyan)
# These are used before the first light_command from HA arrives
DEFAULT_R, DEFAULT_G, DEFAULT_B = 24, 187, 242
```
### Light entity configuration
The light entity is automatically registered with LVA on startup and will appear in Home Assistant as `light.<satellite>_leds`. You can customize it in HA's UI:
- **Entity name** — rename to your preference in HA
- **Color** — adjust in HA's light picker (RGB mode)
- **Brightness** — scale all animations
- **Toggle on/off** — show/hide idle glow
- **Automations** — react to time of day, other entities, etc.
For details, see [Home Assistant Light Entity](#home-assistant-light-entity) above.
### Command-line arguments
The container command in `compose.yml` accepts:
| Argument | Default | Description |
|---|---|---|
| `--host` | `localhost` | LVA container hostname or IP |
| `--port` | `6055` | LVA peripheral API port |
| `--debug` | off | Enable verbose debug logging |
To connect to LVA running on a different host:
```yaml
command:
- "--host"
- "192.168.1.50" # Use your actual device ip address
- "--port"
- "6055"
```
---
## Connection to LVA
The controller connects to LVA's peripheral WebSocket API at `ws://<host>:6055`. The LVA container must have the peripheral API enabled (it is on by default) and port 6055 must be reachable.
On startup the controller:
1. Connects to LVA and registers a Light entity for the LED ring with Home Assistant
2. Receives a state snapshot (current mute state, volume, HA connection status, and light state)
3. Sets the LED ring to the appropriate animation immediately
4. Begins listening for events from LVA and button presses from the hardware
If the connection is lost (LVA restarted, network drop), the controller shows a red twinkle animation and automatically reconnects every 3 seconds.
---
## Using multipress events in Home Assistant
The multipress commands (`button_double_press`, `button_triple_press`, `button_long_press`) are sent to LVA's peripheral API and exposed as button press events. You can trigger Home Assistant automations based on these events.
### Example Home Assistant automation
Create an automation file or use the automation UI:
```yaml
automation:
- alias: "Double press context button for Movie Time"
trigger:
platform: event
event_type: button_press_event
event_data:
press_type: double_press
action:
- service: scene.turn_on
target:
entity_id: scene.movie_time
- alias: "Long press context button for Do Not Disturb"
trigger:
platform: event
event_type: button_press_event
event_data:
press_type: long_press
action:
- service: input_boolean.turn_on
target:
entity_id: input_boolean.do_not_disturb
```
---
## Drivers summary
| Component | Driver needed | Where to install | How |
|---|---|---|---|
| LED ring (SK6812) | PWM kernel overlay | **Host Pi** | `dtoverlay=pwm,pin=12,func=4` in `config.txt`, then reboot |
| Buttons | None | — | `gpiozero` + `lgpio` installed inside container via pip |
| Microphone (XMOS XVF3800) | None | — | Enumerates as USB audio device automatically |
| I2S line-out (optional) | HiFiBerry DAC overlay | **Host Pi** | `dtoverlay=hifiberry-dac` in `config.txt`, then reboot |
---
## Troubleshooting
### LEDs do not light up
1. Confirm `dtoverlay=pwm,pin=12,func=4` is in `config.txt` and the Pi has been rebooted.
2. Confirm onboard audio is disabled (`dtparam=audio=on` is commented out).
3. Check the container is running as root (`user: "0:0"` in compose file) — `rpi-ws281x` requires root for DMA access via `/dev/mem`.
4. Run with `--debug` and look for `LED ring initialised` in the logs. If `rpi_ws281x not found` appears, the Python package failed to install — rebuild the image.
5. **If you see only pipeline animations (listening, thinking, etc.) but no idle glow:** The light entity defaults to **off** in Home Assistant to match Voice PE behavior. Turn on the `light.<satellite>_leds` entity in HA to show the idle LED color.
### Buttons do not respond
1. Confirm `/dev/gpiochip0` (or `/dev/gpiochip4` on Pi 5) is mapped in the compose `devices` section.
2. Run with `--debug` — each button press logs `Button → <command>`.
### Light entity not appearing in Home Assistant
1. Confirm the Satellite1 container is connected to LVA: check `docker compose logs` for `Connected to LVA peripheral API`.
2. Confirm LVA is connected to Home Assistant: the LVA logs should show `Home Assistant connected` and the satellite integration should be visible in HA's integrations.
3. Restart Home Assistant or refresh the Devices page — the new light entity should appear under the satellite integration.
4. Entity ID will be `light.<satellite>_leds` where `<satellite>` is your satellite name in HA (e.g., `light.kitchen_satellite_leds`).
### Container exits immediately
1. Run `docker compose logs` — a missing `/dev/mem` or `/dev/vcio` device produces a clear error at startup.
2. Ensure `privileged: true` is set in the compose file.
### LVA not reachable
1. Confirm LVA is running and `--disable-peripheral-api` was not passed.
2. With `network_mode: host`, `localhost` resolves to the Pi itself. If LVA is in a separate Docker network (not host mode), use its container IP or service name instead.
3. Check that port 6055 is not blocked: `nc -zv localhost 6055`.
+47
View File
@@ -0,0 +1,47 @@
FROM python:3.13-slim-bookworm
ENV LANG=C.UTF-8
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
LABEL \
org.opencontainers.image.title="Satellite 1 HAT Peripheral Controller" \
org.opencontainers.image.description="LED ring and button controller for Satellite 1 HAT Board, communicating with Linux Voice Assistant" \
org.opencontainers.image.source="https://github.com/OHF-Voice/linux-voice-assistant"
# ----------------------------------------------------------------------------
# System packages
#
# build-essential – compiles rpi-ws281x C extension
# python3-dev – Python C headers for native extensions
# git – rpi-ws281x sometimes pulls from git during install
#
# Note: libgpiod-dev is NOT needed — gpiozero uses the lgpio backend
# (installed via pip) which works on Pi 3/4/5 without RPi.GPIO.
# ----------------------------------------------------------------------------
RUN apt-get update && \
apt-get install --yes --no-install-recommends \
build-essential \
python3-dev \
git \
ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# ----------------------------------------------------------------------------
# Python dependencies
#
# websockets – WebSocket client to talk to LVA peripheral API
# rpi-ws281x – SK6812 / WS2812 LED ring driver (PWM + DMA)
# gpiozero – GPIO button input (Pi 3/4/5 compatible)
# lgpio – gpiozero backend; works on Pi 3/4/5 including Pi 5
# ----------------------------------------------------------------------------
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY Satellite1_HAT_Board.py ./
ENTRYPOINT ["python3", "Satellite1_HAT_Board.py"]
CMD []
File diff suppressed because it is too large. Load diff
+148
View File
@@ -0,0 +1,148 @@
---
# Satellite 1 HAT Board – peripheral controller
#
# Runs alongside the linux-voice-assistant container.
# Connects to LVA's peripheral WebSocket API (port 6055) and drives the
# 12-LED SK6812 ring and four hardware buttons on the Satellite 1 HAT.
#
# IMPORTANT – read the hardware notes at the bottom of this file before
# starting the container for the first time.
services:
satellite1-peripheral:
build: .
image: lva/satellite1_hat_board:local
container_name: satellite1_hat_board
restart: unless-stopped
# -----------------------------------------------------------------------
# PWM + DMA access (required by rpi-ws281x for LED ring control)
#
# rpi-ws281x drives the SK6812 LEDs via hardware PWM (GPIO 12, channel 0)
# and DMA. This requires access to /dev/mem for DMA buffer allocation.
# There is no narrower capability; privileged is the practical requirement
# on Raspberry Pi OS when using PWM mode.
# -----------------------------------------------------------------------
privileged: true
# -----------------------------------------------------------------------
# Device access
# -----------------------------------------------------------------------
devices:
# GPIO memory map – needed by RPi.GPIO for button input
- /dev/gpiomem:/dev/gpiomem
# Full physical memory map – needed by rpi-ws281x DMA engine
- /dev/mem:/dev/mem
# Video core mailbox – used by rpi-ws281x to allocate DMA buffers
- /dev/vcio:/dev/vcio
# -----------------------------------------------------------------------
# Run as root inside the container.
# rpi-ws281x's DMA allocator requires root access to /dev/mem and /dev/vcio.
# -----------------------------------------------------------------------
user: "0:0"
group_add:
# gpio group – allows RPi.GPIO to open /dev/gpiomem without root
# (redundant when user is root but good practice to keep for non-root builds)
- gpio
# -----------------------------------------------------------------------
# Environment variables
# -----------------------------------------------------------------------
environment:
- PYTHONUNBUFFERED=1
# -----------------------------------------------------------------------
# Network
#
# Uses the host network so "localhost" resolves to the Pi itself and
# the LVA container's port 6055 is directly reachable.
# If LVA is on a different host, change --host below to its IP address
# and remove network_mode: host.
# -----------------------------------------------------------------------
network_mode: host
# -----------------------------------------------------------------------
# Command-line arguments passed to Satellite1_HAT_Board.py
# -----------------------------------------------------------------------
command:
- "--host"
- "localhost" # Change to LVA container IP if on a different host
- "--port"
- "6055"
# Uncomment for verbose logging during setup / troubleshooting:
# - "--debug"
# -----------------------------------------------------------------------
# Health check – verifies the process is still running
# -----------------------------------------------------------------------
healthcheck:
test: ["CMD", "pgrep", "-f", "Satellite1_HAT_Board.py"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
# -----------------------------------------------------------------------
# Logging – keep last 10 MB across 3 rotated files
# -----------------------------------------------------------------------
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# =============================================================================
# HARDWARE NOTES – read before first run
# =============================================================================
#
# 1. SPI / PWM KERNEL OVERLAY
# rpi-ws281x can drive LEDs via PWM (recommended), PCM, or SPI.
# This compose file uses PWM on GPIO 12 (PWM0, channel 0).
# Add the following line to /boot/firmware/config.txt (or /boot/config.txt
# on older Raspberry Pi OS):
#
# dtoverlay=pwm,pin=12,func=4
#
# Then reboot. Without this overlay the PWM pin is not mapped and the
# LED ring will not light up.
#
# Alternatively, SPI mode can be used (GPIO 10, SPI0 MOSI). If you prefer
# SPI, enable it via:
# dtoverlay=spi0-1cs
# and change LED_GPIO_PIN to 10 and LED_CHANNEL to 0 in satellite1_peripheral.py,
# then also set LED_DMA to 10 and remove the need for /dev/vcio.
#
# 2. AUDIO CONFLICT WITH PWM
# The Raspberry Pi's onboard audio also uses PWM. If you use the 3.5 mm
# headphone jack, it conflicts with PWM0. Disable onboard audio in
# /boot/firmware/config.txt:
#
# # dtparam=audio=on ← comment this out
#
# The Satellite 1 HAT has its own I2S audio codec (XMOS XVF3800), so
# onboard audio is not needed.
#
# 3. SATELLITE 1 HAT I2S AUDIO DRIVER
# The HAT's microphone array is driven by the XMOS XVF3800 over USB.
# No additional kernel driver is required — it enumerates as a standard
# USB audio device. LVA picks it up automatically via soundcard.
# If you use the Satellite 1 HAT's line-out, add to config.txt:
#
# dtoverlay=hifiberry-dac
#
# 4. GPIO GROUP
# Make sure the user running Docker belongs to the gpio and spi groups
# on the host:
#
# sudo usermod -aG gpio,spi $USER
#
# 5. MEMORY SPLIT (optional)
# On Pi Zero 2 W, increase GPU memory to at least 64 MB so the VideoCore
# mailbox has enough address space for the DMA allocator:
#
# gpu_mem=64 (in /boot/firmware/config.txt)
#
# =============================================================================
Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

@@ -0,0 +1,4 @@
websockets>=12.0
rpi-ws281x>=5.0.0
gpiozero>=2.0
lgpio>=0.2
File renamed without changes.
File renamed without changes.
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://app.kilo.ai/config.json",
"model": "kilo/codegemma-2b:free",
"instructions": ["AGENTS.md"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}
+535 -144
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import argparse
import asyncio
import errno
import json
import logging
import sys
@@ -8,20 +9,28 @@ import threading
import time
from pathlib import Path
from queue import Queue
from typing import Dict, List, Optional, Set, Union
from typing import List, Optional, Union
import numpy as np
import soundcard as sc
from aioesphomeapi.api_pb2 import NumberStateResponse # type: ignore # pylint: disable=no-name-in-module
from getmac import get_mac_address # type: ignore
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType
from .models import Preferences, ServerState, WakeWordType
from .mpv_player import MpvMediaPlayer
from .peripheral_api import LVAEvent, PeripheralAPIServer
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 .util import get_default_interface
from .util import get_default_ipv4
_LOGGER = logging.getLogger(__name__)
_MODULE_DIR = Path(__file__).parent
@@ -36,11 +45,12 @@ _SOUNDS_DIR = _REPO_DIR / "sounds"
async def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--name"
"--name",
help="Real name for the device",
)
parser.add_argument(
"--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(
"--list-input-devices",
@@ -48,39 +58,49 @@ async def main() -> None:
help="List audio input devices and exit",
)
parser.add_argument(
"--audio-input-block-size",
type=int,
default=1024
"--audio-input-block-size",
type=int,
default=1024,
)
parser.add_argument(
"--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(
"--list-output-devices",
action="store_true",
help="List audio output devices and exit",
)
parser.add_argument("--mic-volume", type=int, default=100, choices=list(range(1, 101)), help="Microphone volume level (1 to 100)")
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
parser.add_argument("--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4))
parser.add_argument(
"--audio-input-channels",
type=int,
default=1,
choices=(1, 2),
help="Number of mic channels to capture and stream (1=mono, 2=dual-channel voice)",
)
parser.add_argument(
"--wake-word-dir",
default=[_WAKEWORDS_DIR],
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(
"--wake-model",
default="okay_nabu",
help="Id of active wake model"
"--wake-model",
default="okay_nabu",
help="File name of the first active wake model",
)
parser.add_argument(
"--stop-model",
default="stop",
help="Id of stop model"
"--stop-model",
default="stop",
help="File name of the stop model",
)
parser.add_argument(
"--download-dir",
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(
"--refractory-seconds",
@@ -89,12 +109,25 @@ async def main() -> None:
help="Seconds before wake word can be activated again",
)
parser.add_argument(
"--wakeup-sound",
default=str(_SOUNDS_DIR / "wake_word_triggered.flac")
"--continue-conversation-delay",
type=float,
default=0.5,
help="Seconds to wait after TTS finishes before opening the mic for continued conversation (default: 0.5)",
)
parser.add_argument(
"--timer-finished-sound",
default=str(_SOUNDS_DIR / "timer_finished.flac")
"--wakeup-sound",
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(
"--start-listening-sound",
default=str(_SOUNDS_DIR / "start_listening_button.flac"),
help="Directory and file name and sound for start listening button (when you press button to talk)",
)
parser.add_argument(
"--timer-finished-sound",
default=str(_SOUNDS_DIR / "timer_finished.flac"),
help="Directory and file name for timer finished sound",
)
parser.add_argument(
"--processing-sound",
@@ -111,33 +144,119 @@ async def main() -> None:
default=str(_SOUNDS_DIR / "mute_switch_off.flac"),
help="Sound to play when unmuting the assistant",
)
parser.add_argument("--preferences-file", default=_REPO_DIR / "preferences.json")
parser.add_argument(
"--button-double-press-sound",
default=str(_SOUNDS_DIR / "button_double_press.flac"),
help="Sound to play for button double press",
)
parser.add_argument(
"--button-triple-press-sound",
default=str(_SOUNDS_DIR / "button_triple_press.flac"),
help="Sound to play for button triple press",
)
parser.add_argument(
"--button-long-press-sound",
default=str(_SOUNDS_DIR / "button_long_press.flac"),
help="Sound to play for button long press",
)
parser.add_argument(
"--preferences-file",
default=_REPO_DIR / "preferences.json",
help="Directory and file name for the preferences JSON file",
)
parser.add_argument(
"--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: auto-detected by network interface)",
)
parser.add_argument(
"--network-interface",
help="Network interface to use for ESPHome server (default: will be automatically detected)",
help="Network interface the application listens on (default: auto-detected by gateway)",
)
# Note that default port is also set in docker-entrypoint.sh
parser.add_argument(
"--port", type=int,
default=6053,
help="Port for ESPHome server (default: 6053)"
"--port",
type=int,
default=6053,
help="Port the application is listening on (default: 6053)",
)
parser.add_argument(
"--enable-thinking-sound",
action="store_true",
help="Enable thinking sound on startup",
)
# ------------------------------------------------------------------
# Peripheral API (LEDs, buttons, HAT boards)
# ------------------------------------------------------------------
parser.add_argument(
"--debug", action="store_true", help="Print DEBUG messages to console"
"--peripheral-host",
default="0.0.0.0",
help="Bind address for the peripheral WebSocket API (default: 0.0.0.0)",
)
parser.add_argument(
"--peripheral-port",
type=int,
default=6055,
help="Port for the peripheral WebSocket API (default: 6055)",
)
parser.add_argument(
"--peripheral-volume-step",
type=float,
default=PeripheralAPIServer.DEFAULT_VOLUME_STEP,
metavar="STEP",
help="Volume change per button press, 0.0–1.0 (default: %(default)s)",
)
parser.add_argument(
"--disable-peripheral-api",
action="store_true",
help="Disable the peripheral WebSocket API entirely",
)
parser.add_argument(
"--peripheral-startup-wait",
type=float,
default=2.0,
metavar="SECONDS",
help="Seconds to wait for peripherals to connect and register their entities before HA enumerates the ESPHome API (default: %(default)s; set 0 to skip).",
)
# ------------------------------------------------------------------
parser.add_argument(
"--timer-max-ring-seconds",
type=float,
default=900.0, # 15 minutes
help="Seconds before a ringing timer auto-stops (default: 900)",
)
parser.add_argument(
"--listen-during-wake-sound",
action="store_true",
help="Start listening immediately after wake word detection, without waiting for the wake sound to finish",
)
parser.add_argument(
"--debug",
action="store_true",
help="Add this to enable debug logging",
)
parser.add_argument(
"--colored-debug",
action="store_true",
help="Add this to enable colored debug logging",
)
parser.add_argument(
"--output-only",
action="store_true",
help="Enable output only mode",
)
args = parser.parse_args()
if args.colored_debug:
args.debug = True
_setup_logging(args)
elif args.debug:
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig(level=logging.INFO)
_LOGGER.debug(args)
if args.list_input_devices:
print("Input devices")
print("Audio Input devices:")
print("=" * 13)
for idx, mic in enumerate(sc.all_microphones()):
print(f"[{idx}]", mic.name)
@@ -147,49 +266,60 @@ async def main() -> None:
from mpv import MPV
player = MPV()
print("Output devices")
print("Audio output devices:")
print("=" * 14)
for speaker in player.audio_device_list: # type: ignore
print(speaker["name"] + ":", speaker["description"])
return
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
# Resolve network interface for mac-adress detection
# Resolve network interface for mac-address detection
if not args.network_interface:
print("No network interface specified, try to detect default interface")
network_interface = get_default_interface()
print(f"Default interface detected:", network_interface)
print(f"Default interface detected: {network_interface}")
else:
print("Network interface specified")
network_interface = args.network_interface
print(f"Using network interface: ", network_interface)
print(f"Using network interface: {network_interface}")
# Resolve ip_address where the application will be listening
if not args.host:
print("No host (ip-address) specified, try to detect IP-Address")
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:
print("Host specified")
print(f"Using host: ", args.host)
print(f"Using host: {args.host}")
host_ip_address = args.host
# Resolve mac
mac_address = get_mac_address(interface=network_interface)
mac_address_clean = mac_address.replace(":", "")
if not (mac_address := get_mac_address(interface=network_interface)):
print("No Mac address was found, app stopped.")
sys.exit(1)
mac_address_clean = mac_address.replace(":", "").lower()
# Resolve name
if not args.name:
print("No name specified, try to autogenerate name")
device_name = f"lva-{mac_address_clean}"
print(f"Name autogenerated: {device_name}")
print("No friendly name specified, try to autogenerate name")
friendly_name = f"LVA - {mac_address_clean}"
print(f"Friendly name autogenerated: {friendly_name}")
else:
print("Name specified")
print(f"Using name: {args.name}")
device_name = args.name
print("Friendly name specified")
print(f"Using friendly 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
args.download_dir = Path(args.download_dir)
@@ -208,33 +338,27 @@ async def main() -> None:
# Load available wake words
wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir]
# If the operator explicitly pointed --wake-word-dir (or the WAKE_WORD_DIR
# env var) at the openWakeWord subdirectory, prefer resolving --wake-model
# to an openWakeWord model of the same name instead of a same-named
# microWakeWord one. Checked before the automatic dirs below are appended,
# since those always include the openWakeWord path and would otherwise
# make every configuration look like an openWakeWord preference.
preferred_wake_word_type = WakeWordType.OPEN_WAKE_WORD if any("openwakeword" in str(ww_dir).lower() for ww_dir in wake_word_dirs) else None
# openWakeWord models ship in their own subdirectory under the default
# wakewords dir. find_available_wake_words() only globs the top level of
# each directory it's given, so this must be added explicitly or the OWW
# models never get discovered (and never show up in the HA dropdown).
# Appended after the user-specified dirs so OWW entries are inserted
# (and therefore displayed) after the microWakeWord ones.
oww_dir = _WAKEWORDS_DIR / "openWakeWord"
if oww_dir not in wake_word_dirs:
wake_word_dirs.append(oww_dir)
wake_word_dirs.append(args.download_dir / "external_wake_words")
available_wake_words: Dict[str, AvailableWakeWord] = {}
for wake_word_dir in wake_word_dirs:
for model_config_path in wake_word_dir.glob("*.json"):
model_id = model_config_path.stem
if model_id == args.stop_model:
# Don't show stop model as an available wake word
continue
with open(model_config_path, "r", encoding="utf-8") as model_config_file:
model_config = json.load(model_config_file)
model_type = WakeWordType(model_config["type"])
if model_type == WakeWordType.OPEN_WAKE_WORD:
wake_word_path = model_config_path.parent / model_config["model"]
else:
wake_word_path = model_config_path
available_wake_words[model_id] = AvailableWakeWord(
id=model_id,
type=WakeWordType(model_type),
wake_word=model_config["wake_word"],
trained_languages=model_config.get("trained_languages", []),
wake_word_path=wake_word_path,
)
_LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys())))
available_wake_words = find_available_wake_words(wake_word_dirs, args.stop_model)
# Load preferences
preferences_path = Path(args.preferences_file)
@@ -246,51 +370,49 @@ async def main() -> None:
else:
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:
preferences.thinking_sound = 1
if args.mic_auto_gain or args.mic_noise_suppression:
try:
import webrtc_noise_gain # type: ignore[import-untyped] # noqa: F401
except ImportError:
_LOGGER.exception("Extras for webrtc are not installed")
sys.exit(1)
if args.mic_volume > 0.0:
preferences.mic_volume = args.mic_volume
if args.mic_auto_gain > 0:
preferences.mic_auto_gain = args.mic_auto_gain
if args.mic_noise_suppression > 0:
preferences.mic_noise_suppression = args.mic_noise_suppression
# Load wake/stop models
active_wake_words: Set[str] = set()
wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
if preferences.active_wake_words:
# Load preferred models
for wake_word_id in preferences.active_wake_words:
wake_word = available_wake_words.get(wake_word_id)
if wake_word is None:
_LOGGER.warning("Unrecognized wake word id: %s", wake_word_id)
continue
_LOGGER.debug("Loading wake model: %s", wake_word_id)
wake_models[wake_word_id] = wake_word.load()
active_wake_words.add(wake_word_id)
if not wake_models:
# Load default model
wake_word_id = args.wake_model
wake_word = available_wake_words[wake_word_id]
_LOGGER.debug("Loading wake model: %s", wake_word_id)
wake_models[wake_word_id] = wake_word.load()
active_wake_words.add(wake_word_id)
wake_models, active_wake_words, fallback_used = load_wake_models(
available_wake_words,
[word for word in preferences.active_wake_words if word is not None],
args.wake_model,
preferred_type=preferred_wake_word_type,
)
# TODO: allow openWakeWord for "stop"
stop_model: Optional[MicroWakeWord] = None
for wake_word_dir in wake_word_dirs:
stop_config_path = wake_word_dir / f"{args.stop_model}.json"
if not stop_config_path.exists():
continue
_LOGGER.debug("Loading stop model: %s", stop_config_path)
stop_model = MicroWakeWord.from_config(stop_config_path)
break
stop_model = load_stop_model(wake_word_dirs, args.stop_model)
assert stop_model is not None
state = ServerState(
name=device_name,
friendly_name=friendly_name,
network_interface=network_interface,
mac_address=get_mac_address(interface=network_interface),
mac_address=mac_address,
ip_address=host_ip_address,
version=version,
esphome_version=esphome_version,
audio_queue=Queue(),
entities=[],
available_wake_words=available_wake_words,
@@ -300,19 +422,118 @@ async def main() -> None:
music_player=MpvMediaPlayer(device=args.audio_output_device),
tts_player=MpvMediaPlayer(device=args.audio_output_device),
wakeup_sound=args.wakeup_sound,
start_listening_sound=args.start_listening_sound,
timer_finished_sound=args.timer_finished_sound,
processing_sound=args.processing_sound,
mute_sound=args.mute_sound,
unmute_sound=args.unmute_sound,
button_double_press_sound=args.button_double_press_sound,
button_triple_press_sound=args.button_triple_press_sound,
button_long_press_sound=args.button_long_press_sound,
preferences=preferences,
preferences_path=preferences_path,
refractory_seconds=args.refractory_seconds,
continue_conversation_delay=args.continue_conversation_delay,
output_only=args.output_only,
download_dir=args.download_dir,
volume=initial_volume,
mic_volume=preferences.mic_volume,
mic_auto_gain=preferences.mic_auto_gain,
mic_noise_suppression=preferences.mic_noise_suppression,
audio_input_channels=args.audio_input_channels,
timer_max_ring_seconds=args.timer_max_ring_seconds,
listen_during_wake_sound=args.listen_during_wake_sound,
)
if args.enable_thinking_sound:
if fallback_used:
# Fallback to the default model was used, save as active wake words
_LOGGER.debug("Fallback was used, save default wake words in Preferences.")
state.preferences.active_wake_words = list(active_wake_words)
state.active_wake_words = active_wake_words
state.wake_words = wake_models
state.save_preferences()
state.wake_words_changed = True
if args.enable_thinking_sound or args.mic_auto_gain or args.mic_noise_suppression:
state.save_preferences()
initial_volume_percent = int(round(initial_volume * 100))
state.music_player.set_volume(initial_volume_percent)
state.tts_player.set_volume(initial_volume_percent)
# ------------------------------------------------------------------
# Peripheral API (optional – LEDs, buttons, HAT boards)
# ------------------------------------------------------------------
peripheral_api: Optional[PeripheralAPIServer] = None
if not args.disable_peripheral_api:
peripheral_api = PeripheralAPIServer(
host=args.peripheral_host,
port=args.peripheral_port,
volume_step=args.peripheral_volume_step,
)
peripheral_api.set_state(state)
state.peripheral_api = peripheral_api
# ------------------------------------------------------------------
# ESPHome TCP server (with retry on EADDRINUSE)
# ------------------------------------------------------------------
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 # connection 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)
# ------------------------------------------------------------------
# Audio processing thread
# ------------------------------------------------------------------
process_audio_thread = threading.Thread(
target=process_audio,
args=(state, mic, args.audio_input_block_size),
@@ -320,33 +541,89 @@ async def main() -> None:
)
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)
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()
# ------------------------------------------------------------------
# Start peripheral API and signal "getting started" to peripherals
# ------------------------------------------------------------------
if peripheral_api is not None:
await peripheral_api.start()
await peripheral_api.emit_event(LVAEvent.ZEROCONF, {"status": "getting_started"})
# Give peripherals a window to connect and register their Light
# entities before HA enumerates over the ESPHome native API. The
# ESPHome server is bound but not yet serving (serve_forever runs
# below), so any HA connection sits queued in the kernel for the
# duration of this wait. Peripherals that register later still
# work, but the new entities only show up in HA after the
# integration reconnects.
if args.peripheral_startup_wait > 0:
_LOGGER.info(
"Waiting %.1fs for peripherals to register entities…",
args.peripheral_startup_wait,
)
await asyncio.sleep(args.peripheral_startup_wait)
try:
async with server:
async with server: # type: ignore[union-attr]
_LOGGER.info("Server started (host=%s, port=%s)", host_ip_address, args.port)
await server.serve_forever()
await server.serve_forever() # type: ignore[union-attr]
except KeyboardInterrupt:
pass
finally:
state.audio_queue.put_nowait(None)
process_audio_thread.join()
if peripheral_api is not None:
await peripheral_api.stop()
_LOGGER.debug("Server stopped")
# -----------------------------------------------------------------------------
def _setup_logging(args: argparse.Namespace) -> None:
COLORS = {
logging.DEBUG: "\033[36m",
logging.INFO: "\033[32m",
logging.WARNING: "\033[33m",
logging.ERROR: "\033[31m",
logging.CRITICAL: "\033[35m",
}
RESET = "\033[0m"
original_format = logging.Formatter.format
def colored_format(self, record: logging.LogRecord) -> str:
color = COLORS.get(record.levelno, RESET)
return f"{color}{original_format(self, record)}{RESET}"
logging.Formatter.format = colored_format # type: ignore
handler = logging.StreamHandler()
handler.setFormatter(
logging.Formatter(
fmt="%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
)
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO,
handlers=[handler],
)
# -----------------------------------------------------------------------------
def process_audio(state: ServerState, mic, block_size: int):
"""Process audio chunks from the microphone."""
n_channels = state.audio_input_channels
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
micro_features: Optional[MicroWakeWordFeatures] = None
@@ -357,35 +634,119 @@ def process_audio(state: ServerState, mic, block_size: int):
has_oww = False
last_active: Optional[float] = None
webrtc: Optional[WebRTCProcessor] = None
try:
_LOGGER.debug("Opening audio input device: %s", mic.name)
with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in:
with mic.recorder(samplerate=16000, channels=n_channels, blocksize=block_size) as mic_in:
while True:
audio_chunk_array = mic_in.record(block_size).reshape(-1)
audio_chunk = (
(np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
.astype("<i2") # little-endian 16-bit signed
.tobytes()
)
# Shape: (block_size, n_channels) for stereo, (block_size, 1) for mono.
raw = mic_in.record(block_size) # float32, range [-1, 1]
mic_vol_scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
if state.satellite is None:
# Build per-channel byte arrays. Channel 0 is the primary
# microphone; channel 1 (when present) is the reference/speaker
# feed used for server-side AEC.
channel_chunks: list[bytes] = []
for ch in range(n_channels):
col = raw[:, ch] if n_channels > 1 else raw.reshape(-1)
chunk = (np.clip(col * mic_vol_scalar, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
channel_chunks.append(chunk)
# Primary channel drives WebRTC and wake-word detection.
audio_chunk = channel_chunks[0]
agc = state.preferences.mic_auto_gain or 0
ns = state.preferences.mic_noise_suppression or 0
if agc > 0 or ns > 0:
if webrtc is None:
webrtc = WebRTCProcessor(agc_level=agc, ns_level=ns)
else:
webrtc.update_settings(agc, ns)
audio_chunk = webrtc.process(audio_chunk)
if not audio_chunk:
continue
if state.satellite is None or not hasattr(state.satellite, "_is_streaming_audio"):
continue
# WAKE WORD
if (not wake_words) or (state.wake_words_changed and state.wake_words):
# Update list of wake word models to process
state.wake_words_changed = False
wake_words = [
ww
for ww in state.wake_words.values()
if ww.id in state.active_wake_words
]
wake_words = [ww for ww in state.wake_words.values() if ww.id in state.active_wake_words]
# TODO: Load default stop word value from json into state and preferences missing.
has_oww = False
for wake_word in wake_words:
for idx, wake_word in enumerate(wake_words):
# Load default threshold from model json
wake_word_id = wake_word.id if hasattr(wake_word, "id") else next(iter(state.wake_words.keys()))
available_word = state.available_wake_words.get(wake_word_id)
# _LOGGER.debug("word= %s", state.available_wake_words.get(wake_word_id))
default_threshold = available_word.probability_cutoff if available_word else 0.7
_LOGGER.debug("Using default threshold %.3f for wake word '%s' from model config", default_threshold, wake_word_id)
# Check preferences override
if idx == 0:
old_val = state.wake_word_1_threshold
if state.preferences.wake_word_1_sensitivity is not None:
state.wake_word_1_threshold = state.preferences.wake_word_1_sensitivity
else:
state.wake_word_1_threshold = default_threshold
_LOGGER.debug("Wake Word 1 threshold set to %.3f (was %.3f, preferences: %s)", state.wake_word_1_threshold, old_val, state.preferences.wake_word_1_sensitivity)
elif idx == 1:
old_val = state.wake_word_2_threshold
if state.preferences.wake_word_2_sensitivity is not None:
state.wake_word_2_threshold = state.preferences.wake_word_2_sensitivity
else:
state.wake_word_2_threshold = default_threshold
_LOGGER.debug("Wake Word 2 threshold set to %.3f (was %.3f, preferences: %s)", state.wake_word_2_threshold, old_val, state.preferences.wake_word_2_sensitivity)
if isinstance(wake_word, OpenWakeWord):
has_oww = True
# Sync entity states after threshold values were updated
if state.satellite is not None:
_LOGGER.debug("Updating WebUI entities with new threshold values")
# Wake Word 1
if state.satellite.state.sensitivity_1_number_entity is not None:
_LOGGER.debug(" → Syncing Wake Word 1 entity to value %.3f", state.wake_word_1_threshold)
state.satellite.state.sensitivity_1_number_entity.sync_with_state()
_LOGGER.debug(" ✅ Wake Word 1 entity now has value %.3f", state.satellite.state.sensitivity_1_number_entity.value)
# Wake Word 2
if state.satellite.state.sensitivity_2_number_entity is not None:
_LOGGER.debug(" → Syncing Wake Word 2 entity to value %.3f", state.wake_word_2_threshold)
state.satellite.state.sensitivity_2_number_entity.sync_with_state()
_LOGGER.debug(" ✅ Wake Word 2 entity now has value %.3f", state.satellite.state.sensitivity_2_number_entity.value)
# Stop Word
if state.satellite.state.stop_sensitivity_number_entity is not None:
_LOGGER.debug(" → Syncing Stop Word entity to value %.3f", state.stop_word_threshold)
state.satellite.state.stop_sensitivity_number_entity.sync_with_state()
_LOGGER.debug(" ✅ Stop Word entity now has value %.3f", state.satellite.state.stop_sensitivity_number_entity.value)
_LOGGER.debug("All sensitivity entities synced successfully")
# Force push new state to connected Home Assistant instance
if state.satellite is not None:
try:
_LOGGER.debug("Pushing updated state values to Home Assistant")
for entity in [
state.satellite.state.sensitivity_1_number_entity,
state.satellite.state.sensitivity_2_number_entity,
state.satellite.state.stop_sensitivity_number_entity,
]:
if entity is not None:
state.satellite.send_messages([NumberStateResponse(key=entity.key, state=entity.value)]) # type: ignore[attr-defined]
_LOGGER.debug(" → Pushed value %.3f for entity %d", entity.value, entity.key)
except Exception as e:
_LOGGER.debug("Could not push state (no client connected yet): %s", e)
# TODO: Save settings: At this moment settings are only saved when changed in the UI. Means that the default value can change while updating since its not saved in preferences.
if micro_features is None:
micro_features = MicroWakeWordFeatures()
@@ -393,7 +754,9 @@ def process_audio(state: ServerState, mic, block_size: int):
oww_features = OpenWakeWordFeatures.from_builtin()
try:
state.satellite.handle_audio(audio_chunk)
# Both channels travel in one message: data=ch0 (enhanced), data2=ch1 (raw reference)
audio_chunk_2 = channel_chunks[1] if n_channels >= 2 else None
state.satellite.handle_audio(audio_chunk, audio_chunk_2)
assert micro_features is not None
micro_inputs.clear()
@@ -404,47 +767,75 @@ def process_audio(state: ServerState, mic, block_size: int):
oww_inputs.clear()
oww_inputs.extend(oww_features.process_streaming(audio_chunk))
for wake_word in wake_words:
for wake_word_index, wake_word in enumerate(wake_words):
activated = False
# Set dynamic threshold depending on wake word index
if wake_word_index == 0:
threshold = state.wake_word_1_threshold
# _LOGGER.debug("Set wake word %d probability cutoff to %.3f", wake_word_index+1, state.wake_word_1_threshold)
elif wake_word_index == 1:
threshold = state.wake_word_2_threshold
# _LOGGER.debug("Set wake word %d probability cutoff to %.3f", wake_word_index+1, state.wake_word_2_threshold)
else:
threshold = 0.7
# _LOGGER.debug("Set wake word %d probability cutoff to fallback value 0.7", wake_word_index+1)
if isinstance(wake_word, MicroWakeWord):
# No debugging when no detection
wake_word.debug_probabilities = False
# set microWakeWord cutoff
wake_word.probability_cutoff = threshold
for micro_input in micro_inputs:
if wake_word.process_streaming(micro_input):
wake_word.debug_probabilities = True
activated = True
elif isinstance(wake_word, OpenWakeWord):
for oww_input in oww_inputs:
for prob in wake_word.process_streaming(oww_input):
if prob > 0.5:
if prob > threshold:
_LOGGER.debug("Wake word '%s' activated (probability %.3f exceeded threshold %.3f)", wake_word.wake_word, prob, threshold) # type: ignore[attr-defined]
activated = True
if activated and not state.muted:
# Check refractory
now = time.monotonic()
if (last_active is None) or (
(now - last_active) > state.refractory_seconds
):
if (last_active is None) or ((now - last_active) > state.refractory_seconds):
state.satellite.wakeup(wake_word)
last_active = now
# Always process to keep state correct
stopped = False
# No debugging when no detection
state.stop_word.debug_probabilities = False
# Apply stop word sensitivity threshold
state.stop_word.probability_cutoff = state.stop_word_threshold
# _LOGGER.debug("Set stop word probability cutoff to %.3f", state.stop_word_threshold)
for micro_input in micro_inputs:
if state.stop_word.process_streaming(micro_input):
state.stop_word.debug_probabilities = True
stopped = True
if (
stopped
and (state.stop_word.id in state.active_wake_words)
and not state.muted
):
if stopped and (state.stop_word.id in state.active_wake_words) and not state.muted:
_LOGGER.debug("Stop word detected")
state.satellite.stop()
except Exception:
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected error handling audio")
except Exception:
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected error processing audio")
sys.exit(1)
# -----------------------------------------------------------------------------
if __name__ == "__main__":
def run():
asyncio.run(main())
if __name__ == "__main__":
run()
+4 -11
View File
@@ -5,7 +5,7 @@ import logging
import threading
from abc import abstractmethod
from collections.abc import Iterable
from typing import TYPE_CHECKING, List, Optional
from typing import TYPE_CHECKING, Optional
# pylint: disable=no-name-in-module
from aioesphomeapi._frame_helper.packets import make_plain_text_packets
@@ -77,20 +77,13 @@ class APIServer(asyncio.Protocol):
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:
return
packets = [
(PROTO_TO_MESSAGE_TYPE[msg.__class__], msg.SerializeToString())
for msg in msgs
]
packets = [(PROTO_TO_MESSAGE_TYPE[msg.__class__], msg.SerializeToString()) for msg in msgs]
packet_bytes = make_plain_text_packets(packets)
if (
self._loop is not None
and self._loop_thread_id is not None
and threading.get_ident() != self._loop_thread_id
):
if self._loop is not None and self._loop_thread_id is not None and threading.get_ident() != self._loop_thread_id:
self._loop.call_soon_threadsafe(self._writelines, packet_bytes)
return
+531 -24
View File
@@ -1,24 +1,37 @@
import logging
from abc import abstractmethod
from collections.abc import Iterable
from typing import Callable, List, Optional, Union
import logging
# pylint: disable=no-name-in-module
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
EventResponse,
LightCommandRequest,
LightStateResponse,
ListEntitiesEventResponse,
ListEntitiesLightResponse,
ListEntitiesMediaPlayerResponse,
ListEntitiesNumberResponse,
ListEntitiesRequest,
ListEntitiesSelectResponse,
ListEntitiesSwitchResponse,
MediaPlayerCommandRequest,
MediaPlayerStateResponse,
NumberCommandRequest,
NumberStateResponse,
SelectCommandRequest,
SelectStateResponse,
SubscribeHomeAssistantStatesRequest,
SwitchCommandRequest,
SwitchStateResponse,
)
from aioesphomeapi.model import (
ColorMode,
EntityCategory,
MediaPlayerCommand,
MediaPlayerEntityFeature,
MediaPlayerState,
NumberMode,
)
from google.protobuf import message
@@ -58,6 +71,8 @@ class MediaPlayerEntity(ESPHomeEntity):
object_id: str,
music_player: MpvMediaPlayer,
announce_player: MpvMediaPlayer,
initial_volume: float = 1.0,
on_volume_changed: Optional[Callable[[float], None]] = None,
) -> None:
ESPHomeEntity.__init__(self, server)
@@ -65,13 +80,28 @@ class MediaPlayerEntity(ESPHomeEntity):
self.name = name
self.object_id = object_id
self.state = MediaPlayerState.IDLE
self.volume = 1.0
self.volume = max(0.0, min(1.0, initial_volume))
self.muted = False
self.previous_volume = 1.0
self.music_player = music_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}]")
def _broadcast_state(self, msgs: Iterable[message.Message]) -> None:
"""Push an asynchronous state change to all connected clients.
Playback-completion callbacks fire outside any request, so the update
must reach every subscribed client rather than the single connection in
``self.server`` (which may belong to another client, or be closed).
"""
state = getattr(self.server, "state", None)
if state is not None:
state.broadcast(msgs)
else: # pragma: no cover - no ServerState (e.g. a bare APIServer)
self.server.send_messages(msgs)
def play(
self,
url: Union[str, List[str]],
@@ -79,34 +109,30 @@ class MediaPlayerEntity(ESPHomeEntity):
done_callback: Optional[Callable[[], None]] = None,
) -> Iterable[message.Message]:
if announcement:
self._log.debug("PLAY: announcement true")
if self.music_player.is_playing:
# Announce, resume music
self.music_player.pause()
self.announce_player.play(
url,
done_callback=lambda: call_all(
self.music_player.resume, done_callback
),
done_callback=lambda: call_all(self.music_player.resume, done_callback),
)
else:
# Announce, idle
self.announce_player.play(
url,
done_callback=lambda: call_all(
self.server.send_messages(
[self._update_state(MediaPlayerState.IDLE)]
),
lambda: self._broadcast_state([self._update_state(MediaPlayerState.IDLE)]),
done_callback,
),
)
else:
self._log.debug("PLAY: announcement false")
# Music
self.music_player.play(
url,
done_callback=lambda: call_all(
self.server.send_messages(
[self._update_state(MediaPlayerState.IDLE)]
),
lambda: self._broadcast_state([self._update_state(MediaPlayerState.IDLE)]),
done_callback,
),
)
@@ -116,10 +142,15 @@ class MediaPlayerEntity(ESPHomeEntity):
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
self._log.debug("handle_message called with msg: %s", msg)
# Suppress warning for irrelevant NumberCommandRequest
if isinstance(msg, (NumberCommandRequest, SelectCommandRequest)):
return
if isinstance(msg, MediaPlayerCommandRequest) and (msg.key == self.key):
self._log.debug("MediaPlayerCommandRequest matched for this key")
if msg.has_media_url:
self._log.debug("Executing PLAY")
self._log.debug("Message has media URL: %s", msg.media_url)
announcement = msg.has_announcement and msg.announcement
yield from self.play(msg.media_url, announcement=announcement)
@@ -164,10 +195,12 @@ class MediaPlayerEntity(ESPHomeEntity):
elif msg.has_volume:
self._log.debug("Message has volume: %.2f", msg.volume)
volume = int(msg.volume * 100)
self.music_player.set_volume(volume)
self.announce_player.set_volume(volume)
self.volume = msg.volume
self._apply_volume(msg.volume, persist=True)
if hasattr(self.server, "state") and getattr(self.server, "state", None) is not None:
self._log.debug("Persisting volume to preferences")
self.server.state.persist_volume(self.volume)
else:
self._log.warning("Cannot persist volume - server.state not available")
yield self._update_state(self.state)
elif isinstance(msg, ListEntitiesRequest):
@@ -186,6 +219,8 @@ class MediaPlayerEntity(ESPHomeEntity):
self._log.warning("Unknown message type received: %s", type(msg))
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
return self._get_state_message()
@@ -197,6 +232,43 @@ class MediaPlayerEntity(ESPHomeEntity):
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 +290,7 @@ class MuteSwitchEntity(ESPHomeEntity):
self.object_id = object_id
self._get_muted = get_muted
self._set_muted = set_muted
self._switch_state = (
self._get_muted()
) # Sync internal state with actual muted value on init
self._switch_state = self._get_muted() # Sync internal state with actual muted value on init
def update_set_muted(self, set_muted: Callable[[bool], None]) -> None:
# Update the callback used to change the mute state.
@@ -275,15 +345,11 @@ class ThinkingSoundEntity(ESPHomeEntity):
self._set_thinking_sound_enabled = set_thinking_sound_enabled
self._switch_state = self._get_thinking_sound_enabled() # Sync internal state
def update_get_thinking_sound_enabled(
self, get_thinking_sound_enabled: Callable[[], bool]
) -> None:
def update_get_thinking_sound_enabled(self, get_thinking_sound_enabled: Callable[[], bool]) -> None:
# Update the callback used to read the thinking sound enabled state.
self._get_thinking_sound_enabled = get_thinking_sound_enabled
def update_set_thinking_sound_enabled(
self, set_thinking_sound_enabled: Callable[[bool], None]
) -> None:
def update_set_thinking_sound_enabled(self, set_thinking_sound_enabled: Callable[[bool], None]) -> None:
# Update the callback used to change the thinking sound enabled state.
self._set_thinking_sound_enabled = set_thinking_sound_enabled
@@ -311,3 +377,444 @@ class ThinkingSoundEntity(ESPHomeEntity):
# Always return our internal switch state
self.sync_with_state()
yield SwitchStateResponse(key=self.key, state=self._switch_state)
class MicSettingEntity(ESPHomeEntity):
def __init__(
self,
server: APIServer,
key: int,
name: str,
object_id: str,
get_value: Callable[[], Union[float, str]],
set_value: Callable[[Union[float, str]], None],
min_value: float = 0.0,
max_value: float = 1.0,
options: Optional[List[str]] = None,
icon: str = "mdi:microphone",
) -> None:
ESPHomeEntity.__init__(self, server)
self.key = key
self.name = name
self.object_id = object_id
self.options = options # If present, this behaves as a Dropdown
self.min_value = min_value
self.max_value = max_value
self._get_value = get_value
self._set_value = set_value
self._state = self._get_value()
self.icon = icon
def sync_with_state(self) -> None:
"""Sync internal state with the actual value."""
self._state = self._get_value()
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
# --- 1. HANDLE COMMANDS FROM HOME ASSISTANT ---
if self.options:
if isinstance(msg, SelectCommandRequest) and (msg.key == self.key):
new_val = msg.state
self._state = new_val
self._set_value(new_val)
yield SelectStateResponse(key=self.key, state=new_val)
else:
if isinstance(msg, NumberCommandRequest) and (msg.key == self.key):
new_val = msg.state
self._state = new_val
self._set_value(new_val)
yield NumberStateResponse(key=self.key, state=new_val)
# --- 2. DISCOVERY (TELL HA WHAT TYPE TO SHOW) ---
if isinstance(msg, ListEntitiesRequest):
if self.options:
yield ListEntitiesSelectResponse(
object_id=self.object_id,
key=self.key,
name=self.name,
options=self.options,
entity_category=EntityCategory.CONFIG,
icon=self.icon,
)
else:
yield ListEntitiesNumberResponse(
object_id=self.object_id,
key=self.key,
name=self.name,
min_value=self.min_value,
max_value=self.max_value,
step=1.0,
entity_category=EntityCategory.CONFIG,
icon=self.icon,
)
# --- 3. INITIAL SYNC / STATE UPDATES ---
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
self.sync_with_state()
if self.options:
yield SelectStateResponse(key=self.key, state=str(self._state))
else:
yield NumberStateResponse(key=self.key, state=float(self._state))
def update_get_value(self, get_value: Callable[[], Union[float, str]]) -> None:
self._get_value = get_value
def update_set_value(self, set_value: Callable[[Union[float, str]], None]) -> None:
self._set_value = set_value
# -----------------------------------------------------------------------------
class WakeWord1SensitivityNumberEntity(ESPHomeEntity):
def __init__(
self,
server: APIServer,
key: int,
name: str,
object_id: str,
get_sensitivity: Callable[[], float],
set_sensitivity: Callable[[float], None],
initial_value: float = 0.5,
) -> None:
ESPHomeEntity.__init__(self, server)
self.key = key
self.name = name
self.object_id = object_id
self._get_sensitivity = get_sensitivity
self._set_sensitivity = set_sensitivity
self.value = initial_value
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
def update_get_sensitivity(self, get_sensitivity: Callable[[], float]) -> None:
self._get_sensitivity = get_sensitivity
def update_set_sensitivity(self, set_sensitivity: Callable[[float], None]) -> None:
self._set_sensitivity = set_sensitivity
def sync_with_state(self) -> None:
old_value = self.value
self.value = self._get_sensitivity()
self._log.debug("Entity synchronized: old=%.3f new=%.3f", old_value, self.value)
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
if isinstance(msg, NumberCommandRequest) and (msg.key == self.key):
new_value = float(msg.state)
self._log.debug("Sensitivity value changed: %s => %s", self.value, new_value)
self.value = new_value
self._set_sensitivity(new_value)
yield NumberStateResponse(key=self.key, state=self.value)
elif isinstance(msg, ListEntitiesRequest):
yield ListEntitiesNumberResponse(
object_id=self.object_id,
key=self.key,
name=self.name,
entity_category=EntityCategory.CONFIG,
min_value=0.0,
max_value=1.0,
step=0.001,
mode=NumberMode.BOX,
)
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
self.sync_with_state()
yield NumberStateResponse(key=self.key, state=self.value)
class WakeWord2SensitivityNumberEntity(ESPHomeEntity):
def __init__(
self,
server: APIServer,
key: int,
name: str,
object_id: str,
get_sensitivity: Callable[[], float],
set_sensitivity: Callable[[float], None],
initial_value: float = 0.5,
) -> None:
ESPHomeEntity.__init__(self, server)
self.key = key
self.name = name
self.object_id = object_id
self._get_sensitivity = get_sensitivity
self._set_sensitivity = set_sensitivity
self.value = initial_value
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
def update_get_sensitivity(self, get_sensitivity: Callable[[], float]) -> None:
self._get_sensitivity = get_sensitivity
def update_set_sensitivity(self, set_sensitivity: Callable[[float], None]) -> None:
self._set_sensitivity = set_sensitivity
def sync_with_state(self) -> None:
old_value = self.value
self.value = self._get_sensitivity()
self._log.debug("Entity synchronized: old=%.3f new=%.3f", old_value, self.value)
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
if isinstance(msg, NumberCommandRequest) and (msg.key == self.key):
new_value = float(msg.state)
self._log.debug("Second wake word sensitivity value changed: %s => %s", self.value, new_value)
self.value = new_value
self._set_sensitivity(new_value)
yield NumberStateResponse(key=self.key, state=self.value)
elif isinstance(msg, ListEntitiesRequest):
yield ListEntitiesNumberResponse(
object_id=self.object_id,
key=self.key,
name=self.name,
entity_category=EntityCategory.CONFIG,
min_value=0.0,
max_value=1.0,
step=0.001,
mode=NumberMode.BOX,
)
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
self.sync_with_state()
yield NumberStateResponse(key=self.key, state=self.value)
class StopWordSensitivityNumberEntity(ESPHomeEntity):
def __init__(
self,
server: APIServer,
key: int,
name: str,
object_id: str,
get_sensitivity: Callable[[], float],
set_sensitivity: Callable[[float], None],
initial_value: float = 0.5,
) -> None:
ESPHomeEntity.__init__(self, server)
self.key = key
self.name = name
self.object_id = object_id
self._get_sensitivity = get_sensitivity
self._set_sensitivity = set_sensitivity
self.value = initial_value
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
def update_get_sensitivity(self, get_sensitivity: Callable[[], float]) -> None:
self._get_sensitivity = get_sensitivity
def update_set_sensitivity(self, set_sensitivity: Callable[[float], None]) -> None:
self._set_sensitivity = set_sensitivity
def sync_with_state(self) -> None:
old_value = self.value
self.value = self._get_sensitivity()
self._log.debug("Entity synchronized: old=%.3f new=%.3f", old_value, self.value)
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
if isinstance(msg, NumberCommandRequest) and (msg.key == self.key):
new_value = float(msg.state)
self._log.debug("Stop word sensitivity value changed: %s => %s", self.value, new_value)
self.value = new_value
self._set_sensitivity(new_value)
yield NumberStateResponse(key=self.key, state=self.value)
elif isinstance(msg, ListEntitiesRequest):
yield ListEntitiesNumberResponse(
object_id=self.object_id,
key=self.key,
name=self.name,
entity_category=EntityCategory.CONFIG,
icon="mdi:hand-back-left",
min_value=0.0,
max_value=1.0,
step=0.001,
mode=NumberMode.BOX,
)
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
self.sync_with_state()
yield NumberStateResponse(key=self.key, state=self.value)
class LEDLightEntity(ESPHomeEntity):
"""RGB Light entity for peripheral LEDs.
The peripheral declares its capabilities (effects, RGB, brightness)
via the register_light command. When Home Assistant changes the
entity, on_changed fires so the peripheral API server can broadcast
a light_command event back to the peripheral, which applies the
new state to its hardware.
"""
def __init__(
self,
server: APIServer,
key: int,
name: str,
object_id: str,
effects: Optional[List[str]] = None,
supports_rgb: bool = True,
supports_brightness: bool = True,
on_changed: Optional[Callable[[], None]] = None,
icon: str = "mdi:led-strip-variant",
) -> None:
ESPHomeEntity.__init__(self, server)
self.key = key
self.name = name
self.object_id = object_id
self.icon = icon
self._on_changed = on_changed
self.effects_list: List[str] = list(effects) if effects else []
self._supports_rgb = supports_rgb
self._supports_brightness = supports_brightness
# Off by default, matching the HA Voice PE LED Ring
# (restore_mode RESTORE_DEFAULT_OFF): the resting LEDs stay dark
# until the user turns the light on. Voice animations are driven
# separately by the peripheral and play regardless.
self.is_on: bool = False
# Match the HA Voice PE LED Ring initial state: a light blue at 66%
# brightness (red 9.4%, green 73.3%, blue 94.9%).
self.brightness: float = 0.66
self.red: float = 0.094
self.green: float = 0.733
self.blue: float = 0.949
# Default effect: first declared, or empty if none.
self.effect: str = self.effects_list[0] if self.effects_list else ""
def update_on_changed(self, on_changed: Optional[Callable[[], None]]) -> None:
self._on_changed = on_changed
def _color_mode(self) -> ColorMode:
if self._supports_rgb:
return ColorMode.RGB
if self._supports_brightness:
return ColorMode.BRIGHTNESS
return ColorMode.ON_OFF
def state_dict(self) -> dict:
"""Payload for the light_command event.
Includes object_id so a peripheral that registered more than one
Light can route the event to the right hardware.
"""
return {
"object_id": self.object_id,
"state": self.is_on,
"brightness": self.brightness,
"red": self.red,
"green": self.green,
"blue": self.blue,
"effect": self.effect,
}
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
if isinstance(msg, LightCommandRequest) and msg.key == self.key:
changed = False
if msg.has_state:
self.is_on = bool(msg.state)
changed = True
if msg.has_brightness and self._supports_brightness:
self.brightness = max(0.0, min(1.0, float(msg.brightness)))
changed = True
if msg.has_rgb and self._supports_rgb:
self.red = max(0.0, min(1.0, float(msg.red)))
self.green = max(0.0, min(1.0, float(msg.green)))
self.blue = max(0.0, min(1.0, float(msg.blue)))
changed = True
if msg.has_effect:
requested = str(msg.effect)
if requested in self.effects_list:
self.effect = requested
changed = True
if changed and self._on_changed is not None:
self._on_changed()
yield self._state_response()
elif isinstance(msg, ListEntitiesRequest):
yield ListEntitiesLightResponse(
object_id=self.object_id,
key=self.key,
name=self.name,
supported_color_modes=[int(self._color_mode())],
effects=self.effects_list,
icon=self.icon,
entity_category=EntityCategory.CONFIG,
)
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
yield self._state_response()
def _state_response(self) -> LightStateResponse:
return LightStateResponse(
key=self.key,
state=self.is_on,
brightness=self.brightness,
color_mode=int(self._color_mode()),
color_brightness=self.brightness,
red=self.red,
green=self.green,
blue=self.blue,
effect=self.effect,
)
class ButtonEventSensorEntity(ESPHomeEntity):
def __init__(
self,
server: APIServer,
key: int,
name: str,
object_id: str,
) -> None:
ESPHomeEntity.__init__(self, server)
self.key = key
self.name = name
self.object_id = object_id
self.event_types = ["single_press", "double_press", "triple_press", "long_press"]
self._current_event: Optional[str] = None
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
def update_state(self, event_type: str) -> None:
"""Update the event state with a button press event."""
self._current_event = event_type
self._log.debug("Button event state updated: %s", event_type)
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
if isinstance(msg, ListEntitiesRequest):
yield ListEntitiesEventResponse(
object_id=self.object_id,
key=self.key,
name=self.name,
device_class="button",
event_types=self.event_types,
)
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
# Wait until a press fires: yielding with an empty
# event_type makes HA reject the state and fail the
# whole ESPHome config entry to load.
if self._current_event:
yield self._get_state_message()
def _get_state_message(self) -> EventResponse:
return EventResponse(
key=self.key,
event_type=self._current_event or "",
)
# Backward compatibility export aliases
__all__ = [
"ESPHomeEntity",
"MediaPlayerEntity",
"MuteSwitchEntity",
"ThinkingSoundEntity",
"LEDLightEntity",
"ButtonEventSensorEntity",
"WakeWord1SensitivityNumberEntity",
"WakeWord2SensitivityNumberEntity",
"StopWordSensitivityNumberEntity",
# Old class names for backward compatibility
"WakeWordSensitivityNumberEntity",
"SecondWakeWordSensitivityNumberEntity",
]
WakeWordSensitivityNumberEntity = WakeWord1SensitivityNumberEntity
SecondWakeWordSensitivityNumberEntity = WakeWord2SensitivityNumberEntity
# -----------------------------------------------------------------------------
+162 -3
View File
@@ -2,21 +2,29 @@
import json
import logging
from collections.abc import Iterable
from dataclasses import asdict, dataclass, field
from enum import Enum
from pathlib import Path
from queue import Queue
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union
if TYPE_CHECKING:
from google.protobuf import message
from pymicro_wakeword import MicroWakeWord
from pyopen_wakeword import OpenWakeWord
from .entity import (
ButtonEventSensorEntity,
ESPHomeEntity,
LEDLightEntity,
MediaPlayerEntity,
MicSettingEntity,
MuteSwitchEntity,
StopWordSensitivityNumberEntity,
ThinkingSoundEntity,
WakeWord1SensitivityNumberEntity,
WakeWord2SensitivityNumberEntity,
)
from .mpv_player import MpvMediaPlayer
from .satellite import VoiceSatelliteProtocol
@@ -36,6 +44,7 @@ class AvailableWakeWord:
wake_word: str
trained_languages: List[str]
wake_word_path: Path
probability_cutoff: float = 0.7
def load(self) -> "Union[MicroWakeWord, OpenWakeWord]":
if self.type == WakeWordType.MICRO_WAKE_WORD:
@@ -54,18 +63,46 @@ class AvailableWakeWord:
raise ValueError(f"Unexpected wake word type: {self.type}")
@dataclass
class LightRegistration:
"""Capabilities a peripheral declares for one of its Light entities.
The peripheral sends this with the register_light command after
connecting. LVA materialises a matching LEDLightEntity so HA can
control it.
"""
name: str
object_id: str
icon: str = "mdi:led-strip-variant"
effects: List[str] = field(default_factory=list)
supports_rgb: bool = True
supports_brightness: bool = True
@dataclass
class Preferences:
active_wake_words: List[str] = field(default_factory=list)
active_wake_words: List[Optional[str]] = field(default_factory=list)
volume: Optional[float] = None
thinking_sound: int = 0 # 0 = disabled, 1 = enabled
wake_word_1_sensitivity: Optional[float] = None
wake_word_2_sensitivity: Optional[float] = None
stop_word_sensitivity: Optional[float] = None
mic_auto_gain: int = 0
mic_noise_suppression: int = 0
mic_volume: int = 100 # 1–100, default maximum
@dataclass
class ServerState:
name: str
friendly_name: str
mac_address: str
ip_address: str
network_interface: str
version: str
esphome_version: str
audio_queue: "Queue[Optional[bytes]]"
entities: "List[ESPHomeEntity]"
available_wake_words: "Dict[str, AvailableWakeWord]"
@@ -75,23 +112,84 @@ class ServerState:
music_player: "MpvMediaPlayer"
tts_player: "MpvMediaPlayer"
wakeup_sound: str
start_listening_sound: str
processing_sound: str
timer_finished_sound: str
mute_sound: str
unmute_sound: str
button_double_press_sound: str
button_triple_press_sound: str
button_long_press_sound: str
preferences: Preferences
preferences_path: Path
download_dir: Path
continue_conversation_delay: float = 0.5 # seconds to wait after TTS before opening mic
media_player_entity: "Optional[MediaPlayerEntity]" = None
satellite: "Optional[VoiceSatelliteProtocol]" = None
connections: "List[VoiceSatelliteProtocol]" = field(default_factory=list)
mute_switch_entity: "Optional[MuteSwitchEntity]" = None
thinking_sound_entity: "Optional[ThinkingSoundEntity]" = None
button_event_sensor_entity: "Optional[ButtonEventSensorEntity]" = None
# Lights declared by peripherals via register_light. Survives HA
# reconnects so the satellite can rebuild its entities whenever it
# is constructed again.
pending_lights: "List[LightRegistration]" = field(default_factory=list)
# Materialised LightEntities keyed by object_id, so light_command
# events can be routed back to the right peripheral hardware.
led_light_entities: "Dict[str, LEDLightEntity]" = field(default_factory=dict)
# True once a peripheral sends register_button. Gates creation of
# ButtonEventSensorEntity so the HA device page only shows the button
# entity when hardware that actually supports button presses is present.
# Survives HA reconnects so the entity is re-registered automatically.
pending_button: bool = False
# Optional peripheral WebSocket API (LEDs, buttons, HAT boards).
# Assigned in __main__ before the event loop starts.
peripheral_api: "Optional[Any]" = None # PeripheralAPIServer at runtime
sensitivity_1_number_entity: "Optional[WakeWord1SensitivityNumberEntity]" = None
sensitivity_2_number_entity: "Optional[WakeWord2SensitivityNumberEntity]" = None
stop_sensitivity_number_entity: "Optional[StopWordSensitivityNumberEntity]" = None
mic_gain_entity: "Optional[MicSettingEntity]" = None
mic_noise_suppression_entity: "Optional[MicSettingEntity]" = None
mic_volume_entity: "Optional[MicSettingEntity]" = None
wake_words_changed: bool = False
refractory_seconds: float = 2.0
thinking_sound_enabled: bool = False
output_only: bool = False
muted: bool = False
connected: bool = False
volume: float = 1.0
oww_probability_cutoff: float = 0.7 # Dynamic threshold for OpenWakeWord
oww_second_probability_cutoff: float = 0.7 # Dynamic threshold for second OpenWakeWord
oww_stop_probability_cutoff: float = 0.5 # Dynamic threshold for Stop word
wake_word_1_threshold: float = 0.7
wake_word_2_threshold: float = 0.7
stop_word_threshold: float = 0.5
mic_auto_gain: int = 0
mic_noise_suppression: int = 0
mic_volume: int = 100 # 1–100, default maximum
audio_input_channels: int = 2 # number of mic channels to stream
timer_max_ring_seconds: float = 900.0
listen_during_wake_sound: bool = False
def broadcast(self, msgs: "Iterable[message.Message]") -> None:
"""Send messages to every connected API client.
Entity state changes that happen asynchronously (not in response to a
request) must reach *all* subscribed clients, not just whichever
connection happens to be referenced by an entity's ``server``. Without
this fan-out a second API client leaves Home Assistant stuck on a stale
state (e.g. ``playing`` after playback has finished).
"""
messages = list(msgs)
if not messages:
return
for connection in list(self.connections):
connection.send_messages(messages)
def save_preferences(self) -> None:
"""Save preferences as JSON."""
@@ -99,5 +197,66 @@ class ServerState:
self.preferences_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.preferences_path, "w", encoding="utf-8") as preferences_file:
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")
# Notify peripheral container (thread-safe; may be called from mpv callbacks)
api = self.peripheral_api
if api is not None:
from .peripheral_api import LVAEvent # local import avoids circular dep
api.emit_event_sync(LVAEvent.VOLUME_CHANGED, {"volume": round(clamped_volume, 3)})
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()
+52 -23
View File
@@ -1,8 +1,9 @@
# mpv_player.py
import logging
from typing import Union, List, Callable, Optional
from typing import Callable, List, Optional, Union
from .player.libmpv import LibMpvPlayer
from .player.state import PlayerState
class MpvMediaPlayer:
@@ -17,6 +18,7 @@ class MpvMediaPlayer:
self._log = logging.getLogger(self.__class__.__name__)
self._player = LibMpvPlayer(device=device)
self._done_callback: Optional[Callable[[], None]] = None
self._playlist: List[str] = []
self._log.debug("MpvMediaPlayer initialized (device=%s)", device)
@@ -30,34 +32,55 @@ class MpvMediaPlayer:
Play a media URL.
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.
stop_first: Kept for API compatibility.
"""
# LVA currently only uses single URLs
if isinstance(url, list):
self._log.debug("Received URL list, using first entry")
url = url[0]
# Handle single URL vs list
if isinstance(url, str):
urls = [url]
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:
self._log.debug(
"Stopping active playback before starting new media"
)
# Not self.stop() → this would call done_callback
self._player.stop(for_replacement=True)
if self._player.state() != PlayerState.IDLE:
self._log.debug("Stopping active playback before starting new media")
self._player.stop(for_replacement=True)
self._done_callback = None
self._log.info("Playing media: %s", url)
self._log.debug(
"play(url=%s, stop_first=%s, done_callback=%s)",
url,
stop_first,
bool(done_callback),
)
self._log.info("Playing %d URL(s): %s", len(urls), urls[0])
# Store playlist and callback
self._playlist = urls
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:
"""Pause playback."""
@@ -82,12 +105,18 @@ class MpvMediaPlayer:
finally:
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:
"""
Set playback volume.
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._player.set_volume(volume)
@@ -97,7 +126,7 @@ class MpvMediaPlayer:
Temporarily reduce volume.
Args:
factor: Volume multiplier (0.0–1.0).
factor: Volume multiplier (0.0-1.0).
"""
self._log.debug("duck(factor=%.2f)", factor)
self._player.duck(factor)
@@ -105,4 +134,4 @@ class MpvMediaPlayer:
def unduck(self) -> None:
"""Restore volume after ducking."""
self._log.debug("unduck() called")
self._player.unduck()
self._player.unduck()
+719
View File
@@ -0,0 +1,719 @@
"""WebSocket peripheral API.
Bridges LVA state to a separate peripheral container (LEDs, buttons, HAT boards).
Protocol (JSON over WebSocket):
Events (LVA → peripheral): {"event": "<name>", "data": {...}}
Commands (peripheral → LVA): {"command": "<name>", "data": {...}}
Snapshot (on connect): {"event": "snapshot", "data": {...}}
Feedback events emitted by LVA
-------------------------------
wake_word_detected
listening
stt_text data: {"text": str}
Emitted when HA returns the recognised speech transcript.
Use this to display what the user said on a screen or LED ticker.
thinking
tts_text data: {"text": str}
Emitted when HA returns the assistant's response text, just
before TTS audio begins playing.
Use this to display the assistant's reply on a screen.
tts_speaking
tts_finished
pipeline_error data: {"reason": <str>}
Emitted when the voice pipeline reports an error (STT failure,
intent error, etc.). NOT emitted when HA disconnects — that
uses the separate ``disconnected`` event below.
Peripheral containers should show a brief red error animation
(e.g. 3 red flashes then off) and then return to idle.
disconnected
Emitted when the HA TCP connection is lost.
Connected leds with peripheral containers should
show a red twinkle / "no connection" animation and keep retrying
until they see a ``zeroconf`` event with status "connected".
NOTE: if LVA itself is not running the peripheral container will
see a WebSocket connection failure on its end — that is also
a "disconnected" condition to handle with the same animation.
idle
muted data: {"muted": true/false}
timer_ticking data: {"id": str, "name": str, "total_seconds": int, "seconds_left": int}
timer_updated data: {"id": str, "name": str, "total_seconds": int, "seconds_left": int}
timer_ringing data: {"id": str, "name": str, "total_seconds": int, "seconds_left": int}
media_player_playing Emitted when HA sends music/media to the music_player
(non-announcement playback). Not emitted for TTS or
voice pipeline announcements — those use tts_speaking.
volume_changed data: {"volume": 0.0–1.0}
volume_muted data: {"muted": true/false}
zeroconf data: {"status": "getting_started" | "connected"}
light_command data: {"object_id": str, "state": bool, "brightness": float,
"red": float, "green": float, "blue": float, "effect": str}
Fires when HA changes a Light entity that a peripheral
previously registered via register_light. The peripheral
matches on object_id and applies the new state. The
effect names are those the peripheral declared at
registration; e.g. "Voice Assistant" runs the pipeline
animations.
Commands accepted from the peripheral container
------------------------------------------------
start_listening
stop_pipeline Abort the active voice pipeline at any phase — listening,
thinking, speaking or wake word active. Calls satellite.stop() which
cleans up STT streaming, sends VoiceAssistantAnnounceFinished
to HA, unducking music, and emits idle to peripherals.
mute_mic
unmute_mic
volume_up
volume_down
set_volume data: {"volume": 0.0–1.0}
stop_timer_ringing
pause_media_player
resume_media_player
stop_media_player
button_single_press
button_double_press
button_triple_press
button_long_press
register_light data: {"name": str, "object_id": str, "effects": [str],
"supports_rgb": bool, "supports_brightness": bool}
The peripheral declares an LED Light it wants exposed in
HA. LVA creates a matching ESPHome Light entity (visible
as light.<satellite>_<object_id>) and routes HA changes
back to the peripheral as light_command events. Send
once after connecting; duplicate registrations for the
same object_id are ignored.
register_button
The peripheral declares that it has physical buttons and
wants a Button Press event entity exposed in HA. LVA
creates a ButtonEventSensorEntity (visible as
event.<satellite>_button_press_event) that fires
single_press, double_press, triple_press, and long_press
events to Home Assistant when the corresponding
button_* commands are sent. Send once after connecting;
duplicate registrations are ignored.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, Optional, Set
from aioesphomeapi.api_pb2 import MediaPlayerStateResponse # type: ignore[attr-defined] # pylint: disable=no-name-in-module
from aioesphomeapi.model import MediaPlayerState # type: ignore[import]
if TYPE_CHECKING:
from .models import ServerState
_LOGGER = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Public enumerations
# ---------------------------------------------------------------------------
class LVAEvent(str, Enum):
"""Events broadcast from LVA to peripheral clients."""
WAKE_WORD_DETECTED = "wake_word_detected"
LISTENING = "listening"
STT_TEXT = "stt_text"
THINKING = "thinking"
TTS_TEXT = "tts_text"
TTS_SPEAKING = "tts_speaking"
TTS_FINISHED = "tts_finished"
PIPELINE_ERROR = "pipeline_error"
DISCONNECTED = "disconnected"
IDLE = "idle"
MUTED = "muted"
TIMER_TICKING = "timer_ticking"
TIMER_UPDATED = "timer_updated"
TIMER_RINGING = "timer_ringing"
MEDIA_PLAYER_PLAYING = "media_player_playing"
VOLUME_CHANGED = "volume_changed"
VOLUME_MUTED = "volume_muted"
ZEROCONF = "zeroconf"
LIGHT_COMMAND = "light_command"
class LVACommand(str, Enum):
"""Commands accepted from peripheral clients."""
START_LISTENING = "start_listening"
STOP_PIPELINE = "stop_pipeline"
MUTE_MIC = "mute_mic"
UNMUTE_MIC = "unmute_mic"
VOLUME_UP = "volume_up"
VOLUME_DOWN = "volume_down"
SET_VOLUME = "set_volume"
STOP_TIMER_RINGING = "stop_timer_ringing"
STOP_MEDIA_PLAYER = "stop_media_player"
PAUSE_MEDIA_PLAYER = "pause_media_player"
RESUME_MEDIA_PLAYER = "resume_media_player"
BUTTON_SINGLE_PRESS = "button_single_press"
BUTTON_DOUBLE_PRESS = "button_double_press"
BUTTON_TRIPLE_PRESS = "button_triple_press"
BUTTON_LONG_PRESS = "button_long_press"
REGISTER_LIGHT = "register_light"
REGISTER_BUTTON = "register_button"
# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
class PeripheralAPIServer:
"""
WebSocket server that bridges LVA state to a peripheral container.
Usage
-----
1. Construct in ``__main__.main()``.
2. ``peripheral_api.set_state(state)`` once ``ServerState`` exists.
3. ``await peripheral_api.start()`` inside the running event loop.
4. Call ``emit_event_sync()`` from any thread (mpv callbacks, audio thread).
"""
DEFAULT_VOLUME_STEP: float = 0.05
LATE_ENTITY_RECONNECT_DEBOUNCE_S: float = 1.5
LATE_ENTITY_RECONNECT_COOLDOWN_S: float = 60.0
def __init__(
self,
host: str = "0.0.0.0",
port: int = 6055,
volume_step: float = DEFAULT_VOLUME_STEP,
) -> None:
self._host = host
self._port = port
self._volume_step = volume_step
self._clients: Set[Any] = set()
self._state: Optional[ServerState] = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._server: Any = None
# Last conversation exchange — sent in the snapshot to newly-connecting clients
self._last_stt_text: Optional[str] = None
self._last_tts_text: Optional[str] = None
# Current event state — replayed to newly-connecting clients so they can
# show the correct animation immediately without waiting for the next event.
# Only "state" events are tracked (pipeline, timer, media, muted, idle).
# Transient/informational events (stt_text, tts_text, volume_changed, etc.)
# are not tracked because they carry no ongoing visual state.
self._current_state: Optional[LVAEvent] = None
self._current_state_data: Optional[Dict[str, Any]] = None
# Safeguarded HA reconnect trigger for late entity registrations.
self._pending_entity_reconnect_task: Optional[asyncio.Task] = None
self._last_ha_reconnect_at: float = 0.0
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def set_state(self, state: "ServerState") -> None:
"""Attach the shared ``ServerState`` so commands can read/mutate it."""
self._state = state
async def start(self) -> None:
"""Start the WebSocket server inside the running event loop."""
try:
from websockets.server import serve # type: ignore[import]
except ImportError:
_LOGGER.error("websockets package not installed – peripheral API disabled. Install with: pip install websockets")
return
self._loop = asyncio.get_running_loop()
self._server = await serve(self._handle_client, self._host, self._port)
_LOGGER.info("Peripheral API listening at ws://%s:%d", self._host, self._port)
async def stop(self) -> None:
"""Gracefully shut down the server and all client connections."""
if self._pending_entity_reconnect_task is not None:
self._pending_entity_reconnect_task.cancel()
self._pending_entity_reconnect_task = None
if self._server is not None:
self._server.close()
await self._server.wait_closed()
_LOGGER.info("Peripheral API stopped")
# ------------------------------------------------------------------
# Client handling
# ------------------------------------------------------------------
async def _handle_client(self, websocket: Any) -> None:
addr = getattr(websocket, "remote_address", "unknown")
self._clients.add(websocket)
_LOGGER.info("Peripheral client connected: %s", addr)
await self._send_snapshot(websocket)
try:
async for raw in websocket:
await self._dispatch_command(raw)
except Exception as exc: # pylint: disable=broad-except
_LOGGER.debug("Peripheral client %s error: %s", addr, exc)
finally:
self._clients.discard(websocket)
_LOGGER.info("Peripheral client disconnected: %s", addr)
async def _send_snapshot(self, websocket: Any) -> None:
"""Push current LVA state to a newly connected peripheral client."""
state = self._state
if state is None:
return
payload = json.dumps(
{
"event": "snapshot",
"data": {
"muted": state.muted,
"volume": round(state.volume, 3),
"ha_connected": state.connected,
"last_stt_text": self._last_stt_text,
"last_tts_text": self._last_tts_text,
},
}
)
try:
await websocket.send(payload)
except Exception: # pylint: disable=broad-except
return
# Replay the current event state so the client immediately shows the
# right animation — e.g. a timer ticking animation when reconnecting
# mid-timer, or the muted indicator when reconnecting while muted.
current_state = self._current_state
if current_state is None:
return
if current_state == LVAEvent.DISCONNECTED and state.connected:
return
state_payload: Dict[str, Any] = {"event": current_state.value}
if self._current_state_data:
state_payload["data"] = self._current_state_data
try:
await websocket.send(json.dumps(state_payload))
except Exception: # pylint: disable=broad-except
pass
# ------------------------------------------------------------------
# Command dispatch
# ------------------------------------------------------------------
async def _dispatch_command(self, raw: str) -> None:
"""Parse and execute a JSON command from the peripheral container."""
try:
msg: Dict[str, Any] = json.loads(raw)
except json.JSONDecodeError:
_LOGGER.warning("Peripheral: invalid JSON: %.200s", raw)
return
command: str = msg.get("command", "")
if not command:
return
_LOGGER.debug("Peripheral command received: %s", command)
state = self._state
if state is None:
return
satellite = state.satellite
if command == LVACommand.START_LISTENING:
if satellite is None or state.muted:
return
satellite.start_listening() # plays sound, then starts pipeline
elif command == LVACommand.STOP_PIPELINE:
# Stops the voice pipeline at any active phase:
# listening, thinking, speaking or wake word active.
if satellite is not None:
satellite.stop()
elif command == LVACommand.MUTE_MIC:
if satellite is not None and not state.muted:
satellite._set_muted(True) # pylint: disable=protected-access
await self._push_mute_switch(satellite, muted=True)
elif command == LVACommand.UNMUTE_MIC:
if satellite is not None and state.muted:
satellite._set_muted(False) # pylint: disable=protected-access
await self._push_mute_switch(satellite, muted=False)
elif command in (LVACommand.VOLUME_UP, LVACommand.VOLUME_DOWN):
delta = self._volume_step if command == LVACommand.VOLUME_UP else -self._volume_step
new_vol = max(0.0, min(1.0, state.volume + delta))
vol_pct = int(round(new_vol * 100))
state.music_player.set_volume(vol_pct)
state.tts_player.set_volume(vol_pct)
if state.media_player_entity is not None:
state.media_player_entity.volume = new_vol
state.media_player_entity.previous_volume = new_vol
# Push the new volume to HA so its media player entity updates in real time
if satellite is not None:
satellite.send_messages(
[
MediaPlayerStateResponse(
key=state.media_player_entity.key,
state=state.media_player_entity.state,
volume=new_vol,
muted=state.media_player_entity.muted,
)
]
)
# persist_volume also emits VOLUME_CHANGED via models.py
state.persist_volume(new_vol)
elif command == LVACommand.SET_VOLUME:
data = msg.get("data", {})
volume = data.get("volume")
if not isinstance(volume, (int, float)):
_LOGGER.warning("Peripheral: invalid volume in set_volume command: %s", volume)
return
new_vol = max(0.0, min(1.0, float(volume)))
vol_pct = int(round(new_vol * 100))
state.music_player.set_volume(vol_pct)
state.tts_player.set_volume(vol_pct)
if state.media_player_entity is not None:
state.media_player_entity.volume = new_vol
state.media_player_entity.previous_volume = new_vol
# Push the new volume to HA so its media player entity updates in real time
if satellite is not None:
satellite.send_messages(
[
MediaPlayerStateResponse(
key=state.media_player_entity.key,
state=state.media_player_entity.state,
volume=new_vol,
muted=state.media_player_entity.muted,
)
]
)
# persist_volume also emits VOLUME_CHANGED via models.py
state.persist_volume(new_vol)
elif command == LVACommand.STOP_TIMER_RINGING:
if satellite is None:
return
if getattr(satellite, "_timer_finished", False):
satellite._timer_finished = False # pylint: disable=protected-access
state.active_wake_words.discard(state.stop_word.id)
state.tts_player.stop()
satellite.unduck()
await self.emit_event(LVAEvent.IDLE)
elif command == LVACommand.STOP_MEDIA_PLAYER:
state.music_player.stop()
if state.media_player_entity is not None:
state.media_player_entity.state = MediaPlayerState.IDLE
if satellite is not None:
satellite.send_messages([self._create_media_player_response(MediaPlayerState.IDLE)])
elif command == LVACommand.PAUSE_MEDIA_PLAYER:
state.music_player.pause()
if state.media_player_entity is not None:
state.media_player_entity.state = MediaPlayerState.PAUSED
if satellite is not None:
satellite.send_messages([self._create_media_player_response(MediaPlayerState.PAUSED)])
elif command == LVACommand.RESUME_MEDIA_PLAYER:
state.music_player.resume()
if state.media_player_entity is not None:
state.media_player_entity.state = MediaPlayerState.PLAYING
if satellite is not None:
satellite.send_messages([self._create_media_player_response(MediaPlayerState.PLAYING)])
elif command == LVACommand.BUTTON_SINGLE_PRESS:
if state.button_event_sensor_entity is not None:
state.button_event_sensor_entity.update_state("single_press")
if satellite is not None:
satellite.send_messages([state.button_event_sensor_entity._get_state_message()]) # pylint: disable=protected-access
elif command == LVACommand.BUTTON_DOUBLE_PRESS:
state.tts_player.play(state.button_double_press_sound)
if state.button_event_sensor_entity is not None:
state.button_event_sensor_entity.update_state("double_press")
if satellite is not None:
satellite.send_messages([state.button_event_sensor_entity._get_state_message()]) # pylint: disable=protected-access
elif command == LVACommand.BUTTON_TRIPLE_PRESS:
state.tts_player.play(state.button_triple_press_sound)
if state.button_event_sensor_entity is not None:
state.button_event_sensor_entity.update_state("triple_press")
if satellite is not None:
satellite.send_messages([state.button_event_sensor_entity._get_state_message()]) # pylint: disable=protected-access
elif command == LVACommand.BUTTON_LONG_PRESS:
state.tts_player.play(state.button_long_press_sound)
if state.button_event_sensor_entity is not None:
state.button_event_sensor_entity.update_state("long_press")
if satellite is not None:
satellite.send_messages([state.button_event_sensor_entity._get_state_message()]) # pylint: disable=protected-access
elif command == LVACommand.REGISTER_LIGHT:
self._register_light(msg.get("data") or {}, satellite)
elif command == LVACommand.REGISTER_BUTTON:
self._register_button(satellite)
def _register_light(self, data: Dict[str, Any], satellite: Any) -> None:
"""Register a Light declared by a peripheral.
Idempotent on object_id: repeat registrations (e.g. after a
peripheral reconnect) keep the existing entity and its state.
"""
from .models import LightRegistration # local import to avoid a cycle
object_id = str(data.get("object_id", "")).strip()
if not object_id:
_LOGGER.warning("register_light without object_id; ignoring")
return
state = self._state
if state is None:
return
if any(spec.object_id == object_id for spec in state.pending_lights):
# Same light already on file; nothing to do.
return
spec = LightRegistration(
name=str(data.get("name", "LEDs")),
object_id=object_id,
icon=str(data.get("icon", "mdi:led-strip-variant")),
effects=[str(e) for e in data.get("effects", []) if e],
supports_rgb=bool(data.get("supports_rgb", True)),
supports_brightness=bool(data.get("supports_brightness", True)),
)
state.pending_lights.append(spec)
_LOGGER.info("Light registered: %s (effects=%s)", object_id, spec.effects)
# If the satellite is already running, materialise the entity
# now so future messages route correctly. HA only sees it
# after the integration reconnects, but LVA stays consistent.
if satellite is not None:
satellite.register_pending_lights()
self._schedule_ha_reconnect_for_late_entity("light", object_id)
def _register_button(self, satellite: Any) -> None:
"""Register button press event support declared by a peripheral.
Idempotent: repeat registrations from a reconnecting peripheral
are a no-op — the existing entity and its accumulated event state
are preserved.
When the satellite is already running, the entity is materialised
immediately so subsequent button_* commands route correctly.
HA only sees the new entity after the integration is reloaded, but
the same startup-wait window that applies to register_light applies
here too (see ``--peripheral-startup-wait``).
"""
state = self._state
if state is None:
return
if state.pending_button:
# Already registered; keep the existing entity.
return
state.pending_button = True
_LOGGER.info("Button event sensor registered by peripheral")
if satellite is not None:
satellite.register_pending_button()
self._schedule_ha_reconnect_for_late_entity("button", "button_press_event")
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _schedule_ha_reconnect_for_late_entity(self, entity_kind: str, entity_id: str) -> None:
"""Reconnect HA once for late peripheral entity registrations."""
state = self._state
if state is None or not state.connected or state.satellite is None:
return
if self._loop is None:
return
if self._pending_entity_reconnect_task is not None and not self._pending_entity_reconnect_task.done():
_LOGGER.debug(
"Coalescing late %s registration for '%s' into pending HA reconnect",
entity_kind,
entity_id,
)
return
async def _trigger() -> None:
try:
await asyncio.sleep(self.LATE_ENTITY_RECONNECT_DEBOUNCE_S)
state_now = self._state
if state_now is None or not state_now.connected:
return
satellite_now = state_now.satellite
if satellite_now is None:
return
now = time.monotonic()
if now - self._last_ha_reconnect_at < self.LATE_ENTITY_RECONNECT_COOLDOWN_S:
_LOGGER.info(
"Skipping HA reconnect after late %s '%s': cooldown active",
entity_kind,
entity_id,
)
return
transport = getattr(satellite_now, "_transport", None)
if transport is None:
return
self._last_ha_reconnect_at = now
_LOGGER.info(
"Late %s '%s' registered after HA handshake; forcing one HA reconnect",
entity_kind,
entity_id,
)
transport.close()
except asyncio.CancelledError:
return
self._pending_entity_reconnect_task = self._loop.create_task(_trigger())
async def _push_mute_switch(self, satellite: Any, *, muted: bool) -> None:
"""Reflect a peripheral-triggered mute change to Home Assistant."""
state = self._state
if state is None or state.mute_switch_entity is None:
return
entity = state.mute_switch_entity
entity._switch_state = muted # pylint: disable=protected-access
# pylint: disable=no-name-in-module
from aioesphomeapi.api_pb2 import SwitchStateResponse # type: ignore[attr-defined]
satellite.send_messages([SwitchStateResponse(key=entity.key, state=muted)])
# ------------------------------------------------------------------
# Event emission
# ------------------------------------------------------------------
async def emit_event(
self,
event: LVAEvent,
data: Optional[Dict[str, Any]] = None,
) -> None:
"""Broadcast an event to all connected peripheral clients."""
# Cache the last conversation text so newly-connecting clients get it in the snapshot
if event == LVAEvent.STT_TEXT and data:
self._last_stt_text = data.get("text")
elif event == LVAEvent.TTS_TEXT and data:
self._last_tts_text = data.get("text")
# Clear both sides at the start of a new pipeline run
elif event == LVAEvent.LISTENING:
self._last_stt_text = None
self._last_tts_text = None
# ----------------------------------------------------------------
# Track current "state" so newly-connecting clients receive it on
# connect via _send_snapshot. Only persistent/visual states are
# stored — transient informational events are skipped.
# ----------------------------------------------------------------
_STATE_EVENTS = {
LVAEvent.WAKE_WORD_DETECTED,
LVAEvent.LISTENING,
LVAEvent.THINKING,
LVAEvent.TTS_SPEAKING,
LVAEvent.TTS_FINISHED,
LVAEvent.IDLE,
LVAEvent.MUTED,
LVAEvent.TIMER_TICKING,
LVAEvent.TIMER_RINGING,
LVAEvent.MEDIA_PLAYER_PLAYING,
LVAEvent.DISCONNECTED,
LVAEvent.PIPELINE_ERROR,
}
if event in _STATE_EVENTS:
self._current_state = event
self._current_state_data = data or None
elif event == LVAEvent.TIMER_UPDATED and self._current_state == LVAEvent.TIMER_TICKING:
# Keep state as TIMER_TICKING but refresh the countdown data
self._current_state_data = data or None
if not self._clients:
return
payload: Dict[str, Any] = {"event": event.value}
if data:
payload["data"] = data
raw_msg = json.dumps(payload)
dead: Set[Any] = set()
for ws in list(self._clients):
try:
await ws.send(raw_msg)
except Exception: # pylint: disable=broad-except
dead.add(ws)
self._clients -= dead
_LOGGER.debug(
"Peripheral event %-25s → %d client(s)",
event.value,
len(self._clients),
)
def emit_event_sync(
self,
event: LVAEvent,
data: Optional[Dict[str, Any]] = None,
) -> None:
"""
Thread-safe fire-and-forget event emission.
Safe to call from mpv callbacks, the audio processing thread, or any
non-async context while the asyncio event loop is running.
"""
loop = self._loop
if loop is None or loop.is_closed():
return
asyncio.run_coroutine_threadsafe(self.emit_event(event, data), loop)
def _create_media_player_response(self, state: MediaPlayerState) -> MediaPlayerStateResponse:
"""Create a MediaPlayerStateResponse with current entity state."""
assert self._state is not None
media_entity = self._state.media_player_entity
assert media_entity is not None
return MediaPlayerStateResponse(
key=media_entity.key,
state=state,
volume=media_entity.volume,
muted=media_entity.muted,
)
+2 -1
View File
@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
from .state import PlayerState
from linux_voice_assistant.player.state import PlayerState
class AudioPlayer(ABC):
+69 -15
View File
@@ -1,10 +1,11 @@
import logging
import threading
from typing import Optional, Callable
from typing import Callable, Optional
import mpv
from .base import AudioPlayer
from .state import PlayerState
from linux_voice_assistant.player.base import AudioPlayer
from linux_voice_assistant.player.state import PlayerState
class LibMpvPlayer(AudioPlayer):
@@ -18,27 +19,46 @@ class LibMpvPlayer(AudioPlayer):
"""
def __init__(self, device: Optional[str] = None) -> None:
self._log = logging.getLogger(self.__class__.__name__)
self._state: PlayerState = PlayerState.IDLE
self._state_lock = threading.Lock()
# Volume handling
self._user_volume: float = 100.0 # 0.0 – 100.0
self._duck_factor: float = 1.0 # 0.0 – 1.0
self._user_volume: float = 100.0 # 0.0 – 100.0
self._duck_factor: float = 1.0 # 0.0 – 1.0
# mpv setup
self._mpv = mpv.MPV(
audio_display=False,
log_handler=self._on_mpv_log,
loglevel="error",
cache="yes",
demuxer_max_bytes="32MiB",
cache_secs="20",
)
if device:
self._mpv["audio-device"] = device
# Pre-buffer audio before the sink starts clocking samples out.
# The default (0.2 s) is too tight for short notification sounds on
# PulseAudio/PipeWire: the sink stream takes a few ms to initialise
# and the very first samples are dropped before it is ready, making
# short files (<1 s) appear to start mid-way through.
# 0.8 s gives the output pipeline enough headroom without adding any
# noticeable latency for a user-facing notification sound.
self._mpv["audio-buffer"] = 0.8
# Keep the PulseAudio/PipeWire stream open between files by outputting
# silence when idle. This eliminates the per-play sink re-initialisation
# penalty entirely, so back-to-back short sounds (wakeup → TTS, mute →
# unmute) never lose their first samples regardless of system load.
self._mpv["audio-stream-silence"] = True
# Callback Handling
self._done_callback: Optional[Callable[[], None]] = None
self._suppress_end_event = False
self._mpv.event_callback("end-file")(self._on_end_file)
self._mpv.event_callback("start-file")(self._on_start_file)
# -------- Playback control --------
@@ -56,10 +76,11 @@ class LibMpvPlayer(AudioPlayer):
done_callback: Optional callback invoked when playback finishes.
stop_first: If True, start playback in paused state.
"""
self._done_callback = done_callback
self._mpv.pause = stop_first
with self._state_lock:
self._log.debug("play: current_state=%s", self._state)
self._done_callback = done_callback
self._set_state(PlayerState.LOADING)
self._mpv.pause = stop_first
self._mpv.play(url)
def pause(self) -> None:
@@ -70,6 +91,7 @@ class LibMpvPlayer(AudioPlayer):
def resume(self) -> None:
"""Resume playback if paused."""
self._log.debug("unduck() called")
with self._state_lock:
self._mpv.pause = False
self._set_state(PlayerState.PLAYING)
@@ -78,13 +100,14 @@ class LibMpvPlayer(AudioPlayer):
"""
Stop playback.
If called for track replacement, suppresses end-of-playback handling
(state transition to IDLE and done callback) to allow seamless
playback transitions.
If called for track replacement, clears the callback to prevent
it from being invoked during the transition.
"""
self._log.debug("unduck() called")
with self._state_lock:
if for_replacement:
self._suppress_end_event = True
# Clear callback to prevent invocation during track transition
self._done_callback = None
self._mpv.stop()
def state(self) -> PlayerState:
@@ -101,6 +124,7 @@ class LibMpvPlayer(AudioPlayer):
Args:
volume: Volume level (0.0–100.0).
"""
self._log.debug("unduck() called")
with self._state_lock:
self._user_volume = max(0.0, min(100.0, float(volume)))
self._apply_volume()
@@ -112,12 +136,14 @@ class LibMpvPlayer(AudioPlayer):
Args:
factor: Ducking factor (0.0–1.0).
"""
self._log.debug("unduck() called")
with self._state_lock:
self._duck_factor = max(0.0, min(1.0, float(factor)))
self._apply_volume()
def unduck(self) -> None:
"""Restore volume to the user-defined level."""
self._log.debug("unduck() called")
with self._state_lock:
self._duck_factor = 1.0
self._apply_volume()
@@ -126,6 +152,7 @@ class LibMpvPlayer(AudioPlayer):
def _apply_volume(self) -> None:
"""Apply effective volume (user volume × duck factor) to mpv."""
self._log.debug("unduck() called")
effective = self._user_volume * self._duck_factor
self._mpv.volume = max(0.0, min(100.0, effective))
@@ -133,8 +160,27 @@ class LibMpvPlayer(AudioPlayer):
callback: Optional[Callable[[], None]] = None
with self._state_lock:
if self._suppress_end_event:
self._suppress_end_event = False
# mpv events: event.data is a MpvEventEndFile object with a 'reason' attribute
# 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
self._set_state(PlayerState.IDLE)
@@ -142,12 +188,20 @@ class LibMpvPlayer(AudioPlayer):
self._done_callback = None
if callback is not None:
self._log.debug("_on_end_file: invoking callback")
try:
callback()
except RuntimeError:
# Callback errors must never break the player
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:
"""
Handle mpv log messages.
@@ -160,4 +214,4 @@ class LibMpvPlayer(AudioPlayer):
def _set_state(self, new_state: PlayerState) -> None:
"""Update internal player state."""
self._state = new_state
self._state = new_state
File diff suppressed because it is too large. Load diff
+65 -5
View File
@@ -1,10 +1,70 @@
"""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
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:
for item in filter(None, callables):
@@ -27,7 +87,7 @@ def get_default_interface():
# gateway_info is a tuple: (gateway_ip, interface_name)
interface_name = gateway_info[1]
#print(f"Default interface: {interface_name}")
# print(f"Default interface: {interface_name}")
return interface_name
@@ -36,7 +96,7 @@ def get_default_ipv4(interface: str):
return None
addresses = netifaces.ifaddresses(interface)
ipv4_info = addresses.get(netifaces.AF_INET)
ipv4_info = addresses.get(netifaces.AF_INET) # type: ignore
if not ipv4_info:
return None
+269
View File
@@ -0,0 +1,269 @@
#!/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 _find_matching_wake_word_id(
available_wake_words: Dict[str, AvailableWakeWord],
requested_id: str,
wake_word_type: Optional[WakeWordType] = None,
) -> Optional[str]:
"""
Finds a wake word id matching requested_id, optionally restricted to a single type.
Tries, in order:
1. An exact id match.
2. An id that starts with "{requested_id}_", which covers openWakeWord's
versioned filenames (e.g. "hey_jarvis" -> "hey_jarvis_v0.1").
Args:
available_wake_words: Dictionary with all available wake words
requested_id: ID (or base name) to look for
wake_word_type: If given, only consider wake words of this type
Returns:
The matching id, or None if no match was found
"""
prefix = f"{requested_id}_"
for candidate_id, wake_word in available_wake_words.items():
if wake_word_type is not None and wake_word.type != wake_word_type:
continue
if candidate_id == requested_id or candidate_id.startswith(prefix):
return candidate_id
return None
def load_wake_models(
available_wake_words: Dict[str, AvailableWakeWord],
active_wake_word_ids: Optional[List[str]],
default_wake_word_id: str,
preferred_type: Optional[WakeWordType] = None,
) -> 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
preferred_type: If given, prefer resolving default_wake_word_id (and the
"okay_nabu" fallback) to a wake word of this type before falling back
to a type-agnostic match. Lets the operator's --wake-word-dir choice
(e.g. pointing at the openWakeWord subdirectory) decide which model
variant gets activated when the requested id exists for more than
one wake word engine.
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
wake_word = None
# If the operator's --wake-word-dir choice implies a preferred engine
# (e.g. pointing at the openWakeWord subdirectory), try to resolve the
# requested id to a model of that type first, even if a same-named
# model of a different type also exists.
if preferred_type is not None:
matched_id = _find_matching_wake_word_id(available_wake_words, default_wake_word_id, preferred_type)
if matched_id is not None:
wake_word_id = matched_id
wake_word = available_wake_words[wake_word_id]
_LOGGER.debug("Resolved default wake word '%s' to '%s' (preferred type %s)", default_wake_word_id, wake_word_id, preferred_type)
if wake_word is None:
# Fall back to a type-agnostic match (covers exact ids and
# versioned openWakeWord filenames like "hey_jarvis_v0.1")
matched_id = _find_matching_wake_word_id(available_wake_words, default_wake_word_id)
if matched_id is not None:
wake_word_id = matched_id
wake_word = available_wake_words[wake_word_id]
if wake_word is None:
_LOGGER.error("❌ Default wake word '%s' not found!", wake_word_id)
# Try fallback to 'okay_nabu', respecting the same type preference
wake_word_id = "okay_nabu"
matched_id = None
if preferred_type is not None:
matched_id = _find_matching_wake_word_id(available_wake_words, wake_word_id, preferred_type)
if matched_id is None:
matched_id = _find_matching_wake_word_id(available_wake_words, wake_word_id)
if matched_id is not None:
wake_word_id = matched_id
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 socket
import uuid
from typing import Optional
_LOGGER = logging.getLogger(__name__)
@@ -18,7 +17,11 @@ MDNS_TARGET_IP = "224.0.0.251"
class HomeAssistantZeroconf:
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:
self.port = port
self.mac_address = mac_address
@@ -45,4 +48,3 @@ class HomeAssistantZeroconf:
)
await self._aiozc.async_register_service(service_info)
_LOGGER.debug("Zeroconf discovery enabled: %s", service_info)
+8 -18
View File
@@ -1,26 +1,16 @@
[mypy]
python_version >= 3.11
warn_return_any = True
warn_unused_configs = True
[mypy-setuptools.*]
ignore_missing_imports = True
[mypy-aioesphomeapi.api_pb2.*]
ignore_missing_imports = True
[mypy-pymicro_wakeword.*]
ignore_missing_imports = True
[mypy-pyopen_wakeword.*]
# Ignore missing stubs for modules without py.typed marker
[mypy-mpv.*]
ignore_missing_imports = True
[mypy-soundcard.*]
ignore_missing_imports = True
[mypy-mpv.*]
ignore_missing_imports = True
[mypy-google.protobuf.*]
ignore_missing_imports = True
[mypy-aioesphomeapi.*]
[mypy-aioesphomeapi.api_pb2]
ignore_missing_imports = True
allow_subclassing_any = True
disable_error_code = attr-defined
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more