Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e51c0eb3aa | ||
|
|
1af2fa4333 | ||
|
|
3b18ce23b1 |
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"
|
||||
|
||||
@@ -20,14 +20,14 @@ 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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create version files
|
||||
run: |
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout PR code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
allow-unsafe-pr-checkout: true
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create version files
|
||||
run: |
|
||||
|
||||
@@ -21,9 +21,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- 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
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
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
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
node_modules
|
||||
package.json
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
bun.lock
|
||||
yarn.lock
|
||||
worktrees
|
||||
agent-manager.json
|
||||
@@ -1,14 +0,0 @@
|
||||
# Lint Command
|
||||
|
||||
Runs all linting checks for the project (black, isort, flake8, mypy, pylint).
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/lint # Run all linting checks
|
||||
/lint --auto # Auto-fix formatting issues (black, isort)
|
||||
```
|
||||
|
||||
## Command
|
||||
|
||||
./script/lint
|
||||
@@ -1,14 +0,0 @@
|
||||
# Run Command
|
||||
|
||||
Runs the Linux Voice Assistant application.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/run # Run the application
|
||||
/run --help # Show CLI help
|
||||
```
|
||||
|
||||
## Command
|
||||
|
||||
./script/run
|
||||
@@ -1,14 +0,0 @@
|
||||
# Setup Command
|
||||
|
||||
Installs project dependencies in a virtual environment.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/setup # Install dependencies
|
||||
/setup --dev # Install dev dependencies (linting, testing tools)
|
||||
```
|
||||
|
||||
## Command
|
||||
|
||||
./script/setup
|
||||
@@ -1,15 +0,0 @@
|
||||
# Test Command
|
||||
|
||||
Runs the pytest unit tests for the project.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/tests # Run all tests
|
||||
/tests <path> # Run tests in specific path
|
||||
/tests -k <pattern> # Run tests matching pattern
|
||||
```
|
||||
|
||||
## Command
|
||||
|
||||
./script/tests
|
||||
@@ -1,70 +0,0 @@
|
||||
# PR Review Standards
|
||||
|
||||
## Review Philosophy
|
||||
|
||||
- Only comment when confident an issue exists
|
||||
- Be concise: one sentence per comment when possible
|
||||
- Focus on actionable feedback, not observations
|
||||
- When reviewing text, only comment on clarity issues if the text is genuinely confusing or could lead to errors
|
||||
|
||||
## What to Analyze
|
||||
|
||||
Review all code changes for:
|
||||
|
||||
- Code quality and style consistency with the existing codebase
|
||||
- Potential bugs or issues
|
||||
- Performance implications
|
||||
- Blocking IO in async code
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
- Documentation updates if needed
|
||||
|
||||
## Project Standards
|
||||
|
||||
Respect the project standards as outlined in AGENTS.md. Any deviations must be raised as `[PROBLEM]`.
|
||||
|
||||
Key standards for LVA:
|
||||
- Python 3.11, 3.12, 3.13 supported
|
||||
- Black formatting with 200 char line length
|
||||
- isort import sorting with black profile
|
||||
- mypy type checking with strict settings
|
||||
- pylint code quality checks (many checks disabled in pyproject.toml)
|
||||
|
||||
## Architecture-Specific Checks
|
||||
|
||||
- `linux_voice_assistant/wake_word.py` - Wake word model loading is hardware-dependent, verify no assumptions about specific hardware
|
||||
- `linux_voice_assistant/mpv_player.py` - Audio output requires `libmpv-dev`, check for proper error handling
|
||||
- `linux_voice_assistant/webrtc.py` - Noise suppression/gain algorithms need careful review for audio quality
|
||||
- `linux_voice_assistant/satellite.py` - ESPHome API protocol handling must be robust
|
||||
|
||||
## CI Context
|
||||
|
||||
Lint runs on Python 3.13, tests run on Python 3.12 with libmpv-dev.
|
||||
|
||||
### What CI Checks
|
||||
|
||||
- **Lint**: `.github/workflows/lint.yml` runs all linting checks
|
||||
- **Tests**: `.github/workflows/tests.yml` runs pytest unit tests
|
||||
|
||||
## Skip These (Low Value)
|
||||
|
||||
Do not comment on:
|
||||
|
||||
- Style/formatting (pre-commit handles this)
|
||||
- Test failures (CI catches this)
|
||||
- Missing dependencies (CI catches this)
|
||||
- Minor naming suggestions
|
||||
- Suggestions to add comments
|
||||
- Logging suggestions unless security-related
|
||||
|
||||
## Issue Categories
|
||||
|
||||
Categorize every issue as:
|
||||
|
||||
- `[CRITICAL]` — must fix before merging (bugs, security issues, broken functionality)
|
||||
- `[PROBLEM]` — should fix (code quality, bad patterns, missing tests)
|
||||
- `[SUGGESTION]` — optional improvement (style, minor refactors, nice-to-haves)
|
||||
|
||||
## PR Title
|
||||
|
||||
The PR title must be a functional description of the change. It must NOT contain conventional commit prefixes such as `feat:`, `fix:`, `refactor:`, `chore:`, etc. Labels categorize PRs, not the title. Flag as `[PROBLEM]` if the title uses such prefixes.
|
||||
@@ -1,44 +0,0 @@
|
||||
name: review-pr
|
||||
description: Use when asked to review a GitHub pull request, PR link is shared, or user says /review-pr
|
||||
|
||||
# Review GitHub Pull Request
|
||||
|
||||
Review the GitHub pull request: $ARGUMENTS.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `gh pr view` to get PR details and description.
|
||||
2. Run `gh pr diff` to see all changes.
|
||||
3. Compare the local commit hash with the remote PR head commit.
|
||||
- Make sure to do your work in a git worktree to avoid affecting the checked out branch.
|
||||
- If they don't match, run `gh pr checkout` to check out the PR locally.
|
||||
- **HARD STOP**: If `gh pr checkout` fails for ANY reason, STOP immediately. Do NOT attempt workarounds. Alert about the failure and wait for instructions.
|
||||
- After checkout, verify the local commit hash matches the remote one. If they still don't match, STOP.
|
||||
4. Run `gh pr checks` to see CI status.
|
||||
5. Apply the review standards defined in `REVIEW_STANDARDS.md` (same directory).
|
||||
6. Output review feedback to the CONSOLE only. DO NOT act on GitHub (no posting comments, no approving, no requesting changes).
|
||||
|
||||
## Output Format
|
||||
|
||||
List specific comments per file/line that need attention. Do not list things that are already correct.
|
||||
|
||||
Each comment:
|
||||
|
||||
1. State the severity ([CRITICAL], [PROBLEM], [SUGGESTION])
|
||||
2. State the problem (1 sentence)
|
||||
3. Why it matters (1 sentence, if needed)
|
||||
4. Suggested fix (snippet or specific action)
|
||||
|
||||
End with an overall assessment and summary:
|
||||
|
||||
```
|
||||
Overall assessment: <approve | request changes | comment>
|
||||
- [CRITICAL] <issue>
|
||||
- [PROBLEM] <issue>
|
||||
- [SUGGESTION] <improvement>
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- DO NOT make any changes to the code.
|
||||
- No need to run tests or linters, just review the code changes.
|
||||
@@ -1,129 +0,0 @@
|
||||
# Project Context for AI Agents
|
||||
|
||||
## Behaviour
|
||||
|
||||
- NEVER automatically reply on Github (PRs or Discussions) without explicit consent from the developer.
|
||||
- Always run `./script/lint` and `./script/tests` after code changes to ensure standards are met.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Linux Voice Assistant (LVA)** - A Linux-based voice satellite software for Home Assistant using the ESPHome protocol.
|
||||
|
||||
### Purpose
|
||||
- Provides voice control and interaction for Home Assistant via the Assist integration
|
||||
- Runs on x64/ARM64 Linux hardware with PulseAudio/PipeWire audio support
|
||||
- Performs local wake word detection (OpenWakeWord/MicroWakeWord) and on-device processing
|
||||
|
||||
### Architecture
|
||||
- **Protocol**: ESPHome API via `aioesphomeapi` for Home Assistant integration
|
||||
- **Wake Words**: Supports both OpenWakeWord and MicroWakeWord engines
|
||||
- **Audio**: Uses `soundcard` library for input, `python-mpv` for output
|
||||
- **Peripherals**: WebSocket API for LEDs, buttons, HAT boards
|
||||
- **Discovery**: mDNS/ZeroConf for auto-discovery by Home Assistant
|
||||
|
||||
### Key Components
|
||||
- `linux_voice_assistant/__main__.py` - Main entry point with CLI argument parsing
|
||||
- `linux_voice_assistant/satellite.py` - VoiceSatelliteProtocol implementation (ESPHome API)
|
||||
- `linux_voice_assistant/wake_word.py` - Wake word model loading and detection
|
||||
- `linux_voice_assistant/webrtc.py` - WebRTC noise gain/NS processing
|
||||
- `linux_voice_assistant/mpv_player.py` - MPV-based media player
|
||||
- `linux_voice_assistant/entity.py` - Entity definitions (MediaPlayer, MuteSwitch, etc.)
|
||||
- `linux_voice_assistant/api_server.py` - Peripheral WebSocket API server
|
||||
- `linux_voice_assistant/models.py` - Preferences and ServerState dataclasses
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
./script/setup --dev # Install dev dependencies in virtual environment
|
||||
```
|
||||
|
||||
### Linting
|
||||
```bash
|
||||
./script/lint # Run all linting checks
|
||||
./script/lint_black # Black formatting check (add --auto to fix)
|
||||
./script/lint_isort # isort import sorting check (add --auto to fix)
|
||||
./script/lint_flake8 # flake8 style checks
|
||||
./script/lint_mypy # mypy type checking
|
||||
./script/lint_pylint # pylint code quality checks
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
./script/tests # Run pytest unit tests
|
||||
```
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
- **Python**: 3.11, 3.12, 3.13 supported
|
||||
- **Formatting**: Black (200 char line length, py312/py312 target)
|
||||
- **Import Sorting**: isort with black profile
|
||||
- **Type Checking**: mypy with strict settings
|
||||
- **Linting**: pylint (many checks disabled in pyproject.toml for practical reasons)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Unit tests in `tests/unit/` using pytest with asyncio mode
|
||||
- Test helper `make_state()` in `tests/unit/conftest.py` for creating state fixtures
|
||||
- Mocks used heavily for hardware dependencies (audio devices, MPV, wake words)
|
||||
|
||||
## CI/CD
|
||||
|
||||
- GitHub Actions for lint checks and unit tests
|
||||
- Tests run on Python 3.12 with libmpv-dev system dependency
|
||||
- Lint runs on Python 3.13
|
||||
|
||||
## Branching and PRs
|
||||
|
||||
- All PRs target `main` (primary development branch). `stable` is for production releases.
|
||||
- PRs labeled `bugfix` + `backport-to-stable` are automatically backported to `stable` — use only for bugs also present in `stable`.
|
||||
|
||||
## Agent Commands
|
||||
|
||||
When making code changes, run these commands in order:
|
||||
|
||||
1. **Format code**: `./script/lint_black --auto` and `./script/lint_isort --auto`
|
||||
2. **Run linting**: `./script/lint`
|
||||
3. **Run tests**: `./script/tests`
|
||||
|
||||
## Code Style
|
||||
|
||||
### Comments
|
||||
|
||||
Only use comments to explain complex, multi-line blocks of code. Do not comment obvious operations. Inline comments are there to explain code parts that need explaining - keep them when editing code.
|
||||
|
||||
### Docstring Format
|
||||
|
||||
Use Sphinx-style docstrings with `:param:` syntax. For simple functions, a single-line docstring is fine. The docstring should provide clarity to the caller, not explain how it works technically.
|
||||
|
||||
```python
|
||||
def my_function(param1: str, param2: int, param3: bool = False) -> str:
|
||||
"""
|
||||
Brief one-line description of the function.
|
||||
:param param1: Description of what param1 is used for.
|
||||
:param param2: Description of what param2 is used for.
|
||||
:param param3: Description of what param3 is used for.
|
||||
"""
|
||||
```
|
||||
|
||||
Do **not** use Google-style (`Args:`) or bullet-style (`- param:`) docstrings.
|
||||
|
||||
### File structure
|
||||
|
||||
Private methods should be at the bottom of the file, public at the top.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before claiming completion:
|
||||
- [ ] Ran `./script/lint` - all checks passed
|
||||
- [ ] Ran `./script/tests` - all tests passed
|
||||
- [ ] For audio-related changes: Note that hardware testing (microphone/speaker) was NOT performed
|
||||
- [ ] Did NOT claim hardware behavior is verified unless actually exercised
|
||||
- [ ] Changes are focused on one feature or fix per change
|
||||
|
||||
## Areas Requiring Extra Care
|
||||
|
||||
- `linux_voice_assistant/wake_word.py` - Wake word model loading is hardware-dependent
|
||||
- `linux_voice_assistant/mpv_player.py` - Audio output requires `libmpv-dev`
|
||||
- `linux_voice_assistant/webrtc.py` - Noise suppression/gain algorithms
|
||||
- `linux_voice_assistant/satellite.py` - ESPHome API protocol handling
|
||||
@@ -113,7 +113,6 @@ 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 is a detailed explanation on the gain, noise suppression, and wake word sensitivity flags in the [audio options](docs/audio_options.md) file.
|
||||
|
||||
@@ -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
|
||||
@@ -128,7 +124,7 @@ if [ -n "${PERIPHERAL_VOLUME_STEP}" ]; then
|
||||
EXTRA_ARGS+=( "--peripheral-volume-step" "$PERIPHERAL_VOLUME_STEP" )
|
||||
fi
|
||||
|
||||
if [ -n "$DISABLE_PERIPHERAL_API" = "1" ]; then
|
||||
if [ -n "${DISABLE_PERIPHERAL_API}" ]; then
|
||||
EXTRA_ARGS+=( "--disable-peripheral-api" "$DISABLE_PERIPHERAL_API" )
|
||||
fi
|
||||
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -260,9 +260,6 @@ 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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -164,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]
|
||||
@@ -186,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
|
||||
|
||||
@@ -204,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()
|
||||
@@ -218,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,
|
||||
@@ -231,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:
|
||||
@@ -264,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
|
||||
@@ -708,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)
|
||||
@@ -738,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", ""))
|
||||
@@ -789,12 +777,6 @@ class LVAClient:
|
||||
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 …")
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"$schema": "https://app.kilo.ai/config.json",
|
||||
"model": "kilo/codegemma-2b:free",
|
||||
"instructions": ["AGENTS.md"]
|
||||
}
|
||||
@@ -234,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",
|
||||
@@ -246,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)
|
||||
@@ -273,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")
|
||||
@@ -586,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],
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -292,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
|
||||
@@ -518,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.
|
||||
|
||||
@@ -547,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
|
||||
|
||||
@@ -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")
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ classifiers = [
|
||||
]
|
||||
requires-python = ">=3.11.0"
|
||||
dependencies = [
|
||||
"aioesphomeapi==45.3.1",
|
||||
"aioesphomeapi==45.6.2",
|
||||
"netifaces2==0.0.22",
|
||||
"soundcard<1",
|
||||
"numpy>=2,<3",
|
||||
@@ -32,7 +32,7 @@ dependencies = [
|
||||
"zeroconf<1",
|
||||
"getmac<1",
|
||||
"types-protobuf",
|
||||
"websockets==12",
|
||||
"websockets==16.1.1",
|
||||
"webrtc-noise-gain>=1,<2",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
|
||||
Reference in new issue
Block a user