Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79cc2a12a9 | ||
|
|
0ec78567a6 | ||
|
|
2c38f5f46a | ||
|
|
f750b83a2c | ||
|
|
dd1d5faff6 | ||
|
|
ff2c213e41 | ||
|
|
e9a9e1090b | ||
|
|
65e90dac55 | ||
|
|
8ad701db32 |
No files matched your search
@@ -8,9 +8,6 @@
|
||||
### 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"
|
||||
@@ -67,21 +64,6 @@ LVA_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie"
|
||||
# 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"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Linux Voice Assistant - Copilot Instructions
|
||||
|
||||
For comprehensive development guidelines, see [AGENTS.md](../AGENTS.md).
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**Development Commands:**
|
||||
```bash
|
||||
./script/setup --dev # Install dev dependencies
|
||||
./script/lint # Run all linting checks
|
||||
./script/tests # Run pytest unit tests
|
||||
```
|
||||
|
||||
## 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
|
||||
- If you're uncertain whether something is an issue, don't comment
|
||||
|
||||
### What to Analyze
|
||||
- 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]`.
|
||||
|
||||
### 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.
|
||||
@@ -20,41 +20,23 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
- name: Set lowercase registry and repository
|
||||
run: |
|
||||
echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
# Build Docker Images (amd64 and arm64)
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
|
||||
with:
|
||||
images: ${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}
|
||||
tags: |
|
||||
type=raw,value=nightly
|
||||
labels: |
|
||||
org.opencontainers.image.authors="Open Home Foundation"
|
||||
org.opencontainers.image.base.name="docker.io/library/python:3.13-slim-trixie"
|
||||
org.opencontainers.image.description="Linux-Voice-Assistant for Home Assistant"
|
||||
org.opencontainers.image.documentation="https://github.com/OHF-Voice/linux-voice-assistant/blob/main/README.md"
|
||||
org.opencontainers.image.licenses="Apache-2.0"
|
||||
org.opencontainers.image.source="https://github.com/OHF-Voice/linux-voice-assistant"
|
||||
org.opencontainers.image.title="Linux-Voice-Assistant"
|
||||
org.opencontainers.image.url="https://github.com/OHF-Voice/linux-voice-assistant"
|
||||
org.opencontainers.image.vendor="Open Home Foundation"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create version files
|
||||
run: |
|
||||
@@ -64,15 +46,15 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/aarch64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}:nightly
|
||||
labels: "nightly"
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v4
|
||||
uses: actions/attest-build-provenance@v1.4.4
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
@@ -21,10 +21,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout PR code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
allow-unsafe-pr-checkout: true
|
||||
|
||||
- name: Set variables
|
||||
run: |
|
||||
@@ -33,34 +32,16 @@ jobs:
|
||||
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
# Build Docker Images (amd64 and arm64)
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
|
||||
with:
|
||||
images: ${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}-pr
|
||||
tags: |
|
||||
type=raw,value=${{ env.PR_NUMBER }}
|
||||
labels: |
|
||||
org.opencontainers.image.authors="Open Home Foundation"
|
||||
org.opencontainers.image.base.name="docker.io/library/python:3.13-slim-trixie"
|
||||
org.opencontainers.image.description="Linux-Voice-Assistant for Home Assistant"
|
||||
org.opencontainers.image.documentation="https://github.com/OHF-Voice/linux-voice-assistant/blob/main/README.md"
|
||||
org.opencontainers.image.licenses="Apache-2.0"
|
||||
org.opencontainers.image.source="https://github.com/OHF-Voice/linux-voice-assistant"
|
||||
org.opencontainers.image.title="Linux-Voice-Assistant"
|
||||
org.opencontainers.image.url="https://github.com/OHF-Voice/linux-voice-assistant"
|
||||
org.opencontainers.image.vendor="Open Home Foundation"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create version files
|
||||
run: |
|
||||
@@ -70,15 +51,15 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/aarch64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
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
|
||||
uses: actions/attest-build-provenance@v1.4.4
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}-pr
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
@@ -21,21 +21,21 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
# Build Docker Images (amd64 and arm64)
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository }}
|
||||
# E.g. tag v1.2.0 produces 3 tags: '1.2.0', '1.2', and 'latest'
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/aarch64
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v4
|
||||
uses: actions/attest-build-provenance@v1
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: main
|
||||
- uses: rhysd/changelog-from-release/action@v3
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
lint_black:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
lint_flake8:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
lint_isort:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
lint_mypy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
lint_pylint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
|
||||
@@ -28,8 +28,7 @@
|
||||
- `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` - ESPHome API server
|
||||
- `linux_voice_assistant/peripheral_api.py` - Peripheral WebSocket API server
|
||||
- `linux_voice_assistant/api_server.py` - Peripheral WebSocket API server
|
||||
- `linux_voice_assistant/models.py` - Preferences and ServerState dataclasses
|
||||
|
||||
## Development Commands
|
||||
@@ -127,4 +126,4 @@ Before claiming completion:
|
||||
- `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
|
||||
- `linux_voice_assistant/satellite.py` - ESPHome API protocol handling
|
||||
@@ -1,94 +1,3 @@
|
||||
<a id="v1.1.15"></a>
|
||||
# [v1.1.15](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.15) - 2026-08-02
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### Hotfix
|
||||
* Fix variable handling in docker entrypoint script
|
||||
|
||||
### Exciting New Features 🎉
|
||||
* Add --music-output-device to route music separately from voice by [@matthewdva](https://github.com/matthewdva) in [#350](https://github.com/OHF-Voice/linux-voice-assistant/pull/350)
|
||||
|
||||
### Other Changes
|
||||
* Update Jabra Speak DOCS.md by [@mrbubble62](https://github.com/mrbubble62) in [#387](https://github.com/OHF-Voice/linux-voice-assistant/pull/387)
|
||||
* Bump docker/login-action from 4.5.1 to 4.6.0 by [@dependabot](https://github.com/dependabot)[bot] in [#386](https://github.com/OHF-Voice/linux-voice-assistant/pull/386)
|
||||
* Documentation Improvements by [@omaramin-2000](https://github.com/omaramin-2000) in [#385](https://github.com/OHF-Voice/linux-voice-assistant/pull/385)
|
||||
|
||||
## New Contributors
|
||||
* [@mrbubble62](https://github.com/mrbubble62) made their first contribution in [#387](https://github.com/OHF-Voice/linux-voice-assistant/pull/387)
|
||||
* [@matthewdva](https://github.com/matthewdva) made their first contribution in [#350](https://github.com/OHF-Voice/linux-voice-assistant/pull/350)
|
||||
|
||||
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.14...v1.1.15
|
||||
|
||||
[Changes][v1.1.15]
|
||||
|
||||
|
||||
<a id="v1.1.14"></a>
|
||||
# [v1.1.14](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.14) - 2026-07-28
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### Exciting New Features 🎉
|
||||
* Colored Debug Logging and move Voice Event to Info level in logger by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#271](https://github.com/OHF-Voice/linux-voice-assistant/pull/271)
|
||||
### Other Changes
|
||||
* build(dev): enhance local development environment and setup workflow by [@florian-asche](https://github.com/florian-asche) in [#358](https://github.com/OHF-Voice/linux-voice-assistant/pull/358)
|
||||
* docs(github): establish repository governance and automation workflows by [@florian-asche](https://github.com/florian-asche) in [#359](https://github.com/OHF-Voice/linux-voice-assistant/pull/359)
|
||||
* Bump docker/build-push-action from 5.3.0 to 7.3.0 by [@dependabot](https://github.com/dependabot)[bot] in [#364](https://github.com/OHF-Voice/linux-voice-assistant/pull/364)
|
||||
* Bump actions/attest-build-provenance from 1 to 4 by [@dependabot](https://github.com/dependabot)[bot] in [#363](https://github.com/OHF-Voice/linux-voice-assistant/pull/363)
|
||||
* Bump docker/metadata-action from 5.5.1 to 6.2.0 by [@dependabot](https://github.com/dependabot)[bot] in [#362](https://github.com/OHF-Voice/linux-voice-assistant/pull/362)
|
||||
* Bump docker/setup-qemu-action from 3 to 4 by [@dependabot](https://github.com/dependabot)[bot] in [#361](https://github.com/OHF-Voice/linux-voice-assistant/pull/361)
|
||||
* Bump docker/login-action from 3.1.0 to 4.4.0 by [@dependabot](https://github.com/dependabot)[bot] in [#360](https://github.com/OHF-Voice/linux-voice-assistant/pull/360)
|
||||
* chore(ci): update github actions and docker dependencies by [@florian-asche](https://github.com/florian-asche) in [#368](https://github.com/OHF-Voice/linux-voice-assistant/pull/368)
|
||||
* fix Respeaker USB Mic Array v2 LEDs by [@litinoveweedle](https://github.com/litinoveweedle) in [#357](https://github.com/OHF-Voice/linux-voice-assistant/pull/357)
|
||||
* Update install.md - Fixed log path by [@maximilianovermeyer](https://github.com/maximilianovermeyer) in [#372](https://github.com/OHF-Voice/linux-voice-assistant/pull/372)
|
||||
* Bump docker/setup-buildx-action from 3 to 4 by [@dependabot](https://github.com/dependabot)[bot] in [#371](https://github.com/OHF-Voice/linux-voice-assistant/pull/371)
|
||||
* fix race condition between LVA registration to the HA and peripheral … by [@litinoveweedle](https://github.com/litinoveweedle) in [#373](https://github.com/OHF-Voice/linux-voice-assistant/pull/373)
|
||||
* Bump actions/checkout from 4 to 7 by [@dependabot](https://github.com/dependabot)[bot] in [#370](https://github.com/OHF-Voice/linux-voice-assistant/pull/370)
|
||||
* feat(ai): integrate Kilo agent framework and coding instructions by [@florian-asche](https://github.com/florian-asche) in [#366](https://github.com/OHF-Voice/linux-voice-assistant/pull/366)
|
||||
* Bump docker/login-action from 4.4.0 to 4.5.0 by [@dependabot](https://github.com/dependabot)[bot] in [#375](https://github.com/OHF-Voice/linux-voice-assistant/pull/375)
|
||||
* docs: add realtime scheduling tuning for constrained hardware Closes [#164](https://github.com/OHF-Voice/linux-voice-assistant/issues/164) by [@SpiliosDimakopoulos](https://github.com/SpiliosDimakopoulos) in [#376](https://github.com/OHF-Voice/linux-voice-assistant/pull/376)
|
||||
* Bump docker/login-action from 4.5.0 to 4.5.1 by [@dependabot](https://github.com/dependabot)[bot] in [#377](https://github.com/OHF-Voice/linux-voice-assistant/pull/377)
|
||||
|
||||
## New Contributors
|
||||
* [@dependabot](https://github.com/dependabot)[bot] made their first contribution in [#364](https://github.com/OHF-Voice/linux-voice-assistant/pull/364)
|
||||
* [@litinoveweedle](https://github.com/litinoveweedle) made their first contribution in [#357](https://github.com/OHF-Voice/linux-voice-assistant/pull/357)
|
||||
* [@maximilianovermeyer](https://github.com/maximilianovermeyer) made their first contribution in [#372](https://github.com/OHF-Voice/linux-voice-assistant/pull/372)
|
||||
* [@SpiliosDimakopoulos](https://github.com/SpiliosDimakopoulos) made their first contribution in [#376](https://github.com/OHF-Voice/linux-voice-assistant/pull/376)
|
||||
|
||||
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.13...v1.1.14
|
||||
|
||||
[Changes][v1.1.14]
|
||||
|
||||
|
||||
<a id="v1.1.13"></a>
|
||||
# [v1.1.13](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.13) - 2026-07-18
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### Exciting New Features 🎉
|
||||
* (Temporary workaround) Switch between MWW/OWW models from the UI by [@omaramin-2000](https://github.com/omaramin-2000) in [#348](https://github.com/OHF-Voice/linux-voice-assistant/pull/348)
|
||||
* Leds and buttons events by [@omaramin-2000](https://github.com/omaramin-2000) in [#266](https://github.com/OHF-Voice/linux-voice-assistant/pull/266)
|
||||
* Add Tests For LVA by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#312](https://github.com/OHF-Voice/linux-voice-assistant/pull/312)
|
||||
* feat: add --listen-during-wake-sound argument by [@wangwillian0](https://github.com/wangwillian0) in [#273](https://github.com/OHF-Voice/linux-voice-assistant/pull/273)
|
||||
### Other Changes
|
||||
* perf(player): limit cache to 32 MiB by [@florian-asche](https://github.com/florian-asche) in [#309](https://github.com/OHF-Voice/linux-voice-assistant/pull/309)
|
||||
* Allow using python versions above 3.11 by [@omaramin-2000](https://github.com/omaramin-2000) in [#345](https://github.com/OHF-Voice/linux-voice-assistant/pull/345)
|
||||
* Modify USB control transfer settings in respeaker_usb_mic_array.py by [@omaramin-2000](https://github.com/omaramin-2000) in [#349](https://github.com/OHF-Voice/linux-voice-assistant/pull/349)
|
||||
* Broadcast entity state to all API clients; don't desync on a second connection by [@JSRossie](https://github.com/JSRossie) in [#329](https://github.com/OHF-Voice/linux-voice-assistant/pull/329)
|
||||
* Fix lint_mypy by [@omaramin-2000](https://github.com/omaramin-2000) in [#352](https://github.com/OHF-Voice/linux-voice-assistant/pull/352)
|
||||
|
||||
## New Contributors
|
||||
* [@JSRossie](https://github.com/JSRossie) made their first contribution in [#329](https://github.com/OHF-Voice/linux-voice-assistant/pull/329)
|
||||
* [@wangwillian0](https://github.com/wangwillian0) made their first contribution in [#273](https://github.com/OHF-Voice/linux-voice-assistant/pull/273)
|
||||
|
||||
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.12...v1.1.13
|
||||
|
||||
[Changes][v1.1.13]
|
||||
|
||||
|
||||
<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
|
||||
|
||||
@@ -390,9 +299,6 @@ Initial release
|
||||
[Changes][v1.0.0]
|
||||
|
||||
|
||||
[v1.1.15]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.14...v1.1.15
|
||||
[v1.1.14]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.13...v1.1.14
|
||||
[v1.1.13]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.12...v1.1.13
|
||||
[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
|
||||
|
||||
@@ -6,13 +6,12 @@ ENV PYTHONUNBUFFERED=1
|
||||
|
||||
LABEL \
|
||||
org.opencontainers.image.authors="Open Home Foundation" \
|
||||
org.opencontainers.image.description="Linux-Voice-Assistant for Home Assistant" \
|
||||
org.opencontainers.image.description="Voice assistant for Home Assistant" \
|
||||
org.opencontainers.image.documentation="https://github.com/OHF-Voice/linux-voice-assistant/blob/main/README.md" \
|
||||
org.opencontainers.image.licenses="Apache-2.0" \
|
||||
org.opencontainers.image.source="https://github.com/OHF-Voice/linux-voice-assistant" \
|
||||
org.opencontainers.image.title="Linux-Voice-Assistant" \
|
||||
org.opencontainers.image.url="https://github.com/OHF-Voice/linux-voice-assistant" \
|
||||
org.opencontainers.image.vendor="Open Home Foundation"
|
||||
org.opencontainers.image.url="https://github.com/OHF-Voice/linux-voice-assistant"
|
||||
|
||||
### Install packages:
|
||||
# - avahi-utils: For zeroconf/mDNS discovery by Home Assistant
|
||||
|
||||
@@ -19,7 +19,7 @@ Because it runs on a full Linux system and offers access significantly more loca
|
||||
- Supports multiple wake words and languages
|
||||
- Supports multiple architectures (linux/amd64 and linux/aarch64)
|
||||
- Automated builds with artifact attestation for security
|
||||
- Supports [announcments](https://www.home-assistant.io/actions/assist_satellite.announce/), [start](https://www.home-assistant.io/actions/assist_satellite.start_conversation/)/continue conversation, and timers
|
||||
- Supports announcments, start/continue conversation, and timers
|
||||
- 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](https://github.com/florian-asche/PiCompose)
|
||||
@@ -53,7 +53,7 @@ Alternatively if on a lower budget then suggest could use other microphone-array
|
||||
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 repository inside Home Assistant before you can install it.
|
||||
> 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):
|
||||
|
||||
@@ -74,7 +74,7 @@ For all other users, we have different installation methods available (Docker, s
|
||||
💡 **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] [--music-output-device MUSIC_OUTPUT_DEVICE] [--list-output-devices] [--wake-word-dir WAKE_WORD_DIR] [--mic-auto-gain] [--mic-noise-suppression]
|
||||
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] [--listen-during-wake-sound] [--debug]
|
||||
```
|
||||
@@ -85,7 +85,6 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE]
|
||||
| `--audio-input-device` | Soundcard name for input device | Autodetected |
|
||||
| `--audio-input-block-size` | Audio input block size in samples | 1024 |
|
||||
| `--audio-output-device` | mpv name for output device | Autodetected |
|
||||
| `--music-output-device` | mpv name for the music/media output device | `--audio-output-device` |
|
||||
| `--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 |
|
||||
@@ -114,10 +113,9 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE]
|
||||
| `--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 |
|
||||
|
||||
💡 **Note:** There are detailed explanations on the controlled entities from device page in the [configuration](docs/configuration.md) file.
|
||||
💡 **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
|
||||
|
||||
|
||||
@@ -9,10 +9,6 @@ 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
|
||||
@@ -44,10 +40,6 @@ if [ -n "${AUDIO_OUTPUT_DEVICE}" ]; then
|
||||
EXTRA_ARGS+=( "--audio-output-device" "$AUDIO_OUTPUT_DEVICE" )
|
||||
fi
|
||||
|
||||
if [ -n "${MUSIC_OUTPUT_DEVICE}" ]; then
|
||||
EXTRA_ARGS+=( "--music-output-device" "$MUSIC_OUTPUT_DEVICE" )
|
||||
fi
|
||||
|
||||
if [ -n "${MIC_VOLUME}" ]; then
|
||||
EXTRA_ARGS+=( "--mic-volume" "$MIC_VOLUME" )
|
||||
fi
|
||||
@@ -132,8 +124,8 @@ if [ -n "${PERIPHERAL_VOLUME_STEP}" ]; then
|
||||
EXTRA_ARGS+=( "--peripheral-volume-step" "$PERIPHERAL_VOLUME_STEP" )
|
||||
fi
|
||||
|
||||
if [ "$DISABLE_PERIPHERAL_API" = "1" ]; then
|
||||
EXTRA_ARGS+=( "--disable-peripheral-api" )
|
||||
if [ -n "${DISABLE_PERIPHERAL_API}" ]; then
|
||||
EXTRA_ARGS+=( "--disable-peripheral-api" "$DISABLE_PERIPHERAL_API" )
|
||||
fi
|
||||
|
||||
if [ "$ENABLE_OUTPUT_ONLY" = "1" ]; then
|
||||
|
||||
@@ -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
|
||||
@@ -1,130 +0,0 @@
|
||||
# Dashboard / Home Assistant Entities
|
||||
|
||||
Once your satellite is connected, Home Assistant exposes a set of controls on its device page (via the ESPHome integration) that let you change LVA's behavior at runtime, without restarting the container or editing config files. This document walks through each of those entities.
|
||||
|
||||
> **Go to [Settings > Devices & services](https://my.home-assistant.io/redirect/integrations/) and select the ESPHome integration.**
|
||||
|
||||

|
||||
|
||||
## Wake Word
|
||||
|
||||
The device page exposes one or two wake word selects (**Wake Word 1**, **Wake Word 2**), depending on whether your assistant is configured for dual wake words. Changing the selection here takes effect immediately and is persisted to `preferences.json`, so it survives restarts.
|
||||
|
||||

|
||||
|
||||
💡 **Note:** Models suffixed with **(OWW)** in the dropdown are [openWakeWord](https://github.com/dscripka/openWakeWord) models; everything else is a [microWakeWord](https://github.com/kahrendt/microWakeWord) model. Both engines can be mixed and matched, but keep in mind:
|
||||
|
||||
- **microWakeWord** models are lighter-weight and use engine that can run on lower-end devices and are located in the `wakewords` directory.
|
||||
- **openWakeWord** models use higher engine with more accuracy and are located in the `wakewords/openWakeWord` directory.
|
||||
|
||||
See the [Wake Word section](install_application.md#wake-word) of the installation guide for the full list of bundled models and how to add custom ones.
|
||||
|
||||
## Mute Switch
|
||||
|
||||
The **Mute** switch disables wake word detection and stops the assistant from responding to any wake word or button-triggered listening, without stopping the LVA process itself. While muted, a configurable sound plays to confirm the mute/unmute transition (`--mute-sound` / `--unmute-sound`).
|
||||
|
||||

|
||||
|
||||
## Thinking Sound
|
||||
|
||||
The **Thinking Sound** switch toggles whether a short audible chime plays while the assistant is processing your request (between the end of listening and the start of the response). This is the same setting as the `--enable-thinking-sound` CLI flag / `ENABLE_THINKING_SOUND` environment variable, but can be flipped on or off at runtime from this switch. The change is saved to `preferences.json`.
|
||||
|
||||

|
||||
|
||||
## Media Player
|
||||
|
||||
The **Media Player** entity controls playback (play/pause/stop) and volume for both:
|
||||
|
||||
- Music/media played through the satellite, and
|
||||
- The assist pipeline itself (wake word chime, TTS responses, thinking/processing sound, etc.)
|
||||
|
||||
Both share a single volume control — there is no separate volume for the pipeline versus music playback. Adjusting the volume slider on this entity changes both at once, and the value is persisted to `preferences.json` so it's restored on the next startup.
|
||||
|
||||

|
||||
|
||||
## Microphone Settings
|
||||
|
||||
### Volume
|
||||
|
||||
Controls the microphone volume to adjust the area distance from which the microphone can pick up your voice. The volume slider is persisted to `preferences.json` so it's restored on the next startup.
|
||||
|
||||

|
||||
|
||||
### 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 entities to change these gain and noise suppression from which you can edit them 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
|
||||
|
||||
## Support
|
||||
|
||||
If a dashboard control doesn't behave as expected, enable `--debug` (or `ENABLE_DEBUG=1`) and check the logs — every change made from the Home Assistant device page is logged when it's received and persisted.
|
||||
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -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/log/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/logs/picompose.log
|
||||
- More information within the PiCompose project.
|
||||
|
||||
### Option B) Install on your own:
|
||||
|
||||
@@ -176,7 +176,6 @@ Environment=PREFERENCES_FILE="/home/pi/linux-voice-assistant/preferences.json"
|
||||
# Environment=PORT="6053"
|
||||
# Environment=AUDIO_INPUT_DEVICE="default"
|
||||
# Environment=AUDIO_OUTPUT_DEVICE="default"
|
||||
# Environment=MUSIC_OUTPUT_DEVICE="default"
|
||||
# Environment=MIC_VOLUME="1.0"
|
||||
# Environment=MIC_AUTO_GAIN="0"
|
||||
# Environment=MIC_NOISE_SUPPRESSION="0"
|
||||
@@ -261,20 +260,16 @@ The following variables can be configured in the `.env` or in the service file:
|
||||
| `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` | Autodetected | Audio input device name |
|
||||
| `AUDIO_OUTPUT_DEVICE` | Autodetected | Audio output device name |
|
||||
| `MUSIC_OUTPUT_DEVICE` | `AUDIO_OUTPUT_DEVICE` | Music/media output device name (defaults to the voice output device) |
|
||||
| `MIC_VOLUME` | 100 | Control microphone volume |
|
||||
| `MIC_AUTO_GAIN` | 0 | Add WebRTC Gain to Mic |
|
||||
| `MIC_NOISE_SUPPRESSION` | 0 | Add WebRTC Noise Suppression to Mic |
|
||||
| `AUDIO_INPUT_CHANNELS` | 2 | Number of audio input channels |
|
||||
| `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 |
|
||||
@@ -288,7 +283,7 @@ The following variables can be configured in the `.env` or in the service file:
|
||||
| `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 mute off |
|
||||
| `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 |
|
||||
@@ -316,8 +311,6 @@ If you want to use your own sounds, you can add them to the `sounds/custom` aka
|
||||
|
||||
### Wake Word:
|
||||
|
||||
💡 **Note:** The active wake word(s) can also be changed at runtime from the Home Assistant device page, without editing `.env` or restarting LVA — see [Wake Word](configuration.md#wake-word) in the dashboard documentation.
|
||||
|
||||
#### Available Wake Word Models:
|
||||
|
||||
The following wake word models are available:
|
||||
@@ -330,6 +323,7 @@ The following wake word models are available:
|
||||
- `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
|
||||
|
||||
@@ -340,9 +334,6 @@ The following wake word models are available:
|
||||
- `hey_mycroft` - Mycroft wake word
|
||||
- `hey_rhasspy` - Rhasspy wake word
|
||||
|
||||
### Stop Word:
|
||||
|
||||
The `stop` wake word is used to stop the current process (response, announcement, or timer). It is available in the `wakewords` directory (using the microWakeWord engine) and can be referenced in the `.env` file with `STOP_MODEL=stop`.
|
||||
|
||||
### Custom Wake Word:
|
||||
|
||||
|
||||
@@ -54,31 +54,11 @@ 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 = {
|
||||
default.clock.rate = 16000
|
||||
}
|
||||
|
||||
context.modules = [
|
||||
{ name = libpipewire-module-rt
|
||||
args = {
|
||||
nice.level = -11
|
||||
rt.prio = 88
|
||||
}
|
||||
flags = [ ifexists nofail ]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### Applying the Changes
|
||||
|
||||
@@ -153,37 +153,7 @@ export XDG_RUNTIME_DIR=/run/user/1000
|
||||
export PULSE_SERVER=unix:/run/user/1000/pulse/native
|
||||
python main.py
|
||||
```
|
||||
To make it persistent across reboots
|
||||
```
|
||||
mkdir -p ~/.config/systemd/user/
|
||||
nano ~/.config/systemd/user/jabra-lva.service
|
||||
```
|
||||
|
||||
```
|
||||
[Unit]
|
||||
Description=Jabra Speak 410 LVA Controller
|
||||
After=network.target pipewire.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/linux-voice-assistant/examples/Jabra Speak 410
|
||||
ExecStart=%h/linux-voice-assistant/venv/bin/python main.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment="VOLUME_CONTROL=pipewire"
|
||||
Environment="LVA_WS_URL=ws://localhost:6055"
|
||||
Environment="XDG_RUNTIME_DIR=/run/user/1000"
|
||||
Environment="PULSE_SERVER=unix:/run/user/1000/pulse/native"
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
Enable and start service
|
||||
```
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable jabra-lva
|
||||
systemctl --user start jabra-lva
|
||||
```
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -361,12 +361,7 @@
|
||||
volBar.style.width = pct + '%';
|
||||
}
|
||||
if (ev === 'volume_muted' && data) {
|
||||
const isMuted = !!data.muted;
|
||||
metaMuted.textContent = isMuted ? 'yes' : 'no';
|
||||
if (isMuted) {
|
||||
metaVol.textContent = '0%';
|
||||
volBar.style.width = '0%';
|
||||
}
|
||||
metaMuted.textContent = data.muted ? 'yes' : 'no';
|
||||
}
|
||||
if (ev === 'zeroconf' && data) {
|
||||
metaHa.textContent = data.status === 'connected' ? 'yes' : 'connecting…';
|
||||
|
||||
@@ -75,7 +75,6 @@ LED 0 sits above MIC_L and LED 2 sits above MIC_R. When the microphone is muted,
|
||||
| **Timer ringing** | **Blue flash (repeating)** | **All 3** |
|
||||
| Timer ticking | Dim cyan, brightness ∝ time left | All 3 |
|
||||
| Media playing | Dim green steady | All 3 |
|
||||
| **Volume muted** | **Solid red** | **LED 1 only (center led)** |
|
||||
|
||||
"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.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ LED behaviours
|
||||
pipeline_error : red flash on all 3 LEDs
|
||||
timer_ringing : blue flash on all 3 LEDs (repeating)
|
||||
timer_ticking : all 3 dim cyan, brightness proportional to time left
|
||||
volume_muted : LED 1 solid red, LED 0 and LED 2 off
|
||||
media_playing : dim green steady on all 3 LEDs
|
||||
not_ready/no_ha : dim red pulse on all 3 LEDs
|
||||
|
||||
On connect the script registers an HA Light entity with LVA via the
|
||||
@@ -130,7 +130,7 @@ RECONNECT_DELAY_S = 3.0
|
||||
# is used to route incoming light_command events back to this script.
|
||||
LIGHT_OBJECT_ID = "leds"
|
||||
LIGHT_NAME = "LEDs"
|
||||
LIGHT_ICON = "mdi:led-strip-varient"
|
||||
LIGHT_ICON = "mdi:circle-outline"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
@@ -157,7 +157,6 @@ class AssistState(str, Enum):
|
||||
TIMER_TICKING = "timer_ticking"
|
||||
TIMER_RINGING = "timer_ringing"
|
||||
MEDIA_PLAYING = "media_player_playing"
|
||||
VOLUME_MUTED = "volume_muted"
|
||||
|
||||
|
||||
# Effect name. Must match the LEDLightEntity effects list the peripheral
|
||||
@@ -176,9 +175,8 @@ class SharedState:
|
||||
self._lock = threading.Lock()
|
||||
self.assist_state: AssistState = AssistState.NOT_READY
|
||||
self.ha_connected: bool = False
|
||||
self.muted: bool = False # mic mute
|
||||
self.muted: bool = False
|
||||
self.volume: float = 1.0
|
||||
self.volume_muted: bool = False # media volume zero
|
||||
self.timer_total_seconds: int = 0
|
||||
self.timer_seconds_left: int = 0
|
||||
# Light entity state, driven by HA via light_command events.
|
||||
@@ -215,7 +213,6 @@ class SharedState:
|
||||
"ha_connected": self.ha_connected,
|
||||
"muted": self.muted,
|
||||
"volume": self.volume,
|
||||
"volume_muted": self.volume_muted,
|
||||
"timer_total_seconds": self.timer_total_seconds,
|
||||
"timer_seconds_left": self.timer_seconds_left,
|
||||
"light_is_on": self.light_is_on,
|
||||
@@ -382,9 +379,6 @@ class LEDAnimator:
|
||||
# Left and right LEDs red (mic positions), centre off
|
||||
self._task = asyncio.create_task(self._muted())
|
||||
|
||||
elif state == AssistState.VOLUME_MUTED:
|
||||
self._task = asyncio.create_task(self._volume_muted())
|
||||
|
||||
elif state == AssistState.ERROR:
|
||||
self._task = asyncio.create_task(
|
||||
self._flash_all(RED, flashes=3, on_ms=150, off_ms=100, then_off=True)
|
||||
@@ -462,13 +456,6 @@ class LEDAnimator:
|
||||
self._leds.set(2, RED)
|
||||
self._leds.show(self._brightness(0.6))
|
||||
|
||||
async def _volume_muted(self) -> None:
|
||||
"""Centre LED red; left and right off."""
|
||||
self._leds.set(0, OFF)
|
||||
self._leds.set(1, RED)
|
||||
self._leds.set(2, OFF)
|
||||
self._leds.show(self._brightness(0.6))
|
||||
|
||||
async def _flash_all(
|
||||
self,
|
||||
color: ColorSource,
|
||||
@@ -935,11 +922,11 @@ class LVAClient:
|
||||
self._state.update(volume=data.get("volume", 1.0))
|
||||
|
||||
elif event == "volume_muted":
|
||||
volume_muted = data.get("muted", True)
|
||||
self._state.update(volume_muted=volume_muted)
|
||||
if volume_muted:
|
||||
self._state.update(assist_state=AssistState.VOLUME_MUTED)
|
||||
elif self._state.assist_state == AssistState.VOLUME_MUTED:
|
||||
muted = data.get("muted", False)
|
||||
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 == "zeroconf":
|
||||
|
||||
@@ -55,7 +55,6 @@ All animations mirror the Home Assistant Voice PE ESPHome firmware exactly.
|
||||
| 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 |
|
||||
| Volume muted | Solid ring + red indicator | Red at the bottom-mid LED |
|
||||
|
||||
"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.
|
||||
|
||||
@@ -236,4 +235,4 @@ The seeed-voicecard driver uses I2S — it does **not** use SPI — so there is
|
||||
|
||||
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`.
|
||||
3. If LVA runs in a separate Docker network, use its container IP or service name as `--host`.
|
||||
@@ -93,15 +93,6 @@ SPI_DEVICE = 1
|
||||
# 10 ≈ 32 % (gentle default), 31 = maximum.
|
||||
LED_BRIGHTNESS = 10
|
||||
|
||||
# LED that lights up solid red while the media player (speaker) output is
|
||||
# muted at volume 0 — the `volume_muted` peripheral event. Ring index 6 is
|
||||
# the bottom-middle position (180°, directly opposite index 0 at the top),
|
||||
# following the same 0=top / 3=right / 6=bottom / 9=left convention used for
|
||||
# the mic corner indicators below. Sits between the two bottom mic corners
|
||||
# (LEDs 4 and 7) so it reads as a distinct "speaker muted" dot rather than
|
||||
# overlapping a mic indicator.
|
||||
VOLUME_MUTED_LED_INDEX = 6
|
||||
|
||||
# Default ring colour (R, G, B) — matches HA Voice PE default
|
||||
DEFAULT_R, DEFAULT_G, DEFAULT_B = 24, 187, 242
|
||||
|
||||
@@ -133,7 +124,6 @@ class AssistState(str, Enum):
|
||||
TIMER_TICKING = "timer_ticking"
|
||||
TIMER_RINGING = "timer_ringing"
|
||||
MEDIA_PLAYING = "media_player_playing"
|
||||
VOLUME_MUTED = "volume_muted"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -145,9 +135,8 @@ class SharedState:
|
||||
self._lock = threading.Lock()
|
||||
self.assist_state: AssistState = AssistState.NOT_READY
|
||||
self.ha_connected: bool = False
|
||||
self.muted: bool = False # mic mute
|
||||
self.muted: bool = False
|
||||
self.volume: float = 1.0
|
||||
self.volume_muted: bool = False # media player (speaker) mute
|
||||
self.timer_total_seconds: int = 0
|
||||
self.timer_seconds_left: int = 0
|
||||
# Light entity state, driven by HA via light_command events.
|
||||
@@ -173,7 +162,6 @@ class SharedState:
|
||||
"ha_connected": self.ha_connected,
|
||||
"muted": self.muted,
|
||||
"volume": self.volume,
|
||||
"volume_muted": self.volume_muted,
|
||||
"timer_total_seconds": self.timer_total_seconds,
|
||||
"timer_seconds_left": self.timer_seconds_left,
|
||||
"light_is_on": self.light_is_on,
|
||||
@@ -329,19 +317,6 @@ class LEDRing:
|
||||
self._set(7, _scale(RED, factor))
|
||||
self._set(10, _scale(RED, factor))
|
||||
|
||||
def _apply_volume_muted_indicator(self) -> None:
|
||||
"""
|
||||
Overlay a solid red dot on the bottom-middle LED (index
|
||||
``VOLUME_MUTED_LED_INDEX``) when the media player output is muted.
|
||||
|
||||
Applied after the current pipeline animation has already rendered
|
||||
its frame, so the indicator shows up consistently no matter which
|
||||
animation is active — mirrors how ``_apply_mic_indicators`` marks
|
||||
the microphone corners, but for the distinct speaker-mute state.
|
||||
"""
|
||||
self._set(VOLUME_MUTED_LED_INDEX, RED)
|
||||
self._write()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Animation implementations
|
||||
# ------------------------------------------------------------------
|
||||
@@ -490,7 +465,6 @@ class LEDRing:
|
||||
snap = self._state.snapshot
|
||||
color = self._color()
|
||||
muted = snap["muted"]
|
||||
volume_muted = snap["volume_muted"]
|
||||
t_total = snap["timer_total_seconds"]
|
||||
t_left = snap["timer_seconds_left"]
|
||||
|
||||
@@ -521,9 +495,6 @@ class LEDRing:
|
||||
else:
|
||||
sleep = self._anim_off()
|
||||
|
||||
if volume_muted:
|
||||
self._apply_volume_muted_indicator()
|
||||
|
||||
time.sleep(sleep)
|
||||
|
||||
|
||||
@@ -719,17 +690,7 @@ class LVAClient:
|
||||
self._state.update(volume=data.get("volume", 1.0))
|
||||
|
||||
elif event == "volume_muted":
|
||||
# Media player (speaker) mute state, carried as {"muted": bool}
|
||||
# per the peripheral API — distinct from the microphone `muted`
|
||||
# event above. Drives the bottom-middle LED indicator rather
|
||||
# than replacing the whole ring animation, since the assist
|
||||
# pipeline can still be doing something else at the same time.
|
||||
volume_muted = data.get("muted", True)
|
||||
self._state.update(volume_muted=volume_muted)
|
||||
if volume_muted:
|
||||
self._state.update(assist_state=AssistState.VOLUME_MUTED)
|
||||
elif self._state.assist_state == AssistState.VOLUME_MUTED:
|
||||
self._state.update(assist_state=AssistState.IDLE)
|
||||
self._state.update(muted=data.get("muted", False))
|
||||
|
||||
elif event == "zeroconf":
|
||||
status = data.get("status", "")
|
||||
@@ -832,4 +793,4 @@ def main() -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
@@ -36,7 +36,6 @@ All animations mirror the Home Assistant Voice PE ESPHome firmware exactly.
|
||||
| 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 |
|
||||
| Volume muted | Red mic indicator | LED 9 (bottom at the AUX jack corner) solid red to indicate the media player volume is zero; other LEDs off |
|
||||
|
||||
"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.
|
||||
|
||||
|
||||
@@ -86,13 +86,6 @@ USB_PRODUCT_ID = 0x0018
|
||||
LED_COUNT = 12
|
||||
LED_BRIGHTNESS = 10 # 0–31 (APA102 5-bit global brightness register)
|
||||
|
||||
# LED that lights up solid red while the media player (speaker) output is
|
||||
# muted at volume 0 — the `volume_muted` peripheral event. LED 9 is the
|
||||
# board's bottom-middle position (DOA 270°), sitting right between the
|
||||
# micro-USB connector and the 3.5 mm AUX jack on the board edge — the same
|
||||
# "bottom" position already used by the mic indicator mapping below.
|
||||
VOLUME_MUTED_LED_INDEX = 9
|
||||
|
||||
RECONNECT_DELAY_S = 3.0
|
||||
|
||||
|
||||
@@ -120,7 +113,6 @@ class AssistState(str, Enum):
|
||||
TIMER_TICKING = "timer_ticking"
|
||||
TIMER_RINGING = "timer_ringing"
|
||||
MEDIA_PLAYING = "media_player_playing"
|
||||
VOLUME_MUTED = "volume_muted"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -132,9 +124,8 @@ class SharedState:
|
||||
self._lock = threading.Lock()
|
||||
self.assist_state: AssistState = AssistState.NOT_READY
|
||||
self.ha_connected: bool = False
|
||||
self.muted: bool = False # mic mute
|
||||
self.muted: bool = False
|
||||
self.volume: float = 1.0
|
||||
self.volume_muted: bool = False # media player volume zero
|
||||
self.timer_total_seconds: int = 0
|
||||
self.timer_seconds_left: int = 0
|
||||
# Light entity state, driven by HA via light_command events.
|
||||
@@ -160,7 +151,6 @@ class SharedState:
|
||||
"ha_connected": self.ha_connected,
|
||||
"muted": self.muted,
|
||||
"volume": self.volume,
|
||||
"volume_muted": self.volume_muted,
|
||||
"timer_total_seconds": self.timer_total_seconds,
|
||||
"timer_seconds_left": self.timer_seconds_left,
|
||||
"light_is_on": self.light_is_on,
|
||||
@@ -174,9 +164,9 @@ class SharedState:
|
||||
# ===========================================================================
|
||||
# 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.
|
||||
# The ReSpeaker Mic Array v2.0 accepts LED data via a USB vendor control
|
||||
# transfer. Each LED is set as an APA102 frame: 0xFF start, then per-LED
|
||||
# bytes in the order [start_frame | brightness, B, G, R].
|
||||
# ===========================================================================
|
||||
|
||||
RGB = Tuple[int, int, int]
|
||||
@@ -196,17 +186,20 @@ def _scale(color: RGB, factor: float) -> RGB:
|
||||
|
||||
class USBLEDRing:
|
||||
"""
|
||||
Controls the 12 LEDs on the ReSpeaker Mic Array v2.0 over USB.
|
||||
Controls the 12 APA102 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.
|
||||
Uses a USB vendor control transfer (bmRequestType=0x40, bRequest=0,
|
||||
wValue=0x0001, wIndex=0x8001) to push raw LED data to the XMOS firmware.
|
||||
This is the same protocol used by Seeed's pixel_ring library.
|
||||
"""
|
||||
|
||||
_CMD_SHOW = 0x06
|
||||
_CMD_BRIGHTNESS = 0x20
|
||||
# USB control transfer parameters matching the pixel_ring reference implementation.
|
||||
# _CTRL_TYPE_OUT = 0x40: bmRequestType = CTRL_OUT | CTRL_TYPE_VENDOR | CTRL_RECIPIENT_DEVICE.
|
||||
# Recipient is "device" (bits 4:0 = 0x00), so no interface needs to be claimed
|
||||
# and the kernel audio driver must NOT be detached.
|
||||
_CTRL_TIMEOUT = 8000 # ms
|
||||
_CTRL_REQUEST = 0
|
||||
_CTRL_VALUE = 0x0001
|
||||
_CTRL_INDEX = 0x1C
|
||||
_CTRL_TYPE_OUT = 0x40 # vendor, device, host-to-device
|
||||
|
||||
@@ -214,7 +207,6 @@ class USBLEDRing:
|
||||
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()
|
||||
@@ -228,8 +220,12 @@ class USBLEDRing:
|
||||
USB_VENDOR_ID, USB_PRODUCT_ID,
|
||||
)
|
||||
return
|
||||
try:
|
||||
if dev.is_kernel_driver_active(0):
|
||||
dev.detach_kernel_driver(0)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
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,
|
||||
@@ -241,32 +237,24 @@ class USBLEDRing:
|
||||
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.
|
||||
Frame layout (matches pixel_ring / APA102 USB protocol):
|
||||
[0xFF, 0xFF, 0xFF, 0xFF] ← start frame
|
||||
[0xE0|bright5, B, G, R] × LED_COUNT ← one word per LED
|
||||
[0xFF, ...] ← end frame (⌈n/2⌉ bytes)
|
||||
"""
|
||||
bright5 = max(0, min(31, int(brightness * LED_BRIGHTNESS)))
|
||||
|
||||
frame: list[int] = []
|
||||
frame = [0xFF, 0xFF, 0xFF, 0xFF] # start frame
|
||||
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]
|
||||
frame += [0xE0 | bright5, bb, bg, br]
|
||||
frame += [0xFF] * math.ceil(LED_COUNT / 2) # end frame
|
||||
|
||||
with self._dev_lock:
|
||||
if self._dev is None:
|
||||
@@ -274,14 +262,17 @@ class USBLEDRing:
|
||||
_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)
|
||||
self._dev.ctrl_transfer(
|
||||
self._CTRL_TYPE_OUT,
|
||||
self._CTRL_REQUEST,
|
||||
self._CTRL_VALUE,
|
||||
self._CTRL_INDEX,
|
||||
frame,
|
||||
self._CTRL_TIMEOUT,
|
||||
)
|
||||
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
|
||||
@@ -417,22 +408,6 @@ class LEDRing:
|
||||
self._leds.set(6, _scale(RED, factor))
|
||||
self._leds.set(9, _scale(RED, factor))
|
||||
|
||||
def _apply_volume_muted_indicator(self) -> None:
|
||||
"""
|
||||
Overlay a solid red dot on the bottom-middle LED (index
|
||||
``VOLUME_MUTED_LED_INDEX``, DOA 270°) when the media player output
|
||||
is muted at volume 0.
|
||||
|
||||
Sits right between the board's micro-USB connector and 3.5 mm AUX
|
||||
jack. Applied after the current pipeline animation has already
|
||||
rendered its frame, so the indicator shows up consistently no
|
||||
matter which animation is active — mirrors how
|
||||
``_apply_mic_indicators`` marks the microphone cardinal points, but
|
||||
for the distinct speaker-mute state.
|
||||
"""
|
||||
self._leds.set(VOLUME_MUTED_LED_INDEX, RED)
|
||||
self._leds.show()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Animations — each returns sleep time in seconds
|
||||
# ------------------------------------------------------------------
|
||||
@@ -581,7 +556,6 @@ class LEDRing:
|
||||
snap = self._state.snapshot
|
||||
color = self._color()
|
||||
muted = snap["muted"]
|
||||
volume_muted = snap["volume_muted"]
|
||||
t_total = snap["timer_total_seconds"]
|
||||
t_left = snap["timer_seconds_left"]
|
||||
|
||||
@@ -612,9 +586,6 @@ class LEDRing:
|
||||
else:
|
||||
sleep = self._anim_off()
|
||||
|
||||
if volume_muted:
|
||||
self._apply_volume_muted_indicator()
|
||||
|
||||
time.sleep(sleep)
|
||||
|
||||
|
||||
@@ -738,19 +709,11 @@ class LVAClient:
|
||||
_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,
|
||||
muted=data.get("muted", False),
|
||||
volume=data.get("volume", 1.0),
|
||||
ha_connected=ha_connected,
|
||||
ha_connected=data.get("ha_connected", False),
|
||||
)
|
||||
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)
|
||||
@@ -768,12 +731,7 @@ class LVAClient:
|
||||
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)
|
||||
self._state.update(assist_state=AssistState.MUTED, muted=True)
|
||||
|
||||
elif event == "pipeline_error":
|
||||
_LOGGER.warning("LVA pipeline error: %s", data.get("reason", ""))
|
||||
@@ -813,23 +771,12 @@ class LVAClient:
|
||||
self._state.update(volume=data.get("volume", 1.0))
|
||||
|
||||
elif event == "volume_muted":
|
||||
Volume_muted = data.get("muted", True)
|
||||
self._state.update(volume_muted=Volume_muted)
|
||||
if Volume_muted:
|
||||
self._state.update(assist_state=AssistState.VOLUME_MUTED)
|
||||
elif self._state.assist_state == AssistState.VOLUME_MUTED:
|
||||
self._state.update(assist_state=AssistState.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 …")
|
||||
|
||||
@@ -68,7 +68,6 @@ In Home Assistant, you can:
|
||||
- Thinking → Pulse in your color
|
||||
- Speaking → Reverse spin in your color
|
||||
- Muted → Solid ring in your color + red at mic positions
|
||||
- Volume muted → Solid ring in your color + red at AUX jack corner LEDs
|
||||
- Timer → Countdown arc in your color
|
||||
|
||||
3. **Set brightness** — scales all animations by the chosen brightness (0–100%). Useful for adapting to ambient lighting.
|
||||
@@ -189,7 +188,6 @@ All animations mirror the Home Assistant Voice PE ESPHome firmware exactly. The
|
||||
| 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 |
|
||||
| Volume muted | Solid ring + red indicator | red at the AUX jack corner LEDs positions 11, 0, 1 to indicate the media player volume is zero |
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -98,15 +98,6 @@ LED_BRIGHTNESS = 168 # 66 % of 255 – matches ESPHome default
|
||||
LED_INVERT = False
|
||||
LED_CHANNEL = 0
|
||||
|
||||
# LEDs physically sitting in the AUX (3.5 mm) audio jack corner of the board,
|
||||
# identified by their silkscreen designators on the Satellite1 HAT (D35, D1,
|
||||
# D2). These three ring positions light up solid red whenever the media
|
||||
# player volume is at zero (the `volume_muted` peripheral event), independent
|
||||
# of whatever pipeline animation is currently running. Verify the mapping
|
||||
# against your board revision — adjust if your unit's jack corner sits at a
|
||||
# different position on the ring.
|
||||
AUX_JACK_LED_INDICES = (11, 0, 1)
|
||||
|
||||
# Default ring color (ESPHome default: 9.4 % R, 73.3 % G, 94.9 % B)
|
||||
DEFAULT_R, DEFAULT_G, DEFAULT_B = 24, 187, 242
|
||||
|
||||
@@ -232,7 +223,6 @@ class AssistState(str, Enum):
|
||||
TIMER_TICKING = "timer_ticking"
|
||||
TIMER_RINGING = "timer_ringing"
|
||||
MEDIA_PLAYING = "media_player_playing"
|
||||
VOLUME_MUTED = "volume_muted"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -244,9 +234,8 @@ class SharedState:
|
||||
self._lock = threading.Lock()
|
||||
self.assist_state: AssistState = AssistState.NOT_READY
|
||||
self.ha_connected: bool = False
|
||||
self.muted: bool = False # mic mute
|
||||
self.muted: bool = False
|
||||
self.volume: float = 1.0
|
||||
self.volume_muted: bool = False # media player volume zero
|
||||
self.timer_total_seconds: int = 0
|
||||
self.timer_seconds_left: int = 0
|
||||
# Light entity state, driven by HA via light_command events.
|
||||
@@ -276,7 +265,6 @@ class SharedState:
|
||||
"ha_connected": self.ha_connected,
|
||||
"muted": self.muted,
|
||||
"volume": self.volume,
|
||||
"volume_muted": self.volume_muted,
|
||||
"timer_total_seconds": self.timer_total_seconds,
|
||||
"timer_seconds_left": self.timer_seconds_left,
|
||||
"light_is_on": self.light_is_on,
|
||||
@@ -577,21 +565,6 @@ class LEDRing:
|
||||
self._write()
|
||||
return 0.1
|
||||
|
||||
def _apply_volume_muted_indicator(self) -> None:
|
||||
"""
|
||||
Overlay a solid red indicator on the AUX jack corner LEDs
|
||||
(designators D35, D1, D2 — see ``AUX_JACK_LED_INDICES``) when the
|
||||
media player output is muted.
|
||||
|
||||
Applied as a post-render overlay after the current pipeline
|
||||
animation has already written its frame, so the indicator shows up
|
||||
consistently no matter which animation is active, without having to
|
||||
thread the flag through every individual ``_anim_*`` method.
|
||||
"""
|
||||
for i in AUX_JACK_LED_INDICES:
|
||||
self._set(i, RED)
|
||||
self._write()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Animation loop
|
||||
# ------------------------------------------------------------------
|
||||
@@ -604,7 +577,6 @@ class LEDRing:
|
||||
snap = self._state.snapshot
|
||||
color = self._color()
|
||||
muted = snap["muted"]
|
||||
volume_muted = snap["volume_muted"]
|
||||
t_total = snap["timer_total_seconds"]
|
||||
t_left = snap["timer_seconds_left"]
|
||||
ha_connected = snap["ha_connected"]
|
||||
@@ -648,9 +620,6 @@ class LEDRing:
|
||||
else:
|
||||
sleep = self._anim_off()
|
||||
|
||||
if volume_muted:
|
||||
self._apply_volume_muted_indicator()
|
||||
|
||||
time.sleep(sleep)
|
||||
|
||||
|
||||
@@ -1017,9 +986,6 @@ class LVAClient:
|
||||
self._state.update(
|
||||
muted=data.get("muted", False),
|
||||
volume=data.get("volume", 1.0),
|
||||
# Not currently sent by LVA's snapshot payload, but read it
|
||||
# defensively in case a future LVA version adds it.
|
||||
volume_muted=data.get("volume_muted", False),
|
||||
ha_connected=data.get("ha_connected", False),
|
||||
)
|
||||
snap = self._state.snapshot
|
||||
@@ -1106,17 +1072,7 @@ class LVAClient:
|
||||
self._state.update(volume=data.get("volume", 1.0))
|
||||
|
||||
elif event == "volume_muted":
|
||||
# Media player (speaker) mute state, distinct from the
|
||||
# microphone `muted` event above. Drives the red indicator on
|
||||
# the AUX jack corner LEDs (see AUX_JACK_LED_INDICES) rather
|
||||
# than swapping the whole ring animation, since the assist
|
||||
# pipeline can still be doing something else at the same time.
|
||||
volume_muted = data.get("muted", True)
|
||||
self._state.update(volume_muted=volume_muted)
|
||||
if volume_muted:
|
||||
self._state.update(assist_state=AssistState.VOLUME_MUTED)
|
||||
elif self._state.assist_state == AssistState.VOLUME_MUTED:
|
||||
self._state.update(assist_state=AssistState.IDLE)
|
||||
self._state.update(muted=data.get("muted", False))
|
||||
|
||||
# --- Zeroconf / connection events ----------------------------------
|
||||
elif event == "zeroconf":
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"$schema": "https://app.kilo.ai/config.json",
|
||||
"model": "kilo/codegemma-2b:free",
|
||||
"permission": {
|
||||
"bash": "allow",
|
||||
"read": {
|
||||
"*": "ask"
|
||||
}
|
||||
},
|
||||
"instructions": ["AGENTS.md"]
|
||||
}
|
||||
@@ -18,7 +18,7 @@ from getmac import get_mac_address # type: ignore
|
||||
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
|
||||
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
|
||||
|
||||
from .models import Preferences, ServerState, WakeWordType, initial_stop_word_threshold
|
||||
from .models import Preferences, ServerState, WakeWordType
|
||||
from .mpv_player import MpvMediaPlayer
|
||||
from .peripheral_api import LVAEvent, PeripheralAPIServer
|
||||
from .satellite import VoiceSatelliteProtocol
|
||||
@@ -66,10 +66,6 @@ async def main() -> None:
|
||||
"--audio-output-device",
|
||||
help="Name for the audio output device (see --list-output-devices)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--music-output-device",
|
||||
help="mpv name for the music/media output device (defaults to --audio-output-device)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-output-devices",
|
||||
action="store_true",
|
||||
@@ -238,11 +234,6 @@ async def main() -> None:
|
||||
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",
|
||||
@@ -250,15 +241,6 @@ async def main() -> None:
|
||||
)
|
||||
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("Audio Input devices:")
|
||||
print("=" * 13)
|
||||
@@ -277,6 +259,9 @@ async def main() -> None:
|
||||
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-address detection
|
||||
if not args.network_interface:
|
||||
print("No network interface specified, try to detect default interface")
|
||||
@@ -379,10 +364,6 @@ async def main() -> None:
|
||||
initial_volume = max(0.0, min(1.0, float(initial_volume)))
|
||||
preferences.volume = initial_volume
|
||||
|
||||
# Load stop word sensitivity from preferences on startup, and ensure it's between 0.0 and 1.0
|
||||
initial_threshold = initial_stop_word_threshold(preferences.stop_word_sensitivity)
|
||||
preferences.stop_word_sensitivity = initial_threshold
|
||||
|
||||
if args.enable_thinking_sound:
|
||||
preferences.thinking_sound = 1
|
||||
|
||||
@@ -427,7 +408,7 @@ async def main() -> None:
|
||||
wake_words=wake_models,
|
||||
active_wake_words=active_wake_words,
|
||||
stop_word=stop_model,
|
||||
music_player=MpvMediaPlayer(device=args.music_output_device or args.audio_output_device),
|
||||
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,
|
||||
@@ -445,7 +426,6 @@ async def main() -> None:
|
||||
output_only=args.output_only,
|
||||
download_dir=args.download_dir,
|
||||
volume=initial_volume,
|
||||
stop_word_threshold=initial_threshold,
|
||||
mic_volume=preferences.mic_volume,
|
||||
mic_auto_gain=preferences.mic_auto_gain,
|
||||
mic_noise_suppression=preferences.mic_noise_suppression,
|
||||
@@ -595,38 +575,6 @@ async def main() -> None:
|
||||
_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],
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -182,8 +182,6 @@ class MediaPlayerEntity(ESPHomeEntity):
|
||||
self.music_player.set_volume(0)
|
||||
self.announce_player.set_volume(0)
|
||||
self.muted = True
|
||||
if hasattr(self.server, "state") and getattr(self.server, "state", None) is not None:
|
||||
self.server.state.persist_volume(self.volume)
|
||||
yield self._update_state(self.state)
|
||||
|
||||
elif command == MediaPlayerCommand.UNMUTE:
|
||||
@@ -193,8 +191,6 @@ class MediaPlayerEntity(ESPHomeEntity):
|
||||
self.music_player.set_volume(int(self.volume * 100))
|
||||
self.announce_player.set_volume(int(self.volume * 100))
|
||||
self.muted = False
|
||||
if hasattr(self.server, "state") and getattr(self.server, "state", None) is not None:
|
||||
self.server.state.persist_volume(self.volume)
|
||||
yield self._update_state(self.state)
|
||||
|
||||
elif msg.has_volume:
|
||||
|
||||
@@ -217,7 +217,6 @@ class ServerState:
|
||||
_LOGGER.debug("Skipping save - volume unchanged")
|
||||
return
|
||||
|
||||
previous_muted = self.volume == 0.0
|
||||
self.volume = clamped_volume
|
||||
self.preferences.volume = clamped_volume
|
||||
_LOGGER.info("Saving volume %s to %s", clamped_volume, self.preferences_path)
|
||||
@@ -231,10 +230,6 @@ class ServerState:
|
||||
|
||||
api.emit_event_sync(LVAEvent.VOLUME_CHANGED, {"volume": round(clamped_volume, 3)})
|
||||
|
||||
new_muted = clamped_volume == 0.0
|
||||
if previous_muted != new_muted:
|
||||
api.emit_event_sync(LVAEvent.VOLUME_MUTED, {"muted": new_muted})
|
||||
|
||||
def persist_mic_gain(self, gain: float) -> None:
|
||||
"""Persist the microphone auto gain value."""
|
||||
gain_int = int(gain)
|
||||
@@ -265,14 +260,3 @@ class ServerState:
|
||||
self.preferences.mic_volume = volume_int
|
||||
_LOGGER.info("Saving mic_volume %s to %s", volume_int, self.preferences_path)
|
||||
self.save_preferences()
|
||||
|
||||
|
||||
def initial_stop_word_threshold(saved_sensitivity: Optional[float]) -> float:
|
||||
"""
|
||||
Resolve the stop word probability cutoff to start from, clamped to 0.0-1.0.
|
||||
:param saved_sensitivity: Value persisted in preferences, or None if it has never been set.
|
||||
"""
|
||||
if saved_sensitivity is None:
|
||||
return ServerState.stop_word_threshold
|
||||
|
||||
return max(0.0, min(1.0, float(saved_sensitivity)))
|
||||
@@ -99,7 +99,6 @@ 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
|
||||
|
||||
@@ -181,8 +180,6 @@ class PeripheralAPIServer:
|
||||
"""
|
||||
|
||||
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,
|
||||
@@ -211,10 +208,6 @@ class PeripheralAPIServer:
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -237,10 +230,6 @@ class PeripheralAPIServer:
|
||||
|
||||
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()
|
||||
@@ -278,7 +267,6 @@ class PeripheralAPIServer:
|
||||
"data": {
|
||||
"muted": state.muted,
|
||||
"volume": round(state.volume, 3),
|
||||
"volume_muted": state.volume == 0.0,
|
||||
"ha_connected": state.connected,
|
||||
"last_stt_text": self._last_stt_text,
|
||||
"last_tts_text": self._last_tts_text,
|
||||
@@ -293,20 +281,14 @@ class PeripheralAPIServer:
|
||||
# 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
|
||||
if self._current_state is not None:
|
||||
state_payload: Dict[str, Any] = {"event": self._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
|
||||
@@ -519,8 +501,6 @@ class PeripheralAPIServer:
|
||||
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.
|
||||
|
||||
@@ -548,66 +528,10 @@ class PeripheralAPIServer:
|
||||
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
|
||||
@@ -660,7 +584,6 @@ class PeripheralAPIServer:
|
||||
LVAEvent.MEDIA_PLAYER_PLAYING,
|
||||
LVAEvent.DISCONNECTED,
|
||||
LVAEvent.PIPELINE_ERROR,
|
||||
LVAEvent.VOLUME_MUTED,
|
||||
}
|
||||
if event in _STATE_EVENTS:
|
||||
self._current_state = event
|
||||
|
||||
@@ -507,7 +507,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def handle_voice_event(self, event_type: VoiceAssistantEventType, data: Dict[str, str]) -> None:
|
||||
_LOGGER.info("Voice event: type=%s, data=%s", event_type.name, data)
|
||||
_LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data)
|
||||
|
||||
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
|
||||
self._tts_url = data.get("url")
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# PR Review Workflow for Linux Voice Assistant
|
||||
|
||||
## Review Procedure
|
||||
|
||||
When reviewing a PR, follow this checklist:
|
||||
|
||||
### 1. Understanding the Change
|
||||
- Identify the files modified and their purpose
|
||||
- Check if changes align with project architecture
|
||||
- Verify no unrelated rewrites are suggested
|
||||
|
||||
### 2. Bug Detection
|
||||
- Check for logic errors in `satellite.py`, `wake_word.py`, `webrtc.py`
|
||||
- Verify type safety (mypy compliance)
|
||||
- Check for proper error handling
|
||||
|
||||
### 3. Testing
|
||||
- Verify tests exist for changed code
|
||||
- Check that `./script/tests` passes
|
||||
- Note if hardware-dependent code lacks simulation
|
||||
|
||||
### 4. Areas Requiring Extra Care
|
||||
- `linux_voice_assistant/wake_word.py` - hardware-dependent
|
||||
- `linux_voice_assistant/mpv_player.py` - requires libmpv-dev
|
||||
- `linux_voice_assistant/webrtc.py` - noise suppression algorithms
|
||||
- `linux_voice_assistant/satellite.py` - ESPHome API protocol
|
||||
|
||||
### 5. Output Format
|
||||
Keep summary concise, cite file:line numbers, mention test coverage.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Completion Receipt Template
|
||||
|
||||
```
|
||||
subject: {CHANGE_DESCRIPTION}
|
||||
files_modified:
|
||||
- {FILES_MODIFIED}
|
||||
checks_run:
|
||||
- {CHECKS_RUN}
|
||||
not_checked:
|
||||
- {NOT_CHECKED}
|
||||
verdict: pass | fail | incomplete
|
||||
reason: {REASON}
|
||||
```
|
||||
|
||||
## Verification Checklist
|
||||
- [ ] Ran `./script/lint` - all checks passed
|
||||
- [ ] Ran `./script/tests` - all tests passed
|
||||
- [ ] For audio-related changes: Note hardware testing NOT performed
|
||||
- [ ] Did NOT claim hardware behavior verified unless exercised
|
||||
- [ ] Changes focused on one feature or fix
|
||||
@@ -0,0 +1,16 @@
|
||||
# PR Review - Output Template
|
||||
|
||||
## Summary
|
||||
{BRIEF_SUMMARY}
|
||||
|
||||
## Issues Found
|
||||
| File | Line | Issue | Severity |
|
||||
|------|------|-------|----------|
|
||||
{
|
||||
"subject": "PR review",
|
||||
"files_reviewed": [{FILES_REVIEWED}],
|
||||
"checks_run": [{CHECKS_RUN}],
|
||||
"not_checked": [{NOT_CHECKED}],
|
||||
"verdict": "pass | fail | incomplete",
|
||||
"reason": "{REASON}"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# PR Review Verification
|
||||
|
||||
When reviewing PRs, explicitly state what was and wasn't verified:
|
||||
|
||||
## Required Evidence
|
||||
- Lines changed and why
|
||||
- Tests touched or added
|
||||
- Linter/type checks run
|
||||
|
||||
## Common Gaps to Note
|
||||
- Hardware/audio paths not tested
|
||||
- Integration behavior not verified
|
||||
- Performance implications not measured
|
||||
|
||||
## Outcome Labels
|
||||
- **pass**: All checks passed, no issues found
|
||||
- **fail**: Issues found that block merge
|
||||
- **incomplete**: Changes look correct but some verification gaps exist
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Unit tests for shared models."""
|
||||
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -192,17 +192,6 @@ class TestPersistVolume:
|
||||
state.persist_volume(1.0)
|
||||
assert state.volume == 1.0
|
||||
|
||||
def test_emits_volume_muted_event_when_volume_zero(self, tmp_path):
|
||||
from linux_voice_assistant.peripheral_api import LVAEvent
|
||||
|
||||
state = make_server_state(tmp_path)
|
||||
state.peripheral_api = Mock()
|
||||
|
||||
state.persist_volume(0.0)
|
||||
|
||||
state.peripheral_api.emit_event_sync.assert_any_call(LVAEvent.VOLUME_CHANGED, {"volume": 0.0})
|
||||
state.peripheral_api.emit_event_sync.assert_any_call(LVAEvent.VOLUME_MUTED, {"muted": True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ServerState.persist_mic_gain()
|
||||
@@ -282,30 +271,3 @@ class TestPersistMicNoise:
|
||||
with patch.object(state, "save_preferences") as mock_save:
|
||||
state.persist_mic_noise(4.0)
|
||||
mock_save.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# initial_stop_word_threshold()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInitialStopWordThreshold:
|
||||
def test_saved_sensitivity_is_restored(self):
|
||||
from linux_voice_assistant.models import initial_stop_word_threshold
|
||||
|
||||
assert initial_stop_word_threshold(0.9) == pytest.approx(0.9)
|
||||
|
||||
def test_falls_back_to_server_state_default_when_never_set(self):
|
||||
from linux_voice_assistant.models import ServerState, initial_stop_word_threshold
|
||||
|
||||
assert initial_stop_word_threshold(None) == pytest.approx(ServerState.stop_word_threshold)
|
||||
|
||||
def test_clamped_to_one_when_above(self):
|
||||
from linux_voice_assistant.models import initial_stop_word_threshold
|
||||
|
||||
assert initial_stop_word_threshold(1.5) == pytest.approx(1.0)
|
||||
|
||||
def test_clamped_to_zero_when_below(self):
|
||||
from linux_voice_assistant.models import initial_stop_word_threshold
|
||||
|
||||
assert initial_stop_word_threshold(-0.5) == pytest.approx(0.0)
|
||||