Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73b494275d | ||
|
|
2dd27e27b1 | ||
|
|
f1345e95cf | ||
|
|
980ceeef2c | ||
|
|
32c9872f78 | ||
|
|
1ea79aa8bb | ||
|
|
730f706655 | ||
|
|
9192d8423b | ||
|
|
3ea32895ee | ||
|
|
0de631160e | ||
|
|
48d9868d1d | ||
|
|
4c9d92f499 | ||
|
|
a21d217d45 | ||
|
|
d2c2e6fbb9 | ||
|
|
0722c40f67 | ||
|
|
72311bf58a | ||
|
|
995da72569 | ||
|
|
79dde095b0 | ||
|
|
c284806b73 | ||
|
|
4ee7bb04bc | ||
|
|
341e3dcb9c | ||
|
|
7732be0de8 | ||
|
|
8b060d7346 | ||
|
|
943c56161f | ||
|
|
88fb2c5a49 | ||
|
|
9067648c87 | ||
|
|
95323553a5 | ||
|
|
0081f2edee | ||
|
|
b3cd3ec7cf | ||
|
|
47e617fcab | ||
|
|
7a62a75aa6 | ||
|
|
c1906a3f20 | ||
|
|
0dce320db2 | ||
|
|
412035e6b2 | ||
|
|
4194aa42ec | ||
|
|
ede3785539 | ||
|
|
0130d2241f | ||
|
|
96fd54a008 | ||
|
|
fbc4e05fbd | ||
|
|
8bb118b411 | ||
|
|
abf6cd07b7 | ||
|
|
a5c37de4ec | ||
|
|
9ddb929423 | ||
|
|
c3885a494b | ||
|
|
d4f5b549da | ||
|
|
9d19ffe6da | ||
|
|
496cd90a8c | ||
|
|
043e8fb1ff | ||
|
|
735731b63e | ||
|
|
a57b13b2d6 | ||
|
|
bfabc232c7 | ||
|
|
3027b33f51 | ||
|
|
cddec7709c | ||
|
|
be9c1220cd | ||
|
|
893476ebf2 | ||
|
|
50f6fef860 | ||
|
|
cd453b685a | ||
|
|
79c34cf0b3 | ||
|
|
fc8ee9b691 | ||
|
|
7868f8fc8b | ||
|
|
5771fb9e1e | ||
|
|
25cd705d88 | ||
|
|
f5b07381c6 | ||
|
|
50ac5f8783 | ||
|
|
f81d21e37b | ||
|
|
53ae51cf52 | ||
|
|
454a1ea281 | ||
|
|
18d0a19cb4 | ||
|
|
d6b803e6d6 | ||
|
|
b53678e0f3 | ||
|
|
efbad18275 | ||
|
|
1104614608 | ||
|
|
54c6b010f3 | ||
|
|
1fc29241b4 | ||
|
|
5b1d3b75e2 | ||
|
|
06d30ac95e | ||
|
|
a62800f5ad | ||
|
|
29f11c7dbc | ||
|
|
f2e3d8e7d3 | ||
|
|
b800f93f50 | ||
|
|
68d2890fdd | ||
|
|
a30a10c95a | ||
|
|
43f7cd3e7d | ||
|
|
ecace7011f | ||
|
|
80e0d6254e | ||
|
|
85312d3f55 | ||
|
|
9dcc4542a4 | ||
|
|
5a732f8421 | ||
|
|
020b1f4ecf | ||
|
|
15b87942c5 | ||
|
|
614bce4c55 | ||
|
|
c4e444f8ca | ||
|
|
c81a707c6a | ||
|
|
981a786a46 | ||
|
|
1ae6b18475 | ||
|
|
7de510cb1f | ||
|
|
38c064fc68 | ||
|
|
0d34e67823 | ||
|
|
ac69618d29 | ||
|
|
c91511c93e | ||
|
|
76308cc209 | ||
|
|
70fab99c9f | ||
|
|
7074dfb278 | ||
|
|
033ff45baf | ||
|
|
3834f51179 | ||
|
|
cf5494642e | ||
|
|
e1670ba5cf | ||
|
|
05356acb3e | ||
|
|
d6dbf83158 | ||
|
|
358e02afd5 | ||
|
|
ffabc7d9fd |
No files matched your search
@@ -8,3 +8,5 @@
|
|||||||
!/sounds/*
|
!/sounds/*
|
||||||
!/wakewords/*
|
!/wakewords/*
|
||||||
!/wakewords/openWakeWord/*
|
!/wakewords/openWakeWord/*
|
||||||
|
!/version.txt
|
||||||
|
!/version_githash.txt
|
||||||
+29
-7
@@ -1,6 +1,10 @@
|
|||||||
# Linux-Voice-Assistant - Docker Environment Configuration
|
# Linux-Voice-Assistant - Docker Environment Configuration
|
||||||
# Copy this file to .env and customize for your setup by 'cp .env.example .env'
|
# Copy this file to .env and customize for your setup by 'cp .env.example .env'
|
||||||
|
|
||||||
|
### Specify another docker image (optional):
|
||||||
|
# LVA_IMAGE="ghcr.io/ohf-voice/linux-voice-assistant"
|
||||||
|
# LVA_IMAGE_TAG="latest"
|
||||||
|
|
||||||
### Enable debug mode (optional):
|
### Enable debug mode (optional):
|
||||||
# ENABLE_DEBUG="1"
|
# ENABLE_DEBUG="1"
|
||||||
|
|
||||||
@@ -14,13 +18,14 @@ LVA_USER_ID="1000"
|
|||||||
LVA_USER_GROUP="1000"
|
LVA_USER_GROUP="1000"
|
||||||
|
|
||||||
### Name for the client (optional):
|
### Name for the client (optional):
|
||||||
# CLIENT_NAME="speaker_livvingroom"
|
# CLIENT_NAME="speaker_livingroom"
|
||||||
|
|
||||||
### PulseAudio socket path on the host:
|
### PulseAudio socket path on the host:
|
||||||
# PulseAudio Server: /run/user/1000/pulse
|
# PulseAudio Server: /run/user/1000/pulse
|
||||||
# Pipewire Server: /run/user/1000/pulse/native
|
# Pipewire Server: /run/user/1000/pulse/native
|
||||||
LVA_PULSE_SERVER="unix:/run/user/${LVA_USER_ID}/pulse/native"
|
LVA_PULSE_SERVER="/run/user/${LVA_USER_ID}/pulse/native"
|
||||||
LVA_XDG_RUNTIME_DIR="/run/user/${LVA_USER_ID}"
|
LVA_XDG_RUNTIME_DIR="/run/user/${LVA_USER_ID}"
|
||||||
|
LVA_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie"
|
||||||
|
|
||||||
### Path to the preferences file (optional):
|
### Path to the preferences file (optional):
|
||||||
# PREFERENCES_FILE="/app/configuration/preferences.json"
|
# PREFERENCES_FILE="/app/configuration/preferences.json"
|
||||||
@@ -40,21 +45,38 @@ LVA_XDG_RUNTIME_DIR="/run/user/${LVA_USER_ID}"
|
|||||||
### Audio output device (optional):
|
### Audio output device (optional):
|
||||||
# AUDIO_OUTPUT_DEVICE="default"
|
# AUDIO_OUTPUT_DEVICE="default"
|
||||||
|
|
||||||
# Enable thinking sound (optional):
|
### Mic Gain (optional) passed as an argument to LVA will overide the value in thepreferences file on boot but can be changed at runtime in Home Assistant:
|
||||||
|
# MIC_AUTO_GAIN="0"
|
||||||
|
|
||||||
|
### Mic Noise Suppression (optional) passed as an argument to LVA will overide the value in thepreferences file on boot but can be changed at runtime in Home Assistant:
|
||||||
|
# MIC_NOISE_SUPPRESSION="0"
|
||||||
|
|
||||||
|
### Enable thinking sound (optional):
|
||||||
# ENABLE_THINKING_SOUND="1"
|
# ENABLE_THINKING_SOUND="1"
|
||||||
|
|
||||||
# Wake model (optional):
|
### Wake word directory (optional):
|
||||||
|
# path for custom files in docker is for example "app/wakewords/custom"
|
||||||
|
# WAKE_WORD_DIR="app/wakewords"
|
||||||
|
|
||||||
|
### Wake model (optional):
|
||||||
# WAKE_MODEL="okay_nabu"
|
# WAKE_MODEL="okay_nabu"
|
||||||
|
|
||||||
# Stop model (optional):
|
### Stop model (optional):
|
||||||
# STOP_MODEL="stop"
|
# STOP_MODEL="stop"
|
||||||
|
|
||||||
# Refactory seconds (optional):
|
### Seconds after which the timer stops ringing (optional):
|
||||||
|
# TIMER_MAX_RING_SECONDS="900"
|
||||||
|
|
||||||
|
### Refactory seconds (optional):
|
||||||
# REFACTORY_SECONDS="2"
|
# REFACTORY_SECONDS="2"
|
||||||
|
|
||||||
# Sound files (optional):
|
### Sound files (optional):
|
||||||
|
# path for custom files in docker is for example "sounds/custom/your_soundfile.flac"
|
||||||
# WAKEUP_SOUND="sounds/wake_word_triggered.flac"
|
# WAKEUP_SOUND="sounds/wake_word_triggered.flac"
|
||||||
# TIMER_FINISHED_SOUND="sounds/timer_finished.flac"
|
# TIMER_FINISHED_SOUND="sounds/timer_finished.flac"
|
||||||
# PROCESSING_SOUND="sounds/processing.wav"
|
# PROCESSING_SOUND="sounds/processing.wav"
|
||||||
# MUTE_SOUND="sounds/mute_switch_on.flac"
|
# MUTE_SOUND="sounds/mute_switch_on.flac"
|
||||||
# UNMUTE_SOUND="sounds/mute_switch_off.flac"
|
# UNMUTE_SOUND="sounds/mute_switch_off.flac"
|
||||||
|
|
||||||
|
### Enable output-only mode (optional):
|
||||||
|
# ENABLE_OUTPUT_ONLY="1"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# .github/release.yml
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
exclude:
|
||||||
|
labels:
|
||||||
|
- ignore-for-release
|
||||||
|
categories:
|
||||||
|
- title: Breaking Changes 🛠
|
||||||
|
labels:
|
||||||
|
- Semver-Major
|
||||||
|
- breaking-change
|
||||||
|
- title: Exciting New Features 🎉
|
||||||
|
labels:
|
||||||
|
- Semver-Minor
|
||||||
|
- enhancement
|
||||||
|
- title: Other Changes
|
||||||
|
labels:
|
||||||
|
- "*"
|
||||||
+5
-6
@@ -8,7 +8,7 @@ This project uses three separate Docker build workflows that automatically build
|
|||||||
|----------|---------|--------------|
|
|----------|---------|--------------|
|
||||||
| [docker-build-release.yml](.github/workflows/docker-build-release.yml) | Tags: `v1.2.0`, `v1.2.0-alpha1` | `1.2.0`, `1.2`, `latest` |
|
| [docker-build-release.yml](.github/workflows/docker-build-release.yml) | Tags: `v1.2.0`, `v1.2.0-alpha1` | `1.2.0`, `1.2`, `latest` |
|
||||||
| [docker-build-nightly.yml](.github/workflows/docker-build-nightly.yml) | Push to `main` | `nightly` |
|
| [docker-build-nightly.yml](.github/workflows/docker-build-nightly.yml) | Push to `main` | `nightly` |
|
||||||
| [docker-build-branch.yml](.github/workflows/docker-build-branch.yml) | All other branches | Branch name |
|
| [docker-build-pr.yml](.github/workflows/docker-build-pr.yml) | Pull requests | `pr-<pr-number>` |
|
||||||
|
|
||||||
## Detailed Description
|
## Detailed Description
|
||||||
|
|
||||||
@@ -40,17 +40,16 @@ Automatically builds nightly images from the main branch.
|
|||||||
ghcr.io/ohf-voice/linux-voice-assistant:nightly
|
ghcr.io/ohf-voice/linux-voice-assistant:nightly
|
||||||
```
|
```
|
||||||
|
|
||||||
### Branch Workflow (`docker-build-branch.yml`)
|
### PR Workflow (`docker-build-pr.yml`)
|
||||||
|
|
||||||
Builds images for all other branches.
|
Builds images for pull requests using the PR number as tag.
|
||||||
|
|
||||||
**Triggers:**
|
**Triggers:**
|
||||||
- Push to all branches except `main`
|
- Pull request opened, synchronized, or reopened
|
||||||
- No tags
|
|
||||||
|
|
||||||
**Image Tag:**
|
**Image Tag:**
|
||||||
```
|
```
|
||||||
ghcr.io/ohf-voice/linux-voice-assistant:<branch-name>
|
ghcr.io/ohf-voice/linux-voice-assistant:pr-<pr-number>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Multi-Architecture Support
|
## Multi-Architecture Support
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||||
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
|
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
|
||||||
with:
|
with:
|
||||||
@@ -36,6 +37,13 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Create version files
|
||||||
|
run: |
|
||||||
|
echo "nightly" > version.txt
|
||||||
|
echo "${{ github.sha }}" > version_githash.txt
|
||||||
|
ls -ltr
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
|
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
name: Build branch
|
---
|
||||||
|
name: Build PR
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
pull_request_target:
|
||||||
branches-ignore:
|
types: [opened, synchronize, reopened]
|
||||||
- "main"
|
|
||||||
tags-ignore:
|
|
||||||
- "**"
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
@@ -13,6 +11,8 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-push-image:
|
build-and-push-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: pr-build
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -20,45 +20,47 @@ jobs:
|
|||||||
attestations: write
|
attestations: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout PR code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Extract clean branch name
|
- name: Set variables
|
||||||
id: extract_branch
|
|
||||||
run: |
|
run: |
|
||||||
echo "CLEAN_BRANCH_NAME=$(echo ${{ github.head_ref || github.ref_name }} | sed 's|.*/||')" >> $GITHUB_ENV
|
echo "PR_NUMBER=${{ github.event.pull_request.number || github.run_number }}" >> $GITHUB_ENV
|
||||||
echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||||
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@v3.1.0
|
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ github.token }}
|
password: ${{ github.token }}
|
||||||
|
|
||||||
# Build Docker Images (amd64 and arm64)
|
# Build Docker Images (amd64 and arm64)
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Create version files
|
||||||
|
run: |
|
||||||
|
echo "${{ env.PR_NUMBER }}" > version.txt
|
||||||
|
echo "${{ github.sha }}" > version_githash.txt
|
||||||
|
ls -ltr
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@v5.3.0
|
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/aarch64
|
platforms: linux/amd64,linux/aarch64
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: "${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}-pr:${{ env.PR_NUMBER }}"
|
||||||
${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}:${{ env.CLEAN_BRANCH_NAME }}
|
labels: "${{ env.PR_NUMBER }}"
|
||||||
labels: |
|
|
||||||
branch=${{ env.CLEAN_BRANCH_NAME }}
|
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
- name: Generate artifact attestation
|
||||||
uses: actions/attest-build-provenance@v1.4.4
|
uses: actions/attest-build-provenance@v1.4.4
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ env.REGISTRY }}/${{ github.repository }}
|
subject-name: ${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}-pr
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
subject-digest: ${{ steps.push.outputs.digest }}
|
||||||
push-to-registry: true
|
push-to-registry: true
|
||||||
@@ -45,6 +45,13 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
|
||||||
|
- name: Create version files
|
||||||
|
run: |
|
||||||
|
echo "${{ steps.meta.outputs.version }}" > version.txt
|
||||||
|
echo "${{ github.sha }}" > version_githash.txt
|
||||||
|
ls -ltr
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
|
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: Generate changelog
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changelog:
|
||||||
|
name: Update changelog
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
- uses: rhysd/changelog-from-release/action@v3
|
||||||
|
with:
|
||||||
|
file: CHANGELOG.md
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
name: Lint Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "main"
|
||||||
|
tags:
|
||||||
|
- "v[0-9]+.[0-9]+.[0-9]+" # For releases, e.g. v1.2.0
|
||||||
|
- "v[0-9]+.[0-9]+.[0-9]+-[a-zA-Z0-9]+" # For prereleases, e.g. v1.3.0-alpha1
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint_black:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python 3.13
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
script/setup --dev
|
||||||
|
- name: Run black
|
||||||
|
run: ./script/lint_black
|
||||||
|
|
||||||
|
lint_flake8:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python 3.13
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
script/setup --dev
|
||||||
|
- name: Run flake8
|
||||||
|
run: ./script/lint_flake8
|
||||||
|
|
||||||
|
lint_isort:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python 3.13
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
script/setup --dev
|
||||||
|
- name: Run isort
|
||||||
|
run: ./script/lint_isort
|
||||||
|
|
||||||
|
lint_mypy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python 3.13
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
script/setup --dev
|
||||||
|
- name: Run mypy
|
||||||
|
run: |
|
||||||
|
./script/lint_mypy
|
||||||
|
|
||||||
|
lint_pylint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python 3.13
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
script/setup --dev
|
||||||
|
- name: Run pylint
|
||||||
|
run: ./script/lint_pylint
|
||||||
|
|
||||||
@@ -21,3 +21,6 @@ venv/
|
|||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
.env
|
.env
|
||||||
|
dev.env
|
||||||
|
version.txt
|
||||||
|
version_githash.txt
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[settings]
|
|
||||||
multi_line_output=3
|
|
||||||
include_trailing_comma=True
|
|
||||||
force_grid_wrap=0
|
|
||||||
use_parentheses=True
|
|
||||||
line_length=88
|
|
||||||
+112
-11
@@ -1,17 +1,118 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased
|
## 1.1.9
|
||||||
|
|
||||||
- Add support for custom/external wake words
|
### Improvements
|
||||||
- Add `--download-dir <DIR>` to store downloaded wake word models/configs
|
- Cookie file and other default parameters by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/231
|
||||||
- Switch to `soundcard` instead of `sounddevice`
|
|
||||||
- Add `--list-input-devices` and `--list-output-devices`
|
## 1.1.8
|
||||||
- Use `pymicro-wakeword` for microWakeWord
|
|
||||||
- Add zeroconf/mDNS discovery
|
### Features
|
||||||
- Support openWakeWord with `pyopen-wakeword`
|
- Add changelog generator based on release information by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/226
|
||||||
- Support multiple wake words
|
|
||||||
- Save active wake words to preferences JSON file
|
## 1.1.7
|
||||||
- Refactor main into separate files
|
|
||||||
|
### Exciting New Features 🎉
|
||||||
|
- Add linting scripts and pipeline by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/201
|
||||||
|
|
||||||
|
### Other Changes
|
||||||
|
- add build flag option for low end devices by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/189
|
||||||
|
- replaced branch build by PR build only by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/203
|
||||||
|
- Change when lint pipeline will start by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/204
|
||||||
|
- fix(script): use os.environ instead of subprocess.environ in setup script by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/205
|
||||||
|
- docs: add documentation for low power device setup and improve setup script by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/206
|
||||||
|
- fix: cookie error (#127) by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/210
|
||||||
|
- fix(config): remove unix: prefix from default pulseaudio socket path … by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/221
|
||||||
|
- Update the changelog file automatically on each release by @omaramin-2000 in https://github.com/OHF-Voice/linux-voice-assistant/pull/224
|
||||||
|
- Enhance hardware compatibility details in README by @Hedda in https://github.com/OHF-Voice/linux-voice-assistant/pull/222
|
||||||
|
- add changelog based on release notes by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/225
|
||||||
|
|
||||||
|
## 1.1.6
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Start listening after wakeup sound
|
||||||
|
- Prevent wake word from interrupting active voice pipeline
|
||||||
|
- Ensure wake word state is set after the wake sound
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Script/run error handling and return code consistency
|
||||||
|
- Move port check into Python application
|
||||||
|
- Help output on application parameters
|
||||||
|
- Added default sample rate to pulse documentation
|
||||||
|
|
||||||
|
## 1.1.5
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fix timer finished sound not looping
|
||||||
|
- Suppress spurious end-file event when starting playback
|
||||||
|
- Fix tts.speak and announce
|
||||||
|
- Fix duck/unduck on timers
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Volume persistence across restarts
|
||||||
|
- Enhance play method for playlist support
|
||||||
|
- Add version and hash to docker container
|
||||||
|
- Allow portainer stack to point directly to docker-compose.yml
|
||||||
|
- Improve documentation
|
||||||
|
|
||||||
|
## 1.1.4
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Added custom paths to docker for wakeword and sounds
|
||||||
|
- Added wake-word-dir variable
|
||||||
|
- Changed default pipewire configuration
|
||||||
|
- Updated documentation regarding wakeword and sounds
|
||||||
|
- Documentation corrections
|
||||||
|
|
||||||
|
## 1.1.3
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Hotfix: typo in network-interface output order causing application crash
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Correct LVA_USER_ID variable name
|
||||||
|
|
||||||
|
## 1.1.2
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
- MAC address detection has been fixed. After updating, it may be necessary
|
||||||
|
to remove and re-add the device in Home Assistant.
|
||||||
|
- The docker-compose.yml and .env.example files were updated. Please review
|
||||||
|
and adapt your configuration if required.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- Add variable for network-interface with auto-detection enabled by default
|
||||||
|
- Add MAC address autodetection based on detected or specified network interface
|
||||||
|
- Switch from listening on all IP addresses to binding to the IP of the default interface
|
||||||
|
- Move auto-name creation from shell to Python (name is now optional and auto-generated)
|
||||||
|
- Add first version of docker-compose for developers
|
||||||
|
- Changes EXTRA_ARGS variable to bash array for better variable expansion
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fix non-working variables (e.g. sound-files)
|
||||||
|
- Fix client-name validation (no spaces allowed)
|
||||||
|
- Fix example configuration
|
||||||
|
|
||||||
|
## 1.1.1
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Handle STOP command correctly in media_player
|
||||||
|
|
||||||
|
## 1.1.0
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- Support custom/external wake words
|
||||||
|
- Add an option to play thinking sound
|
||||||
|
- Add mute/unmute sounds
|
||||||
|
- Add sound customization
|
||||||
|
- Handle announcement separately from the media player
|
||||||
|
- Add Docker build pipeline and compose files
|
||||||
|
- Don't require name for list commands
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fix PEP 508 dependency operator
|
||||||
|
- Fix mpv pause state when loading new media
|
||||||
|
- Fix issues with audio handling
|
||||||
|
|
||||||
## 1.0.0
|
## 1.0.0
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.13-slim-trixie
|
FROM python:3.12-slim-trixie
|
||||||
|
|
||||||
ENV LANG C.UTF-8
|
ENV LANG C.UTF-8
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
@@ -27,7 +27,7 @@ LABEL \
|
|||||||
# - iproute2: For ss command in entrypoint (port check)
|
# - iproute2: For ss command in entrypoint (port check)
|
||||||
# - procps: For pgrep in healthcheck
|
# - procps: For pgrep in healthcheck
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install --yes --no-install-recommends \
|
apt-get install --yes --no-install-recommends \
|
||||||
avahi-utils \
|
avahi-utils \
|
||||||
pulseaudio-utils \
|
pulseaudio-utils \
|
||||||
alsa-utils \
|
alsa-utils \
|
||||||
@@ -54,6 +54,8 @@ COPY sounds/ ./sounds/
|
|||||||
COPY ../wakewords/ ./wakewords/
|
COPY ../wakewords/ ./wakewords/
|
||||||
COPY linux_voice_assistant/ ./linux_voice_assistant/
|
COPY linux_voice_assistant/ ./linux_voice_assistant/
|
||||||
COPY docker-entrypoint.sh ./
|
COPY docker-entrypoint.sh ./
|
||||||
|
COPY version.txt ./
|
||||||
|
COPY version_githash.txt ./
|
||||||
|
|
||||||
### Run installation:
|
### Run installation:
|
||||||
RUN chmod +x docker-entrypoint.sh
|
RUN chmod +x docker-entrypoint.sh
|
||||||
|
|||||||
@@ -2,41 +2,56 @@
|
|||||||
|
|
||||||
[](https://github.com/OHF-Voice/linux-voice-assistant/actions/workflows/docker-build-release.yml) [](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant) [](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/LICENSE.md) [](https://github.com/OHF-Voice/linux-voice-assistant/commits) [](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant)
|
[](https://github.com/OHF-Voice/linux-voice-assistant/actions/workflows/docker-build-release.yml) [](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant) [](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/LICENSE.md) [](https://github.com/OHF-Voice/linux-voice-assistant/commits) [](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant)
|
||||||
|
|
||||||
Experimental Linux-Voice-Assistant for [Home Assistant](https://www.home-assistant.io/) that uses the [ESPHome](https://esphome.io/) protocol/API (via [aioesphomeapi](https://github.com/esphome/aioesphomeapi)).
|
An experimental Linux-Voice-Assistant software for [Home Assistant](https://www.home-assistant.io/) remote voice control and interaction.
|
||||||
|
|
||||||
|
This project enables you to build a Linux-based voice assistant designed to use [Assist](https://www.home-assistant.io/voice_control/) for Home Assistant. It allows you to create your own smart speaker that runs on any x64 or ARM64 hardware capable of handling local audio processing (using PulseAudio).
|
||||||
|
|
||||||
|
Unlike simpler voice satellites that run on microcontrollers with very limited compute power, this setup can perform local wake word detection (OWW/MWW) and process some data on-device.
|
||||||
|
|
||||||
|
Because it runs on a full Linux system and offers access significantly more local computing resources for additional features and other integrations on the same satellite, this approach also provides greater flexibility for customization (such as for example experiment with using PipeWire).
|
||||||
|
|
||||||
[](https://www.openhomefoundation.org/)
|
[](https://www.openhomefoundation.org/)
|
||||||
|
|
||||||
## Features:
|
## Features
|
||||||
|
|
||||||
- Works with [Home Assistant](https://www.home-assistant.io/integrations/esphome/)
|
- Works with [Home Assistant](https://www.home-assistant.io/integrations/esphome/) using the [ESPHome](https://esphome.io/) protocol/API (via [aioesphomeapi](https://github.com/esphome/aioesphomeapi))
|
||||||
- Local wake word detection using integrated [OpenWakeWord](https://github.com/dscripka/openWakeWord) or [MicroWakeWord](https://github.com/kahrendt/microWakeWord)
|
- Feature local on-device wake word detection using integrated [OpenWakeWord](https://github.com/dscripka/openWakeWord) or [MicroWakeWord](https://github.com/kahrendt/microWakeWord)
|
||||||
|
- Supports multiple wake words and languages
|
||||||
- Supports multiple architectures (linux/amd64 and linux/aarch64)
|
- Supports multiple architectures (linux/amd64 and linux/aarch64)
|
||||||
- Automated builds with artifact attestation for security
|
- Automated builds with artifact attestation for security
|
||||||
- Supports multiple wake words and languages
|
|
||||||
- Supports announcments, start/continue conversation, and timers
|
- Supports announcments, start/continue conversation, and timers
|
||||||
- Tested with Python 3.13 and Python 3.11.
|
- Tested and works with Python 3.11 and Python 3.12.
|
||||||
- Prebuild docker image available on [GitHub Container Registry](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant)
|
- Prebuild docker image available on [GitHub Container Registry](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant)
|
||||||
- Prebuild Raspberry Pi image
|
- Prebuild [Raspberry Pi image](https://github.com/florian-asche/PiCompose)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
## Usage:
|
### Hardware
|
||||||
|
|
||||||
### Hardware:
|
A more extensive list for possible compatible hardware can be found in the [PiCompose documentation](https://github.com/florian-asche/PiCompose) but basically any microphone that works with [PipeWire (multimedia framework for Linux)](https://pipewire.org/) can in theory be used for voice input with the prebuild image from there, you should however preferably use a far-field microphone-array solution if want better result.
|
||||||
|
|
||||||
You can for example use the Raspberry Pi Zero 2W with the [Satellite1 Hat Board](https://futureproofhomes.net/products/satellite1-top-microphone-board), the [Respeaker Lite](https://wiki.seeedstudio.com/reSpeaker_usb_v3/) or the [Respeaker 2Mic_Hat](https://wiki.seeedstudio.com/ReSpeaker_2_Mics_Pi_HAT/). A list for possible compatible hardware can be found in the [PiCompose documentation](https://github.com/florian-asche/PiCompose) but basically any microphone that works with [Pipewire](https://pipewire.org/) can be used with the prebuild image.
|
Two solutions recommended for test setups today is to use a Raspberry Pi Zero 2 W SBC (Single Board Computer with built-in WiFi) in combination with the [Satellite1 Hat Board](https://futureproofhomes.net/products/satellite1-top-microphone-board) or the [Respeaker Lite](https://wiki.seeedstudio.com/reSpeaker_usb_v3/). Those have microphone-array designed for far-field voice capture with the added benefit of using an onboard XMOS DSP microcontroller with custom firmware which does advanced audio pre-processing for microphone cleanup that result in very good voice recognition capabilities (as it runs algorithms for Noise Suppression, Acoustic Echo Cancellation, Interference Cancellation, and Automatic Gain Control).
|
||||||
|
|
||||||
### Software:
|
Alternatively if on a lower budget then suggest could try other untested microphone-array boards like example the [reSpeaker 2-Mics Pi HAT V2.0](https://wiki.seeedstudio.com/ReSpeaker_2_Mics_Pi_HAT/) (which uses a much more basic audio codec chip).
|
||||||
|
|
||||||
#### Installation:
|
As for the minimum required compute performance on these satellites the target reference hardware for testing is currently a 64-bit ARM-based SBC based on Raspberry Pi RP3A0 SiP (System-in-Package); which means the Raspberry Pi Zero 2 W, Raspberry Pi Compute Module 3E (Raspberry Pi CM3E), or other development boards that uses the Compute Module Zero" (Raspberry Pi CM0), as all of which have similar specifications to the Raspberry Pi 3 B/B+ but with a CPU running at a lower frequency.
|
||||||
|
|
||||||
We have different installation methods available (Docker, systemd), each with its own dedicated instructions.
|
But you can also install LVA on AMD64 devices, for example on your Linux desktop computer.
|
||||||
|
|
||||||
See [Linux-Voice-Assistant - Installation](docs/install.md).
|
### Software
|
||||||
|
|
||||||
#### Parameter overview:
|
#### Installation
|
||||||
|
|
||||||
|
For Raspberry Pi users, we provide a prebuild image that can be flashed to a SD card. See [PiCompose](https://github.com/florian-asche/PiCompose).
|
||||||
|
|
||||||
|
For all other users, we have different installation methods available (Docker, systemd), each with its own dedicated instructions. See [Linux-Voice-Assistant - Installation](docs/install.md).
|
||||||
|
|
||||||
|
#### Parameter overview
|
||||||
|
|
||||||
|
💡 **Note:** There is an [environment variable](docs/install_application.md#environment-variables-reference) for each parameter if you use docker or systemd based setup.
|
||||||
|
|
||||||
``` sh
|
``` sh
|
||||||
usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE] [--list-input-devices] [--audio-input-block-size AUDIO_INPUT_BLOCK_SIZE] [--audio-output-device AUDIO_OUTPUT_DEVICE] [--list-output-devices] [--wake-word-dir WAKE_WORD_DIR]
|
usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE] [--list-input-devices] [--audio-input-block-size AUDIO_INPUT_BLOCK_SIZE] [--audio-output-device AUDIO_OUTPUT_DEVICE] [--list-output-devices] [--wake-word-dir WAKE_WORD_DIR] [--mic-auto-gain] [--mic-noise-suppression]
|
||||||
[--wake-model WAKE_MODEL] [--stop-model STOP_MODEL] [--download-dir DOWNLOAD_DIR] [--refractory-seconds REFRACTORY_SECONDS] [--wakeup-sound WAKEUP_SOUND] [--timer-finished-sound TIMER_FINISHED_SOUND] [--processing-sound PROCESSING_SOUND]
|
[--wake-model WAKE_MODEL] [--stop-model STOP_MODEL] [--download-dir DOWNLOAD_DIR] [--refractory-seconds REFRACTORY_SECONDS] [--wakeup-sound WAKEUP_SOUND] [--timer-finished-sound TIMER_FINISHED_SOUND] [--processing-sound PROCESSING_SOUND]
|
||||||
[--mute-sound MUTE_SOUND] [--unmute-sound UNMUTE_SOUND] [--preferences-file PREFERENCES_FILE] [--host HOST] [--network-interface NETWORK_INTERFACE] [--port PORT] [--enable-thinking-sound] [--debug]
|
[--mute-sound MUTE_SOUND] [--unmute-sound UNMUTE_SOUND] [--preferences-file PREFERENCES_FILE] [--host HOST] [--network-interface NETWORK_INTERFACE] [--port PORT] [--enable-thinking-sound] [--debug]
|
||||||
```
|
```
|
||||||
@@ -44,14 +59,18 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE]
|
|||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|-----------|-------------|---------|
|
|-----------|-------------|---------|
|
||||||
| `--name` | Name of the voice assistant device (required) | Autogenerated (`lva-MAC-ADDRESS`) |
|
| `--name` | Name of the voice assistant device (required) | Autogenerated (`lva-MAC-ADDRESS`) |
|
||||||
| `--audio-input-device` | Soundcard name for input device | System default microphone |
|
| `--audio-input-device` | Soundcard name for input device | Autodetected |
|
||||||
| `--audio-input-block-size` | Audio input block size in samples | 1024 |
|
| `--audio-input-block-size` | Audio input block size in samples | 1024 |
|
||||||
| `--audio-output-device` | mpv name for output device | System default speaker |
|
| `--audio-output-device` | mpv name for output device | Autodetected |
|
||||||
|
| `--mic-volume` | Control microphone volume | 1.0 |
|
||||||
|
| `--mic-auto-gain` | Add WebRTC Gain to Mic | 0 |
|
||||||
|
| `--mic-noise-suppression` | Add WebRTC Noise Suppression to Mic | 0 |
|
||||||
| `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` |
|
| `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` |
|
||||||
| `--wake-model` | ID of active wake word model | `okay_nabu` |
|
| `--wake-model` | ID of active wake word model | `okay_nabu` |
|
||||||
| `--stop-model` | ID of stop model | `stop` |
|
| `--stop-model` | ID of stop model | `stop` |
|
||||||
| `--download-dir` | Directory to download custom wake word models, etc. | `local/` |
|
| `--download-dir` | Directory to download custom wake word models, etc. | `local/` |
|
||||||
| `--refractory-seconds` | Seconds before wake word can be activated again | 2.0 |
|
| `--refractory-seconds` | Seconds before wake word can be activated again | 2.0 |
|
||||||
|
| `--timer-max-ring-seconds` | Seconds after which the timer stops ringing | 900.0 |
|
||||||
| `--wakeup-sound` | Sound file played when wake word is detected | `sounds/wake_word_triggered.flac` |
|
| `--wakeup-sound` | Sound file played when wake word is detected | `sounds/wake_word_triggered.flac` |
|
||||||
| `--timer-finished-sound` | Sound file played when timer finishes | `sounds/timer_finished.flac` |
|
| `--timer-finished-sound` | Sound file played when timer finishes | `sounds/timer_finished.flac` |
|
||||||
| `--processing-sound` | Sound played while assistant is processing | `sounds/processing.wav` |
|
| `--processing-sound` | Sound played while assistant is processing | `sounds/processing.wav` |
|
||||||
@@ -63,8 +82,12 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE]
|
|||||||
| `--port` | Port for ESPHome server | 6053 |
|
| `--port` | Port for ESPHome server | 6053 |
|
||||||
| `--enable-thinking-sound` | Enable thinking sound on startup | False |
|
| `--enable-thinking-sound` | Enable thinking sound on startup | False |
|
||||||
| `--debug` | Print DEBUG messages to console | False |
|
| `--debug` | Print DEBUG messages to console | False |
|
||||||
|
| `--output-only` | Enable output only mode | False |
|
||||||
|
|
||||||
## Build Information:
|
💡 **Note:** There is a detailed explanation on the gain, noise suppression, and wake word sensitivity flags in the [audio options](docs/audio_options.md) file.
|
||||||
|
|
||||||
|
|
||||||
|
## Build Information
|
||||||
|
|
||||||
Image builds can be tracked in this repository's `Actions` tab, and utilize [artifact attestation](https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds) to certify provenance.
|
Image builds can be tracked in this repository's `Actions` tab, and utilize [artifact attestation](https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds) to certify provenance.
|
||||||
|
|
||||||
@@ -75,3 +98,64 @@ The Docker images are built using GitHub Actions, which provides:
|
|||||||
- Regular updates and maintenance
|
- Regular updates and maintenance
|
||||||
|
|
||||||
The documentation for the build process can be found in the [GitHub Actions Workflows](.github/workflow.md) file.
|
The documentation for the build process can be found in the [GitHub Actions Workflows](.github/workflow.md) file.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Code Quality Checks
|
||||||
|
|
||||||
|
The project uses the following tools to ensure code quality:
|
||||||
|
- **Black**: Code formatting (88 characters per line, PEP 8 compliant)
|
||||||
|
- **isort**: Import sorting compatible with Black
|
||||||
|
- **flake8**: Style and syntax checks
|
||||||
|
- **pylint**: Code quality checks
|
||||||
|
- **mypy**: Static type analysis
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
To use the development tools (linting, testing, etc.), you need to install the required dependencies:
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
./script/setup --dev
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting Commands
|
||||||
|
|
||||||
|
#### Run all linting checks
|
||||||
|
``` sh
|
||||||
|
./script/lint...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Individual linting commands (with auto-fix support)
|
||||||
|
|
||||||
|
| Script | Description | Auto-fix Available? |
|
||||||
|
|--------|-------------|---------------------|
|
||||||
|
| `./script/lint_black` | Checks Python code formatting with Black | Yes, use `--auto` flag |
|
||||||
|
| `./script/lint_flake8` | Runs style and syntax checks with flake8 | No |
|
||||||
|
| `./script/lint_isort` | Checks import sorting with isort | Yes, use `--auto` flag |
|
||||||
|
| `./script/lint_mypy` | Runs static type analysis with mypy | No |
|
||||||
|
| `./script/lint_pylint` | Runs code quality checks with pylint | Yes, use `--auto` flag |
|
||||||
|
|
||||||
|
#### Examples
|
||||||
|
|
||||||
|
Run a specific lint check:
|
||||||
|
``` sh
|
||||||
|
./script/lint_black
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-fix formatting issues (Black + isort):
|
||||||
|
``` sh
|
||||||
|
./script/lint_black --auto
|
||||||
|
./script/lint_isort --auto
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Run the test suite:
|
||||||
|
``` sh
|
||||||
|
./script/test
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details.
|
||||||
+28
-10
@@ -2,30 +2,38 @@
|
|||||||
services:
|
services:
|
||||||
# Fix permissions for wakeword_data and configuration volume
|
# Fix permissions for wakeword_data and configuration volume
|
||||||
fix-permissions:
|
fix-permissions:
|
||||||
image: "ghcr.io/ohf-voice/linux-voice-assistant:latest"
|
image: "${LVA_IMAGE:-ghcr.io/ohf-voice/linux-voice-assistant-pr}:${LVA_IMAGE_TAG:-nightly}"
|
||||||
entrypoint: []
|
entrypoint: []
|
||||||
command: "chown -R ${LVA_USER_ID}:${LVA_USER_GROUP} /app/local /app/configuration"
|
command: "chown -R ${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000} /app/local /app/configuration /app/wakewords/custom /app/sounds/custom"
|
||||||
env_file: .env
|
environment:
|
||||||
|
# Pulseaudio
|
||||||
|
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
|
||||||
|
- PULSE_SERVER=${LVA_PULSE_SERVER:-/run/user/1000/pulse/native}
|
||||||
|
- PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
|
||||||
|
env_file: ${ENV_FILE:-.env}
|
||||||
group_add:
|
group_add:
|
||||||
- audio
|
- audio
|
||||||
volumes:
|
volumes:
|
||||||
# Wakeword data and configuration
|
# Wakeword data and configuration
|
||||||
- wakeword_data:/app/local
|
- wakeword_data:/app/local
|
||||||
|
- wakeword_custom:/app/wakewords/custom
|
||||||
- configuration:/app/configuration
|
- configuration:/app/configuration
|
||||||
|
- sounds_custom:/app/sounds/custom
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
# Linux Voice Assistant
|
# Linux Voice Assistant
|
||||||
linux-voice-assistant:
|
linux-voice-assistant:
|
||||||
container_name: linux-voice-assistant
|
container_name: linux-voice-assistant
|
||||||
image: "ghcr.io/ohf-voice/linux-voice-assistant:latest"
|
image: "${LVA_IMAGE:-ghcr.io/ohf-voice/linux-voice-assistant-pr}:${LVA_IMAGE_TAG:-nightly}"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
user: "${LVA_USER_ID}:${LVA_USER_GROUP}"
|
user: "${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000}"
|
||||||
environment:
|
environment:
|
||||||
# Pulseaudio
|
# Pulseaudio
|
||||||
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR}
|
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
|
||||||
- PULSE_SERVER=${LVA_PULSE_SERVER}
|
- PULSE_SERVER=${LVA_PULSE_SERVER:-/run/user/1000/pulse/native}
|
||||||
env_file: .env
|
- PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
|
||||||
|
env_file: ${ENV_FILE:-.env}
|
||||||
group_add:
|
group_add:
|
||||||
- audio
|
- audio
|
||||||
# Priority for audio process
|
# Priority for audio process
|
||||||
@@ -34,15 +42,21 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# Wakeword data and configuration
|
# Wakeword data and configuration
|
||||||
- wakeword_data:/app/local
|
- wakeword_data:/app/local
|
||||||
|
- wakeword_custom:/app/wakewords/custom
|
||||||
- configuration:/app/configuration
|
- configuration:/app/configuration
|
||||||
|
- sounds_custom:/app/sounds/custom
|
||||||
# Time and zone
|
# Time and zone
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
# Pulseaudio
|
# Pulseaudio
|
||||||
# /run/user/1000:/run/user/1000
|
# /run/user/1000:/run/user/1000
|
||||||
- ${LVA_XDG_RUNTIME_DIR}:${LVA_XDG_RUNTIME_DIR}
|
- ${LVA_XDG_RUNTIME_DIR:-/run/user/1000}:${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
|
||||||
# Mount source code for development
|
# Mount source code for development
|
||||||
- ./linux_voice_assistant:/app/linux_voice_assistant
|
- ./linux_voice_assistant:/app/linux_voice_assistant
|
||||||
|
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh
|
||||||
|
- ./script:/app/script
|
||||||
|
- ./version.txt:/app/version.txt
|
||||||
|
- ./version_githash.txt:/app/version_githash.txt
|
||||||
depends_on:
|
depends_on:
|
||||||
- fix-permissions
|
- fix-permissions
|
||||||
# Health check for process
|
# Health check for process
|
||||||
@@ -52,11 +66,15 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 90s
|
start_period: 90s
|
||||||
#command:
|
command:
|
||||||
|
# - "--mic-noise-suppression"
|
||||||
|
# - "1"
|
||||||
# - --additional-parameters-here
|
# - --additional-parameters-here
|
||||||
# - --but-use-env-if-possible
|
# - --but-use-env-if-possible
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
wakeword_data:
|
wakeword_data:
|
||||||
|
wakeword_custom:
|
||||||
configuration:
|
configuration:
|
||||||
|
sounds_custom:
|
||||||
+21
-9
@@ -2,30 +2,38 @@
|
|||||||
services:
|
services:
|
||||||
# Fix permissions for wakeword_data and configuration volume
|
# Fix permissions for wakeword_data and configuration volume
|
||||||
fix-permissions:
|
fix-permissions:
|
||||||
image: "ghcr.io/ohf-voice/linux-voice-assistant:latest"
|
image: "${LVA_IMAGE:-ghcr.io/ohf-voice/linux-voice-assistant}:${LVA_IMAGE_TAG:-latest}"
|
||||||
entrypoint: []
|
entrypoint: []
|
||||||
command: "chown -R ${LVA_USER_ID}:${LVA_USER_GROUP} /app/local /app/configuration"
|
command: "chown -R ${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000} /app/local /app/configuration /app/wakewords/custom /app/sounds/custom"
|
||||||
env_file: .env
|
environment:
|
||||||
|
# Pulseaudio
|
||||||
|
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
|
||||||
|
- PULSE_SERVER=${LVA_PULSE_SERVER:-/run/user/1000/pulse/native}
|
||||||
|
- PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
|
||||||
|
env_file: ${ENV_FILE:-.env}
|
||||||
group_add:
|
group_add:
|
||||||
- audio
|
- audio
|
||||||
volumes:
|
volumes:
|
||||||
# Wakeword data and configuration
|
# Wakeword data and configuration
|
||||||
- wakeword_data:/app/local
|
- wakeword_data:/app/local
|
||||||
|
- wakeword_custom:/app/wakewords/custom
|
||||||
- configuration:/app/configuration
|
- configuration:/app/configuration
|
||||||
|
- sounds_custom:/app/sounds/custom
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
# Linux Voice Assistant
|
# Linux Voice Assistant
|
||||||
linux-voice-assistant:
|
linux-voice-assistant:
|
||||||
container_name: linux-voice-assistant
|
container_name: linux-voice-assistant
|
||||||
image: "ghcr.io/ohf-voice/linux-voice-assistant:latest"
|
image: "${LVA_IMAGE:-ghcr.io/ohf-voice/linux-voice-assistant}:${LVA_IMAGE_TAG:-latest}"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
user: "${LVA_USER_ID}:${LVA_USER_GROUP}"
|
user: "${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000}"
|
||||||
environment:
|
environment:
|
||||||
# Pulseaudio
|
# Pulseaudio
|
||||||
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR}
|
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
|
||||||
- PULSE_SERVER=${LVA_PULSE_SERVER}
|
- PULSE_SERVER=${LVA_PULSE_SERVER:-/run/user/1000/pulse/native}
|
||||||
env_file: .env
|
- PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
|
||||||
|
env_file: ${ENV_FILE:-.env}
|
||||||
group_add:
|
group_add:
|
||||||
- audio
|
- audio
|
||||||
# Priority for audio process
|
# Priority for audio process
|
||||||
@@ -34,13 +42,15 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# Wakeword data and configuration
|
# Wakeword data and configuration
|
||||||
- wakeword_data:/app/local
|
- wakeword_data:/app/local
|
||||||
|
- wakeword_custom:/app/wakewords/custom
|
||||||
- configuration:/app/configuration
|
- configuration:/app/configuration
|
||||||
|
- sounds_custom:/app/sounds/custom
|
||||||
# Time and zone
|
# Time and zone
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
# Pulseaudio
|
# Pulseaudio
|
||||||
# /run/user/1000:/run/user/1000
|
# /run/user/1000:/run/user/1000
|
||||||
- ${LVA_XDG_RUNTIME_DIR}:${LVA_XDG_RUNTIME_DIR}
|
- ${LVA_XDG_RUNTIME_DIR:-/run/user/1000}:${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
|
||||||
depends_on:
|
depends_on:
|
||||||
- fix-permissions
|
- fix-permissions
|
||||||
# Health check for process
|
# Health check for process
|
||||||
@@ -57,4 +67,6 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
wakeword_data:
|
wakeword_data:
|
||||||
|
wakeword_custom:
|
||||||
configuration:
|
configuration:
|
||||||
|
sounds_custom:
|
||||||
+36
-23
@@ -40,10 +40,26 @@ if [ -n "${AUDIO_OUTPUT_DEVICE}" ]; then
|
|||||||
EXTRA_ARGS+=( "--audio-output-device" "$AUDIO_OUTPUT_DEVICE" )
|
EXTRA_ARGS+=( "--audio-output-device" "$AUDIO_OUTPUT_DEVICE" )
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -n "${MIC_VOLUME}" ]; then
|
||||||
|
EXTRA_ARGS+=( "--mic-volume" "$MIC_VOLUME" )
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${MIC_AUTO_GAIN}" ]; then
|
||||||
|
EXTRA_ARGS+=( "--mic-auto-gain" "$MIC_AUTO_GAIN" )
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${MIC_NOISE_SUPPRESSION}" ]; then
|
||||||
|
EXTRA_ARGS+=( "--mic-noise-suppression" "$MIC_NOISE_SUPPRESSION" )
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$ENABLE_THINKING_SOUND" = "1" ]; then
|
if [ "$ENABLE_THINKING_SOUND" = "1" ]; then
|
||||||
EXTRA_ARGS+=( "--enable-thinking-sound" )
|
EXTRA_ARGS+=( "--enable-thinking-sound" )
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -n "${WAKE_WORD_DIR}" ]; then
|
||||||
|
EXTRA_ARGS+=( "--wake-word-dir" "$WAKE_WORD_DIR" )
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -n "${WAKE_MODEL}" ]; then
|
if [ -n "${WAKE_MODEL}" ]; then
|
||||||
EXTRA_ARGS+=( "--wake-model" "$WAKE_MODEL" )
|
EXTRA_ARGS+=( "--wake-model" "$WAKE_MODEL" )
|
||||||
fi
|
fi
|
||||||
@@ -76,13 +92,32 @@ if [ -n "${UNMUTE_SOUND}" ]; then
|
|||||||
EXTRA_ARGS+=( "--unmute-sound" "$UNMUTE_SOUND" )
|
EXTRA_ARGS+=( "--unmute-sound" "$UNMUTE_SOUND" )
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -n "${TIMER_MAX_RING_SECONDS}" ]; then
|
||||||
|
EXTRA_ARGS+=( "--timer-max-ring-seconds" "$TIMER_MAX_RING_SECONDS" )
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ENABLE_OUTPUT_ONLY" = "1" ]; then
|
||||||
|
EXTRA_ARGS+=( "--output-only" )
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add cookie file for pulseaudio to prevent errors
|
||||||
|
PULSE_COOKIE=${PULSE_COOKIE:-"/run/user/1000/pulse/cookie"}
|
||||||
|
if [[ "$PULSE_COOKIE" != "DISABLED" ]]; then
|
||||||
|
if [ ! -f "$PULSE_COOKIE" ]; then
|
||||||
|
echo "Creating PulseAudio cookie file at $PULSE_COOKIE"
|
||||||
|
touch "$PULSE_COOKIE"
|
||||||
|
chmod 600 "$PULSE_COOKIE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
### Wait for PulseAudio
|
### Wait for PulseAudio
|
||||||
# Wait for PulseAudio to be available before starting the application
|
# Wait for PulseAudio to be available before starting the application
|
||||||
CP_MAX_RETRIES=30
|
CP_MAX_RETRIES=30
|
||||||
CP_RETRY_DELAY=1
|
CP_RETRY_DELAY=1
|
||||||
### while maybe besser?
|
### while maybe besser?
|
||||||
echo "Checking port $PORT..."
|
echo "Checking PulseAudio service status..."
|
||||||
for i in $(seq 1 $CP_MAX_RETRIES); do
|
for i in $(seq 1 $CP_MAX_RETRIES); do
|
||||||
# Check if PulseAudio is running
|
# Check if PulseAudio is running
|
||||||
if pactl info >/dev/null 2>&1; then
|
if pactl info >/dev/null 2>&1; then
|
||||||
@@ -100,28 +135,6 @@ for i in $(seq 1 $CP_MAX_RETRIES); do
|
|||||||
done
|
done
|
||||||
|
|
||||||
|
|
||||||
### Check port availability
|
|
||||||
# PORT variable is used from env
|
|
||||||
PA_MAX_RETRIES=30
|
|
||||||
PA_RETRY_DELAY=2
|
|
||||||
echo "Checking port $PORT..."
|
|
||||||
for i in $(seq 1 $PA_MAX_RETRIES); do
|
|
||||||
# Wait for port to be free (in case of rapid restarts)
|
|
||||||
if ! ss -tln | grep -q ":${PORT} "; then
|
|
||||||
echo "Port $PORT is available"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ $i -eq $PA_MAX_RETRIES ]; then
|
|
||||||
echo "ERROR: Port $PORT still in use after $((PA_MAX_RETRIES * PA_RETRY_DELAY)) seconds"
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Attempt $i/$PA_MAX_RETRIES: Port $PORT in use, waiting ${PA_RETRY_DELAY}s..."
|
|
||||||
sleep $PA_RETRY_DELAY
|
|
||||||
done
|
|
||||||
|
|
||||||
|
|
||||||
### Start application
|
### Start application
|
||||||
if [ "$LIST_DEVICES" = "1" ]; then
|
if [ "$LIST_DEVICES" = "1" ]; then
|
||||||
echo "list input devices"
|
echo "list input devices"
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
# Additional Audio Settings
|
|
||||||
|
|
||||||
This document is still in an early stage and will be updated soon.
|
|
||||||
|
|
||||||
## Pipewire:
|
|
||||||
|
|
||||||
tbd
|
|
||||||
|
|
||||||
## Pulseaudio:
|
|
||||||
|
|
||||||
### Acoustic Echo Cancellation:
|
|
||||||
|
|
||||||
Enable the echo cancel PulseAudio module:
|
|
||||||
|
|
||||||
``` sh
|
|
||||||
pactl load-module module-echo-cancel \
|
|
||||||
aec_method=webrtc \
|
|
||||||
aec_args="analog_gain_control=0 digital_gain_control=1 noise_suppression=1"
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify that the `echo-cancel-source` and `echo-cancel-sink` devices are present:
|
|
||||||
|
|
||||||
``` sh
|
|
||||||
pactl list short sources
|
|
||||||
pactl list short sinks
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the new devices:
|
|
||||||
|
|
||||||
``` sh
|
|
||||||
# The device names may be different on your system.
|
|
||||||
# Double check with --list-input-devices and --list-output-devices
|
|
||||||
python3 -m linux_voice_assistant ... \
|
|
||||||
--audio-input-device 'Echo-Cancel Source' \
|
|
||||||
--audio-output-device 'pipewire/echo-cancel-sink'
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Audio Options: Gain, Noise Suppression, and Wake Word Sensitivity
|
||||||
|
|
||||||
|
## What do Gain and Noise Suppression do
|
||||||
|
|
||||||
|
### Gain
|
||||||
|
|
||||||
|
When you change gain, LVA(using the webrtc library) automatically adjusts the microphone input volume to keep it at a consistent level. If you're speaking quietly it boosts the signal, if you're speaking loudly it reduces it. Useful in the following ways:-
|
||||||
|
|
||||||
|
- Microphones that are too quiet by default
|
||||||
|
- Environments where you move around relative to the mic
|
||||||
|
- Keeping wake word detection consistent regardless of speaking volume
|
||||||
|
|
||||||
|
### Noise Suppression
|
||||||
|
|
||||||
|
When you change noise suppression, LVA(using the webrtc library) filters out constant background noise from the audio signal. It works by learning what "silence" sounds like in your environment and subtracting that from the audio. Useful in the following ways:
|
||||||
|
|
||||||
|
- Noisy environments
|
||||||
|
- Improving STT accuracy by sending cleaner audio to Home Assistant
|
||||||
|
- Reducing false wake word triggers from background noise
|
||||||
|
|
||||||
|
## Using Gain and Noise Suppression with LVA
|
||||||
|
|
||||||
|
LVA implements gain and Noise Suppression in two ways:
|
||||||
|
|
||||||
|
- [CLI Argument/ENV file](#cli-argumentenv-file)
|
||||||
|
- [Home Assistant Entity](#home-assistant-entity)
|
||||||
|
|
||||||
|
### CLI Argument/ENV file
|
||||||
|
|
||||||
|
#### CLI Argument
|
||||||
|
|
||||||
|
Using docker-entrypoint.sh the following flags can be used to set gain and noise suppression:
|
||||||
|
- `--mic-auto-gain`(ranges from 0-31)
|
||||||
|
- `--mic-noise-suppression`(ranges from 0-4)
|
||||||
|
|
||||||
|
#### ENV file
|
||||||
|
|
||||||
|
You can add/edit these variables in the .env file to set gain and noise suppression:
|
||||||
|
- `MIC_AUTO_GAIN="0"`
|
||||||
|
- `MIC_NOISE_SUPPRESSION="0"`
|
||||||
|
|
||||||
|
|
||||||
|
### Home Assistant Entity
|
||||||
|
|
||||||
|
LVA exposes two slider entities to change these gain and noise suppression from which you can edit the gain and noise suppression at runtime.
|
||||||
|
|
||||||
|
💡 **Note:** Setting the flag and ENV values to 0 turns them off and are not used.
|
||||||
|
|
||||||
|
💡 **Note:** Keep in mind that when the flags are set they will overwrite the previous value in the preferences file and also they will be overridden if the value is changed in Home Assistant(Also applies to the ENV file but the ENV file will always overwrite the last value on startup).
|
||||||
|
|
||||||
|
|
||||||
|
## Wake Word Sensitivity
|
||||||
|
|
||||||
|
LVA exposes three numeric controls in the Home Assistant device page for fine-grained sensitivity tuning. These let you dial in the exact probability threshold that best matches your microphone quality, room acoustics, and false-activation tolerance.
|
||||||
|
|
||||||
|
| Entity | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| **Wake Word 1 Sensitivity** | Probability cutoff for the primary wake word | From model manifest |
|
||||||
|
| **Wake Word 2 Sensitivity** | Probability cutoff for the secondary wake word (if active) | From model manifest |
|
||||||
|
| **Stop Word Sensitivity** | Probability cutoff for the stop word | From model manifest |
|
||||||
|
|
||||||
|
Values range from `0.0` to `1.0`:
|
||||||
|
|
||||||
|
- **Higher value** (e.g. `0.95`) → more selective, fewer false activations, may miss quieter or accented speech
|
||||||
|
- **Lower value** (e.g. `0.50`) → more responsive, but more likely to trigger on similar-sounding words
|
||||||
|
The defaults are read directly from each model's `.json` manifest file (the `probability_cutoff` field), so bundled models like `okay_nabu` (0.85), `hey_jarvis` (0.97), and custom downloaded models all start at their author-tested baseline. Changes made in the Home Assistant UI are persisted in `preferences.json` and survive restarts.
|
||||||
|
|
||||||
|
**Tuning guide:**
|
||||||
|
|
||||||
|
- If the wake word **rarely activates** (misses your voice): lower the value slightly, e.g. by `0.05` steps
|
||||||
|
- If the wake word **activates too often** (false triggers from TV, music, or similar words): raise the value slightly
|
||||||
|
- Start from the model's default and make small adjustments — a change of `0.05–0.10` is usually enough to notice a difference
|
||||||
|
- Far-field microphones with noise cancellation (e.g. ReSpeaker, Satellite1) generally work well at the default values; basic USB microphones may need a lower threshold
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Enabling Acoustic Echo Cancellation
|
||||||
|
|
||||||
|
Acoustic Echo Cancellation (AEC) is a type of sound processing used to cancel out the noise coming out of your speaker and going into your mic. In LVA this functionality can be useful to allow LVA to listen to wake words even when audio is playing, particularly when a timer is playing. PulseAudio and PipeWire already provide built-in modules for AEC.
|
||||||
|
|
||||||
|
## Enabling AEC
|
||||||
|
|
||||||
|
### PulseAudio
|
||||||
|
```sh
|
||||||
|
pactl load-module module-echo-cancel source_name=aec_mic aec_method=webrtc
|
||||||
|
```
|
||||||
|
|
||||||
|
To make permanent, add to `/etc/pulse/default.pa`:
|
||||||
|
```
|
||||||
|
load-module module-echo-cancel source_name=aec_mic aec_method=webrtc
|
||||||
|
```
|
||||||
|
|
||||||
|
### PipeWire
|
||||||
|
```sh
|
||||||
|
pw-cli load-module libpipewire-module-echo-cancel '{ aec.method=webrtc source.props={ node.name=aec_mic } }'
|
||||||
|
```
|
||||||
|
|
||||||
|
To make permanent, add the following to the `context.modules` section of `/etc/pipewire/pipewire.conf`:
|
||||||
|
```
|
||||||
|
{ name = libpipewire-module-echo-cancel
|
||||||
|
args = {
|
||||||
|
aec.method = webrtc
|
||||||
|
source.props = { node.name = aec_mic }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if you are using a standard PipeWire installation without a custom `pipewire.conf`, create a new file inside `pipewire.conf.d/`:
|
||||||
|
```
|
||||||
|
context.modules = [
|
||||||
|
{ name = libpipewire-module-echo-cancel
|
||||||
|
args = {
|
||||||
|
aec.method = webrtc
|
||||||
|
source.props = { node.name = aec_mic }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using the AEC Input
|
||||||
|
|
||||||
|
In the `.env` file:
|
||||||
|
```
|
||||||
|
AUDIO_INPUT_DEVICE="aec_mic"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changing Source and Sink for AEC
|
||||||
|
|
||||||
|
### PulseAudio
|
||||||
|
```sh
|
||||||
|
pactl load-module module-echo-cancel source_name=aec_mic aec_method=webrtc source_master=<mic-source> sink_master=<sink>
|
||||||
|
```
|
||||||
|
|
||||||
|
### PipeWire
|
||||||
|
```sh
|
||||||
|
pw-cli load-module libpipewire-module-echo-cancel '{ aec.method=webrtc source.props={ node.name=aec_mic node.description="Echo Cancelled Mic" } sink.props={ node.name=aec_sink } capture.props={ node.name=<your-mic-node-name> } playback.props={ node.name=<your-speaker-node-name> } }'
|
||||||
|
```
|
||||||
|
|
||||||
|
To make permanent, add to `context.modules` in `/etc/pipewire/pipewire.conf` or a file in `pipewire.conf.d/`:
|
||||||
|
```
|
||||||
|
{ name = libpipewire-module-echo-cancel
|
||||||
|
args = {
|
||||||
|
aec.method = webrtc
|
||||||
|
source.props = {
|
||||||
|
node.name = aec_mic
|
||||||
|
node.description = "Echo Cancelled Mic"
|
||||||
|
}
|
||||||
|
sink.props = {
|
||||||
|
node.name = aec_sink
|
||||||
|
}
|
||||||
|
capture.props = {
|
||||||
|
node.name = <your-mic-node-name>
|
||||||
|
}
|
||||||
|
playback.props = {
|
||||||
|
node.name = <your-speaker-node-name>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
+55
-57
@@ -39,11 +39,13 @@ LVA_VERSION=$(curl -s https://api.github.com/repos/ohf-voice/linux-voice-assista
|
|||||||
echo "Installing version: " $LVA_VERSION
|
echo "Installing version: " $LVA_VERSION
|
||||||
wget https://raw.githubusercontent.com/ohf-voice/linux-voice-assistant/refs/tags/$LVA_VERSION/docker-compose.yml
|
wget https://raw.githubusercontent.com/ohf-voice/linux-voice-assistant/refs/tags/$LVA_VERSION/docker-compose.yml
|
||||||
wget https://raw.githubusercontent.com/ohf-voice/linux-voice-assistant/refs/tags/$LVA_VERSION/.env.example
|
wget https://raw.githubusercontent.com/ohf-voice/linux-voice-assistant/refs/tags/$LVA_VERSION/.env.example
|
||||||
mv .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
💡 **Note:** The LVA_VERSION variable downloads the latest available version of LVA. If you want another version, you can specify it with the LVA_VERSION variable.
|
💡 **Note:** The LVA_VERSION variable downloads the latest available version of LVA. If you want another version, you can specify it with the LVA_VERSION variable.
|
||||||
|
|
||||||
|
You can change the used version in the docker-compose.yml.
|
||||||
|
|
||||||
### Docker Image Tags
|
### Docker Image Tags
|
||||||
|
|
||||||
| Tag | Description | Example |
|
| Tag | Description | Example |
|
||||||
@@ -59,44 +61,7 @@ Edit the .env file and change the values to your needs:
|
|||||||
vim .env
|
vim .env
|
||||||
```
|
```
|
||||||
|
|
||||||
```ini
|
You can change various settings, for example the audio sounds which are played when the wake word is detected or when the timer is finished. You also need to set the correct user and group id, and the pulseaudio server in the `.env`file.
|
||||||
# Linux-Voice-Assistant - Docker Environment Configuration
|
|
||||||
# Copy this file to .env and customize for your setup by 'cp .env.example .env'
|
|
||||||
|
|
||||||
### Enable debug mode (optional):
|
|
||||||
# ENABLE_DEBUG="1"
|
|
||||||
|
|
||||||
### List audio devices (optional):
|
|
||||||
# if enabled normal startup is disabled
|
|
||||||
# LIST_DEVICES="1"
|
|
||||||
|
|
||||||
### User ID:
|
|
||||||
# This is used to set the correct permissions for the accessing the audio device and accessing the PulseAudio socket
|
|
||||||
USERLVA_USER_ID00"
|
|
||||||
LVA_USER_GROUP="1000"
|
|
||||||
|
|
||||||
### Name for the client (optional):
|
|
||||||
# by default it uses the HOSTNAME variable from the piCompose environment which includes the MAC from the network card
|
|
||||||
# CLIENT_NAME="My Voice Assistant Speaker"
|
|
||||||
|
|
||||||
### PulseAudio socket path on the host:
|
|
||||||
# PulseAudio Server: /run/user/1000/pulse
|
|
||||||
# Pipewire Server: /run/user/1000/pulse/native
|
|
||||||
LVA_PULSE_SERVER="unix:/run/user/${USERLVA_USER_IDlse/native"
|
|
||||||
LVA_XDG_RUNTIME_DIR="/run/user/${USERLVA_USER_ID
|
|
||||||
|
|
||||||
### Path to the preferences file (optional):
|
|
||||||
# PREFERENCES_FILE="/app/configuration/preferences.json"
|
|
||||||
|
|
||||||
### Port for the api (optional):
|
|
||||||
# PORT="6053"
|
|
||||||
|
|
||||||
### Audio input device (optional):
|
|
||||||
|
|
||||||
[...]
|
|
||||||
```
|
|
||||||
|
|
||||||
Please use the original file from the repository. You can change various settings, for example the audio sounds which are played when the wake word is detected or when the timer is finished.
|
|
||||||
|
|
||||||
💡 **Note:** You can exit vim with `:wq` or `:q!` if you dont want to save the changes.
|
💡 **Note:** You can exit vim with `:wq` or `:q!` if you dont want to save the changes.
|
||||||
|
|
||||||
@@ -170,6 +135,16 @@ Install the application:
|
|||||||
script/setup
|
script/setup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
💡 **Note:** For hardware with limited performance (like Raspberry Pi Zero 2W or other SBCs), you may need to adjust compilation parameters to ensure the build process completes successfully. The script supports custom `CXXFLAGS` and `MAKEFLAGS`:
|
||||||
|
|
||||||
|
- `--cxxflags`: Custom C++ compilation flags (default: `-O1 -g0`)
|
||||||
|
- `--makeflags`: Custom make flags (default: `-j1`)
|
||||||
|
|
||||||
|
For example, to optimize for low-performance hardware:
|
||||||
|
``` sh
|
||||||
|
script/setup --cxxflags="-O1 -g0" --makeflags="-j1"
|
||||||
|
```
|
||||||
|
|
||||||
Create a systemd service for the application:
|
Create a systemd service for the application:
|
||||||
|
|
||||||
``` sh
|
``` sh
|
||||||
@@ -188,29 +163,34 @@ Wants=network-online.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=pi
|
User=pi
|
||||||
WorkingDirectory=/home/pi/linux-voice-assistant
|
WorkingDirectory=/home/pi/linux-voice-assistant
|
||||||
|
|
||||||
Environment=PATH=/home/pi/linux-voice-assistant/.venv/bin:/usr/bin:/bin
|
Environment=PATH=/home/pi/linux-voice-assistant/.venv/bin:/usr/bin:/bin
|
||||||
# Environment=ENABLE_DEBUG="1"
|
# Environment=ENABLE_DEBUG="1"
|
||||||
# Environment=LIST_DEVICES="1"
|
# Environment=LIST_DEVICES="1"
|
||||||
Environment=LVA_USER_ID="1000"
|
|
||||||
Environment=LVA_USER_GROUP="1000"
|
|
||||||
# Environment=CLIENT_NAME="My Voice Assistant Speaker"
|
# Environment=CLIENT_NAME="My Voice Assistant Speaker"
|
||||||
Environment=LVA_PULSE_SERVER="unix:/run/user/${LVA_USER_ID}/pulse/native"
|
Environment=PULSE_SERVER="/run/user/<replace_with_your_user_id>/pulse/native"
|
||||||
Environment=LVA_XDG_RUNTIME_DIR="/run/user/${LVA_USER_ID}"
|
Environment=XDG_RUNTIME_DIR="/run/user/<replace_with_your_user_id>"
|
||||||
|
Environment=PULSE_COOKIE="/home/pi/linux-voice-assistant/tmp_pulse_cookie"
|
||||||
Environment=PREFERENCES_FILE="/home/pi/linux-voice-assistant/preferences.json"
|
Environment=PREFERENCES_FILE="/home/pi/linux-voice-assistant/preferences.json"
|
||||||
# Environment=NETWORK_INTERFACE="eth0"
|
# Environment=NETWORK_INTERFACE="eth0"
|
||||||
# Environment=HOST="0.0.0.0"
|
# Environment=HOST="0.0.0.0"
|
||||||
# Environment=PORT="6053"
|
# Environment=PORT="6053"
|
||||||
# Environment=AUDIO_INPUT_DEVICE="default"
|
# Environment=AUDIO_INPUT_DEVICE="default"
|
||||||
# Environment=AUDIO_OUTPUT_DEVICE="default"
|
# Environment=AUDIO_OUTPUT_DEVICE="default"
|
||||||
|
# Environment=MIC_VOLUME="1.0"
|
||||||
|
# Environment=MIC_AUTO_GAIN="0"
|
||||||
|
# Environment=MIC_NOISE_SUPPRESSION="0"
|
||||||
# Environment=ENABLE_THINKING_SOUND="1"
|
# Environment=ENABLE_THINKING_SOUND="1"
|
||||||
|
# Environment=WAKE_WORD_DIR="app/wakewords"
|
||||||
# Environment=WAKE-MODEL="okay_nabu"
|
# Environment=WAKE-MODEL="okay_nabu"
|
||||||
|
# Environment=STOP_MODEL="stop"
|
||||||
|
# Environment=TIMER_MAX_RING_SECONDS="900"
|
||||||
# Environment=REFACTORY_SECONDS="2"
|
# Environment=REFACTORY_SECONDS="2"
|
||||||
# Environment=WAKEUP_SOUND="sounds/wake_word_triggered.flac"
|
# Environment=WAKEUP_SOUND="sounds/wake_word_triggered.flac"
|
||||||
# Environment=TIMER_FINISHED_SOUND="sounds/timer_finished.flac"
|
# Environment=TIMER_FINISHED_SOUND="sounds/timer_finished.flac"
|
||||||
# Environment=PROCESSING_SOUND="sounds/processing.wav"
|
# Environment=PROCESSING_SOUND="sounds/processing.wav"
|
||||||
# Environment=MUTE_SOUND="sounds/mute_switch_on.flac"
|
# Environment=MUTE_SOUND="sounds/mute_switch_on.flac"
|
||||||
# Environment=UNMUTE_SOUND="sounds/mute_switch_off.flac"
|
# Environment=UNMUTE_SOUND="sounds/mute_switch_off.flac"
|
||||||
|
# Environment=ENABLE_OUTPUT_ONLY="1"
|
||||||
ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh
|
ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh
|
||||||
# ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh --additional-parameter-if-you-want
|
# ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh --additional-parameter-if-you-want
|
||||||
Restart=always
|
Restart=always
|
||||||
@@ -222,7 +202,7 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
💡 **Note:** We are using the `docker-entrypoint.sh` script to start the application. This script is located in the root of the repository. But there is no docker used. Only the start script is used.
|
💡 **Note:** We are using the `docker-entrypoint.sh` script to start the application. This script is located in the root of the repository. But there is no docker used. Only the start script is used.
|
||||||
|
|
||||||
💡 **Note:** Replace `USERLVA_USER_IDth your actual user id that you want to run the voice assistant. Do the same for `LVA_USER_GROUP`.
|
💡 **Note:** Replace `pi` with your actual user that you want to run the voice assistant. You need to run Pipewire and LVA with the same user in order to provide access to the audio socket. You can also add the group audio to the user which LVA is running on. `sudo usermod -a -G audio pi`
|
||||||
|
|
||||||
Reload the systemd daemon and start the service:
|
Reload the systemd daemon and start the service:
|
||||||
|
|
||||||
@@ -264,27 +244,45 @@ The following variables can be configured in the `.env` or in the service file:
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `USERLVA_USER_ID` | `1000` | User ID for the container (usually 1000 for the first user) |
|
|
||||||
| `GROUP_ID` | `1000` | GROUP ID for the container (usually 1000 for the first users group) |
|
|
||||||
| `CLIENT_NAME` | (optional) | Custom name for this voice assistant instance |
|
|
||||||
| `LVA_PULSE_SERVER` | `unix:/run/user/${LVA_USER_ID}/pulse/native` | Path to the PulseAudio/PipeWire socket |
|
|
||||||
| `LVA_XDG_RUNTIME_DIR` | `/run/user/${LVA_USER_ID}` | XDG runtime directory |
|
|
||||||
| `ENABLE_DEBUG` | (optional) | Set to "1" to enable debug mode |
|
| `ENABLE_DEBUG` | (optional) | Set to "1" to enable debug mode |
|
||||||
| `LIST_DEVICES` | (optional) | Set to "1" to list audio devices instead of starting |
|
| `LIST_DEVICES` | (optional) | Set to "1" to list audio devices instead of starting |
|
||||||
|
| `LVA_USER_ID` | `1000` | User ID for the container (usually 1000 for the first user) |
|
||||||
|
| `LVA_USER_GROUP` | `1000` | GROUP ID for the container (usually 1000 for the first users group) |
|
||||||
|
| `CLIENT_NAME` | (optional) | Custom name for this voice assistant instance |
|
||||||
|
| `LVA_PULSE_SERVER` | `/run/user/${LVA_USER_ID}/pulse/native` | Path to the PulseAudio/PipeWire socket (In some cases a `:unix`infront of the path is needed) |
|
||||||
|
| `LVA_XDG_RUNTIME_DIR` | `/run/user/${LVA_USER_ID}` | XDG runtime directory |
|
||||||
|
| `LVA_PULSE_COOKIE` | `/app/configuration/tmp_pulse_cookie` | Cookie file for PulseAudio if you use encryption. By default disabled. We use a tmp file to avoid errors if the file is not found |
|
||||||
| `PREFERENCES_FILE` | (optional) | Path to a custom preferences JSON file |
|
| `PREFERENCES_FILE` | (optional) | Path to a custom preferences JSON file |
|
||||||
| `NETWORK_INTERFACE` | Autodetected | network card for server |
|
| `NETWORK_INTERFACE` | Autodetected | network card for server |
|
||||||
| `HOST` | Autodetected | API server IP-Address, can be 0.0.0.0 for all interfaces, but only one network card works for MAC-ADDRESS and ESP protocol |
|
| `HOST` | Autodetected | API server IP-Address, can be 0.0.0.0 for all interfaces, but only one network card works for MAC-ADDRESS and ESP protocol |
|
||||||
| `PORT` | `6053` | API server port |
|
| `PORT` | `6053` | API server port |
|
||||||
| `AUDIO_INPUT_DEVICE` | `default` | Audio input device name |
|
| `AUDIO_INPUT_DEVICE` | Autodetected | Audio input device name |
|
||||||
| `AUDIO_OUTPUT_DEVICE` | `default` | Audio output device name |
|
| `AUDIO_OUTPUT_DEVICE` | Autodetected | Audio output device name |
|
||||||
|
| `MIC_VOLUME` | Control microphone volume | 1.0 |
|
||||||
|
| `MIC_AUTO_GAIN` | Add WebRTC Gain to Mic | 0 |
|
||||||
|
| `MIC_NOISE_SUPPRESSION` | Add WebRTC Noise Suppresion to Mic | 0 |
|
||||||
| `ENABLE_THINKING_SOUND` | false | Set to "1" to enable thinking sound |
|
| `ENABLE_THINKING_SOUND` | false | Set to "1" to enable thinking sound |
|
||||||
|
| `WAKE_WORD_DIR` | `app/wakewords` | Path to the wake word directory |
|
||||||
| `WAKE_MODEL` | `okay_nabu` | Wake word model to use |
|
| `WAKE_MODEL` | `okay_nabu` | Wake word model to use |
|
||||||
|
| `STOP_MODEL` | `stop` | Stop model to use |
|
||||||
|
| `TIMER_MAX_RING_SECONDS` | `900` | Seconds after which the timer stops ringing |
|
||||||
| `REFACTORY_SECONDS` | `2` | Refractory period in seconds after wake word |
|
| `REFACTORY_SECONDS` | `2` | Refractory period in seconds after wake word |
|
||||||
| `WAKEUP_SOUND` | `sounds/wake_word_triggered.flac` | Sound file for wake word triggered |
|
| `WAKEUP_SOUND` | `sounds/wake_word_triggered.flac` | Sound file for wake word triggered |
|
||||||
| `TIMER_FINISHED_SOUND` | `sounds/timer_finished.flac` | Sound file for timer finished |
|
| `TIMER_FINISHED_SOUND` | `sounds/timer_finished.flac` | Sound file for timer finished |
|
||||||
| `PROCESSING_SOUND` | `sounds/processing.wav` | Sound file for processing state |
|
| `PROCESSING_SOUND` | `sounds/processing.wav` | Sound file for processing state |
|
||||||
| `MUTE_SOUND` | `sounds/mute_switch_on.flac` | Sound file for mute on |
|
| `MUTE_SOUND` | `sounds/mute_switch_on.flac` | Sound file for mute on |
|
||||||
| `UNMUTE_SOUND` | `sounds/mute_switch_off.flac` | Sound file for Configure Audio Devices
|
| `UNMUTE_SOUND` | `sounds/mute_switch_off.flac` | Sound file for Configure Audio Devices
|
||||||
|
| `ENABLE_OUTPUT_ONLY` | (optional) | Set to "1" to enable output-only mode |
|
||||||
|
|
||||||
|
|
||||||
|
💡 **Note:** For the systemd installation some variables set in the service need to be without `LVA_` prefix.
|
||||||
|
|
||||||
|
### Use own soundfiles:
|
||||||
|
|
||||||
|
If you want to use your own sounds, you can add them to the `sounds/custom` aka `/var/lib/docker/volumes/lva_sounds_custom/_data` directory and reference them in the `.env` file.
|
||||||
|
|
||||||
|
|
||||||
|
### Wake Word:
|
||||||
|
|
||||||
#### Available Wake Word Models:
|
#### Available Wake Word Models:
|
||||||
|
|
||||||
@@ -300,13 +298,13 @@ The following wake word models are available:
|
|||||||
- `choo_choo_homie` - Choo Choo Homie wake word
|
- `choo_choo_homie` - Choo Choo Homie wake word
|
||||||
|
|
||||||
|
|
||||||
### Wake Word:
|
### Custom Wake Word:
|
||||||
|
|
||||||
💡 **Note:** The default value for this parameter is the `wakewords` directory in the repository.
|
If you want to use your own wakewords, you can add them to the `wakewords/custom` aka `/var/lib/docker/volumes/lva_wakeword_custom/_data` directory and reference them in the `.env` file.
|
||||||
|
|
||||||
Change the default wake word with `--wake-model <id>` where `<id>` is the name of a model in the `wakewords` directory. For example, `--wake-model hey_jarvis` will load `wakewords/hey_jarvis.tflite` by default.
|
Change the default wake word with `WAKE_MODEL <id>` where `<id>` is the name of a model in the `wakewords` directory. For example, `WAKE_MODEL hey_jarvis` will load `app/wakewords/hey_jarvis.tflite` by default.
|
||||||
|
|
||||||
You can include more wakeword directories by adding `--wake-word-dir <DIR>` where `<DIR>` contains either [microWakeWord][] or [openWakeWord][] config files and `.tflite` models. For example, `--wake-word-dir wakewords/openWakeWord` will include the default wake words for openWakeWord.
|
You can include more wakeword directories by adding `WAKE_WORD_DIR <DIR>` where `<DIR>` contains either [microWakeWord][] or [openWakeWord][] config files and `.tflite` models. For example, `WAKE_WORD_DIR app/wakewords/openWakeWord` will include the default wake words for openWakeWord.
|
||||||
|
|
||||||
If you want to add [other wakewords][wakewords-collection], make sure to create a small JSON config file to identify it as an openWakeWord model. For example, download the [GLaDOS][glados] model to `glados.tflite` and create `glados.json` with:
|
If you want to add [other wakewords][wakewords-collection], make sure to create a small JSON config file to identify it as an openWakeWord model. For example, download the [GLaDOS][glados] model to `glados.tflite` and create `glados.json` with:
|
||||||
|
|
||||||
@@ -318,7 +316,7 @@ If you want to add [other wakewords][wakewords-collection], make sure to create
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Add `--wake-word-dir <DIR>` with the directory containing `glados.tflite` and `glados.json` to your command-line.
|
Add `WAKE_WORD_DIR <DIR>` with the directory containing `glados.tflite` and `glados.json` to your command-line.
|
||||||
|
|
||||||
#### Additional Wakewords:
|
#### Additional Wakewords:
|
||||||
|
|
||||||
|
|||||||
+78
-50
@@ -1,114 +1,138 @@
|
|||||||
# Install Audioservice
|
# Install Audioservice
|
||||||
|
|
||||||
For Linux-Voice-Assistant a Pulseaudio connection to the soundcard is required. Since PulseAudio is not installed by default on Ubuntu 22.04 we also support Pipewire. You can choose either one (A or B) of them.
|
For Linux-Voice-Assistant a Pulseaudio connection to the soundcard is required. Since PulseAudio is no longer the default audio-server we recommend to use and install pipewire (pipewire-pulse) by default. We also support PulseAudio. You can choose either variant A or B from this installation documentation.
|
||||||
|
|
||||||
|
**How does the communication work?**
|
||||||
|
|
||||||
|
The Linux-Voice-Assistant container connects to the PulseAudio(PipeWire-Pulse) server running on the host system. The container does not have direct access to the sound card hardware. Instead, it communicates with the PulseAudio running on the host system, which then handles the actual audio processing and output.
|
||||||
|
|
||||||
## A) Pipewire (recommended):
|
## A) Pipewire (recommended):
|
||||||
|
|
||||||
PipeWire is a multimedia server that provides low-latency audio/video handling. Install it with the following commands:
|
PipeWire is a multimedia server that provides low-latency audio/video handling. Install it with the following commands:
|
||||||
|
|
||||||
``` sh
|
```sh
|
||||||
# Update package database
|
# Update package database
|
||||||
sudo apt update
|
sudo apt update
|
||||||
|
|
||||||
# Install PipeWire and related packages
|
# Install PipeWire and related packages
|
||||||
sudo apt install -y pipewire wireplumber pipewire-audio-client-libraries libspa-0.2-bluetooth pipewire-audio pipewire-pulse dfu-util
|
sudo apt install -y pipewire wireplumber pipewire-audio-client-libraries libspa-0.2-bluetooth pipewire-audio pipewire-pulse dfu-util pulseaudio-utils
|
||||||
```
|
```
|
||||||
|
|
||||||
Link the PipeWire configuration for ALSA applicationssudo ln -sf:
|
Link the PipeWire configuration for ALSA applications:
|
||||||
|
|
||||||
``` sh
|
```sh
|
||||||
ln -s /usr/share/alsa/alsa.conf.d/50-pipewire.conf /etc/alsa/conf.d/
|
sudo ln -s /usr/share/alsa/alsa.conf.d/50-pipewire.conf /etc/alsa/conf.d/
|
||||||
```
|
```
|
||||||
|
|
||||||
Allow services to run without an active user session (optional, for headless setups):
|
Allow services to run without an active user session. This step is **required on headless systems** (e.g. Raspberry Pi OS Lite), since PipeWire runs under the user's systemd session and only starts while someone is logged in. Without it, LVA will silently stop working after you close your SSH session.
|
||||||
|
|
||||||
``` sh
|
```sh
|
||||||
sudo mkdir -p /var/lib/systemd/linger
|
sudo mkdir -p /var/lib/systemd/linger
|
||||||
sudo touch /var/lib/systemd/linger/$USER
|
sudo touch /var/lib/systemd/linger/$USER
|
||||||
```
|
```
|
||||||
|
|
||||||
💡 **Note:** Replace `$USER` with your actual username that you want to run the voice assistant.
|
💡 **Note:** Replace `$USER` with your actual username that you want to run the voice assistant.
|
||||||
|
|
||||||
|
You can verify it worked with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
loginctl show-user $USER | grep Linger
|
||||||
|
# Expected: Linger=yes
|
||||||
|
```
|
||||||
|
|
||||||
### Configure PipeWire (optional):
|
### Configure PipeWire (optional):
|
||||||
|
|
||||||
Create the PipeWire configuration directory:
|
💡 **Note:** If you are not on a desktop system, which is already configured for PipeWire, you can configure it manually.
|
||||||
|
|
||||||
``` sh
|
💡 **Note:** LVA records audio at 16kHz. By default PipeWire may run at a different sample rate (typically 48kHz) and will resample automatically. Setting `default.clock.rate = 16000` avoids this resampling overhead, which is particularly beneficial on low-power hardware such as a Raspberry Pi.
|
||||||
mkdir -p "/etc/pipewire"
|
|
||||||
|
LVA is meant to be configured system wide. So only that method has been documented.
|
||||||
|
|
||||||
|
#### System-wide Configuration
|
||||||
|
```sh
|
||||||
|
sudo mkdir -p /etc/pipewire/pipewire.conf.d
|
||||||
|
sudo vi /etc/pipewire/pipewire.conf.d/linux-voice-assistant.conf
|
||||||
```
|
```
|
||||||
|
|
||||||
Create the file `/etc/pipewire/pipewire.conf` with the following content (minimal configuration for voice assistant use):
|
Add the following content:
|
||||||
|
```
|
||||||
``` ini
|
|
||||||
# Daemon config file for PipeWire
|
|
||||||
context.properties = {
|
context.properties = {
|
||||||
link.max-buffers = 16
|
|
||||||
mem.warn-mlock = true
|
|
||||||
log.level = 3
|
|
||||||
context.num-data-loops = 1
|
|
||||||
core.daemon = true
|
|
||||||
core.name = pipewire-0
|
|
||||||
default.clock.rate = 16000
|
default.clock.rate = 16000
|
||||||
}
|
}
|
||||||
|
|
||||||
context.modules = [
|
|
||||||
{ name = libpipewire-module-rt
|
|
||||||
args = {
|
|
||||||
nice.level = -11
|
|
||||||
rt.prio = 88
|
|
||||||
}
|
|
||||||
flags = [ ifexists nofail ]
|
|
||||||
}
|
|
||||||
{ name = libpipewire-module-protocol-native }
|
|
||||||
{ name = libpipewire-module-profiler }
|
|
||||||
{ name = libpipewire-module-metadata }
|
|
||||||
{ name = libpipewire-module-spa-device-factory }
|
|
||||||
{ name = libpipewire-module-spa-node-factory }
|
|
||||||
{ name = libpipewire-module-client-node }
|
|
||||||
{ name = libpipewire-module-client-device }
|
|
||||||
{ name = libpipewire-module-portal
|
|
||||||
flags = [ ifexists nofail ]
|
|
||||||
}
|
|
||||||
{ name = libpipewire-module-access
|
|
||||||
condition = [ { module.access = true } ]
|
|
||||||
}
|
|
||||||
{ name = libpipewire-module-adapter }
|
|
||||||
{ name = libpipewire-module-link-factory }
|
|
||||||
{ name = libpipewire-module-session-manager }
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Applying the Changes
|
||||||
|
PipeWire runs as a systemd user service under the user session (e.g., UID 1000). When executing commands as root, systemctl --user cannot connect to the user's DBus session unless the required environment variables are set. But as recommend at the end of this documentation part it is better and we recommend to restart the whole system.
|
||||||
|
```sh
|
||||||
|
sudo -u pi XDG_RUNTIME_DIR=/run/user/1000 systemctl --user restart pipewire pipewire-pulse wireplumber
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
💡 **Note:** In certain cases where hardware drivers need to be installed, a system reboot may be required. For hardware such as the Seeed 2-Mic Voice Card, multiple reboots may be needed to ensure the driver is installed and loaded and installed correctly. More about that in the PiCompose documentation within the Ready-to-use Image.
|
||||||
|
|
||||||
## B) PulseAudio:
|
## B) PulseAudio:
|
||||||
|
|
||||||
Make sure that you only run Pulseaudio and there is no Pipewire installed.
|
Make sure that you only run Pulseaudio and there is no Pipewire installed.
|
||||||
|
|
||||||
|
```sh
|
||||||
sudo apt remove --purge pipewire pipewire-pulse wireplumber
|
sudo apt remove --purge pipewire pipewire-pulse wireplumber
|
||||||
sudo apt autoremove
|
sudo apt autoremove
|
||||||
|
```
|
||||||
|
|
||||||
Install Pulseaudio
|
Install Pulseaudio
|
||||||
|
|
||||||
``` sh
|
```sh
|
||||||
sudo apt install pulseaudio pulseaudio-utils dfu-util
|
sudo apt install pulseaudio pulseaudio-utils dfu-util
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Configure pulse
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo vi /etc/pulse/daemon.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the following lines in the file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
default-sample-rate = 16000
|
||||||
|
```
|
||||||
|
|
||||||
Enable and start Pulseaudio
|
Enable and start Pulseaudio
|
||||||
|
|
||||||
``` sh
|
```sh
|
||||||
systemctl --user enable pulseaudio
|
systemctl --user enable pulseaudio
|
||||||
systemctl --user start pulseaudio
|
systemctl --user start pulseaudio
|
||||||
```
|
```
|
||||||
|
|
||||||
Check if Pulseaudio is running
|
Check if Pulseaudio is running
|
||||||
|
|
||||||
``` sh
|
```sh
|
||||||
pulseaudio --check
|
pulseaudio --check
|
||||||
pactl info
|
pactl info
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Reboot the system:
|
||||||
|
|
||||||
|
In some cases to get the audio hardware working you may need to restart your system.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Additional Information:
|
## Additional Information:
|
||||||
|
|
||||||
|
### Debug audio output:
|
||||||
|
|
||||||
|
You can debug the audio output with the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LVA_XDG_RUNTIME_DIR=/run/user/${LVA_USER_ID}
|
||||||
|
sudo aplay -L
|
||||||
|
speaker-test -D pulse -c2 -twav
|
||||||
|
```
|
||||||
|
|
||||||
|
💡 **Note:** Replace `$LVA_USER_ID` with your actual user id that you want to run the voice assistant.
|
||||||
|
|
||||||
|
💡 **Note:** You can replace `pulse` with `default` or `alsa_output.pci-0000_00_1f.3.analog-stereo` or any other audio device.
|
||||||
|
|
||||||
### Set audio volume:
|
### Set audio volume:
|
||||||
|
|
||||||
If your driver or audiodevice is loaded and you can see the device with `aplay -L` then
|
If your driver or audiodevice is loaded and you can see the device with `aplay -L` then
|
||||||
@@ -133,3 +157,7 @@ sudo alsamixer
|
|||||||
```
|
```
|
||||||
|
|
||||||
💡 **Note:** Replace `$LVA_USER_ID` with your actual user id that you want to run the voice assistant.
|
💡 **Note:** Replace `$LVA_USER_ID` with your actual user id that you want to run the voice assistant.
|
||||||
|
|
||||||
|
## Adding Acoustic Echo Cancellation
|
||||||
|
|
||||||
|
Acoustic Echo Cancellation (AEC) is a type of sound processing used to cancel out the noise coming out of your speaker and going into your mic. In LVA this functionality can be useful to allow LVA to listen to wake words even when audio is playing, particularly when a timer is playing. PulseAudio and PipeWire already provide built-in modules for AEC. To enable AEC, see [Linux-Voice-Assistant - Enabling AEC](enabling_aec.md).
|
||||||
File renamed without changes.
File renamed without changes.
+291
-127
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import errno
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
@@ -8,20 +9,27 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
from typing import Dict, List, Optional, Set, Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import soundcard as sc
|
import soundcard as sc
|
||||||
|
from aioesphomeapi.api_pb2 import NumberStateResponse # type: ignore # pylint: disable=no-name-in-module
|
||||||
|
from getmac import get_mac_address # type: ignore
|
||||||
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
|
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
|
||||||
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
|
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
|
||||||
|
|
||||||
from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType
|
from .models import Preferences, ServerState
|
||||||
from .mpv_player import MpvMediaPlayer
|
from .mpv_player import MpvMediaPlayer
|
||||||
from .satellite import VoiceSatelliteProtocol
|
from .satellite import VoiceSatelliteProtocol
|
||||||
from getmac import get_mac_address
|
from .util import (
|
||||||
|
get_default_interface,
|
||||||
|
get_default_ipv4,
|
||||||
|
get_esphome_version,
|
||||||
|
get_version,
|
||||||
|
)
|
||||||
|
from .wake_word import find_available_wake_words, load_stop_model, load_wake_models
|
||||||
|
from .webrtc import WebRTCProcessor
|
||||||
from .zeroconf import HomeAssistantZeroconf
|
from .zeroconf import HomeAssistantZeroconf
|
||||||
from .util import get_default_interface
|
|
||||||
from .util import get_default_ipv4
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
_MODULE_DIR = Path(__file__).parent
|
_MODULE_DIR = Path(__file__).parent
|
||||||
@@ -36,11 +44,12 @@ _SOUNDS_DIR = _REPO_DIR / "sounds"
|
|||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--name"
|
"--name",
|
||||||
|
help="Real name for the device",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--audio-input-device",
|
"--audio-input-device",
|
||||||
help="soundcard name for input device (see --list-input-devices)",
|
help="Name for the audio input device (see --list-input-devices)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--list-input-devices",
|
"--list-input-devices",
|
||||||
@@ -50,37 +59,40 @@ async def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--audio-input-block-size",
|
"--audio-input-block-size",
|
||||||
type=int,
|
type=int,
|
||||||
default=1024
|
default=1024,
|
||||||
|
# todo
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--audio-output-device",
|
"--audio-output-device",
|
||||||
help="mpv name for output device (see --list-output-devices)",
|
help="Name for the audio output device (see --list-output-devices)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--list-output-devices",
|
"--list-output-devices",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="List audio output devices and exit",
|
help="List audio output devices and exit",
|
||||||
)
|
)
|
||||||
|
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
|
||||||
|
parser.add_argument("--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4))
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--wake-word-dir",
|
"--wake-word-dir",
|
||||||
default=[_WAKEWORDS_DIR],
|
default=[_WAKEWORDS_DIR],
|
||||||
action="append",
|
action="append",
|
||||||
help="Directory with wake word models (.tflite) and configs (.json)",
|
help="Directory with wake word models (.tflite) and configuration (.json)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--wake-model",
|
"--wake-model",
|
||||||
default="okay_nabu",
|
default="okay_nabu",
|
||||||
help="Id of active wake model"
|
help="File name of the first active wake model",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--stop-model",
|
"--stop-model",
|
||||||
default="stop",
|
default="stop",
|
||||||
help="Id of stop model"
|
help="File name of the stop model",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--download-dir",
|
"--download-dir",
|
||||||
default=_REPO_DIR / "local",
|
default=_REPO_DIR / "local",
|
||||||
help="Directory to download custom wake word models, etc.",
|
help="Directory to download custom wake word models to",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--refractory-seconds",
|
"--refractory-seconds",
|
||||||
@@ -90,11 +102,13 @@ async def main() -> None:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--wakeup-sound",
|
"--wakeup-sound",
|
||||||
default=str(_SOUNDS_DIR / "wake_word_triggered.flac")
|
default=str(_SOUNDS_DIR / "wake_word_triggered.flac"),
|
||||||
|
help="Directory and file name for wake sound (when you say the wake word)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--timer-finished-sound",
|
"--timer-finished-sound",
|
||||||
default=str(_SOUNDS_DIR / "timer_finished.flac")
|
default=str(_SOUNDS_DIR / "timer_finished.flac"),
|
||||||
|
help="Directory and file name for timer finished sound",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--processing-sound",
|
"--processing-sound",
|
||||||
@@ -111,33 +125,51 @@ async def main() -> None:
|
|||||||
default=str(_SOUNDS_DIR / "mute_switch_off.flac"),
|
default=str(_SOUNDS_DIR / "mute_switch_off.flac"),
|
||||||
help="Sound to play when unmuting the assistant",
|
help="Sound to play when unmuting the assistant",
|
||||||
)
|
)
|
||||||
parser.add_argument("--preferences-file", default=_REPO_DIR / "preferences.json")
|
parser.add_argument(
|
||||||
|
"--preferences-file",
|
||||||
|
default=_REPO_DIR / "preferences.json",
|
||||||
|
help="Directory and file name for the file where the preferences are stored in JSON format",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--host",
|
"--host",
|
||||||
help="IP-Address for ESPHome server", # 0.0.0.0 is IPv4, None is all interfaces
|
help="Optional host IP address to bind to (default: Autodetected by network interface)", # 0.0.0.0 is IPv4, None is all interfaces
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--network-interface",
|
"--network-interface",
|
||||||
help="Network interface to use for ESPHome server (default: will be automatically detected)",
|
help="Network interface the application will be listening on (default: will be automatically detected by gateway)",
|
||||||
)
|
)
|
||||||
# Note that default port is also set in docker-entrypoint.sh
|
# Note that default port is also set in docker-entrypoint.sh
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--port", type=int,
|
"--port",
|
||||||
|
type=int,
|
||||||
default=6053,
|
default=6053,
|
||||||
help="Port for ESPHome server (default: 6053)"
|
help="Port the application is listenening on (default: 6053)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--enable-thinking-sound",
|
"--enable-thinking-sound",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Enable thinking sound on startup",
|
help="Enable thinking finish sound, when the assistant is done thinking and needed more time to process",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--debug", action="store_true", help="Print DEBUG messages to console"
|
"--timer-max-ring-seconds",
|
||||||
|
type=float,
|
||||||
|
default=900.0, # 15 minutes
|
||||||
|
help="Seconds before a ringing timer auto-stops (default: 900)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug",
|
||||||
|
action="store_true",
|
||||||
|
help="Add this to enable debug logging",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable output only mode",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.list_input_devices:
|
if args.list_input_devices:
|
||||||
print("Input devices")
|
print("Audio Input devices:")
|
||||||
print("=" * 13)
|
print("=" * 13)
|
||||||
for idx, mic in enumerate(sc.all_microphones()):
|
for idx, mic in enumerate(sc.all_microphones()):
|
||||||
print(f"[{idx}]", mic.name)
|
print(f"[{idx}]", mic.name)
|
||||||
@@ -147,7 +179,7 @@ async def main() -> None:
|
|||||||
from mpv import MPV
|
from mpv import MPV
|
||||||
|
|
||||||
player = MPV()
|
player = MPV()
|
||||||
print("Output devices")
|
print("Audio output devices:")
|
||||||
print("=" * 14)
|
print("=" * 14)
|
||||||
|
|
||||||
for speaker in player.audio_device_list: # type: ignore
|
for speaker in player.audio_device_list: # type: ignore
|
||||||
@@ -157,39 +189,53 @@ async def main() -> None:
|
|||||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
||||||
_LOGGER.debug(args)
|
_LOGGER.debug(args)
|
||||||
|
|
||||||
# Resolve network interface for mac-adress detection
|
# Resolve network interface for mac-address detection
|
||||||
if not args.network_interface:
|
if not args.network_interface:
|
||||||
print("No network interface specified, try to detect default interface")
|
print("No network interface specified, try to detect default interface")
|
||||||
network_interface = get_default_interface()
|
network_interface = get_default_interface()
|
||||||
print(f"Default interface detected:", network_interface)
|
print(f"Default interface detected: {network_interface}")
|
||||||
else:
|
else:
|
||||||
print("Network interface specified")
|
print("Network interface specified")
|
||||||
print(f"Using network interface: ", network_interface)
|
|
||||||
network_interface = args.network_interface
|
network_interface = args.network_interface
|
||||||
|
print(f"Using network interface: {network_interface}")
|
||||||
|
|
||||||
# Resolve ip_address where the application will be listening
|
# Resolve ip_address where the application will be listening
|
||||||
if not args.host:
|
if not args.host:
|
||||||
print("No host (ip-address) specified, try to detect IP-Address")
|
print("No host (ip-address) specified, try to detect IP-Address")
|
||||||
host_ip_address = get_default_ipv4(network_interface)
|
host_ip_address = get_default_ipv4(network_interface)
|
||||||
print(f"IP-Address detected: ", host_ip_address)
|
print(f"IP-Address detected: {host_ip_address}")
|
||||||
else:
|
else:
|
||||||
print("Host specified")
|
print("Host specified")
|
||||||
print(f"Using host: ", args.host)
|
print(f"Using host: {args.host}")
|
||||||
host_ip_address = args.host
|
host_ip_address = args.host
|
||||||
|
|
||||||
# Resolve mac
|
# Resolve mac
|
||||||
mac_address = get_mac_address(interface=network_interface)
|
if not (mac_address := get_mac_address(interface=network_interface)):
|
||||||
mac_address_clean = mac_address.replace(":", "")
|
print("No Mac address was found, app stopped.")
|
||||||
|
sys.exit(1)
|
||||||
|
mac_address_clean = mac_address.replace(":", "").lower()
|
||||||
|
|
||||||
# Resolve name
|
# Resolve name
|
||||||
if not args.name:
|
if not args.name:
|
||||||
print("No name specified, try to autogenerate name")
|
print("No friendly name specified, try to autogenerate name")
|
||||||
device_name = f"lva-{mac_address_clean}"
|
friendly_name = f"LVA - {mac_address_clean}"
|
||||||
print(f"Name autogenerated: {device_name}")
|
print(f"Friendly name autogenerated: {friendly_name}")
|
||||||
else:
|
else:
|
||||||
print("Name specified")
|
print("Friendly name specified")
|
||||||
print(f"Using name: {args.name}")
|
print(f"Using friendly name: {args.name}")
|
||||||
device_name = args.name
|
friendly_name = args.name
|
||||||
|
|
||||||
|
device_name = f"lva-{mac_address_clean}"
|
||||||
|
|
||||||
|
print(f"Device name: {device_name}")
|
||||||
|
|
||||||
|
# Resolve version
|
||||||
|
version = get_version()
|
||||||
|
print(f"Version: {version}")
|
||||||
|
|
||||||
|
# Resolve esphome version
|
||||||
|
esphome_version = get_esphome_version()
|
||||||
|
print(f"ESPHome api version: {esphome_version}")
|
||||||
|
|
||||||
# Resolve download dir
|
# Resolve download dir
|
||||||
args.download_dir = Path(args.download_dir)
|
args.download_dir = Path(args.download_dir)
|
||||||
@@ -209,32 +255,7 @@ async def main() -> None:
|
|||||||
# Load available wake words
|
# Load available wake words
|
||||||
wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir]
|
wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir]
|
||||||
wake_word_dirs.append(args.download_dir / "external_wake_words")
|
wake_word_dirs.append(args.download_dir / "external_wake_words")
|
||||||
available_wake_words: Dict[str, AvailableWakeWord] = {}
|
available_wake_words = find_available_wake_words(wake_word_dirs, args.stop_model)
|
||||||
|
|
||||||
for wake_word_dir in wake_word_dirs:
|
|
||||||
for model_config_path in wake_word_dir.glob("*.json"):
|
|
||||||
model_id = model_config_path.stem
|
|
||||||
if model_id == args.stop_model:
|
|
||||||
# Don't show stop model as an available wake word
|
|
||||||
continue
|
|
||||||
|
|
||||||
with open(model_config_path, "r", encoding="utf-8") as model_config_file:
|
|
||||||
model_config = json.load(model_config_file)
|
|
||||||
model_type = WakeWordType(model_config["type"])
|
|
||||||
if model_type == WakeWordType.OPEN_WAKE_WORD:
|
|
||||||
wake_word_path = model_config_path.parent / model_config["model"]
|
|
||||||
else:
|
|
||||||
wake_word_path = model_config_path
|
|
||||||
|
|
||||||
available_wake_words[model_id] = AvailableWakeWord(
|
|
||||||
id=model_id,
|
|
||||||
type=WakeWordType(model_type),
|
|
||||||
wake_word=model_config["wake_word"],
|
|
||||||
trained_languages=model_config.get("trained_languages", []),
|
|
||||||
wake_word_path=wake_word_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys())))
|
|
||||||
|
|
||||||
# Load preferences
|
# Load preferences
|
||||||
preferences_path = Path(args.preferences_file)
|
preferences_path = Path(args.preferences_file)
|
||||||
@@ -246,51 +267,42 @@ async def main() -> None:
|
|||||||
else:
|
else:
|
||||||
preferences = Preferences()
|
preferences = Preferences()
|
||||||
|
|
||||||
|
# Load volume from preferences on startup, and ensure it's between 0.0 and 1.0
|
||||||
|
initial_volume = preferences.volume if preferences.volume is not None else 1.0
|
||||||
|
initial_volume = max(0.0, min(1.0, float(initial_volume)))
|
||||||
|
preferences.volume = initial_volume
|
||||||
|
|
||||||
if args.enable_thinking_sound:
|
if args.enable_thinking_sound:
|
||||||
preferences.thinking_sound = 1
|
preferences.thinking_sound = 1
|
||||||
|
|
||||||
|
if args.mic_auto_gain or args.mic_noise_suppression:
|
||||||
|
try:
|
||||||
|
import webrtc_noise_gain # type: ignore[import-untyped] # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
_LOGGER.exception("Extras for webrtc are not installed")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.mic_auto_gain > 0:
|
||||||
|
preferences.mic_auto_gain = args.mic_auto_gain
|
||||||
|
|
||||||
|
if args.mic_noise_suppression > 0:
|
||||||
|
preferences.mic_noise_suppression = args.mic_noise_suppression
|
||||||
|
|
||||||
# Load wake/stop models
|
# Load wake/stop models
|
||||||
active_wake_words: Set[str] = set()
|
wake_models, active_wake_words, fallback_used = load_wake_models(available_wake_words, [word for word in preferences.active_wake_words if word is not None], args.wake_model)
|
||||||
wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
|
|
||||||
if preferences.active_wake_words:
|
|
||||||
# Load preferred models
|
|
||||||
for wake_word_id in preferences.active_wake_words:
|
|
||||||
wake_word = available_wake_words.get(wake_word_id)
|
|
||||||
if wake_word is None:
|
|
||||||
_LOGGER.warning("Unrecognized wake word id: %s", wake_word_id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
_LOGGER.debug("Loading wake model: %s", wake_word_id)
|
|
||||||
wake_models[wake_word_id] = wake_word.load()
|
|
||||||
active_wake_words.add(wake_word_id)
|
|
||||||
|
|
||||||
if not wake_models:
|
|
||||||
# Load default model
|
|
||||||
wake_word_id = args.wake_model
|
|
||||||
wake_word = available_wake_words[wake_word_id]
|
|
||||||
|
|
||||||
_LOGGER.debug("Loading wake model: %s", wake_word_id)
|
|
||||||
wake_models[wake_word_id] = wake_word.load()
|
|
||||||
active_wake_words.add(wake_word_id)
|
|
||||||
|
|
||||||
# TODO: allow openWakeWord for "stop"
|
# TODO: allow openWakeWord for "stop"
|
||||||
stop_model: Optional[MicroWakeWord] = None
|
stop_model = load_stop_model(wake_word_dirs, args.stop_model)
|
||||||
for wake_word_dir in wake_word_dirs:
|
|
||||||
stop_config_path = wake_word_dir / f"{args.stop_model}.json"
|
|
||||||
if not stop_config_path.exists():
|
|
||||||
continue
|
|
||||||
|
|
||||||
_LOGGER.debug("Loading stop model: %s", stop_config_path)
|
|
||||||
stop_model = MicroWakeWord.from_config(stop_config_path)
|
|
||||||
break
|
|
||||||
|
|
||||||
assert stop_model is not None
|
assert stop_model is not None
|
||||||
|
|
||||||
state = ServerState(
|
state = ServerState(
|
||||||
name=device_name,
|
name=device_name,
|
||||||
|
friendly_name=friendly_name,
|
||||||
network_interface=network_interface,
|
network_interface=network_interface,
|
||||||
mac_address=get_mac_address(interface=network_interface),
|
mac_address=mac_address,
|
||||||
ip_address=host_ip_address,
|
ip_address=host_ip_address,
|
||||||
|
version=version,
|
||||||
|
esphome_version=esphome_version,
|
||||||
audio_queue=Queue(),
|
audio_queue=Queue(),
|
||||||
entities=[],
|
entities=[],
|
||||||
available_wake_words=available_wake_words,
|
available_wake_words=available_wake_words,
|
||||||
@@ -307,11 +319,67 @@ async def main() -> None:
|
|||||||
preferences=preferences,
|
preferences=preferences,
|
||||||
preferences_path=preferences_path,
|
preferences_path=preferences_path,
|
||||||
refractory_seconds=args.refractory_seconds,
|
refractory_seconds=args.refractory_seconds,
|
||||||
|
output_only=args.output_only,
|
||||||
download_dir=args.download_dir,
|
download_dir=args.download_dir,
|
||||||
|
volume=initial_volume,
|
||||||
|
mic_volume=preferences.mic_volume,
|
||||||
|
mic_auto_gain=preferences.mic_auto_gain,
|
||||||
|
mic_noise_suppression=preferences.mic_noise_suppression,
|
||||||
|
timer_max_ring_seconds=args.timer_max_ring_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
if args.enable_thinking_sound:
|
if fallback_used:
|
||||||
|
# Fallback to the default model was used, save as active wake words
|
||||||
|
_LOGGER.debug("Fallback was used, save default wake words in Preferences.")
|
||||||
|
state.preferences.active_wake_words = list(active_wake_words)
|
||||||
|
state.active_wake_words = active_wake_words
|
||||||
|
state.wake_words = wake_models
|
||||||
state.save_preferences()
|
state.save_preferences()
|
||||||
|
state.wake_words_changed = True
|
||||||
|
|
||||||
|
if args.enable_thinking_sound or args.mic_auto_gain or args.mic_noise_suppression:
|
||||||
|
state.save_preferences()
|
||||||
|
|
||||||
|
initial_volume_percent = int(round(initial_volume * 100))
|
||||||
|
state.music_player.set_volume(initial_volume_percent)
|
||||||
|
state.tts_player.set_volume(initial_volume_percent)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
max_attempts = 15
|
||||||
|
attempt = 1
|
||||||
|
server = None
|
||||||
|
|
||||||
|
# Validate VoiceSatelliteProtocol initialization BEFORE starting server
|
||||||
|
# This catches errors like missing imports or broken initialization immediately
|
||||||
|
# instead of failing silently only when first client connects
|
||||||
|
_LOGGER.debug("Validating VoiceSatelliteProtocol initialization...")
|
||||||
|
try:
|
||||||
|
# Create test instance to run complete __init__ code path
|
||||||
|
test_protocol = VoiceSatelliteProtocol(state)
|
||||||
|
# Cleanup state reference
|
||||||
|
test_protocol.state.satellite = None
|
||||||
|
del test_protocol
|
||||||
|
_LOGGER.debug("✅ VoiceSatelliteProtocol validation successful")
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.critical("❌ FATAL ERROR in VoiceSatelliteProtocol initialization!", exc_info=True)
|
||||||
|
_LOGGER.critical("Program will exit immediately - fix the error above first!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
while attempt <= max_attempts:
|
||||||
|
try:
|
||||||
|
server = await loop.create_server(lambda: VoiceSatelliteProtocol(state), host=host_ip_address, port=args.port)
|
||||||
|
break # connect successful, exit the loop
|
||||||
|
except OSError as err:
|
||||||
|
message = err.strerror or str(err)
|
||||||
|
if err.errno == errno.EADDRINUSE:
|
||||||
|
message = "address already in use"
|
||||||
|
if attempt < max_attempts:
|
||||||
|
_LOGGER.warning("Attempt %d/%d failed to bind on address (%s, %s): %s. Retrying in 1 second...", attempt, max_attempts, host_ip_address, args.port, message)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
attempt += 1
|
||||||
|
else:
|
||||||
|
_LOGGER.exception("All %d attempts failed to bind on address (%s, %s): %s", max_attempts, host_ip_address, args.port, message)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
process_audio_thread = threading.Thread(
|
process_audio_thread = threading.Thread(
|
||||||
target=process_audio,
|
target=process_audio,
|
||||||
@@ -320,19 +388,14 @@ async def main() -> None:
|
|||||||
)
|
)
|
||||||
process_audio_thread.start()
|
process_audio_thread.start()
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
server = await loop.create_server(
|
|
||||||
lambda: VoiceSatelliteProtocol(state), host=host_ip_address, port=args.port
|
|
||||||
)
|
|
||||||
|
|
||||||
# Auto discovery (zeroconf, mDNS)
|
# Auto discovery (zeroconf, mDNS)
|
||||||
discovery = HomeAssistantZeroconf(port=args.port, name=state.name, mac_address=state.mac_address, host_ip_address=host_ip_address)
|
discovery = HomeAssistantZeroconf(port=args.port, name=state.name, mac_address=state.mac_address, host_ip_address=host_ip_address)
|
||||||
await discovery.register_server()
|
await discovery.register_server()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with server:
|
async with server: # type: ignore
|
||||||
_LOGGER.info("Server started (host=%s, port=%s)", host_ip_address, args.port)
|
_LOGGER.info("Server started (host=%s, port=%s)", host_ip_address, args.port)
|
||||||
await server.serve_forever()
|
await server.serve_forever() # type: ignore
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
@@ -357,35 +420,108 @@ def process_audio(state: ServerState, mic, block_size: int):
|
|||||||
has_oww = False
|
has_oww = False
|
||||||
|
|
||||||
last_active: Optional[float] = None
|
last_active: Optional[float] = None
|
||||||
|
webrtc: Optional[WebRTCProcessor] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_LOGGER.debug("Opening audio input device: %s", mic.name)
|
_LOGGER.debug("Opening audio input device: %s", mic.name)
|
||||||
with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in:
|
with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in:
|
||||||
while True:
|
while True:
|
||||||
audio_chunk_array = mic_in.record(block_size).reshape(-1)
|
audio_chunk_array = mic_in.record(block_size).reshape(-1)
|
||||||
audio_chunk = (
|
# little-endian 16-bit signed
|
||||||
(np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
|
mic_vol_scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
|
||||||
.astype("<i2") # little-endian 16-bit signed
|
audio_chunk = (np.clip(audio_chunk_array * mic_vol_scalar, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
|
||||||
.tobytes()
|
agc = state.preferences.mic_auto_gain or 0
|
||||||
)
|
ns = state.preferences.mic_noise_suppression or 0
|
||||||
|
|
||||||
if state.satellite is None:
|
if agc > 0 or ns > 0:
|
||||||
|
if webrtc is None:
|
||||||
|
webrtc = WebRTCProcessor(agc_level=agc, ns_level=ns)
|
||||||
|
else:
|
||||||
|
webrtc.update_settings(agc, ns)
|
||||||
|
audio_chunk = webrtc.process(audio_chunk)
|
||||||
|
if not audio_chunk:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if state.satellite is None or not hasattr(state.satellite, "_is_streaming_audio"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# WAKE WORD
|
||||||
if (not wake_words) or (state.wake_words_changed and state.wake_words):
|
if (not wake_words) or (state.wake_words_changed and state.wake_words):
|
||||||
# Update list of wake word models to process
|
# Update list of wake word models to process
|
||||||
state.wake_words_changed = False
|
state.wake_words_changed = False
|
||||||
wake_words = [
|
wake_words = [ww for ww in state.wake_words.values() if ww.id in state.active_wake_words]
|
||||||
ww
|
|
||||||
for ww in state.wake_words.values()
|
# TODO: Load default stop word value from json into state and preferences missing.
|
||||||
if ww.id in state.active_wake_words
|
|
||||||
]
|
|
||||||
|
|
||||||
has_oww = False
|
has_oww = False
|
||||||
for wake_word in wake_words:
|
for idx, wake_word in enumerate(wake_words):
|
||||||
|
|
||||||
|
# Load default threshold from model json
|
||||||
|
wake_word_id = wake_word.id if hasattr(wake_word, "id") else next(iter(state.wake_words.keys()))
|
||||||
|
available_word = state.available_wake_words.get(wake_word_id)
|
||||||
|
# _LOGGER.debug("word= %s", state.available_wake_words.get(wake_word_id))
|
||||||
|
default_threshold = available_word.probability_cutoff if available_word else 0.7
|
||||||
|
_LOGGER.debug("Using default threshold %.3f for wake word '%s' from model config", default_threshold, wake_word_id)
|
||||||
|
# Check preferences override
|
||||||
|
if idx == 0:
|
||||||
|
old_val = state.wake_word_1_threshold
|
||||||
|
if state.preferences.wake_word_1_sensitivity is not None:
|
||||||
|
state.wake_word_1_threshold = state.preferences.wake_word_1_sensitivity
|
||||||
|
else:
|
||||||
|
state.wake_word_1_threshold = default_threshold
|
||||||
|
_LOGGER.debug("Wake Word 1 threshold set to %.3f (was %.3f, preferences: %s)", state.wake_word_1_threshold, old_val, state.preferences.wake_word_1_sensitivity)
|
||||||
|
elif idx == 1:
|
||||||
|
old_val = state.wake_word_2_threshold
|
||||||
|
if state.preferences.wake_word_2_sensitivity is not None:
|
||||||
|
state.wake_word_2_threshold = state.preferences.wake_word_2_sensitivity
|
||||||
|
else:
|
||||||
|
state.wake_word_2_threshold = default_threshold
|
||||||
|
_LOGGER.debug("Wake Word 2 threshold set to %.3f (was %.3f, preferences: %s)", state.wake_word_2_threshold, old_val, state.preferences.wake_word_2_sensitivity)
|
||||||
|
|
||||||
if isinstance(wake_word, OpenWakeWord):
|
if isinstance(wake_word, OpenWakeWord):
|
||||||
has_oww = True
|
has_oww = True
|
||||||
|
|
||||||
|
# Sync entity states after threshold values were updated
|
||||||
|
if state.satellite is not None:
|
||||||
|
_LOGGER.debug("Updating WebUI entities with new threshold values")
|
||||||
|
|
||||||
|
# Wake Word 1
|
||||||
|
if state.satellite.state.sensitivity_1_number_entity is not None:
|
||||||
|
_LOGGER.debug(" → Syncing Wake Word 1 entity to value %.3f", state.wake_word_1_threshold)
|
||||||
|
state.satellite.state.sensitivity_1_number_entity.sync_with_state()
|
||||||
|
_LOGGER.debug(" ✅ Wake Word 1 entity now has value %.3f", state.satellite.state.sensitivity_1_number_entity.value)
|
||||||
|
|
||||||
|
# Wake Word 2
|
||||||
|
if state.satellite.state.sensitivity_2_number_entity is not None:
|
||||||
|
_LOGGER.debug(" → Syncing Wake Word 2 entity to value %.3f", state.wake_word_2_threshold)
|
||||||
|
state.satellite.state.sensitivity_2_number_entity.sync_with_state()
|
||||||
|
_LOGGER.debug(" ✅ Wake Word 2 entity now has value %.3f", state.satellite.state.sensitivity_2_number_entity.value)
|
||||||
|
|
||||||
|
# Stop Word
|
||||||
|
if state.satellite.state.stop_sensitivity_number_entity is not None:
|
||||||
|
_LOGGER.debug(" → Syncing Stop Word entity to value %.3f", state.stop_word_threshold)
|
||||||
|
state.satellite.state.stop_sensitivity_number_entity.sync_with_state()
|
||||||
|
_LOGGER.debug(" ✅ Stop Word entity now has value %.3f", state.satellite.state.stop_sensitivity_number_entity.value)
|
||||||
|
|
||||||
|
_LOGGER.debug("All sensitivity entities synced successfully")
|
||||||
|
|
||||||
|
# Force push new state to connected Home Assistant instance
|
||||||
|
if state.satellite is not None:
|
||||||
|
try:
|
||||||
|
_LOGGER.debug("Pushing updated state values to Home Assistant")
|
||||||
|
for entity in [
|
||||||
|
state.satellite.state.sensitivity_1_number_entity,
|
||||||
|
state.satellite.state.sensitivity_2_number_entity,
|
||||||
|
state.satellite.state.stop_sensitivity_number_entity,
|
||||||
|
]:
|
||||||
|
if entity is not None:
|
||||||
|
state.satellite.send_messages([NumberStateResponse(key=entity.key, state=entity.value)]) # type: ignore[attr-defined]
|
||||||
|
_LOGGER.debug(" → Pushed value %.3f for entity %d", entity.value, entity.key)
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.debug("Could not push state (no client connected yet): %s", e)
|
||||||
|
|
||||||
|
# TODO: Save settings: At this moment settings are only saved when changed in the UI. Means that the default value can change while updating since its not saved in preferences.
|
||||||
|
|
||||||
if micro_features is None:
|
if micro_features is None:
|
||||||
micro_features = MicroWakeWordFeatures()
|
micro_features = MicroWakeWordFeatures()
|
||||||
|
|
||||||
@@ -404,38 +540,61 @@ def process_audio(state: ServerState, mic, block_size: int):
|
|||||||
oww_inputs.clear()
|
oww_inputs.clear()
|
||||||
oww_inputs.extend(oww_features.process_streaming(audio_chunk))
|
oww_inputs.extend(oww_features.process_streaming(audio_chunk))
|
||||||
|
|
||||||
for wake_word in wake_words:
|
for wake_word_index, wake_word in enumerate(wake_words):
|
||||||
activated = False
|
activated = False
|
||||||
|
|
||||||
|
# Set dynamic threshold depending on wake word index
|
||||||
|
if wake_word_index == 0:
|
||||||
|
threshold = state.wake_word_1_threshold
|
||||||
|
# _LOGGER.debug("Set wake word %d probability cutoff to %.3f", wake_word_index+1, state.wake_word_1_threshold)
|
||||||
|
elif wake_word_index == 1:
|
||||||
|
threshold = state.wake_word_2_threshold
|
||||||
|
# _LOGGER.debug("Set wake word %d probability cutoff to %.3f", wake_word_index+1, state.wake_word_2_threshold)
|
||||||
|
else:
|
||||||
|
threshold = 0.7
|
||||||
|
# _LOGGER.debug("Set wake word %d probability cutoff to fallback value 0.7", wake_word_index+1)
|
||||||
|
|
||||||
if isinstance(wake_word, MicroWakeWord):
|
if isinstance(wake_word, MicroWakeWord):
|
||||||
|
# No debugging when no detection
|
||||||
|
wake_word.debug_probabilities = False
|
||||||
|
|
||||||
|
# set microWakeWord cutoff
|
||||||
|
wake_word.probability_cutoff = threshold
|
||||||
|
|
||||||
for micro_input in micro_inputs:
|
for micro_input in micro_inputs:
|
||||||
if wake_word.process_streaming(micro_input):
|
if wake_word.process_streaming(micro_input):
|
||||||
|
wake_word.debug_probabilities = True
|
||||||
activated = True
|
activated = True
|
||||||
elif isinstance(wake_word, OpenWakeWord):
|
elif isinstance(wake_word, OpenWakeWord):
|
||||||
for oww_input in oww_inputs:
|
for oww_input in oww_inputs:
|
||||||
for prob in wake_word.process_streaming(oww_input):
|
for prob in wake_word.process_streaming(oww_input):
|
||||||
if prob > 0.5:
|
if prob > threshold:
|
||||||
|
_LOGGER.debug("Wake word '%s' activated (probability %.3f exceeded threshold %.3f)", wake_word.wake_word, prob, threshold) # type: ignore[attr-defined]
|
||||||
activated = True
|
activated = True
|
||||||
|
|
||||||
if activated and not state.muted:
|
if activated and not state.muted:
|
||||||
# Check refractory
|
# Check refractory
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if (last_active is None) or (
|
if (last_active is None) or ((now - last_active) > state.refractory_seconds):
|
||||||
(now - last_active) > state.refractory_seconds
|
|
||||||
):
|
|
||||||
state.satellite.wakeup(wake_word)
|
state.satellite.wakeup(wake_word)
|
||||||
last_active = now
|
last_active = now
|
||||||
|
|
||||||
# Always process to keep state correct
|
# Always process to keep state correct
|
||||||
stopped = False
|
stopped = False
|
||||||
|
|
||||||
|
# No debugging when no detection
|
||||||
|
state.stop_word.debug_probabilities = False
|
||||||
|
|
||||||
|
# Apply stop word sensitivity threshold
|
||||||
|
state.stop_word.probability_cutoff = state.stop_word_threshold
|
||||||
|
# _LOGGER.debug("Set stop word probability cutoff to %.3f", state.stop_word_threshold)
|
||||||
for micro_input in micro_inputs:
|
for micro_input in micro_inputs:
|
||||||
if state.stop_word.process_streaming(micro_input):
|
if state.stop_word.process_streaming(micro_input):
|
||||||
|
state.stop_word.debug_probabilities = True
|
||||||
stopped = True
|
stopped = True
|
||||||
|
|
||||||
if (
|
if stopped and (state.stop_word.id in state.active_wake_words) and not state.muted:
|
||||||
stopped
|
_LOGGER.debug("Stop word detected")
|
||||||
and (state.stop_word.id in state.active_wake_words)
|
|
||||||
and not state.muted
|
|
||||||
):
|
|
||||||
state.satellite.stop()
|
state.satellite.stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.exception("Unexpected error handling audio")
|
_LOGGER.exception("Unexpected error handling audio")
|
||||||
@@ -446,5 +605,10 @@ def process_audio(state: ServerState, mic, block_size: int):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
|
def run():
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
import threading
|
import threading
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
# pylint: disable=no-name-in-module
|
# pylint: disable=no-name-in-module
|
||||||
from aioesphomeapi._frame_helper.packets import make_plain_text_packets
|
from aioesphomeapi._frame_helper.packets import make_plain_text_packets
|
||||||
@@ -77,20 +77,13 @@ class APIServer(asyncio.Protocol):
|
|||||||
|
|
||||||
self.send_messages(msgs)
|
self.send_messages(msgs)
|
||||||
|
|
||||||
def send_messages(self, msgs: List[message.Message]):
|
def send_messages(self, msgs: Iterable[message.Message]):
|
||||||
if self._writelines is None or not msgs:
|
if self._writelines is None or not msgs:
|
||||||
return
|
return
|
||||||
|
|
||||||
packets = [
|
packets = [(PROTO_TO_MESSAGE_TYPE[msg.__class__], msg.SerializeToString()) for msg in msgs]
|
||||||
(PROTO_TO_MESSAGE_TYPE[msg.__class__], msg.SerializeToString())
|
|
||||||
for msg in msgs
|
|
||||||
]
|
|
||||||
packet_bytes = make_plain_text_packets(packets)
|
packet_bytes = make_plain_text_packets(packets)
|
||||||
if (
|
if self._loop is not None and self._loop_thread_id is not None and threading.get_ident() != self._loop_thread_id:
|
||||||
self._loop is not None
|
|
||||||
and self._loop_thread_id is not None
|
|
||||||
and threading.get_ident() != self._loop_thread_id
|
|
||||||
):
|
|
||||||
self._loop.call_soon_threadsafe(self._writelines, packet_bytes)
|
self._loop.call_soon_threadsafe(self._writelines, packet_bytes)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
+344
-24
@@ -1,15 +1,21 @@
|
|||||||
|
import logging
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from typing import Callable, List, Optional, Union
|
from typing import Callable, List, Optional, Union
|
||||||
import logging
|
|
||||||
|
|
||||||
# pylint: disable=no-name-in-module
|
# pylint: disable=no-name-in-module
|
||||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||||
ListEntitiesMediaPlayerResponse,
|
ListEntitiesMediaPlayerResponse,
|
||||||
|
ListEntitiesNumberResponse,
|
||||||
ListEntitiesRequest,
|
ListEntitiesRequest,
|
||||||
|
ListEntitiesSelectResponse,
|
||||||
ListEntitiesSwitchResponse,
|
ListEntitiesSwitchResponse,
|
||||||
MediaPlayerCommandRequest,
|
MediaPlayerCommandRequest,
|
||||||
MediaPlayerStateResponse,
|
MediaPlayerStateResponse,
|
||||||
|
NumberCommandRequest,
|
||||||
|
NumberStateResponse,
|
||||||
|
SelectCommandRequest,
|
||||||
|
SelectStateResponse,
|
||||||
SubscribeHomeAssistantStatesRequest,
|
SubscribeHomeAssistantStatesRequest,
|
||||||
SwitchCommandRequest,
|
SwitchCommandRequest,
|
||||||
SwitchStateResponse,
|
SwitchStateResponse,
|
||||||
@@ -19,6 +25,7 @@ from aioesphomeapi.model import (
|
|||||||
MediaPlayerCommand,
|
MediaPlayerCommand,
|
||||||
MediaPlayerEntityFeature,
|
MediaPlayerEntityFeature,
|
||||||
MediaPlayerState,
|
MediaPlayerState,
|
||||||
|
NumberMode,
|
||||||
)
|
)
|
||||||
from google.protobuf import message
|
from google.protobuf import message
|
||||||
|
|
||||||
@@ -58,6 +65,8 @@ class MediaPlayerEntity(ESPHomeEntity):
|
|||||||
object_id: str,
|
object_id: str,
|
||||||
music_player: MpvMediaPlayer,
|
music_player: MpvMediaPlayer,
|
||||||
announce_player: MpvMediaPlayer,
|
announce_player: MpvMediaPlayer,
|
||||||
|
initial_volume: float = 1.0,
|
||||||
|
on_volume_changed: Optional[Callable[[float], None]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
ESPHomeEntity.__init__(self, server)
|
ESPHomeEntity.__init__(self, server)
|
||||||
|
|
||||||
@@ -65,11 +74,13 @@ class MediaPlayerEntity(ESPHomeEntity):
|
|||||||
self.name = name
|
self.name = name
|
||||||
self.object_id = object_id
|
self.object_id = object_id
|
||||||
self.state = MediaPlayerState.IDLE
|
self.state = MediaPlayerState.IDLE
|
||||||
self.volume = 1.0
|
self.volume = max(0.0, min(1.0, initial_volume))
|
||||||
self.muted = False
|
self.muted = False
|
||||||
self.previous_volume = 1.0
|
self.previous_volume = 1.0
|
||||||
self.music_player = music_player
|
self.music_player = music_player
|
||||||
self.announce_player = announce_player
|
self.announce_player = announce_player
|
||||||
|
self._on_volume_changed = on_volume_changed
|
||||||
|
self.apply_volume_from_state(initial_volume)
|
||||||
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
|
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
|
||||||
|
|
||||||
def play(
|
def play(
|
||||||
@@ -79,34 +90,30 @@ class MediaPlayerEntity(ESPHomeEntity):
|
|||||||
done_callback: Optional[Callable[[], None]] = None,
|
done_callback: Optional[Callable[[], None]] = None,
|
||||||
) -> Iterable[message.Message]:
|
) -> Iterable[message.Message]:
|
||||||
if announcement:
|
if announcement:
|
||||||
|
self._log.debug("PLAY: announcement true")
|
||||||
if self.music_player.is_playing:
|
if self.music_player.is_playing:
|
||||||
# Announce, resume music
|
# Announce, resume music
|
||||||
self.music_player.pause()
|
self.music_player.pause()
|
||||||
self.announce_player.play(
|
self.announce_player.play(
|
||||||
url,
|
url,
|
||||||
done_callback=lambda: call_all(
|
done_callback=lambda: call_all(self.music_player.resume, done_callback),
|
||||||
self.music_player.resume, done_callback
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Announce, idle
|
# Announce, idle
|
||||||
self.announce_player.play(
|
self.announce_player.play(
|
||||||
url,
|
url,
|
||||||
done_callback=lambda: call_all(
|
done_callback=lambda: call_all(
|
||||||
self.server.send_messages(
|
self.server.send_messages([self._update_state(MediaPlayerState.IDLE)]),
|
||||||
[self._update_state(MediaPlayerState.IDLE)]
|
|
||||||
),
|
|
||||||
done_callback,
|
done_callback,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
self._log.debug("PLAY: announcement false")
|
||||||
# Music
|
# Music
|
||||||
self.music_player.play(
|
self.music_player.play(
|
||||||
url,
|
url,
|
||||||
done_callback=lambda: call_all(
|
done_callback=lambda: call_all(
|
||||||
self.server.send_messages(
|
self.server.send_messages([self._update_state(MediaPlayerState.IDLE)]),
|
||||||
[self._update_state(MediaPlayerState.IDLE)]
|
|
||||||
),
|
|
||||||
done_callback,
|
done_callback,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -116,10 +123,15 @@ class MediaPlayerEntity(ESPHomeEntity):
|
|||||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||||
self._log.debug("handle_message called with msg: %s", msg)
|
self._log.debug("handle_message called with msg: %s", msg)
|
||||||
|
|
||||||
|
# Suppress warning for irrelevant NumberCommandRequest
|
||||||
|
if isinstance(msg, (NumberCommandRequest, SelectCommandRequest)):
|
||||||
|
return
|
||||||
|
|
||||||
if isinstance(msg, MediaPlayerCommandRequest) and (msg.key == self.key):
|
if isinstance(msg, MediaPlayerCommandRequest) and (msg.key == self.key):
|
||||||
self._log.debug("MediaPlayerCommandRequest matched for this key")
|
self._log.debug("MediaPlayerCommandRequest matched for this key")
|
||||||
|
|
||||||
if msg.has_media_url:
|
if msg.has_media_url:
|
||||||
|
self._log.debug("Executing PLAY")
|
||||||
self._log.debug("Message has media URL: %s", msg.media_url)
|
self._log.debug("Message has media URL: %s", msg.media_url)
|
||||||
announcement = msg.has_announcement and msg.announcement
|
announcement = msg.has_announcement and msg.announcement
|
||||||
yield from self.play(msg.media_url, announcement=announcement)
|
yield from self.play(msg.media_url, announcement=announcement)
|
||||||
@@ -164,10 +176,12 @@ class MediaPlayerEntity(ESPHomeEntity):
|
|||||||
|
|
||||||
elif msg.has_volume:
|
elif msg.has_volume:
|
||||||
self._log.debug("Message has volume: %.2f", msg.volume)
|
self._log.debug("Message has volume: %.2f", msg.volume)
|
||||||
volume = int(msg.volume * 100)
|
self._apply_volume(msg.volume, persist=True)
|
||||||
self.music_player.set_volume(volume)
|
if hasattr(self.server, "state") and getattr(self.server, "state", None) is not None:
|
||||||
self.announce_player.set_volume(volume)
|
self._log.debug("Persisting volume to preferences")
|
||||||
self.volume = msg.volume
|
self.server.state.persist_volume(self.volume)
|
||||||
|
else:
|
||||||
|
self._log.warning("Cannot persist volume - server.state not available")
|
||||||
yield self._update_state(self.state)
|
yield self._update_state(self.state)
|
||||||
|
|
||||||
elif isinstance(msg, ListEntitiesRequest):
|
elif isinstance(msg, ListEntitiesRequest):
|
||||||
@@ -186,6 +200,8 @@ class MediaPlayerEntity(ESPHomeEntity):
|
|||||||
self._log.warning("Unknown message type received: %s", type(msg))
|
self._log.warning("Unknown message type received: %s", type(msg))
|
||||||
|
|
||||||
def _update_state(self, new_state: MediaPlayerState) -> MediaPlayerStateResponse:
|
def _update_state(self, new_state: MediaPlayerState) -> MediaPlayerStateResponse:
|
||||||
|
self._log.debug("SET NEW STATE: %s => %s", self.state, new_state)
|
||||||
|
self._log.debug("SET NEW STATE: %s => %s", self.state.name, new_state.name)
|
||||||
self.state = new_state
|
self.state = new_state
|
||||||
return self._get_state_message()
|
return self._get_state_message()
|
||||||
|
|
||||||
@@ -197,6 +213,43 @@ class MediaPlayerEntity(ESPHomeEntity):
|
|||||||
muted=self.muted,
|
muted=self.muted,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def apply_volume_from_state(self, volume: float) -> None:
|
||||||
|
"""Synchronize the local volume with the stored state without persisting."""
|
||||||
|
|
||||||
|
clamped = max(0.0, min(1.0, float(volume)))
|
||||||
|
|
||||||
|
if self.muted:
|
||||||
|
self.previous_volume = clamped
|
||||||
|
return
|
||||||
|
|
||||||
|
self._apply_volume(clamped, persist=False)
|
||||||
|
|
||||||
|
def set_volume_callback(self, callback: Optional[Callable[[float], None]]) -> None:
|
||||||
|
"""Update the callback invoked when the volume changes."""
|
||||||
|
|
||||||
|
self._on_volume_changed = callback
|
||||||
|
|
||||||
|
def _apply_volume(
|
||||||
|
self,
|
||||||
|
volume: float,
|
||||||
|
*,
|
||||||
|
persist: bool,
|
||||||
|
remember: bool = True,
|
||||||
|
) -> None:
|
||||||
|
normalized = max(0.0, min(1.0, float(volume)))
|
||||||
|
volume_percent = int(round(normalized * 100))
|
||||||
|
|
||||||
|
self.music_player.set_volume(volume_percent)
|
||||||
|
self.announce_player.set_volume(volume_percent)
|
||||||
|
|
||||||
|
self.volume = normalized
|
||||||
|
|
||||||
|
if remember:
|
||||||
|
self.previous_volume = normalized
|
||||||
|
|
||||||
|
if self._on_volume_changed and persist:
|
||||||
|
self._on_volume_changed(normalized)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -218,9 +271,7 @@ class MuteSwitchEntity(ESPHomeEntity):
|
|||||||
self.object_id = object_id
|
self.object_id = object_id
|
||||||
self._get_muted = get_muted
|
self._get_muted = get_muted
|
||||||
self._set_muted = set_muted
|
self._set_muted = set_muted
|
||||||
self._switch_state = (
|
self._switch_state = self._get_muted() # Sync internal state with actual muted value on init
|
||||||
self._get_muted()
|
|
||||||
) # Sync internal state with actual muted value on init
|
|
||||||
|
|
||||||
def update_set_muted(self, set_muted: Callable[[bool], None]) -> None:
|
def update_set_muted(self, set_muted: Callable[[bool], None]) -> None:
|
||||||
# Update the callback used to change the mute state.
|
# Update the callback used to change the mute state.
|
||||||
@@ -275,15 +326,11 @@ class ThinkingSoundEntity(ESPHomeEntity):
|
|||||||
self._set_thinking_sound_enabled = set_thinking_sound_enabled
|
self._set_thinking_sound_enabled = set_thinking_sound_enabled
|
||||||
self._switch_state = self._get_thinking_sound_enabled() # Sync internal state
|
self._switch_state = self._get_thinking_sound_enabled() # Sync internal state
|
||||||
|
|
||||||
def update_get_thinking_sound_enabled(
|
def update_get_thinking_sound_enabled(self, get_thinking_sound_enabled: Callable[[], bool]) -> None:
|
||||||
self, get_thinking_sound_enabled: Callable[[], bool]
|
|
||||||
) -> None:
|
|
||||||
# Update the callback used to read the thinking sound enabled state.
|
# Update the callback used to read the thinking sound enabled state.
|
||||||
self._get_thinking_sound_enabled = get_thinking_sound_enabled
|
self._get_thinking_sound_enabled = get_thinking_sound_enabled
|
||||||
|
|
||||||
def update_set_thinking_sound_enabled(
|
def update_set_thinking_sound_enabled(self, set_thinking_sound_enabled: Callable[[bool], None]) -> None:
|
||||||
self, set_thinking_sound_enabled: Callable[[bool], None]
|
|
||||||
) -> None:
|
|
||||||
# Update the callback used to change the thinking sound enabled state.
|
# Update the callback used to change the thinking sound enabled state.
|
||||||
self._set_thinking_sound_enabled = set_thinking_sound_enabled
|
self._set_thinking_sound_enabled = set_thinking_sound_enabled
|
||||||
|
|
||||||
@@ -311,3 +358,276 @@ class ThinkingSoundEntity(ESPHomeEntity):
|
|||||||
# Always return our internal switch state
|
# Always return our internal switch state
|
||||||
self.sync_with_state()
|
self.sync_with_state()
|
||||||
yield SwitchStateResponse(key=self.key, state=self._switch_state)
|
yield SwitchStateResponse(key=self.key, state=self._switch_state)
|
||||||
|
|
||||||
|
|
||||||
|
class MicSettingEntity(ESPHomeEntity):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
server: APIServer,
|
||||||
|
key: int,
|
||||||
|
name: str,
|
||||||
|
object_id: str,
|
||||||
|
get_value: Callable[[], Union[float, str]],
|
||||||
|
set_value: Callable[[Union[float, str]], None],
|
||||||
|
min_value: float = 0.0,
|
||||||
|
max_value: float = 1.0,
|
||||||
|
options: Optional[List[str]] = None,
|
||||||
|
icon: str = "mdi:microphone",
|
||||||
|
) -> None:
|
||||||
|
ESPHomeEntity.__init__(self, server)
|
||||||
|
self.key = key
|
||||||
|
self.name = name
|
||||||
|
self.object_id = object_id
|
||||||
|
self.options = options # If present, this behaves as a Dropdown
|
||||||
|
self.min_value = min_value
|
||||||
|
self.max_value = max_value
|
||||||
|
self._get_value = get_value
|
||||||
|
self._set_value = set_value
|
||||||
|
self._state = self._get_value()
|
||||||
|
self.icon = icon
|
||||||
|
|
||||||
|
def sync_with_state(self) -> None:
|
||||||
|
"""Sync internal state with the actual value."""
|
||||||
|
self._state = self._get_value()
|
||||||
|
|
||||||
|
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||||
|
# --- 1. HANDLE COMMANDS FROM HOME ASSISTANT ---
|
||||||
|
if self.options:
|
||||||
|
if isinstance(msg, SelectCommandRequest) and (msg.key == self.key):
|
||||||
|
new_val = msg.state
|
||||||
|
self._state = new_val
|
||||||
|
self._set_value(new_val)
|
||||||
|
yield SelectStateResponse(key=self.key, state=new_val)
|
||||||
|
else:
|
||||||
|
if isinstance(msg, NumberCommandRequest) and (msg.key == self.key):
|
||||||
|
new_val = msg.state
|
||||||
|
self._state = new_val
|
||||||
|
self._set_value(new_val)
|
||||||
|
yield NumberStateResponse(key=self.key, state=new_val)
|
||||||
|
|
||||||
|
# --- 2. DISCOVERY (TELL HA WHAT TYPE TO SHOW) ---
|
||||||
|
if isinstance(msg, ListEntitiesRequest):
|
||||||
|
if self.options:
|
||||||
|
yield ListEntitiesSelectResponse(
|
||||||
|
object_id=self.object_id,
|
||||||
|
key=self.key,
|
||||||
|
name=self.name,
|
||||||
|
options=self.options,
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
icon=self.icon,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
yield ListEntitiesNumberResponse(
|
||||||
|
object_id=self.object_id,
|
||||||
|
key=self.key,
|
||||||
|
name=self.name,
|
||||||
|
min_value=self.min_value,
|
||||||
|
max_value=self.max_value,
|
||||||
|
step=1.0,
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
icon=self.icon,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 3. INITIAL SYNC / STATE UPDATES ---
|
||||||
|
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
||||||
|
self.sync_with_state()
|
||||||
|
if self.options:
|
||||||
|
yield SelectStateResponse(key=self.key, state=str(self._state))
|
||||||
|
else:
|
||||||
|
yield NumberStateResponse(key=self.key, state=float(self._state))
|
||||||
|
|
||||||
|
def update_get_value(self, get_value: Callable[[], Union[float, str]]) -> None:
|
||||||
|
self._get_value = get_value
|
||||||
|
|
||||||
|
def update_set_value(self, set_value: Callable[[Union[float, str]], None]) -> None:
|
||||||
|
self._set_value = set_value
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class WakeWord1SensitivityNumberEntity(ESPHomeEntity):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
server: APIServer,
|
||||||
|
key: int,
|
||||||
|
name: str,
|
||||||
|
object_id: str,
|
||||||
|
get_sensitivity: Callable[[], float],
|
||||||
|
set_sensitivity: Callable[[float], None],
|
||||||
|
initial_value: float = 0.5,
|
||||||
|
) -> None:
|
||||||
|
ESPHomeEntity.__init__(self, server)
|
||||||
|
|
||||||
|
self.key = key
|
||||||
|
self.name = name
|
||||||
|
self.object_id = object_id
|
||||||
|
self._get_sensitivity = get_sensitivity
|
||||||
|
self._set_sensitivity = set_sensitivity
|
||||||
|
self.value = initial_value
|
||||||
|
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
|
||||||
|
|
||||||
|
def update_get_sensitivity(self, get_sensitivity: Callable[[], float]) -> None:
|
||||||
|
self._get_sensitivity = get_sensitivity
|
||||||
|
|
||||||
|
def update_set_sensitivity(self, set_sensitivity: Callable[[float], None]) -> None:
|
||||||
|
self._set_sensitivity = set_sensitivity
|
||||||
|
|
||||||
|
def sync_with_state(self) -> None:
|
||||||
|
old_value = self.value
|
||||||
|
self.value = self._get_sensitivity()
|
||||||
|
self._log.debug("Entity synchronized: old=%.3f new=%.3f", old_value, self.value)
|
||||||
|
|
||||||
|
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||||
|
if isinstance(msg, NumberCommandRequest) and (msg.key == self.key):
|
||||||
|
new_value = float(msg.state)
|
||||||
|
self._log.debug("Sensitivity value changed: %s => %s", self.value, new_value)
|
||||||
|
self.value = new_value
|
||||||
|
self._set_sensitivity(new_value)
|
||||||
|
yield NumberStateResponse(key=self.key, state=self.value)
|
||||||
|
elif isinstance(msg, ListEntitiesRequest):
|
||||||
|
yield ListEntitiesNumberResponse(
|
||||||
|
object_id=self.object_id,
|
||||||
|
key=self.key,
|
||||||
|
name=self.name,
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
min_value=0.0,
|
||||||
|
max_value=1.0,
|
||||||
|
step=0.001,
|
||||||
|
mode=NumberMode.BOX,
|
||||||
|
)
|
||||||
|
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
||||||
|
self.sync_with_state()
|
||||||
|
yield NumberStateResponse(key=self.key, state=self.value)
|
||||||
|
|
||||||
|
|
||||||
|
class WakeWord2SensitivityNumberEntity(ESPHomeEntity):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
server: APIServer,
|
||||||
|
key: int,
|
||||||
|
name: str,
|
||||||
|
object_id: str,
|
||||||
|
get_sensitivity: Callable[[], float],
|
||||||
|
set_sensitivity: Callable[[float], None],
|
||||||
|
initial_value: float = 0.5,
|
||||||
|
) -> None:
|
||||||
|
ESPHomeEntity.__init__(self, server)
|
||||||
|
|
||||||
|
self.key = key
|
||||||
|
self.name = name
|
||||||
|
self.object_id = object_id
|
||||||
|
self._get_sensitivity = get_sensitivity
|
||||||
|
self._set_sensitivity = set_sensitivity
|
||||||
|
self.value = initial_value
|
||||||
|
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
|
||||||
|
|
||||||
|
def update_get_sensitivity(self, get_sensitivity: Callable[[], float]) -> None:
|
||||||
|
self._get_sensitivity = get_sensitivity
|
||||||
|
|
||||||
|
def update_set_sensitivity(self, set_sensitivity: Callable[[float], None]) -> None:
|
||||||
|
self._set_sensitivity = set_sensitivity
|
||||||
|
|
||||||
|
def sync_with_state(self) -> None:
|
||||||
|
old_value = self.value
|
||||||
|
self.value = self._get_sensitivity()
|
||||||
|
self._log.debug("Entity synchronized: old=%.3f new=%.3f", old_value, self.value)
|
||||||
|
|
||||||
|
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||||
|
if isinstance(msg, NumberCommandRequest) and (msg.key == self.key):
|
||||||
|
new_value = float(msg.state)
|
||||||
|
self._log.debug("Second wake word sensitivity value changed: %s => %s", self.value, new_value)
|
||||||
|
self.value = new_value
|
||||||
|
self._set_sensitivity(new_value)
|
||||||
|
yield NumberStateResponse(key=self.key, state=self.value)
|
||||||
|
elif isinstance(msg, ListEntitiesRequest):
|
||||||
|
yield ListEntitiesNumberResponse(
|
||||||
|
object_id=self.object_id,
|
||||||
|
key=self.key,
|
||||||
|
name=self.name,
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
min_value=0.0,
|
||||||
|
max_value=1.0,
|
||||||
|
step=0.001,
|
||||||
|
mode=NumberMode.BOX,
|
||||||
|
)
|
||||||
|
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
||||||
|
self.sync_with_state()
|
||||||
|
yield NumberStateResponse(key=self.key, state=self.value)
|
||||||
|
|
||||||
|
|
||||||
|
class StopWordSensitivityNumberEntity(ESPHomeEntity):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
server: APIServer,
|
||||||
|
key: int,
|
||||||
|
name: str,
|
||||||
|
object_id: str,
|
||||||
|
get_sensitivity: Callable[[], float],
|
||||||
|
set_sensitivity: Callable[[float], None],
|
||||||
|
initial_value: float = 0.5,
|
||||||
|
) -> None:
|
||||||
|
ESPHomeEntity.__init__(self, server)
|
||||||
|
|
||||||
|
self.key = key
|
||||||
|
self.name = name
|
||||||
|
self.object_id = object_id
|
||||||
|
self._get_sensitivity = get_sensitivity
|
||||||
|
self._set_sensitivity = set_sensitivity
|
||||||
|
self.value = initial_value
|
||||||
|
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
|
||||||
|
|
||||||
|
def update_get_sensitivity(self, get_sensitivity: Callable[[], float]) -> None:
|
||||||
|
self._get_sensitivity = get_sensitivity
|
||||||
|
|
||||||
|
def update_set_sensitivity(self, set_sensitivity: Callable[[float], None]) -> None:
|
||||||
|
self._set_sensitivity = set_sensitivity
|
||||||
|
|
||||||
|
def sync_with_state(self) -> None:
|
||||||
|
old_value = self.value
|
||||||
|
self.value = self._get_sensitivity()
|
||||||
|
self._log.debug("Entity synchronized: old=%.3f new=%.3f", old_value, self.value)
|
||||||
|
|
||||||
|
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||||
|
if isinstance(msg, NumberCommandRequest) and (msg.key == self.key):
|
||||||
|
new_value = float(msg.state)
|
||||||
|
self._log.debug("Stop word sensitivity value changed: %s => %s", self.value, new_value)
|
||||||
|
self.value = new_value
|
||||||
|
self._set_sensitivity(new_value)
|
||||||
|
yield NumberStateResponse(key=self.key, state=self.value)
|
||||||
|
elif isinstance(msg, ListEntitiesRequest):
|
||||||
|
yield ListEntitiesNumberResponse(
|
||||||
|
object_id=self.object_id,
|
||||||
|
key=self.key,
|
||||||
|
name=self.name,
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
icon="mdi:hand-back-left",
|
||||||
|
min_value=0.0,
|
||||||
|
max_value=1.0,
|
||||||
|
step=0.001,
|
||||||
|
mode=NumberMode.BOX,
|
||||||
|
)
|
||||||
|
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
||||||
|
self.sync_with_state()
|
||||||
|
yield NumberStateResponse(key=self.key, state=self.value)
|
||||||
|
|
||||||
|
|
||||||
|
# Backward compatibility export aliases
|
||||||
|
__all__ = [
|
||||||
|
"ESPHomeEntity",
|
||||||
|
"MediaPlayerEntity",
|
||||||
|
"MuteSwitchEntity",
|
||||||
|
"ThinkingSoundEntity",
|
||||||
|
"WakeWord1SensitivityNumberEntity",
|
||||||
|
"WakeWord2SensitivityNumberEntity",
|
||||||
|
"StopWordSensitivityNumberEntity",
|
||||||
|
# Old class names for backward compatibility
|
||||||
|
"WakeWordSensitivityNumberEntity",
|
||||||
|
"SecondWakeWordSensitivityNumberEntity",
|
||||||
|
]
|
||||||
|
|
||||||
|
WakeWordSensitivityNumberEntity = WakeWord1SensitivityNumberEntity
|
||||||
|
SecondWakeWordSensitivityNumberEntity = WakeWord2SensitivityNumberEntity
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
@@ -15,8 +15,12 @@ if TYPE_CHECKING:
|
|||||||
from .entity import (
|
from .entity import (
|
||||||
ESPHomeEntity,
|
ESPHomeEntity,
|
||||||
MediaPlayerEntity,
|
MediaPlayerEntity,
|
||||||
|
MicSettingEntity,
|
||||||
MuteSwitchEntity,
|
MuteSwitchEntity,
|
||||||
|
StopWordSensitivityNumberEntity,
|
||||||
ThinkingSoundEntity,
|
ThinkingSoundEntity,
|
||||||
|
WakeWord1SensitivityNumberEntity,
|
||||||
|
WakeWord2SensitivityNumberEntity,
|
||||||
)
|
)
|
||||||
from .mpv_player import MpvMediaPlayer
|
from .mpv_player import MpvMediaPlayer
|
||||||
from .satellite import VoiceSatelliteProtocol
|
from .satellite import VoiceSatelliteProtocol
|
||||||
@@ -36,6 +40,7 @@ class AvailableWakeWord:
|
|||||||
wake_word: str
|
wake_word: str
|
||||||
trained_languages: List[str]
|
trained_languages: List[str]
|
||||||
wake_word_path: Path
|
wake_word_path: Path
|
||||||
|
probability_cutoff: float = 0.7
|
||||||
|
|
||||||
def load(self) -> "Union[MicroWakeWord, OpenWakeWord]":
|
def load(self) -> "Union[MicroWakeWord, OpenWakeWord]":
|
||||||
if self.type == WakeWordType.MICRO_WAKE_WORD:
|
if self.type == WakeWordType.MICRO_WAKE_WORD:
|
||||||
@@ -56,16 +61,27 @@ class AvailableWakeWord:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Preferences:
|
class Preferences:
|
||||||
active_wake_words: List[str] = field(default_factory=list)
|
active_wake_words: List[Optional[str]] = field(default_factory=list)
|
||||||
|
volume: Optional[float] = None
|
||||||
thinking_sound: int = 0 # 0 = disabled, 1 = enabled
|
thinking_sound: int = 0 # 0 = disabled, 1 = enabled
|
||||||
|
wake_word_1_sensitivity: Optional[float] = None
|
||||||
|
wake_word_2_sensitivity: Optional[float] = None
|
||||||
|
stop_word_sensitivity: Optional[float] = None
|
||||||
|
|
||||||
|
mic_auto_gain: int = 0
|
||||||
|
mic_noise_suppression: int = 0
|
||||||
|
mic_volume: int = 100 # 1–100, default maximum
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ServerState:
|
class ServerState:
|
||||||
name: str
|
name: str
|
||||||
|
friendly_name: str
|
||||||
mac_address: str
|
mac_address: str
|
||||||
ip_address: str
|
ip_address: str
|
||||||
network_interface: str
|
network_interface: str
|
||||||
|
version: str
|
||||||
|
esphome_version: str
|
||||||
audio_queue: "Queue[Optional[bytes]]"
|
audio_queue: "Queue[Optional[bytes]]"
|
||||||
entities: "List[ESPHomeEntity]"
|
entities: "List[ESPHomeEntity]"
|
||||||
available_wake_words: "Dict[str, AvailableWakeWord]"
|
available_wake_words: "Dict[str, AvailableWakeWord]"
|
||||||
@@ -87,11 +103,29 @@ class ServerState:
|
|||||||
satellite: "Optional[VoiceSatelliteProtocol]" = None
|
satellite: "Optional[VoiceSatelliteProtocol]" = None
|
||||||
mute_switch_entity: "Optional[MuteSwitchEntity]" = None
|
mute_switch_entity: "Optional[MuteSwitchEntity]" = None
|
||||||
thinking_sound_entity: "Optional[ThinkingSoundEntity]" = None
|
thinking_sound_entity: "Optional[ThinkingSoundEntity]" = None
|
||||||
|
sensitivity_1_number_entity: "Optional[WakeWord1SensitivityNumberEntity]" = None
|
||||||
|
sensitivity_2_number_entity: "Optional[WakeWord2SensitivityNumberEntity]" = None
|
||||||
|
stop_sensitivity_number_entity: "Optional[StopWordSensitivityNumberEntity]" = None
|
||||||
|
mic_gain_entity: "Optional[MicSettingEntity]" = None
|
||||||
|
mic_noise_suppression_entity: "Optional[MicSettingEntity]" = None
|
||||||
|
mic_volume_entity: "Optional[MicSettingEntity]" = None
|
||||||
wake_words_changed: bool = False
|
wake_words_changed: bool = False
|
||||||
refractory_seconds: float = 2.0
|
refractory_seconds: float = 2.0
|
||||||
thinking_sound_enabled: bool = False
|
thinking_sound_enabled: bool = False
|
||||||
|
output_only: bool = False
|
||||||
muted: bool = False
|
muted: bool = False
|
||||||
connected: bool = False
|
connected: bool = False
|
||||||
|
volume: float = 1.0
|
||||||
|
oww_probability_cutoff: float = 0.7 # Dynamic threshold for OpenWakeWord
|
||||||
|
oww_second_probability_cutoff: float = 0.7 # Dynamic threshold for second OpenWakeWord
|
||||||
|
oww_stop_probability_cutoff: float = 0.5 # Dynamic threshold for Stop word
|
||||||
|
wake_word_1_threshold: float = 0.7
|
||||||
|
wake_word_2_threshold: float = 0.7
|
||||||
|
stop_word_threshold: float = 0.5
|
||||||
|
mic_auto_gain: int = 0
|
||||||
|
mic_noise_suppression: int = 0
|
||||||
|
mic_volume: int = 100 # 1–100, default maximum
|
||||||
|
timer_max_ring_seconds: float = 900.0
|
||||||
|
|
||||||
def save_preferences(self) -> None:
|
def save_preferences(self) -> None:
|
||||||
"""Save preferences as JSON."""
|
"""Save preferences as JSON."""
|
||||||
@@ -99,5 +133,59 @@ class ServerState:
|
|||||||
self.preferences_path.parent.mkdir(parents=True, exist_ok=True)
|
self.preferences_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(self.preferences_path, "w", encoding="utf-8") as preferences_file:
|
with open(self.preferences_path, "w", encoding="utf-8") as preferences_file:
|
||||||
json.dump(
|
json.dump(
|
||||||
asdict(self.preferences), preferences_file, ensure_ascii=False, indent=4
|
asdict(self.preferences),
|
||||||
|
preferences_file,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=4,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def persist_volume(self, volume: float) -> None:
|
||||||
|
"""Persist the normalized media volume (0.0 - 1.0)."""
|
||||||
|
clamped_volume = max(0.0, min(1.0, volume))
|
||||||
|
_LOGGER.debug(
|
||||||
|
"persist_volume called: new=%s, current=%s, prefs=%s",
|
||||||
|
clamped_volume,
|
||||||
|
self.volume,
|
||||||
|
self.preferences.volume,
|
||||||
|
)
|
||||||
|
|
||||||
|
if abs(self.volume - clamped_volume) < 0.0001 and self.preferences.volume is not None and abs(self.preferences.volume - clamped_volume) < 0.0001:
|
||||||
|
_LOGGER.debug("Skipping save - volume unchanged")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.volume = clamped_volume
|
||||||
|
self.preferences.volume = clamped_volume
|
||||||
|
_LOGGER.info("Saving volume %s to %s", clamped_volume, self.preferences_path)
|
||||||
|
self.save_preferences()
|
||||||
|
_LOGGER.info("Volume saved successfully")
|
||||||
|
|
||||||
|
def persist_mic_gain(self, gain: float) -> None:
|
||||||
|
"""Persist the microphone auto gain value."""
|
||||||
|
gain_int = int(gain)
|
||||||
|
if self.mic_auto_gain == gain_int and self.preferences.mic_auto_gain == gain_int:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.mic_auto_gain = gain_int
|
||||||
|
self.preferences.mic_auto_gain = gain_int
|
||||||
|
self.save_preferences()
|
||||||
|
|
||||||
|
def persist_mic_noise(self, noise: float) -> None:
|
||||||
|
"""Persist the microphone noise suppression value."""
|
||||||
|
noise_int = int(noise)
|
||||||
|
if self.mic_noise_suppression == noise_int and self.preferences.mic_noise_suppression == noise_int:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.mic_noise_suppression = noise_int
|
||||||
|
self.preferences.mic_noise_suppression = noise_int
|
||||||
|
self.save_preferences()
|
||||||
|
|
||||||
|
def persist_mic_volume(self, volume: float) -> None:
|
||||||
|
"""Persist the microphone input volume (0–100)."""
|
||||||
|
volume_int = max(1, min(100, int(round(volume))))
|
||||||
|
if self.mic_volume == volume_int and self.preferences.mic_volume == volume_int:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.mic_volume = volume_int
|
||||||
|
self.preferences.mic_volume = volume_int
|
||||||
|
_LOGGER.info("Saving mic_volume %s to %s", volume_int, self.preferences_path)
|
||||||
|
self.save_preferences()
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
# mpv_player.py
|
# mpv_player.py
|
||||||
import logging
|
import logging
|
||||||
from typing import Union, List, Callable, Optional
|
from typing import Callable, List, Optional, Union
|
||||||
|
|
||||||
from .player.libmpv import LibMpvPlayer
|
from .player.libmpv import LibMpvPlayer
|
||||||
|
from .player.state import PlayerState
|
||||||
|
|
||||||
|
|
||||||
class MpvMediaPlayer:
|
class MpvMediaPlayer:
|
||||||
@@ -17,6 +18,7 @@ class MpvMediaPlayer:
|
|||||||
self._log = logging.getLogger(self.__class__.__name__)
|
self._log = logging.getLogger(self.__class__.__name__)
|
||||||
self._player = LibMpvPlayer(device=device)
|
self._player = LibMpvPlayer(device=device)
|
||||||
self._done_callback: Optional[Callable[[], None]] = None
|
self._done_callback: Optional[Callable[[], None]] = None
|
||||||
|
self._playlist: List[str] = []
|
||||||
|
|
||||||
self._log.debug("MpvMediaPlayer initialized (device=%s)", device)
|
self._log.debug("MpvMediaPlayer initialized (device=%s)", device)
|
||||||
|
|
||||||
@@ -30,34 +32,55 @@ class MpvMediaPlayer:
|
|||||||
Play a media URL.
|
Play a media URL.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: Media URL or list of URLs (LVA currently uses a single URL).
|
url: Media URL or list of URLs for sequential playback.
|
||||||
done_callback: Optional callback invoked when playback finishes.
|
done_callback: Optional callback invoked when playback finishes.
|
||||||
stop_first: Kept for API compatibility.
|
stop_first: Kept for API compatibility.
|
||||||
"""
|
"""
|
||||||
# LVA currently only uses single URLs
|
# Handle single URL vs list
|
||||||
if isinstance(url, list):
|
if isinstance(url, str):
|
||||||
self._log.debug("Received URL list, using first entry")
|
urls = [url]
|
||||||
url = url[0]
|
else:
|
||||||
|
urls = list(url) # Copy the list
|
||||||
|
|
||||||
# Track is changing
|
if not urls:
|
||||||
|
self._log.warning("play() called with empty URL list")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Track is changing - stop if needed
|
||||||
if self._done_callback is not None:
|
if self._done_callback is not None:
|
||||||
self._log.debug(
|
if self._player.state() != PlayerState.IDLE:
|
||||||
"Stopping active playback before starting new media"
|
self._log.debug("Stopping active playback before starting new media")
|
||||||
)
|
|
||||||
# Not self.stop() → this would call done_callback
|
|
||||||
self._player.stop(for_replacement=True)
|
self._player.stop(for_replacement=True)
|
||||||
self._done_callback = None
|
self._done_callback = None
|
||||||
|
|
||||||
self._log.info("Playing media: %s", url)
|
self._log.info("Playing %d URL(s): %s", len(urls), urls[0])
|
||||||
self._log.debug(
|
|
||||||
"play(url=%s, stop_first=%s, done_callback=%s)",
|
|
||||||
url,
|
|
||||||
stop_first,
|
|
||||||
bool(done_callback),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# Store playlist and callback
|
||||||
|
self._playlist = urls
|
||||||
self._done_callback = done_callback
|
self._done_callback = done_callback
|
||||||
self._player.play(url, done_callback=done_callback, stop_first=stop_first)
|
|
||||||
|
# Start playing first URL
|
||||||
|
next_url = self._playlist.pop(0)
|
||||||
|
self._player.play(next_url, done_callback=self._on_track_finished, stop_first=stop_first)
|
||||||
|
|
||||||
|
def _on_track_finished(self) -> None:
|
||||||
|
"""Called when a track finishes - plays next or invokes done callback."""
|
||||||
|
if self._playlist:
|
||||||
|
# More tracks to play
|
||||||
|
next_url = self._playlist.pop(0)
|
||||||
|
self._log.debug("Playing next URL from playlist: %s", next_url)
|
||||||
|
self._player.play(next_url, done_callback=self._on_track_finished, stop_first=False)
|
||||||
|
else:
|
||||||
|
# Playlist finished
|
||||||
|
callback = self._done_callback
|
||||||
|
self._done_callback = None
|
||||||
|
|
||||||
|
if callback:
|
||||||
|
self._log.debug("Playlist finished, invoking done_callback")
|
||||||
|
try:
|
||||||
|
callback()
|
||||||
|
except Exception as e:
|
||||||
|
self._log.exception("Error in done_callback: %s", e)
|
||||||
|
|
||||||
def pause(self) -> None:
|
def pause(self) -> None:
|
||||||
"""Pause playback."""
|
"""Pause playback."""
|
||||||
@@ -82,12 +105,18 @@ class MpvMediaPlayer:
|
|||||||
finally:
|
finally:
|
||||||
self._done_callback = None
|
self._done_callback = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_playing(self) -> bool:
|
||||||
|
"""Check if the player is currently playing or paused."""
|
||||||
|
state = self._player.state()
|
||||||
|
return state in (PlayerState.PLAYING, PlayerState.PAUSED, PlayerState.LOADING)
|
||||||
|
|
||||||
def set_volume(self, volume: float) -> None:
|
def set_volume(self, volume: float) -> None:
|
||||||
"""
|
"""
|
||||||
Set playback volume.
|
Set playback volume.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
volume: Volume in percent (0.0–100.0).
|
volume: Volume in percent (0.0-100.0).
|
||||||
"""
|
"""
|
||||||
self._log.debug("set_volume(volume=%.2f)", volume)
|
self._log.debug("set_volume(volume=%.2f)", volume)
|
||||||
self._player.set_volume(volume)
|
self._player.set_volume(volume)
|
||||||
@@ -97,7 +126,7 @@ class MpvMediaPlayer:
|
|||||||
Temporarily reduce volume.
|
Temporarily reduce volume.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
factor: Volume multiplier (0.0–1.0).
|
factor: Volume multiplier (0.0-1.0).
|
||||||
"""
|
"""
|
||||||
self._log.debug("duck(factor=%.2f)", factor)
|
self._log.debug("duck(factor=%.2f)", factor)
|
||||||
self._player.duck(factor)
|
self._player.duck(factor)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from .state import PlayerState
|
|
||||||
|
from linux_voice_assistant.player.state import PlayerState
|
||||||
|
|
||||||
|
|
||||||
class AudioPlayer(ABC):
|
class AudioPlayer(ABC):
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from typing import Optional, Callable
|
from typing import Callable, Optional
|
||||||
|
|
||||||
import mpv
|
import mpv
|
||||||
|
|
||||||
from .base import AudioPlayer
|
from linux_voice_assistant.player.base import AudioPlayer
|
||||||
from .state import PlayerState
|
from linux_voice_assistant.player.state import PlayerState
|
||||||
|
|
||||||
|
|
||||||
class LibMpvPlayer(AudioPlayer):
|
class LibMpvPlayer(AudioPlayer):
|
||||||
@@ -18,6 +19,7 @@ class LibMpvPlayer(AudioPlayer):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, device: Optional[str] = None) -> None:
|
def __init__(self, device: Optional[str] = None) -> None:
|
||||||
|
self._log = logging.getLogger(self.__class__.__name__)
|
||||||
self._state: PlayerState = PlayerState.IDLE
|
self._state: PlayerState = PlayerState.IDLE
|
||||||
self._state_lock = threading.Lock()
|
self._state_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -35,10 +37,25 @@ class LibMpvPlayer(AudioPlayer):
|
|||||||
if device:
|
if device:
|
||||||
self._mpv["audio-device"] = device
|
self._mpv["audio-device"] = device
|
||||||
|
|
||||||
|
# Pre-buffer audio before the sink starts clocking samples out.
|
||||||
|
# The default (0.2 s) is too tight for short notification sounds on
|
||||||
|
# PulseAudio/PipeWire: the sink stream takes a few ms to initialise
|
||||||
|
# and the very first samples are dropped before it is ready, making
|
||||||
|
# short files (<1 s) appear to start mid-way through.
|
||||||
|
# 0.8 s gives the output pipeline enough headroom without adding any
|
||||||
|
# noticeable latency for a user-facing notification sound.
|
||||||
|
self._mpv["audio-buffer"] = 0.8
|
||||||
|
|
||||||
|
# Keep the PulseAudio/PipeWire stream open between files by outputting
|
||||||
|
# silence when idle. This eliminates the per-play sink re-initialisation
|
||||||
|
# penalty entirely, so back-to-back short sounds (wakeup → TTS, mute →
|
||||||
|
# unmute) never lose their first samples regardless of system load.
|
||||||
|
self._mpv["audio-stream-silence"] = True
|
||||||
|
|
||||||
# Callback Handling
|
# Callback Handling
|
||||||
self._done_callback: Optional[Callable[[], None]] = None
|
self._done_callback: Optional[Callable[[], None]] = None
|
||||||
self._suppress_end_event = False
|
|
||||||
self._mpv.event_callback("end-file")(self._on_end_file)
|
self._mpv.event_callback("end-file")(self._on_end_file)
|
||||||
|
self._mpv.event_callback("start-file")(self._on_start_file)
|
||||||
|
|
||||||
# -------- Playback control --------
|
# -------- Playback control --------
|
||||||
|
|
||||||
@@ -56,10 +73,11 @@ class LibMpvPlayer(AudioPlayer):
|
|||||||
done_callback: Optional callback invoked when playback finishes.
|
done_callback: Optional callback invoked when playback finishes.
|
||||||
stop_first: If True, start playback in paused state.
|
stop_first: If True, start playback in paused state.
|
||||||
"""
|
"""
|
||||||
self._done_callback = done_callback
|
|
||||||
self._mpv.pause = stop_first
|
|
||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
|
self._log.debug("play: current_state=%s", self._state)
|
||||||
|
self._done_callback = done_callback
|
||||||
self._set_state(PlayerState.LOADING)
|
self._set_state(PlayerState.LOADING)
|
||||||
|
self._mpv.pause = stop_first
|
||||||
self._mpv.play(url)
|
self._mpv.play(url)
|
||||||
|
|
||||||
def pause(self) -> None:
|
def pause(self) -> None:
|
||||||
@@ -70,6 +88,7 @@ class LibMpvPlayer(AudioPlayer):
|
|||||||
|
|
||||||
def resume(self) -> None:
|
def resume(self) -> None:
|
||||||
"""Resume playback if paused."""
|
"""Resume playback if paused."""
|
||||||
|
self._log.debug("unduck() called")
|
||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
self._mpv.pause = False
|
self._mpv.pause = False
|
||||||
self._set_state(PlayerState.PLAYING)
|
self._set_state(PlayerState.PLAYING)
|
||||||
@@ -78,13 +97,14 @@ class LibMpvPlayer(AudioPlayer):
|
|||||||
"""
|
"""
|
||||||
Stop playback.
|
Stop playback.
|
||||||
|
|
||||||
If called for track replacement, suppresses end-of-playback handling
|
If called for track replacement, clears the callback to prevent
|
||||||
(state transition to IDLE and done callback) to allow seamless
|
it from being invoked during the transition.
|
||||||
playback transitions.
|
|
||||||
"""
|
"""
|
||||||
|
self._log.debug("unduck() called")
|
||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
if for_replacement:
|
if for_replacement:
|
||||||
self._suppress_end_event = True
|
# Clear callback to prevent invocation during track transition
|
||||||
|
self._done_callback = None
|
||||||
self._mpv.stop()
|
self._mpv.stop()
|
||||||
|
|
||||||
def state(self) -> PlayerState:
|
def state(self) -> PlayerState:
|
||||||
@@ -101,6 +121,7 @@ class LibMpvPlayer(AudioPlayer):
|
|||||||
Args:
|
Args:
|
||||||
volume: Volume level (0.0–100.0).
|
volume: Volume level (0.0–100.0).
|
||||||
"""
|
"""
|
||||||
|
self._log.debug("unduck() called")
|
||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
self._user_volume = max(0.0, min(100.0, float(volume)))
|
self._user_volume = max(0.0, min(100.0, float(volume)))
|
||||||
self._apply_volume()
|
self._apply_volume()
|
||||||
@@ -112,12 +133,14 @@ class LibMpvPlayer(AudioPlayer):
|
|||||||
Args:
|
Args:
|
||||||
factor: Ducking factor (0.0–1.0).
|
factor: Ducking factor (0.0–1.0).
|
||||||
"""
|
"""
|
||||||
|
self._log.debug("unduck() called")
|
||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
self._duck_factor = max(0.0, min(1.0, float(factor)))
|
self._duck_factor = max(0.0, min(1.0, float(factor)))
|
||||||
self._apply_volume()
|
self._apply_volume()
|
||||||
|
|
||||||
def unduck(self) -> None:
|
def unduck(self) -> None:
|
||||||
"""Restore volume to the user-defined level."""
|
"""Restore volume to the user-defined level."""
|
||||||
|
self._log.debug("unduck() called")
|
||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
self._duck_factor = 1.0
|
self._duck_factor = 1.0
|
||||||
self._apply_volume()
|
self._apply_volume()
|
||||||
@@ -126,6 +149,7 @@ class LibMpvPlayer(AudioPlayer):
|
|||||||
|
|
||||||
def _apply_volume(self) -> None:
|
def _apply_volume(self) -> None:
|
||||||
"""Apply effective volume (user volume × duck factor) to mpv."""
|
"""Apply effective volume (user volume × duck factor) to mpv."""
|
||||||
|
self._log.debug("unduck() called")
|
||||||
effective = self._user_volume * self._duck_factor
|
effective = self._user_volume * self._duck_factor
|
||||||
self._mpv.volume = max(0.0, min(100.0, effective))
|
self._mpv.volume = max(0.0, min(100.0, effective))
|
||||||
|
|
||||||
@@ -133,8 +157,27 @@ class LibMpvPlayer(AudioPlayer):
|
|||||||
callback: Optional[Callable[[], None]] = None
|
callback: Optional[Callable[[], None]] = None
|
||||||
|
|
||||||
with self._state_lock:
|
with self._state_lock:
|
||||||
if self._suppress_end_event:
|
# mpv events: event.data is a MpvEventEndFile object with a 'reason' attribute
|
||||||
self._suppress_end_event = False
|
# The reason is an integer constant (see mpv.END_FILE_REASON_*)
|
||||||
|
end_file_data = event.data
|
||||||
|
reason = getattr(end_file_data, "reason", -1) if end_file_data else -1
|
||||||
|
|
||||||
|
# mpv END_FILE_REASON constants:
|
||||||
|
# 0 = eof (end of file), 1 = stop, 2 = abort, 3 = quit, 4 = error
|
||||||
|
is_eof = reason == 0
|
||||||
|
|
||||||
|
self._log.debug(
|
||||||
|
"_on_end_file: reason=%s (is_eof=%s), state=%s, has_callback=%s",
|
||||||
|
reason,
|
||||||
|
is_eof,
|
||||||
|
self._state,
|
||||||
|
self._done_callback is not None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only process "eof" (reason=0) events as actual track completion.
|
||||||
|
# Other reasons are from track changes, stops, or errors.
|
||||||
|
if not is_eof:
|
||||||
|
self._log.debug("_on_end_file: ignoring non-eof event (reason=%s)", reason)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._set_state(PlayerState.IDLE)
|
self._set_state(PlayerState.IDLE)
|
||||||
@@ -142,12 +185,20 @@ class LibMpvPlayer(AudioPlayer):
|
|||||||
self._done_callback = None
|
self._done_callback = None
|
||||||
|
|
||||||
if callback is not None:
|
if callback is not None:
|
||||||
|
self._log.debug("_on_end_file: invoking callback")
|
||||||
try:
|
try:
|
||||||
callback()
|
callback()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# Callback errors must never break the player
|
# Callback errors must never break the player
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _on_start_file(self, event) -> None:
|
||||||
|
"""Called when mpv starts playing a file."""
|
||||||
|
self._log.debug("unduck() called")
|
||||||
|
with self._state_lock:
|
||||||
|
self._log.debug("_on_start_file: state=%s", self._state)
|
||||||
|
self._set_state(PlayerState.PLAYING)
|
||||||
|
|
||||||
def _on_mpv_log(self, level: str, prefix: str, text: str) -> None:
|
def _on_mpv_log(self, level: str, prefix: str, text: str) -> None:
|
||||||
"""
|
"""
|
||||||
Handle mpv log messages.
|
Handle mpv log messages.
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import asyncio
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import posixpath
|
import posixpath
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from typing import Dict, Optional, Set, Union
|
from typing import Dict, List, Optional, Set, Union
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
from urllib.request import urlopen
|
from urllib.request import urlopen
|
||||||
|
|
||||||
@@ -20,6 +20,8 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
|||||||
ListEntitiesDoneResponse,
|
ListEntitiesDoneResponse,
|
||||||
ListEntitiesRequest,
|
ListEntitiesRequest,
|
||||||
MediaPlayerCommandRequest,
|
MediaPlayerCommandRequest,
|
||||||
|
NumberCommandRequest,
|
||||||
|
SelectCommandRequest,
|
||||||
SubscribeHomeAssistantStatesRequest,
|
SubscribeHomeAssistantStatesRequest,
|
||||||
SwitchCommandRequest,
|
SwitchCommandRequest,
|
||||||
VoiceAssistantAnnounceFinished,
|
VoiceAssistantAnnounceFinished,
|
||||||
@@ -45,7 +47,15 @@ from pymicro_wakeword import MicroWakeWord
|
|||||||
from pyopen_wakeword import OpenWakeWord
|
from pyopen_wakeword import OpenWakeWord
|
||||||
|
|
||||||
from .api_server import APIServer
|
from .api_server import APIServer
|
||||||
from .entity import MediaPlayerEntity, MuteSwitchEntity, ThinkingSoundEntity
|
from .entity import (
|
||||||
|
MediaPlayerEntity,
|
||||||
|
MicSettingEntity,
|
||||||
|
MuteSwitchEntity,
|
||||||
|
StopWordSensitivityNumberEntity,
|
||||||
|
ThinkingSoundEntity,
|
||||||
|
WakeWord1SensitivityNumberEntity,
|
||||||
|
WakeWord2SensitivityNumberEntity,
|
||||||
|
)
|
||||||
from .models import AvailableWakeWord, ServerState, WakeWordType
|
from .models import AvailableWakeWord, ServerState, WakeWordType
|
||||||
from .util import call_all
|
from .util import call_all
|
||||||
|
|
||||||
@@ -63,25 +73,28 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
self.state.satellite = self
|
self.state.satellite = self
|
||||||
self.state.connected = False
|
self.state.connected = False
|
||||||
|
|
||||||
existing_media_players = [
|
# Report capabilities appropriately
|
||||||
entity
|
if state.output_only:
|
||||||
for entity in self.state.entities
|
_LOGGER.debug("Output only features")
|
||||||
if isinstance(entity, MediaPlayerEntity)
|
self.supported_features = VoiceAssistantFeature.API_AUDIO | VoiceAssistantFeature.ANNOUNCE
|
||||||
]
|
else:
|
||||||
|
_LOGGER.debug("Voice assistant features")
|
||||||
|
self.supported_features = (
|
||||||
|
VoiceAssistantFeature.VOICE_ASSISTANT | VoiceAssistantFeature.API_AUDIO | VoiceAssistantFeature.ANNOUNCE | VoiceAssistantFeature.START_CONVERSATION | VoiceAssistantFeature.TIMERS
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_media_players = [entity for entity in self.state.entities if isinstance(entity, MediaPlayerEntity)]
|
||||||
|
|
||||||
if existing_media_players:
|
if existing_media_players:
|
||||||
# Keep the first instance and remove any extras.
|
# Keep the first instance and remove any extras.
|
||||||
self.state.media_player_entity = existing_media_players[0]
|
self.state.media_player_entity = existing_media_players[0]
|
||||||
for extra in existing_media_players[1:]:
|
for extra in existing_media_players[1:]:
|
||||||
self.state.entities.remove(extra)
|
self.state.entities.remove(extra)
|
||||||
|
|
||||||
existing_mute_switches = [
|
existing_mute_switches = [entity for entity in self.state.entities if isinstance(entity, MuteSwitchEntity)]
|
||||||
entity
|
|
||||||
for entity in self.state.entities
|
|
||||||
if isinstance(entity, MuteSwitchEntity)
|
|
||||||
]
|
|
||||||
if existing_mute_switches:
|
if existing_mute_switches:
|
||||||
self.state.mute_switch_entity = existing_mute_switches[0]
|
self.state.mute_switch_entity = existing_mute_switches[0] # type: ignore
|
||||||
for extra in existing_mute_switches[1:]:
|
for extra in existing_mute_switches[1:]: # type: ignore
|
||||||
self.state.entities.remove(extra)
|
self.state.entities.remove(extra)
|
||||||
|
|
||||||
if self.state.media_player_entity is None:
|
if self.state.media_player_entity is None:
|
||||||
@@ -92,12 +105,15 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
object_id="linux_voice_assistant_media_player",
|
object_id="linux_voice_assistant_media_player",
|
||||||
music_player=state.music_player,
|
music_player=state.music_player,
|
||||||
announce_player=state.tts_player,
|
announce_player=state.tts_player,
|
||||||
|
initial_volume=state.volume,
|
||||||
)
|
)
|
||||||
self.state.entities.append(self.state.media_player_entity)
|
self.state.entities.append(self.state.media_player_entity)
|
||||||
elif self.state.media_player_entity not in self.state.entities:
|
elif self.state.media_player_entity not in self.state.entities:
|
||||||
self.state.entities.append(self.state.media_player_entity)
|
self.state.entities.append(self.state.media_player_entity)
|
||||||
|
|
||||||
self.state.media_player_entity.server = self
|
self.state.media_player_entity.server = self
|
||||||
|
self.state.media_player_entity.volume = state.volume
|
||||||
|
self.state.media_player_entity.previous_volume = state.volume
|
||||||
|
|
||||||
# Add/update mute switch entity (like ESPHome Voice PE)
|
# Add/update mute switch entity (like ESPHome Voice PE)
|
||||||
mute_switch = self.state.mute_switch_entity
|
mute_switch = self.state.mute_switch_entity
|
||||||
@@ -120,14 +136,10 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
mute_switch.update_set_muted(self._set_muted)
|
mute_switch.update_set_muted(self._set_muted)
|
||||||
mute_switch.sync_with_state()
|
mute_switch.sync_with_state()
|
||||||
|
|
||||||
existing_thinking_sound_switches = [
|
existing_thinking_sound_switches = [entity for entity in self.state.entities if isinstance(entity, ThinkingSoundEntity)]
|
||||||
entity
|
|
||||||
for entity in self.state.entities
|
|
||||||
if isinstance(entity, ThinkingSoundEntity)
|
|
||||||
]
|
|
||||||
if existing_thinking_sound_switches:
|
if existing_thinking_sound_switches:
|
||||||
self.state.thinking_sound_entity = existing_thinking_sound_switches[0]
|
self.state.thinking_sound_entity = existing_thinking_sound_switches[0] # type: ignore
|
||||||
for extra in existing_thinking_sound_switches[1:]:
|
for extra in existing_thinking_sound_switches[1:]: # type: ignore
|
||||||
self.state.entities.remove(extra)
|
self.state.entities.remove(extra)
|
||||||
|
|
||||||
# Add/update thinking sound entity
|
# Add/update thinking sound entity
|
||||||
@@ -147,29 +159,167 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
self.state.entities.append(thinking_sound_switch)
|
self.state.entities.append(thinking_sound_switch)
|
||||||
|
|
||||||
# Load thinking sound enabled state from preferences (default to False if not set or unknown)
|
# Load thinking sound enabled state from preferences (default to False if not set or unknown)
|
||||||
if hasattr(
|
if hasattr(self.state.preferences, "thinking_sound") and self.state.preferences.thinking_sound in (0, 1):
|
||||||
self.state.preferences, "thinking_sound"
|
self.state.thinking_sound_enabled = bool(self.state.preferences.thinking_sound)
|
||||||
) and self.state.preferences.thinking_sound in (0, 1):
|
|
||||||
self.state.thinking_sound_enabled = bool(
|
|
||||||
self.state.preferences.thinking_sound
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.state.thinking_sound_enabled = False
|
self.state.thinking_sound_enabled = False
|
||||||
|
|
||||||
thinking_sound_switch.server = self
|
thinking_sound_switch.server = self
|
||||||
thinking_sound_switch.update_get_thinking_sound_enabled(
|
thinking_sound_switch.update_get_thinking_sound_enabled(lambda: self.state.thinking_sound_enabled)
|
||||||
lambda: self.state.thinking_sound_enabled
|
thinking_sound_switch.update_set_thinking_sound_enabled(self._set_thinking_sound_enabled)
|
||||||
)
|
|
||||||
thinking_sound_switch.update_set_thinking_sound_enabled(
|
|
||||||
self._set_thinking_sound_enabled
|
|
||||||
)
|
|
||||||
thinking_sound_switch.sync_with_state()
|
thinking_sound_switch.sync_with_state()
|
||||||
|
|
||||||
|
# Add/update Wake Word 1 sensitivity number entity
|
||||||
|
sensitivity_1_entity = self.state.sensitivity_1_number_entity
|
||||||
|
if sensitivity_1_entity is None:
|
||||||
|
sensitivity_1_entity = WakeWord1SensitivityNumberEntity(
|
||||||
|
server=self,
|
||||||
|
key=len(state.entities),
|
||||||
|
name="Wake Word 1 Sensitivity",
|
||||||
|
object_id="wake_word_1_sensitivity",
|
||||||
|
get_sensitivity=lambda: self.state.wake_word_1_threshold,
|
||||||
|
set_sensitivity=self._set_sensitivity_1,
|
||||||
|
initial_value=self.state.wake_word_1_threshold,
|
||||||
|
)
|
||||||
|
self.state.entities.append(sensitivity_1_entity)
|
||||||
|
self.state.sensitivity_1_number_entity = sensitivity_1_entity
|
||||||
|
elif sensitivity_1_entity not in self.state.entities:
|
||||||
|
self.state.entities.append(sensitivity_1_entity)
|
||||||
|
|
||||||
|
sensitivity_1_entity.server = self
|
||||||
|
sensitivity_1_entity.update_get_sensitivity(lambda: self.state.wake_word_1_threshold)
|
||||||
|
sensitivity_1_entity.update_set_sensitivity(self._set_sensitivity_1)
|
||||||
|
|
||||||
|
sensitivity_1_entity.sync_with_state()
|
||||||
|
_LOGGER.debug("INIT: Wake Word 1 entity initialized with value %.3f", sensitivity_1_entity.value)
|
||||||
|
|
||||||
|
# Add/update Wake Word 2 sensitivity number entity
|
||||||
|
sensitivity_2_entity = self.state.sensitivity_2_number_entity
|
||||||
|
if sensitivity_2_entity is None:
|
||||||
|
sensitivity_2_entity = WakeWord2SensitivityNumberEntity(
|
||||||
|
server=self,
|
||||||
|
key=len(state.entities),
|
||||||
|
name="Wake Word 2 Sensitivity",
|
||||||
|
object_id="wake_word_2_sensitivity",
|
||||||
|
get_sensitivity=lambda: self.state.wake_word_2_threshold,
|
||||||
|
set_sensitivity=self._set_sensitivity_2,
|
||||||
|
initial_value=self.state.wake_word_2_threshold,
|
||||||
|
)
|
||||||
|
self.state.entities.append(sensitivity_2_entity)
|
||||||
|
self.state.sensitivity_2_number_entity = sensitivity_2_entity
|
||||||
|
elif sensitivity_2_entity not in self.state.entities:
|
||||||
|
self.state.entities.append(sensitivity_2_entity)
|
||||||
|
|
||||||
|
sensitivity_2_entity.server = self
|
||||||
|
sensitivity_2_entity.update_get_sensitivity(lambda: self.state.wake_word_2_threshold)
|
||||||
|
sensitivity_2_entity.update_set_sensitivity(self._set_sensitivity_2)
|
||||||
|
|
||||||
|
sensitivity_2_entity.sync_with_state()
|
||||||
|
|
||||||
|
# Add/update Stop Word sensitivity number entity
|
||||||
|
stop_sensitivity_entity = self.state.stop_sensitivity_number_entity
|
||||||
|
if stop_sensitivity_entity is None:
|
||||||
|
stop_sensitivity_entity = StopWordSensitivityNumberEntity(
|
||||||
|
server=self,
|
||||||
|
key=len(state.entities),
|
||||||
|
name="Stop Word Sensitivity",
|
||||||
|
object_id="stop_word_sensitivity",
|
||||||
|
get_sensitivity=lambda: self.state.stop_word_threshold,
|
||||||
|
set_sensitivity=self._set_stop_sensitivity,
|
||||||
|
initial_value=self.state.stop_word_threshold,
|
||||||
|
)
|
||||||
|
self.state.entities.append(stop_sensitivity_entity)
|
||||||
|
self.state.stop_sensitivity_number_entity = stop_sensitivity_entity
|
||||||
|
elif stop_sensitivity_entity not in self.state.entities:
|
||||||
|
self.state.entities.append(stop_sensitivity_entity)
|
||||||
|
|
||||||
|
stop_sensitivity_entity.server = self
|
||||||
|
stop_sensitivity_entity.update_get_sensitivity(lambda: self.state.stop_word_threshold)
|
||||||
|
stop_sensitivity_entity.update_set_sensitivity(self._set_stop_sensitivity)
|
||||||
|
|
||||||
|
stop_sensitivity_entity.sync_with_state()
|
||||||
|
|
||||||
|
# Mic Gain
|
||||||
|
if self.state.mic_gain_entity is None:
|
||||||
|
self.state.mic_gain_entity = MicSettingEntity(
|
||||||
|
server=self,
|
||||||
|
key=len(self.state.entities),
|
||||||
|
name="Mic Auto Gain",
|
||||||
|
object_id="mic_gain",
|
||||||
|
min_value=0.0,
|
||||||
|
max_value=31.0,
|
||||||
|
get_value=lambda: float(self.state.mic_auto_gain),
|
||||||
|
set_value=lambda val: self.state.persist_mic_gain(float(val)),
|
||||||
|
icon="mdi:microphone-plus",
|
||||||
|
)
|
||||||
|
self.state.entities.append(self.state.mic_gain_entity)
|
||||||
|
elif self.state.mic_gain_entity not in self.state.entities:
|
||||||
|
self.state.entities.append(self.state.mic_gain_entity)
|
||||||
|
|
||||||
|
self.state.mic_gain_entity.server = self
|
||||||
|
self.state.mic_gain_entity.update_get_value(lambda: float(self.state.mic_auto_gain))
|
||||||
|
self.state.mic_gain_entity.update_set_value(lambda val: self.state.persist_mic_gain(float(val))) # type: ignore[arg-type]
|
||||||
|
self.state.mic_gain_entity.sync_with_state()
|
||||||
|
|
||||||
|
# Mic Noise Suppression
|
||||||
|
_NOISE_OPTIONS = ["Off", "Low", "Medium", "High", "Max"]
|
||||||
|
_NOISE_TO_INT = {label: i for i, label in enumerate(_NOISE_OPTIONS)}
|
||||||
|
|
||||||
|
def _get_noise_label() -> str:
|
||||||
|
return _NOISE_OPTIONS[max(0, min(4, self.state.mic_noise_suppression))]
|
||||||
|
|
||||||
|
def _set_noise_label(label: Union[float, str]) -> None:
|
||||||
|
self.state.persist_mic_noise(float(_NOISE_TO_INT.get(str(label), 0)))
|
||||||
|
|
||||||
|
if self.state.mic_noise_suppression_entity is None:
|
||||||
|
self.state.mic_noise_suppression_entity = MicSettingEntity(
|
||||||
|
server=self,
|
||||||
|
key=len(self.state.entities),
|
||||||
|
name="Mic Noise Suppression",
|
||||||
|
object_id="mic_noise",
|
||||||
|
options=_NOISE_OPTIONS,
|
||||||
|
get_value=_get_noise_label,
|
||||||
|
set_value=_set_noise_label,
|
||||||
|
icon="mdi:waveform",
|
||||||
|
)
|
||||||
|
self.state.entities.append(self.state.mic_noise_suppression_entity)
|
||||||
|
elif self.state.mic_noise_suppression_entity not in self.state.entities:
|
||||||
|
self.state.entities.append(self.state.mic_noise_suppression_entity)
|
||||||
|
|
||||||
|
self.state.mic_noise_suppression_entity.server = self
|
||||||
|
self.state.mic_noise_suppression_entity.update_get_value(_get_noise_label)
|
||||||
|
self.state.mic_noise_suppression_entity.update_set_value(_set_noise_label)
|
||||||
|
self.state.mic_noise_suppression_entity.sync_with_state()
|
||||||
|
|
||||||
|
# Mic Volume
|
||||||
|
if self.state.mic_volume_entity is None:
|
||||||
|
self.state.mic_volume_entity = MicSettingEntity(
|
||||||
|
server=self,
|
||||||
|
key=len(self.state.entities),
|
||||||
|
name="Mic Volume",
|
||||||
|
object_id="mic_volume",
|
||||||
|
min_value=1.0,
|
||||||
|
max_value=100.0,
|
||||||
|
get_value=lambda: float(self.state.mic_volume),
|
||||||
|
set_value=lambda val: self.state.persist_mic_volume(float(val)),
|
||||||
|
icon="mdi:microphone-settings",
|
||||||
|
)
|
||||||
|
self.state.entities.append(self.state.mic_volume_entity)
|
||||||
|
elif self.state.mic_volume_entity not in self.state.entities:
|
||||||
|
self.state.entities.append(self.state.mic_volume_entity)
|
||||||
|
|
||||||
|
self.state.mic_volume_entity.server = self
|
||||||
|
self.state.mic_volume_entity.update_get_value(lambda: float(self.state.mic_volume))
|
||||||
|
self.state.mic_volume_entity.update_set_value(lambda val: self.state.persist_mic_volume(float(val)))
|
||||||
|
|
||||||
|
# ---- Instance variables ----
|
||||||
|
|
||||||
self._is_streaming_audio = False
|
self._is_streaming_audio = False
|
||||||
self._tts_url: Optional[str] = None
|
self._tts_url: Optional[str] = None
|
||||||
self._tts_played = False
|
self._tts_played = False
|
||||||
self._continue_conversation = False
|
self._continue_conversation = False
|
||||||
self._timer_finished = False
|
self._timer_finished = False
|
||||||
|
self._timer_ring_start: Optional[float] = None
|
||||||
self._processing = False
|
self._processing = False
|
||||||
self._pipeline_active = False
|
self._pipeline_active = False
|
||||||
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
|
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
|
||||||
@@ -177,9 +327,7 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
|
|
||||||
def _set_thinking_sound_enabled(self, new_state: bool) -> None:
|
def _set_thinking_sound_enabled(self, new_state: bool) -> None:
|
||||||
self.state.thinking_sound_enabled = bool(new_state)
|
self.state.thinking_sound_enabled = bool(new_state)
|
||||||
self.state.preferences.thinking_sound = (
|
self.state.preferences.thinking_sound = 1 if self.state.thinking_sound_enabled else 0
|
||||||
1 if self.state.thinking_sound_enabled else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.state.thinking_sound_enabled:
|
if self.state.thinking_sound_enabled:
|
||||||
_LOGGER.debug("Thinking sound enabled")
|
_LOGGER.debug("Thinking sound enabled")
|
||||||
@@ -188,6 +336,33 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
pass
|
pass
|
||||||
self.state.save_preferences()
|
self.state.save_preferences()
|
||||||
|
|
||||||
|
def _set_sensitivity_1(self, new_value: float) -> None:
|
||||||
|
self.state.wake_word_1_threshold = float(new_value)
|
||||||
|
self.state.preferences.wake_word_1_sensitivity = float(new_value)
|
||||||
|
self.state.save_preferences()
|
||||||
|
_LOGGER.debug("Wake Word 1 Sensitivity value set to: %s", new_value)
|
||||||
|
# Sync entity state
|
||||||
|
if self.state.sensitivity_1_number_entity is not None:
|
||||||
|
self.state.sensitivity_1_number_entity.sync_with_state()
|
||||||
|
|
||||||
|
def _set_sensitivity_2(self, new_value: float) -> None:
|
||||||
|
self.state.wake_word_2_threshold = float(new_value)
|
||||||
|
self.state.preferences.wake_word_2_sensitivity = float(new_value)
|
||||||
|
self.state.save_preferences()
|
||||||
|
_LOGGER.debug("Wake Word 2 Sensitivity value set to: %s", new_value)
|
||||||
|
# Sync entity state
|
||||||
|
if self.state.sensitivity_2_number_entity is not None:
|
||||||
|
self.state.sensitivity_2_number_entity.sync_with_state()
|
||||||
|
|
||||||
|
def _set_stop_sensitivity(self, new_value: float) -> None:
|
||||||
|
self.state.stop_word_threshold = float(new_value)
|
||||||
|
self.state.preferences.stop_word_sensitivity = float(new_value)
|
||||||
|
self.state.save_preferences()
|
||||||
|
_LOGGER.debug("Stop Word Sensitivity value set to: %s", new_value)
|
||||||
|
# Sync entity state
|
||||||
|
if self.state.stop_sensitivity_number_entity is not None:
|
||||||
|
self.state.stop_sensitivity_number_entity.sync_with_state()
|
||||||
|
|
||||||
def _set_muted(self, new_state: bool) -> None:
|
def _set_muted(self, new_state: bool) -> None:
|
||||||
self.state.muted = bool(new_state)
|
self.state.muted = bool(new_state)
|
||||||
|
|
||||||
@@ -197,7 +372,7 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
self._is_streaming_audio = False
|
self._is_streaming_audio = False
|
||||||
self.state.tts_player.stop()
|
self.state.tts_player.stop()
|
||||||
# Stop any ongoing voice processing
|
# Stop any ongoing voice processing
|
||||||
self.state.stop_word.is_active = False
|
self.state.stop_word.is_active = False # type: ignore
|
||||||
self.state.tts_player.play(self.state.mute_sound)
|
self.state.tts_player.play(self.state.mute_sound)
|
||||||
else:
|
else:
|
||||||
# voice_assistant.start_continuous behavior
|
# voice_assistant.start_continuous behavior
|
||||||
@@ -206,24 +381,20 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
# Resume normal operation - wake word detection will be active again
|
# Resume normal operation - wake word detection will be active again
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def handle_voice_event(
|
def handle_voice_event(self, event_type: VoiceAssistantEventType, data: Dict[str, str]) -> None:
|
||||||
self, event_type: VoiceAssistantEventType, data: Dict[str, str]
|
|
||||||
) -> None:
|
|
||||||
_LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data)
|
_LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data)
|
||||||
|
|
||||||
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
|
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
|
||||||
self._tts_url = data.get("url")
|
self._tts_url = data.get("url")
|
||||||
self._tts_played = False
|
self._tts_played = False
|
||||||
self._continue_conversation = False
|
self._continue_conversation = False
|
||||||
elif (
|
self._pipeline_active = True
|
||||||
event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START
|
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START and self.state.thinking_sound_enabled:
|
||||||
and self.state.thinking_sound_enabled
|
|
||||||
):
|
|
||||||
# Play short "thinking/processing" sound if configured
|
# Play short "thinking/processing" sound if configured
|
||||||
processing = getattr(self.state, "processing_sound", None)
|
processing = getattr(self.state, "processing_sound", None)
|
||||||
if processing:
|
if processing:
|
||||||
_LOGGER.debug("Playing processing sound: %s", processing)
|
_LOGGER.debug("Playing processing sound: %s", processing)
|
||||||
self.state.stop_word.is_active = True
|
self.state.stop_word.is_active = True # type: ignore
|
||||||
self._processing = True
|
self._processing = True
|
||||||
self.duck()
|
self.duck()
|
||||||
self.state.tts_player.play(self.state.processing_sound)
|
self.state.tts_player.play(self.state.processing_sound)
|
||||||
@@ -245,7 +416,11 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
|
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
|
||||||
self._is_streaming_audio = False
|
self._is_streaming_audio = False
|
||||||
if not self._tts_played:
|
if not self._tts_played:
|
||||||
|
self._pipeline_active = False
|
||||||
self._tts_finished()
|
self._tts_finished()
|
||||||
|
# When TTS is playing, keep _pipeline_active = True to block
|
||||||
|
# false wake word detections from speaker audio feedback.
|
||||||
|
# _tts_finished() callback will clear it when playback ends.
|
||||||
|
|
||||||
self._tts_played = False
|
self._tts_played = False
|
||||||
|
|
||||||
@@ -261,6 +436,7 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
if not self._timer_finished:
|
if not self._timer_finished:
|
||||||
self.state.active_wake_words.add(self.state.stop_word.id)
|
self.state.active_wake_words.add(self.state.stop_word.id)
|
||||||
self._timer_finished = True
|
self._timer_finished = True
|
||||||
|
self._timer_ring_start = time.monotonic()
|
||||||
self.duck()
|
self.duck()
|
||||||
self._play_timer_finished()
|
self._play_timer_finished()
|
||||||
|
|
||||||
@@ -272,6 +448,7 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
data[arg.name] = arg.value
|
data[arg.name] = arg.value
|
||||||
|
|
||||||
self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data)
|
self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data)
|
||||||
|
# assist_satellite.announce HERE
|
||||||
elif isinstance(msg, VoiceAssistantAnnounceRequest):
|
elif isinstance(msg, VoiceAssistantAnnounceRequest):
|
||||||
_LOGGER.debug("Announcing: %s", msg.text)
|
_LOGGER.debug("Announcing: %s", msg.text)
|
||||||
|
|
||||||
@@ -291,34 +468,23 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
elif isinstance(msg, VoiceAssistantTimerEventResponse):
|
elif isinstance(msg, VoiceAssistantTimerEventResponse):
|
||||||
self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg)
|
self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg)
|
||||||
elif isinstance(msg, DeviceInfoRequest):
|
elif isinstance(msg, DeviceInfoRequest):
|
||||||
# Compute dynamic device name
|
_LOGGER.debug("Device info request")
|
||||||
base_name = re.sub(r"[\s-]+", "-", self.state.name.lower()).strip("-")
|
|
||||||
mac_no_colon = self.state.mac_address.replace(":", "").lower()
|
|
||||||
mac_last6 = mac_no_colon[-6:]
|
|
||||||
device_name = f"{base_name}-{mac_last6}"
|
|
||||||
|
|
||||||
yield DeviceInfoResponse(
|
yield DeviceInfoResponse(
|
||||||
uses_password=False,
|
uses_password=False,
|
||||||
name=device_name,
|
name=self.state.name,
|
||||||
|
friendly_name=self.state.friendly_name,
|
||||||
|
project_name="Open Home Foundation.Linux Voice Assistant",
|
||||||
|
project_version=self.state.version,
|
||||||
|
esphome_version=self.state.esphome_version,
|
||||||
mac_address=self.state.mac_address,
|
mac_address=self.state.mac_address,
|
||||||
manufacturer="Open Home Foundation",
|
manufacturer="Open Home Foundation",
|
||||||
model="Linux Voice Assistant",
|
model="Linux Voice Assistant",
|
||||||
voice_assistant_feature_flags=(
|
voice_assistant_feature_flags=self.supported_features,
|
||||||
VoiceAssistantFeature.VOICE_ASSISTANT
|
|
||||||
| VoiceAssistantFeature.API_AUDIO
|
|
||||||
| VoiceAssistantFeature.ANNOUNCE
|
|
||||||
| VoiceAssistantFeature.START_CONVERSATION
|
|
||||||
| VoiceAssistantFeature.TIMERS
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
elif isinstance(
|
elif isinstance(
|
||||||
msg,
|
msg,
|
||||||
(
|
(ListEntitiesRequest, SubscribeHomeAssistantStatesRequest, MediaPlayerCommandRequest, SwitchCommandRequest, NumberCommandRequest, SelectCommandRequest),
|
||||||
ListEntitiesRequest,
|
|
||||||
SubscribeHomeAssistantStatesRequest,
|
|
||||||
MediaPlayerCommandRequest,
|
|
||||||
SwitchCommandRequest,
|
|
||||||
),
|
|
||||||
):
|
):
|
||||||
for entity in self.state.entities:
|
for entity in self.state.entities:
|
||||||
yield from entity.handle_message(msg)
|
yield from entity.handle_message(msg)
|
||||||
@@ -326,6 +492,9 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
if isinstance(msg, ListEntitiesRequest):
|
if isinstance(msg, ListEntitiesRequest):
|
||||||
yield ListEntitiesDoneResponse()
|
yield ListEntitiesDoneResponse()
|
||||||
elif isinstance(msg, VoiceAssistantConfigurationRequest):
|
elif isinstance(msg, VoiceAssistantConfigurationRequest):
|
||||||
|
_LOGGER.debug("✅ Received VoiceAssistantConfigurationRequest from Home Assistant")
|
||||||
|
_LOGGER.debug(" -> Request contains %d external wake words", len(msg.external_wake_words))
|
||||||
|
|
||||||
available_wake_words = [
|
available_wake_words = [
|
||||||
VoiceAssistantWakeWord(
|
VoiceAssistantWakeWord(
|
||||||
id=ww.id,
|
id=ww.id,
|
||||||
@@ -335,10 +504,20 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
for ww in self.state.available_wake_words.values()
|
for ww in self.state.available_wake_words.values()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Log available internal wake words first
|
||||||
|
internal_ww_count = len(self.state.available_wake_words)
|
||||||
|
_LOGGER.debug(" -> Found %d internal available wake words", internal_ww_count)
|
||||||
|
for ww in available_wake_words:
|
||||||
|
_LOGGER.debug(" - %s: '%s' (langs: %s)", ww.id, ww.wake_word, ww.trained_languages)
|
||||||
|
|
||||||
for eww in msg.external_wake_words:
|
for eww in msg.external_wake_words:
|
||||||
|
_LOGGER.debug(" -> Processing external wake word: id=%s, word='%s', type=%s", eww.id, eww.wake_word, eww.model_type)
|
||||||
|
|
||||||
if eww.model_type != "micro":
|
if eww.model_type != "micro":
|
||||||
|
_LOGGER.debug(" → Skipping: not micro model type")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
_LOGGER.debug(" → Adding to available wake words")
|
||||||
available_wake_words.append(
|
available_wake_words.append(
|
||||||
VoiceAssistantWakeWord(
|
VoiceAssistantWakeWord(
|
||||||
id=eww.id,
|
id=eww.id,
|
||||||
@@ -348,27 +527,36 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._external_wake_words[eww.id] = eww
|
self._external_wake_words[eww.id] = eww
|
||||||
|
_LOGGER.debug(" → Stored in external wake words cache")
|
||||||
|
|
||||||
|
active_ww_ids = [ww.id for ww in self.state.wake_words.values() if ww.id in self.state.active_wake_words]
|
||||||
|
_LOGGER.debug(" -> Active wake word IDs: %s", active_ww_ids)
|
||||||
|
|
||||||
yield VoiceAssistantConfigurationResponse(
|
yield VoiceAssistantConfigurationResponse(
|
||||||
available_wake_words=available_wake_words,
|
available_wake_words=available_wake_words,
|
||||||
active_wake_words=[
|
active_wake_words=active_ww_ids,
|
||||||
ww.id
|
|
||||||
for ww in self.state.wake_words.values()
|
|
||||||
if ww.id in self.state.active_wake_words
|
|
||||||
],
|
|
||||||
max_active_wake_words=2,
|
max_active_wake_words=2,
|
||||||
)
|
)
|
||||||
_LOGGER.info("Connected to Home Assistant")
|
|
||||||
|
_LOGGER.info("✅ Connected to Home Assistant - Configuration handshake completed")
|
||||||
|
_LOGGER.debug("✅ VoiceAssistantConfigurationResponse sent successfully")
|
||||||
elif isinstance(msg, VoiceAssistantSetConfiguration):
|
elif isinstance(msg, VoiceAssistantSetConfiguration):
|
||||||
# Change active wake words
|
# Change active wake words
|
||||||
active_wake_words: Set[str] = set()
|
active_wake_words: Set[str] = set()
|
||||||
|
new_wake_words: List[Optional[str]] = [None, None]
|
||||||
|
|
||||||
|
# Get old positions before modification
|
||||||
|
old_positions: Dict[str, int] = {}
|
||||||
|
for idx, ww_id in enumerate(self.state.preferences.active_wake_words):
|
||||||
|
if ww_id is not None and idx < 2:
|
||||||
|
old_positions[ww_id] = idx
|
||||||
|
|
||||||
|
# Process new active wake words
|
||||||
for wake_word_id in msg.active_wake_words:
|
for wake_word_id in msg.active_wake_words:
|
||||||
if wake_word_id in self.state.wake_words:
|
if wake_word_id in self.state.wake_words:
|
||||||
# Already active
|
# Already active
|
||||||
active_wake_words.add(wake_word_id)
|
active_wake_words.add(wake_word_id)
|
||||||
continue
|
else:
|
||||||
|
|
||||||
model_info = self.state.available_wake_words.get(wake_word_id)
|
model_info = self.state.available_wake_words.get(wake_word_id)
|
||||||
if not model_info:
|
if not model_info:
|
||||||
# Check external wake words (may require download)
|
# Check external wake words (may require download)
|
||||||
@@ -387,12 +575,35 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
|
|
||||||
_LOGGER.info("Wake word set: %s", wake_word_id)
|
_LOGGER.info("Wake word set: %s", wake_word_id)
|
||||||
active_wake_words.add(wake_word_id)
|
active_wake_words.add(wake_word_id)
|
||||||
break
|
|
||||||
|
# Keep old positions
|
||||||
|
remaining_ww = list(active_wake_words)
|
||||||
|
placed = set()
|
||||||
|
|
||||||
|
# First, place Wake Words in their old positions.
|
||||||
|
for ww_id in remaining_ww:
|
||||||
|
if ww_id in old_positions:
|
||||||
|
pos = old_positions[ww_id]
|
||||||
|
if pos < 2:
|
||||||
|
new_wake_words[pos] = ww_id
|
||||||
|
placed.add(ww_id)
|
||||||
|
|
||||||
|
# Add remaining wake words to free slots
|
||||||
|
free_slots = [i for i in range(2) if new_wake_words[i] is None]
|
||||||
|
for ww_id in remaining_ww:
|
||||||
|
if ww_id not in placed and free_slots:
|
||||||
|
pos = free_slots.pop(0)
|
||||||
|
new_wake_words[pos] = ww_id
|
||||||
|
placed.add(ww_id)
|
||||||
|
|
||||||
|
# If only one wake word is left and it was at position 1, position 0 remains None
|
||||||
|
# Position 2 automatically stays None if not occupied
|
||||||
|
|
||||||
self.state.active_wake_words = active_wake_words
|
self.state.active_wake_words = active_wake_words
|
||||||
_LOGGER.debug("Active wake words: %s", active_wake_words)
|
_LOGGER.debug("Active wake words: %s", active_wake_words)
|
||||||
|
_LOGGER.debug("Wake word positions: [0]=%s, [1]=%s", new_wake_words[0], new_wake_words[1])
|
||||||
|
|
||||||
self.state.preferences.active_wake_words = list(active_wake_words)
|
self.state.preferences.active_wake_words = new_wake_words
|
||||||
self.state.save_preferences()
|
self.state.save_preferences()
|
||||||
self.state.wake_words_changed = True
|
self.state.wake_words_changed = True
|
||||||
|
|
||||||
@@ -405,35 +616,72 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
|
|
||||||
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None:
|
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None:
|
||||||
if self._timer_finished:
|
if self._timer_finished:
|
||||||
# Stop timer instead
|
# Stop the ringing timer, then start a normal wake-up after a short
|
||||||
|
# delay so the transition doesn't feel abrupt.
|
||||||
self._timer_finished = False
|
self._timer_finished = False
|
||||||
|
self._timer_ring_start = None
|
||||||
|
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||||
|
self.unduck()
|
||||||
self.state.tts_player.stop()
|
self.state.tts_player.stop()
|
||||||
_LOGGER.debug("Stopping timer finished sound")
|
_LOGGER.debug("Stopping timer finished sound; will wake up in 1 s")
|
||||||
|
|
||||||
|
wake_word_phrase = wake_word.wake_word # type: ignore
|
||||||
|
|
||||||
|
def _delayed_wakeup() -> None:
|
||||||
|
if self.state.muted or self._pipeline_active:
|
||||||
|
_LOGGER.debug("Delayed wakeup skipped (muted=%s, pipeline_active=%s)", self.state.muted, self._pipeline_active)
|
||||||
|
return
|
||||||
|
_LOGGER.debug("Delayed wakeup: playing wakeup sound for %s", wake_word_phrase)
|
||||||
|
self._pipeline_active = True
|
||||||
|
self.duck()
|
||||||
|
self.state.tts_player.play(
|
||||||
|
self.state.wakeup_sound,
|
||||||
|
done_callback=lambda: self._on_wakeup_sound_finished(wake_word_phrase),
|
||||||
|
)
|
||||||
|
|
||||||
|
threading.Timer(0.1, _delayed_wakeup).start()
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.state.muted:
|
if self.state.muted:
|
||||||
# Don't respond to wake words when muted (voice_assistant.stop behavior)
|
# Don't respond to wake words when muted (voice_assistant.stop behavior)
|
||||||
return
|
return
|
||||||
|
|
||||||
wake_word_phrase = wake_word.wake_word
|
if self._pipeline_active:
|
||||||
|
_LOGGER.debug("Ignoring wake word - pipeline already active")
|
||||||
|
return
|
||||||
|
|
||||||
|
wake_word_phrase = wake_word.wake_word # type: ignore
|
||||||
_LOGGER.debug("Detected wake word: %s", wake_word_phrase)
|
_LOGGER.debug("Detected wake word: %s", wake_word_phrase)
|
||||||
self.send_messages(
|
self._pipeline_active = True
|
||||||
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
|
|
||||||
)
|
|
||||||
self.duck()
|
self.duck()
|
||||||
|
self.state.tts_player.play(
|
||||||
|
self.state.wakeup_sound,
|
||||||
|
done_callback=lambda: self._on_wakeup_sound_finished(wake_word_phrase),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_wakeup_sound_finished(self, wake_word_phrase: str) -> None:
|
||||||
|
"""Callback invoked when the wakeup sound finishes playing."""
|
||||||
|
_LOGGER.debug("Wakeup sound finished, starting audio streaming with wake word: %s", wake_word_phrase)
|
||||||
|
self.send_messages(
|
||||||
|
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)],
|
||||||
|
)
|
||||||
self._is_streaming_audio = True
|
self._is_streaming_audio = True
|
||||||
self.state.tts_player.play(self.state.wakeup_sound)
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.state.active_wake_words.discard(self.state.stop_word.id)
|
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||||
self.state.tts_player.stop()
|
self._pipeline_active = False
|
||||||
|
|
||||||
if self._timer_finished:
|
if self._timer_finished:
|
||||||
self._timer_finished = False
|
self._timer_finished = False
|
||||||
|
self._timer_ring_start = None
|
||||||
|
self.unduck()
|
||||||
|
self.state.tts_player.stop()
|
||||||
_LOGGER.debug("Stopping timer finished sound")
|
_LOGGER.debug("Stopping timer finished sound")
|
||||||
else:
|
else:
|
||||||
|
# tts_player.stop() invokes the done_callback (_tts_finished),
|
||||||
|
# so we don't call _tts_finished() again explicitly.
|
||||||
|
self.state.tts_player.stop()
|
||||||
_LOGGER.debug("TTS response stopped manually")
|
_LOGGER.debug("TTS response stopped manually")
|
||||||
self._tts_finished()
|
|
||||||
|
|
||||||
def play_tts(self) -> None:
|
def play_tts(self) -> None:
|
||||||
if (not self._tts_url) or self._tts_played:
|
if (not self._tts_url) or self._tts_played:
|
||||||
@@ -454,12 +702,14 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
self.state.music_player.unduck()
|
self.state.music_player.unduck()
|
||||||
|
|
||||||
def _tts_finished(self) -> None:
|
def _tts_finished(self) -> None:
|
||||||
|
self._pipeline_active = False
|
||||||
self.state.active_wake_words.discard(self.state.stop_word.id)
|
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||||
self.send_messages([VoiceAssistantAnnounceFinished()])
|
self.send_messages([VoiceAssistantAnnounceFinished()])
|
||||||
|
|
||||||
if self._continue_conversation:
|
if self._continue_conversation:
|
||||||
self.send_messages([VoiceAssistantRequest(start=True)])
|
self.send_messages([VoiceAssistantRequest(start=True)])
|
||||||
self._is_streaming_audio = True
|
self._is_streaming_audio = True
|
||||||
|
self._pipeline_active = True
|
||||||
_LOGGER.debug("Continuing conversation")
|
_LOGGER.debug("Continuing conversation")
|
||||||
else:
|
else:
|
||||||
self.unduck()
|
self.unduck()
|
||||||
@@ -468,13 +718,31 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
|
|
||||||
def _play_timer_finished(self) -> None:
|
def _play_timer_finished(self) -> None:
|
||||||
if not self._timer_finished:
|
if not self._timer_finished:
|
||||||
|
_LOGGER.debug("Timer finished sound stopped")
|
||||||
|
self.unduck()
|
||||||
|
self._timer_ring_start = None
|
||||||
|
return
|
||||||
|
|
||||||
|
# Auto-stop after timer_max_ring_seconds
|
||||||
|
if self._timer_ring_start is not None:
|
||||||
|
elapsed = time.monotonic() - self._timer_ring_start
|
||||||
|
if elapsed >= self.state.timer_max_ring_seconds:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Timer auto-stopped after %.0f seconds (max=%.0f)",
|
||||||
|
elapsed,
|
||||||
|
self.state.timer_max_ring_seconds,
|
||||||
|
)
|
||||||
|
self._timer_finished = False
|
||||||
|
self._timer_ring_start = None
|
||||||
|
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||||
self.unduck()
|
self.unduck()
|
||||||
return
|
return
|
||||||
|
|
||||||
self.state.tts_player.play(
|
self.state.tts_player.play(
|
||||||
self.state.timer_finished_sound,
|
self.state.timer_finished_sound,
|
||||||
done_callback=lambda: call_all(
|
done_callback=lambda: call_all(
|
||||||
lambda: time.sleep(1.0), self._play_timer_finished
|
lambda: time.sleep(1.0),
|
||||||
|
self._play_timer_finished,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -487,6 +755,7 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
self._tts_played = False
|
self._tts_played = False
|
||||||
self._continue_conversation = False
|
self._continue_conversation = False
|
||||||
self._timer_finished = False
|
self._timer_finished = False
|
||||||
|
self._pipeline_active = False
|
||||||
|
|
||||||
# Stop any ongoing audio playback and wake/stop word processing.
|
# Stop any ongoing audio playback and wake/stop word processing.
|
||||||
try:
|
try:
|
||||||
@@ -507,6 +776,15 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
if self.state.mute_switch_entity is not None:
|
if self.state.mute_switch_entity is not None:
|
||||||
self.state.mute_switch_entity.sync_with_state()
|
self.state.mute_switch_entity.sync_with_state()
|
||||||
|
|
||||||
|
if self.state.mic_gain_entity is not None:
|
||||||
|
self.state.mic_gain_entity.sync_with_state()
|
||||||
|
|
||||||
|
if self.state.mic_noise_suppression_entity is not None:
|
||||||
|
self.state.mic_noise_suppression_entity.sync_with_state()
|
||||||
|
|
||||||
|
if self.state.mic_volume_entity is not None:
|
||||||
|
self.state.mic_volume_entity.sync_with_state()
|
||||||
|
|
||||||
_LOGGER.info("Disconnected from Home Assistant; waiting for reconnection")
|
_LOGGER.info("Disconnected from Home Assistant; waiting for reconnection")
|
||||||
|
|
||||||
def process_packet(self, msg_type: int, packet_data: bytes) -> None:
|
def process_packet(self, msg_type: int, packet_data: bytes) -> None:
|
||||||
@@ -514,18 +792,21 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
|
|
||||||
if msg_type == PROTO_TO_MESSAGE_TYPE[AuthenticationRequest]:
|
if msg_type == PROTO_TO_MESSAGE_TYPE[AuthenticationRequest]:
|
||||||
self.state.connected = True
|
self.state.connected = True
|
||||||
|
_LOGGER.debug("Authentication successful, connected to Home Assistant")
|
||||||
# Send states after connect
|
# Send states after connect
|
||||||
states = []
|
states: List[message.Message] = []
|
||||||
for entity in self.state.entities:
|
_LOGGER.debug("Found %d entities in state", len(self.state.entities))
|
||||||
states.extend(
|
for i, entity in enumerate(self.state.entities):
|
||||||
entity.handle_message(SubscribeHomeAssistantStatesRequest())
|
entity_states = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
|
||||||
)
|
states.extend(entity_states)
|
||||||
|
_LOGGER.debug("Entity %d (%s) returned %d state messages", i, type(entity).__name__, len(entity_states))
|
||||||
|
_LOGGER.debug("Total state messages to send: %d", len(states))
|
||||||
self.send_messages(states)
|
self.send_messages(states)
|
||||||
_LOGGER.debug("Sent entity states after connect")
|
for i, msg in enumerate(states):
|
||||||
|
_LOGGER.debug("Sent state message %d: %s", i, type(msg).__name__)
|
||||||
|
_LOGGER.debug("All entity states sent after connect")
|
||||||
|
|
||||||
def _download_external_wake_word(
|
def _download_external_wake_word(self, external_wake_word: VoiceAssistantExternalWakeWord) -> Optional[AvailableWakeWord]:
|
||||||
self, external_wake_word: VoiceAssistantExternalWakeWord
|
|
||||||
) -> Optional[AvailableWakeWord]:
|
|
||||||
eww_dir = self.state.download_dir / "external_wake_words"
|
eww_dir = self.state.download_dir / "external_wake_words"
|
||||||
eww_dir.mkdir(parents=True, exist_ok=True)
|
eww_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -567,16 +848,14 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
# Download model file
|
# Download model file
|
||||||
parsed_url = urlparse(external_wake_word.url)
|
parsed_url = urlparse(external_wake_word.url)
|
||||||
parsed_url = parsed_url._replace(
|
parsed_url = parsed_url._replace(
|
||||||
path=posixpath.join(posixpath.dirname(parsed_url.path), model_path.name)
|
path=posixpath.join(posixpath.dirname(parsed_url.path), model_path.name),
|
||||||
)
|
)
|
||||||
model_url = urlunparse(parsed_url)
|
model_url = urlunparse(parsed_url)
|
||||||
|
|
||||||
_LOGGER.debug("Downloading %s to %s", model_url, model_path)
|
_LOGGER.debug("Downloading %s to %s", model_url, model_path)
|
||||||
with urlopen(model_url) as request:
|
with urlopen(model_url) as request:
|
||||||
if request.status != 200:
|
if request.status != 200:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning("Failed to download: %s, status=%s", model_url, request.status)
|
||||||
"Failed to download: %s, status=%s", model_url, request.status
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
with open(model_path, "wb") as model_file:
|
with open(model_path, "wb") as model_file:
|
||||||
|
|||||||
@@ -1,10 +1,70 @@
|
|||||||
"""Utility methods."""
|
"""Utility methods."""
|
||||||
|
|
||||||
import uuid
|
from collections.abc import Callable
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
# netifaces lib is from netifaces2
|
# netifaces lib is from netifaces2
|
||||||
import netifaces
|
import netifaces
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Optional
|
# Cache for version to avoid repeated file reading
|
||||||
|
_version_cache: Optional[str] = None
|
||||||
|
_esphome_version_cache: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_version() -> str:
|
||||||
|
"""
|
||||||
|
Read the version from version.txt file.
|
||||||
|
|
||||||
|
This function reads the content safely without risk of code injection,
|
||||||
|
as it only reads raw text and performs no evaluation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The version from version.txt or 'unknown' if the file
|
||||||
|
does not exist or cannot be read.
|
||||||
|
"""
|
||||||
|
global _version_cache
|
||||||
|
|
||||||
|
if _version_cache is not None:
|
||||||
|
return _version_cache
|
||||||
|
|
||||||
|
version_file = Path(__file__).parent.parent / "version.txt"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Sicher lesen: nur Rohtext, keine Evaluierung
|
||||||
|
file_version = version_file.read_text(encoding="utf-8").strip()
|
||||||
|
_version_cache = file_version if file_version else "unknown"
|
||||||
|
except (FileNotFoundError, PermissionError, OSError):
|
||||||
|
_version_cache = "unknown"
|
||||||
|
|
||||||
|
return _version_cache
|
||||||
|
|
||||||
|
|
||||||
|
def get_esphome_version() -> str:
|
||||||
|
"""
|
||||||
|
Read the version of the installed aioesphomeapi package.
|
||||||
|
|
||||||
|
This function uses importlib.metadata to safely retrieve the version
|
||||||
|
of an installed Python package without executing any code from the
|
||||||
|
package itself.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The version of aioesphomeapi (e.g., '42.7.0'), or 'unknown'
|
||||||
|
if the package is not installed or the version cannot be read.
|
||||||
|
"""
|
||||||
|
global _esphome_version_cache
|
||||||
|
|
||||||
|
if _esphome_version_cache is not None:
|
||||||
|
return _esphome_version_cache
|
||||||
|
|
||||||
|
try:
|
||||||
|
_esphome_version_cache = version("aioesphomeapi")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
_esphome_version_cache = "unknown"
|
||||||
|
|
||||||
|
return _esphome_version_cache
|
||||||
|
|
||||||
|
|
||||||
def call_all(*callables: Optional[Callable[[], None]]) -> None:
|
def call_all(*callables: Optional[Callable[[], None]]) -> None:
|
||||||
for item in filter(None, callables):
|
for item in filter(None, callables):
|
||||||
@@ -27,7 +87,7 @@ def get_default_interface():
|
|||||||
|
|
||||||
# gateway_info is a tuple: (gateway_ip, interface_name)
|
# gateway_info is a tuple: (gateway_ip, interface_name)
|
||||||
interface_name = gateway_info[1]
|
interface_name = gateway_info[1]
|
||||||
#print(f"Default interface: {interface_name}")
|
# print(f"Default interface: {interface_name}")
|
||||||
return interface_name
|
return interface_name
|
||||||
|
|
||||||
|
|
||||||
@@ -36,7 +96,7 @@ def get_default_ipv4(interface: str):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
addresses = netifaces.ifaddresses(interface)
|
addresses = netifaces.ifaddresses(interface)
|
||||||
ipv4_info = addresses.get(netifaces.AF_INET)
|
ipv4_info = addresses.get(netifaces.AF_INET) # type: ignore
|
||||||
|
|
||||||
if not ipv4_info:
|
if not ipv4_info:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Set, Union
|
||||||
|
|
||||||
|
from pymicro_wakeword import MicroWakeWord
|
||||||
|
from pyopen_wakeword import OpenWakeWord
|
||||||
|
|
||||||
|
from .models import AvailableWakeWord, WakeWordType
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def find_available_wake_words(wake_word_dirs: List[Path], stop_model_id: str) -> Dict[str, AvailableWakeWord]:
|
||||||
|
"""
|
||||||
|
Searches all available wake words in the specified directories.
|
||||||
|
Loads configurations and creates AvailableWakeWord objects.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wake_word_dirs: List of directories to search for wake words
|
||||||
|
stop_model_id: ID of the stop model which will not be listed as available wake word
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with wake word ID as key and AvailableWakeWord object as value
|
||||||
|
"""
|
||||||
|
available_wake_words: Dict[str, AvailableWakeWord] = {}
|
||||||
|
|
||||||
|
_LOGGER.debug("Searching for wake words in directories: %s", [str(d) for d in wake_word_dirs])
|
||||||
|
|
||||||
|
for wake_word_dir in wake_word_dirs:
|
||||||
|
_LOGGER.debug("Checking directory: %s (exists: %s)", wake_word_dir, wake_word_dir.exists())
|
||||||
|
|
||||||
|
config_files = list(wake_word_dir.glob("*.json"))
|
||||||
|
_LOGGER.debug("Found %d JSON configuration files in %s", len(config_files), wake_word_dir)
|
||||||
|
|
||||||
|
for model_config_path in config_files:
|
||||||
|
_LOGGER.debug("Processing configuration file: %s", model_config_path)
|
||||||
|
|
||||||
|
model_id = model_config_path.stem
|
||||||
|
if model_id == stop_model_id:
|
||||||
|
# Skip stop model, do not show as available wake word
|
||||||
|
_LOGGER.debug("Skipping stop model: %s", model_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
with open(model_config_path, "r", encoding="utf-8") as model_config_file:
|
||||||
|
model_config = json.load(model_config_file)
|
||||||
|
model_type = WakeWordType(model_config["type"])
|
||||||
|
|
||||||
|
_LOGGER.debug("Model %s is of type: %s", model_id, model_type)
|
||||||
|
|
||||||
|
if model_type == WakeWordType.OPEN_WAKE_WORD:
|
||||||
|
wake_word_path = model_config_path.parent / model_config["model"]
|
||||||
|
else:
|
||||||
|
wake_word_path = model_config_path
|
||||||
|
|
||||||
|
_LOGGER.debug("Model path resolved to: %s (exists: %s)", wake_word_path, wake_word_path.exists())
|
||||||
|
|
||||||
|
# Get type specific configuration
|
||||||
|
type_config = model_config.get(model_type.value, {})
|
||||||
|
|
||||||
|
_LOGGER.debug("Type specific config for %s (%s): %s", model_id, model_type.value, type_config)
|
||||||
|
|
||||||
|
available_wake_words[model_id] = AvailableWakeWord(
|
||||||
|
id=model_id,
|
||||||
|
type=WakeWordType(model_type),
|
||||||
|
wake_word=model_config["wake_word"],
|
||||||
|
trained_languages=model_config.get("trained_languages", []),
|
||||||
|
wake_word_path=wake_word_path,
|
||||||
|
probability_cutoff=type_config.get("probability_cutoff", 0.7),
|
||||||
|
)
|
||||||
|
_LOGGER.debug("Successfully registered wake word: %s", model_id)
|
||||||
|
|
||||||
|
_LOGGER.debug("Total available wake words found: %d", len(available_wake_words))
|
||||||
|
_LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys())))
|
||||||
|
return available_wake_words
|
||||||
|
|
||||||
|
|
||||||
|
def load_wake_models(
|
||||||
|
available_wake_words: Dict[str, AvailableWakeWord], active_wake_word_ids: Optional[List[str]], default_wake_word_id: str
|
||||||
|
) -> tuple[Dict[str, Union[MicroWakeWord, OpenWakeWord]], Set[str], bool]:
|
||||||
|
"""
|
||||||
|
Loads the specified wake word models.
|
||||||
|
|
||||||
|
If no active wake words are provided, the default model will be loaded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
available_wake_words: Dictionary with all available wake words
|
||||||
|
active_wake_word_ids: List of IDs of wake words to load (may be None)
|
||||||
|
default_wake_word_id: ID of the default model which is loaded if no others are specified
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple with (Dictionary of loaded models, Set of active wake word IDs)
|
||||||
|
"""
|
||||||
|
active_wake_words: Set[str] = set()
|
||||||
|
wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
|
||||||
|
|
||||||
|
_LOGGER.debug("Requested active wake word ids: %s", active_wake_word_ids)
|
||||||
|
_LOGGER.debug("Default wake word id: %s", default_wake_word_id)
|
||||||
|
|
||||||
|
if active_wake_word_ids:
|
||||||
|
# Load preferred models
|
||||||
|
_LOGGER.debug("Loading requested wake word models, count: %d", len(active_wake_word_ids))
|
||||||
|
for index, wake_word_id in enumerate(active_wake_word_ids):
|
||||||
|
_LOGGER.debug("Processing wake word %d/%d: %s", index + 1, len(active_wake_word_ids), wake_word_id)
|
||||||
|
wake_word = available_wake_words.get(wake_word_id)
|
||||||
|
if wake_word is None:
|
||||||
|
_LOGGER.warning("Unknown wake word ID: %s - skipping", wake_word_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
_LOGGER.debug("Loading wake model: %s (%s)", wake_word_id, wake_word.wake_word)
|
||||||
|
try:
|
||||||
|
wake_models[wake_word_id] = wake_word.load()
|
||||||
|
active_wake_words.add(wake_word_id)
|
||||||
|
_LOGGER.debug("✅ Successfully loaded wake model: %s", wake_word_id)
|
||||||
|
except Exception as ex:
|
||||||
|
_LOGGER.error("❌ Failed to load wake model %s: %s", wake_word_id, ex, exc_info=True)
|
||||||
|
|
||||||
|
if not wake_models:
|
||||||
|
# No models loaded, fall back to default model
|
||||||
|
_LOGGER.debug("No wake models loaded, falling back to default model")
|
||||||
|
wake_word_id = default_wake_word_id
|
||||||
|
|
||||||
|
# Check if default wake word exists
|
||||||
|
wake_word = available_wake_words.get(wake_word_id)
|
||||||
|
if wake_word is None:
|
||||||
|
_LOGGER.error("❌ Default wake word '%s' not found!", wake_word_id)
|
||||||
|
|
||||||
|
# Try fallback to 'okay_nabu'
|
||||||
|
wake_word_id = "okay_nabu"
|
||||||
|
wake_word = available_wake_words.get(wake_word_id)
|
||||||
|
|
||||||
|
if wake_word is None:
|
||||||
|
_LOGGER.error("❌ Fallback wake word 'okay_nabu' also not found!")
|
||||||
|
|
||||||
|
# If absolutely nothing works, take first available wake word
|
||||||
|
if available_wake_words:
|
||||||
|
wake_word_id = next(iter(available_wake_words.keys()))
|
||||||
|
wake_word = available_wake_words[wake_word_id]
|
||||||
|
_LOGGER.warning("⚠️ Using first available wake word as last resort: %s", wake_word_id)
|
||||||
|
else:
|
||||||
|
_LOGGER.critical("❌ NO WAKE WORDS FOUND AT ALL! Cannot proceed.")
|
||||||
|
raise RuntimeError("No wake word models available in any search directory")
|
||||||
|
|
||||||
|
# wake_word_id2 = "hey_home_assistant"
|
||||||
|
# wake_word2 = available_wake_words.get(wake_word_id2)
|
||||||
|
|
||||||
|
_LOGGER.debug("Loading default wake model 1: %s", wake_word_id)
|
||||||
|
# _LOGGER.debug("Loading default wake model 2: %s", wake_word_id2)
|
||||||
|
try:
|
||||||
|
wake_models[wake_word_id] = wake_word.load()
|
||||||
|
# if wake_word2 is not None:
|
||||||
|
# wake_models[wake_word_id2] = wake_word2.load()
|
||||||
|
# active_wake_words.add(wake_word_id2)
|
||||||
|
|
||||||
|
active_wake_words.add(wake_word_id)
|
||||||
|
_LOGGER.debug("✅ Successfully loaded default wake model 1: %s", wake_word_id)
|
||||||
|
# _LOGGER.debug("✅ Successfully loaded default wake model 2: %s", wake_word_id2)
|
||||||
|
except Exception as ex:
|
||||||
|
_LOGGER.critical("❌ Failed to load even fallback wake word %s: %s", wake_word_id, ex, exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
_LOGGER.debug("Loaded %d wake models successfully", len(wake_models))
|
||||||
|
_LOGGER.debug("Active wake words: %s", sorted(active_wake_words))
|
||||||
|
|
||||||
|
fallback_used = not active_wake_word_ids or not wake_models
|
||||||
|
|
||||||
|
return wake_models, active_wake_words, fallback_used
|
||||||
|
|
||||||
|
|
||||||
|
def load_stop_model(wake_word_dirs: List[Path], stop_model_id: str) -> Optional[MicroWakeWord]:
|
||||||
|
"""
|
||||||
|
Loads the stop word model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wake_word_dirs: List of directories to search for the stop model
|
||||||
|
stop_model_id: ID of the stop model
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Loaded MicroWakeWord object or None if not found
|
||||||
|
"""
|
||||||
|
_LOGGER.debug("Searching for stop model '%s' in directories: %s", stop_model_id, [str(d) for d in wake_word_dirs])
|
||||||
|
|
||||||
|
for wake_word_dir in wake_word_dirs:
|
||||||
|
stop_config_path = wake_word_dir / f"{stop_model_id}.json"
|
||||||
|
_LOGGER.debug("Checking stop model path: %s (exists: %s)", stop_config_path, stop_config_path.exists())
|
||||||
|
|
||||||
|
if not stop_config_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
_LOGGER.debug("Found stop model configuration at: %s", stop_config_path)
|
||||||
|
_LOGGER.debug("Loading stop model: %s", stop_config_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = MicroWakeWord.from_config(stop_config_path)
|
||||||
|
_LOGGER.debug("Successfully loaded stop model")
|
||||||
|
return model
|
||||||
|
except Exception as ex:
|
||||||
|
_LOGGER.error("Failed to load stop model from %s: %s", stop_config_path, ex, exc_info=True)
|
||||||
|
|
||||||
|
_LOGGER.warning("Stop model '%s' could not be found in any search directory", stop_model_id)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WebRTCProcessor:
|
||||||
|
def __init__(self, agc_level: int = 0, ns_level: int = 0):
|
||||||
|
from webrtc_noise_gain import AudioProcessor # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
self.apm = AudioProcessor(agc_level, ns_level)
|
||||||
|
self.agc_level = agc_level
|
||||||
|
self.ns_level = ns_level
|
||||||
|
self._buffer = bytearray()
|
||||||
|
self.FRAME_SIZE_BYTES = 320 # 160 samples * 2 bytes (16-bit PCM)
|
||||||
|
|
||||||
|
def update_settings(self, agc_level: int, ns_level: int):
|
||||||
|
"""Re-initialize processor if settings changed."""
|
||||||
|
if self.agc_level != agc_level or self.ns_level != ns_level:
|
||||||
|
from webrtc_noise_gain import AudioProcessor
|
||||||
|
|
||||||
|
_LOGGER.debug("Updating WebRTC settings: Gain=%s, NS=%s", agc_level, ns_level)
|
||||||
|
self.apm = AudioProcessor(agc_level, ns_level)
|
||||||
|
self.agc_level = agc_level
|
||||||
|
self.ns_level = ns_level
|
||||||
|
|
||||||
|
def process(self, raw_bytes: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Buffer and process audio.
|
||||||
|
Returns processed bytes (may be shorter than input if buffering).
|
||||||
|
"""
|
||||||
|
self._buffer.extend(raw_bytes)
|
||||||
|
processed_chunks: list[bytes] = []
|
||||||
|
|
||||||
|
while len(self._buffer) >= self.FRAME_SIZE_BYTES:
|
||||||
|
frame = bytes(self._buffer[: self.FRAME_SIZE_BYTES])
|
||||||
|
del self._buffer[: self.FRAME_SIZE_BYTES] # drain in-place
|
||||||
|
|
||||||
|
result = self.apm.Process10ms(frame)
|
||||||
|
processed_chunks.append(result.audio)
|
||||||
|
|
||||||
|
return b"".join(processed_chunks)
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
import uuid
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -18,7 +17,11 @@ MDNS_TARGET_IP = "224.0.0.251"
|
|||||||
|
|
||||||
class HomeAssistantZeroconf:
|
class HomeAssistantZeroconf:
|
||||||
def __init__(
|
def __init__(
|
||||||
self, port: int, mac_address: str, host_ip_address: str, name: Optional[str] = None, host: Optional[str] = None
|
self,
|
||||||
|
port: int,
|
||||||
|
mac_address: str,
|
||||||
|
host_ip_address: str,
|
||||||
|
name: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.port = port
|
self.port = port
|
||||||
self.mac_address = mac_address
|
self.mac_address = mac_address
|
||||||
@@ -45,4 +48,3 @@ class HomeAssistantZeroconf:
|
|||||||
)
|
)
|
||||||
await self._aiozc.async_register_service(service_info)
|
await self._aiozc.async_register_service(service_info)
|
||||||
_LOGGER.debug("Zeroconf discovery enabled: %s", service_info)
|
_LOGGER.debug("Zeroconf discovery enabled: %s", service_info)
|
||||||
|
|
||||||
@@ -1,26 +1,16 @@
|
|||||||
|
|
||||||
[mypy]
|
[mypy]
|
||||||
|
python_version = 3.11
|
||||||
|
warn_return_any = True
|
||||||
|
warn_unused_configs = True
|
||||||
|
|
||||||
[mypy-setuptools.*]
|
# Ignore missing stubs for modules without py.typed marker
|
||||||
ignore_missing_imports = True
|
[mypy-mpv.*]
|
||||||
|
|
||||||
[mypy-aioesphomeapi.api_pb2.*]
|
|
||||||
ignore_missing_imports = True
|
|
||||||
|
|
||||||
[mypy-pymicro_wakeword.*]
|
|
||||||
ignore_missing_imports = True
|
|
||||||
|
|
||||||
[mypy-pyopen_wakeword.*]
|
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
||||||
|
|
||||||
[mypy-soundcard.*]
|
[mypy-soundcard.*]
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
||||||
|
|
||||||
[mypy-mpv.*]
|
[mypy-aioesphomeapi.api_pb2]
|
||||||
ignore_missing_imports = True
|
|
||||||
|
|
||||||
[mypy-google.protobuf.*]
|
|
||||||
ignore_missing_imports = True
|
|
||||||
|
|
||||||
[mypy-aioesphomeapi.*]
|
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
||||||
|
allow_subclassing_any = True
|
||||||
|
disable_error_code = attr-defined
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
[MESSAGES CONTROL]
|
|
||||||
disable=
|
|
||||||
format,
|
|
||||||
abstract-method,
|
|
||||||
cyclic-import,
|
|
||||||
duplicate-code,
|
|
||||||
global-statement,
|
|
||||||
import-outside-toplevel,
|
|
||||||
inconsistent-return-statements,
|
|
||||||
locally-disabled,
|
|
||||||
not-context-manager,
|
|
||||||
too-few-public-methods,
|
|
||||||
too-many-arguments,
|
|
||||||
too-many-branches,
|
|
||||||
too-many-instance-attributes,
|
|
||||||
too-many-lines,
|
|
||||||
too-many-locals,
|
|
||||||
too-many-public-methods,
|
|
||||||
too-many-return-statements,
|
|
||||||
too-many-statements,
|
|
||||||
too-many-boolean-expressions,
|
|
||||||
unnecessary-pass,
|
|
||||||
unused-argument,
|
|
||||||
broad-except,
|
|
||||||
too-many-nested-blocks,
|
|
||||||
invalid-name,
|
|
||||||
unused-import,
|
|
||||||
fixme,
|
|
||||||
useless-super-delegation,
|
|
||||||
missing-module-docstring,
|
|
||||||
missing-class-docstring,
|
|
||||||
missing-function-docstring,
|
|
||||||
import-error,
|
|
||||||
consider-using-with
|
|
||||||
|
|
||||||
[FORMAT]
|
|
||||||
expected-line-ending-format=LF
|
|
||||||
+81
-6
@@ -1,10 +1,9 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=62.3"]
|
requires = ["setuptools>=62.3", "setuptools-scm>=8"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "linux-voice-assistant"
|
name = "linux-voice-assistant"
|
||||||
version = "1.0.0"
|
|
||||||
license = {text = "Apache-2.0"}
|
license = {text = "Apache-2.0"}
|
||||||
description = "Linux voice assistant for Home Assistant using the ESPHome protocol"
|
description = "Linux voice assistant for Home Assistant using the ESPHome protocol"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
@@ -17,13 +16,11 @@ classifiers = [
|
|||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"Topic :: Text Processing :: Linguistic",
|
"Topic :: Text Processing :: Linguistic",
|
||||||
"License :: OSI Approved :: Apache Software License",
|
"License :: OSI Approved :: Apache Software License",
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
]
|
]
|
||||||
requires-python = ">=3.9.0"
|
requires-python = ">=3.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aioesphomeapi==42.7.0",
|
"aioesphomeapi==42.7.0",
|
||||||
"netifaces2==0.0.22",
|
"netifaces2==0.0.22",
|
||||||
@@ -34,7 +31,10 @@ dependencies = [
|
|||||||
"python-mpv>=1,<2",
|
"python-mpv>=1,<2",
|
||||||
"zeroconf<1",
|
"zeroconf<1",
|
||||||
"getmac<1",
|
"getmac<1",
|
||||||
|
"types-protobuf",
|
||||||
|
"webrtc_noise_gain",
|
||||||
]
|
]
|
||||||
|
dynamic = ["version"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -43,8 +43,13 @@ dev = [
|
|||||||
"mypy",
|
"mypy",
|
||||||
"pylint",
|
"pylint",
|
||||||
"pytest",
|
"pytest",
|
||||||
|
"isort",
|
||||||
|
"autopep8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
linux-voice-assistant = "linux_voice_assistant.__main__:run"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
"Source Code" = "http://github.com/OHF-Voice/linux-voice-assistant"
|
"Source Code" = "http://github.com/OHF-Voice/linux-voice-assistant"
|
||||||
|
|
||||||
@@ -54,5 +59,75 @@ zip-safe = true
|
|||||||
include-package-data = true
|
include-package-data = true
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["linux_voice_assistant"]
|
include = ["linux_voice_assistant", "linux_voice_assistant.*"]
|
||||||
exclude = ["tests", "tests.*"]
|
exclude = ["tests", "tests.*"]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 200
|
||||||
|
target-version = ['py312', 'py313']
|
||||||
|
include = '\.pyi?$'
|
||||||
|
exclude = '''
|
||||||
|
/(
|
||||||
|
\.eggs
|
||||||
|
| \.git
|
||||||
|
| \.hg
|
||||||
|
| \.mypy_cache
|
||||||
|
| \.tox
|
||||||
|
| \.venv
|
||||||
|
| _build
|
||||||
|
| buck-out
|
||||||
|
| build
|
||||||
|
| dist
|
||||||
|
)/
|
||||||
|
'''
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
line_length = 200
|
||||||
|
multi_line_output = 3
|
||||||
|
include_trailing_comma = true
|
||||||
|
force_grid_wrap = 0
|
||||||
|
use_parentheses = true
|
||||||
|
|
||||||
|
[tool.pylint."MESSAGES CONTROL"]
|
||||||
|
disable = [
|
||||||
|
"abstract-method",
|
||||||
|
"cyclic-import",
|
||||||
|
"duplicate-code",
|
||||||
|
"global-statement",
|
||||||
|
"import-outside-toplevel",
|
||||||
|
"inconsistent-return-statements",
|
||||||
|
"locally-disabled",
|
||||||
|
"not-context-manager",
|
||||||
|
"too-few-public-methods",
|
||||||
|
"too-many-arguments",
|
||||||
|
"too-many-branches",
|
||||||
|
"too-many-instance-attributes",
|
||||||
|
"too-many-lines",
|
||||||
|
"too-many-locals",
|
||||||
|
"too-many-public-methods",
|
||||||
|
"too-many-return-statements",
|
||||||
|
"too-many-statements",
|
||||||
|
"too-many-boolean-expressions",
|
||||||
|
"unnecessary-pass",
|
||||||
|
"unused-argument",
|
||||||
|
"broad-except",
|
||||||
|
"too-many-nested-blocks",
|
||||||
|
"invalid-name",
|
||||||
|
"unused-import",
|
||||||
|
"fixme",
|
||||||
|
"useless-super-delegation",
|
||||||
|
"missing-module-docstring",
|
||||||
|
"missing-class-docstring",
|
||||||
|
"missing-function-docstring",
|
||||||
|
"import-error",
|
||||||
|
"consider-using-with",
|
||||||
|
"R0917",
|
||||||
|
"C0301",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pylint."DESIGN"]
|
||||||
|
max-args = 50
|
||||||
|
|
||||||
|
[tool.pylint.format]
|
||||||
|
expected-line-ending-format = "LF"
|
||||||
+32
-19
@@ -1,23 +1,36 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env bash
|
||||||
import subprocess
|
|
||||||
import venv
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
_PROGRAM_DIR = _DIR.parent
|
PROJECT_DIR="$SCRIPT_DIR/.."
|
||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
|
||||||
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
|
|
||||||
|
|
||||||
_LINT_DIRS = [_MODULE_DIR]
|
echo "Running all linting checks..."
|
||||||
|
|
||||||
if _VENV_DIR.exists():
|
# Run each lint script
|
||||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
echo "=== Running Black ==="
|
||||||
python_exe = context.env_exe
|
"$SCRIPT_DIR/lint_black"
|
||||||
else:
|
BLACK_EXIT=$?
|
||||||
python_exe = "python3"
|
|
||||||
|
|
||||||
subprocess.check_call([python_exe, "-m", "black"] + _LINT_DIRS + ["--check"])
|
echo -e "\n=== Running isort ==="
|
||||||
subprocess.check_call([python_exe, "-m", "isort"] + _LINT_DIRS + ["--check"])
|
"$SCRIPT_DIR/lint_isort"
|
||||||
subprocess.check_call([python_exe, "-m", "flake8"] + _LINT_DIRS)
|
ISORT_EXIT=$?
|
||||||
subprocess.check_call([python_exe, "-m", "pylint"] + _LINT_DIRS)
|
|
||||||
subprocess.check_call([python_exe, "-m", "mypy"] + _LINT_DIRS)
|
echo -e "\n=== Running flake8 ==="
|
||||||
|
"$SCRIPT_DIR/lint_flake8"
|
||||||
|
FLAKE8_EXIT=$?
|
||||||
|
|
||||||
|
echo -e "\n=== Running pylint ==="
|
||||||
|
"$SCRIPT_DIR/lint_pylint"
|
||||||
|
PYLINT_EXIT=$?
|
||||||
|
|
||||||
|
echo -e "\n=== Running mypy ==="
|
||||||
|
"$SCRIPT_DIR/lint_mypy"
|
||||||
|
MYPY_EXIT=$?
|
||||||
|
|
||||||
|
# Check if any linting failed
|
||||||
|
if [ $BLACK_EXIT -ne 0 ] || [ $ISORT_EXIT -ne 0 ] || [ $FLAKE8_EXIT -ne 0 ] || [ $PYLINT_EXIT -ne 0 ] || [ $MYPY_EXIT -ne 0 ]; then
|
||||||
|
echo -e "\n❌ Linting failed!"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo -e "\n✅ All linting checks passed!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import subprocess
|
||||||
|
import venv
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_DIR = Path(__file__).parent
|
||||||
|
_PROGRAM_DIR = _DIR.parent
|
||||||
|
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||||
|
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
|
||||||
|
|
||||||
|
_LINT_DIRS = [_MODULE_DIR]
|
||||||
|
|
||||||
|
if _VENV_DIR.exists():
|
||||||
|
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
||||||
|
python_exe = context.env_exe
|
||||||
|
else:
|
||||||
|
python_exe = "python3"
|
||||||
|
|
||||||
|
# Überprüfen, ob --auto angegeben wurde
|
||||||
|
auto_fix = "--auto" in sys.argv
|
||||||
|
|
||||||
|
if auto_fix:
|
||||||
|
# Automatisch formatieren
|
||||||
|
subprocess.check_call([python_exe, "-m", "black"] + _LINT_DIRS)
|
||||||
|
else:
|
||||||
|
# Nur prüfen, ob Formatierung korrekt ist
|
||||||
|
subprocess.check_call([python_exe, "-m", "black"] + _LINT_DIRS + ["--check", "--color", "--diff"])
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import subprocess
|
||||||
|
import venv
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_DIR = Path(__file__).parent
|
||||||
|
_PROGRAM_DIR = _DIR.parent
|
||||||
|
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||||
|
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
|
||||||
|
|
||||||
|
_LINT_DIRS = [_MODULE_DIR]
|
||||||
|
|
||||||
|
if _VENV_DIR.exists():
|
||||||
|
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
||||||
|
python_exe = context.env_exe
|
||||||
|
else:
|
||||||
|
python_exe = "python3"
|
||||||
|
|
||||||
|
# Flake8 führt nur Prüfungen durch, keine automatischen Korrekturen
|
||||||
|
subprocess.check_call([python_exe, "-m", "flake8"] + _LINT_DIRS)
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import subprocess
|
||||||
|
import venv
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_DIR = Path(__file__).parent
|
||||||
|
_PROGRAM_DIR = _DIR.parent
|
||||||
|
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||||
|
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
|
||||||
|
|
||||||
|
_LINT_DIRS = [_MODULE_DIR]
|
||||||
|
|
||||||
|
if _VENV_DIR.exists():
|
||||||
|
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
||||||
|
python_exe = context.env_exe
|
||||||
|
else:
|
||||||
|
python_exe = "python3"
|
||||||
|
|
||||||
|
# Überprüfen, ob --auto angegeben wurde
|
||||||
|
auto_fix = "--auto" in sys.argv
|
||||||
|
|
||||||
|
if auto_fix:
|
||||||
|
# Automatisch formatieren
|
||||||
|
subprocess.check_call([python_exe, "-m", "isort"] + _LINT_DIRS)
|
||||||
|
else:
|
||||||
|
# Nur prüfen, ob Formatierung korrekt ist
|
||||||
|
subprocess.check_call([python_exe, "-m", "isort"] + _LINT_DIRS + ["--check", "--diff"])
|
||||||
@@ -2,13 +2,14 @@
|
|||||||
import subprocess
|
import subprocess
|
||||||
import venv
|
import venv
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
_DIR = Path(__file__).parent
|
||||||
_PROGRAM_DIR = _DIR.parent
|
_PROGRAM_DIR = _DIR.parent
|
||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||||
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
|
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
|
||||||
|
|
||||||
_FORMAT_DIRS = [_MODULE_DIR]
|
_LINT_DIRS = [_MODULE_DIR]
|
||||||
|
|
||||||
if _VENV_DIR.exists():
|
if _VENV_DIR.exists():
|
||||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
||||||
@@ -16,5 +17,5 @@ if _VENV_DIR.exists():
|
|||||||
else:
|
else:
|
||||||
python_exe = "python3"
|
python_exe = "python3"
|
||||||
|
|
||||||
subprocess.check_call([python_exe, "-m", "black"] + _FORMAT_DIRS)
|
# Mypy Ausführung
|
||||||
subprocess.check_call([python_exe, "-m", "isort"] + _FORMAT_DIRS)
|
subprocess.check_call([python_exe, "-m", "mypy"] + _LINT_DIRS)
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import subprocess
|
||||||
|
import venv
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_DIR = Path(__file__).parent
|
||||||
|
_PROGRAM_DIR = _DIR.parent
|
||||||
|
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||||
|
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
|
||||||
|
|
||||||
|
_LINT_DIRS = [_MODULE_DIR]
|
||||||
|
|
||||||
|
if _VENV_DIR.exists():
|
||||||
|
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
||||||
|
python_exe = context.env_exe
|
||||||
|
else:
|
||||||
|
python_exe = "python3"
|
||||||
|
|
||||||
|
# Überprüfen, ob --auto angegeben wurde
|
||||||
|
auto_fix = "--auto" in sys.argv
|
||||||
|
|
||||||
|
if auto_fix:
|
||||||
|
# Automatisch Korrekturen durchführen
|
||||||
|
subprocess.check_call([python_exe, "-m", "pylint"] + _LINT_DIRS + ["--fix"])
|
||||||
|
else:
|
||||||
|
# Nur prüfen, ob Code den Pylint-Standards entspricht
|
||||||
|
subprocess.check_call([python_exe, "-m", "pylint"] + _LINT_DIRS)
|
||||||
+2
-1
@@ -14,4 +14,5 @@ if _VENV_DIR.exists():
|
|||||||
else:
|
else:
|
||||||
python_exe = "python3"
|
python_exe = "python3"
|
||||||
|
|
||||||
subprocess.check_call([python_exe, "-m", "linux_voice_assistant"] + sys.argv[1:])
|
result = subprocess.run([python_exe, "-m", "linux_voice_assistant"] + sys.argv[1:])
|
||||||
|
sys.exit(result.returncode)
|
||||||
+14
-2
@@ -2,6 +2,7 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import subprocess
|
import subprocess
|
||||||
import venv
|
import venv
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
_DIR = Path(__file__).parent
|
||||||
@@ -10,6 +11,8 @@ _VENV_DIR = _PROGRAM_DIR / ".venv"
|
|||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--dev", action="store_true", help="Install dev requirements")
|
parser.add_argument("--dev", action="store_true", help="Install dev requirements")
|
||||||
|
parser.add_argument("--cxxflags", type=str, help="CXXFLAGS for compilation (default: -O1 -g0)")
|
||||||
|
parser.add_argument("--makeflags", type=str, help="MAKEFLAGS for compilation (default: -j1)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Create virtual environment
|
# Create virtual environment
|
||||||
@@ -17,6 +20,13 @@ builder = venv.EnvBuilder(with_pip=True)
|
|||||||
context = builder.ensure_directories(_VENV_DIR)
|
context = builder.ensure_directories(_VENV_DIR)
|
||||||
builder.create(_VENV_DIR)
|
builder.create(_VENV_DIR)
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
env = {}
|
||||||
|
if args.cxxflags:
|
||||||
|
env["CXXFLAGS"] = args.cxxflags
|
||||||
|
if args.makeflags:
|
||||||
|
env["MAKEFLAGS"] = args.makeflags
|
||||||
|
|
||||||
# Upgrade dependencies
|
# Upgrade dependencies
|
||||||
pip = [context.env_exe, "-m", "pip"]
|
pip = [context.env_exe, "-m", "pip"]
|
||||||
subprocess.check_call(pip + ["install", "--upgrade", "pip"])
|
subprocess.check_call(pip + ["install", "--upgrade", "pip"])
|
||||||
@@ -25,8 +35,10 @@ subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"])
|
|||||||
# Install requirements
|
# Install requirements
|
||||||
subprocess.check_call(pip + ["install", "-e", str(_PROGRAM_DIR)])
|
subprocess.check_call(pip + ["install", "-e", str(_PROGRAM_DIR)])
|
||||||
|
|
||||||
|
|
||||||
|
# Install dev requirements
|
||||||
if args.dev:
|
if args.dev:
|
||||||
# Install dev requirements
|
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"]
|
pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"],
|
||||||
|
env={**dict(os.environ), **env}
|
||||||
)
|
)
|
||||||
@@ -12,11 +12,3 @@ ignore =
|
|||||||
E203,
|
E203,
|
||||||
D202,
|
D202,
|
||||||
W504
|
W504
|
||||||
|
|
||||||
[isort]
|
|
||||||
multi_line_output = 3
|
|
||||||
include_trailing_comma=True
|
|
||||||
force_grid_wrap=0
|
|
||||||
use_parentheses=True
|
|
||||||
line_length=88
|
|
||||||
indent = " "
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Automated testing
|
||||||
|
|
||||||
|
tbd
|
||||||
|
|
||||||
|
|
||||||
|
# Manual testing
|
||||||
|
|
||||||
|
## Functions from HA side:
|
||||||
|
|
||||||
|
### Announcement:
|
||||||
|
|
||||||
|
Function in HA is: assist_satellite.announce
|
||||||
|
|
||||||
|
### TTS:
|
||||||
|
|
||||||
|
Function in HA is: tts.speak
|
||||||
|
|
||||||
|
## Play music from MA:
|
||||||
|
|
||||||
|
### Start and stop
|
||||||
|
Start playing a playlist. Check if it is playing. Stop playing. Check if it is stopped.
|
||||||
|
|
||||||
|
### Next track
|
||||||
|
|
||||||
|
Start playing a playlist. Check if it is playing. Next track. Check if it is playing.
|
||||||
|
|
||||||
|
### Pause
|
||||||
|
|
||||||
|
## Timer
|
||||||
|
|
||||||
|
Play music and then start a timer. Test if duck and unduck works. Test if unduck work when using wake word or stop word to stop the timer ringing.
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
placeholder
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
placeholder
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"type": "openWakeWord",
|
"type": "openWakeWord",
|
||||||
"wake_word": "Alexa",
|
"wake_word": "Alexa",
|
||||||
"model": "alexa_v0.1.tflite"
|
"model": "alexa_v0.1.tflite",
|
||||||
|
"openWakeWord": {
|
||||||
|
"probability_cutoff": 0.7
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"type": "openWakeWord",
|
"type": "openWakeWord",
|
||||||
"wake_word": "Hey Jarvis",
|
"wake_word": "Hey Jarvis",
|
||||||
"model": "hey_jarvis_v0.1.tflite"
|
"model": "hey_jarvis_v0.1.tflite",
|
||||||
|
"openWakeWord": {
|
||||||
|
"probability_cutoff": 0.7
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"type": "openWakeWord",
|
"type": "openWakeWord",
|
||||||
"wake_word": "Hey Mycroft",
|
"wake_word": "Hey Mycroft",
|
||||||
"model": "hey_mycroft_v0.1.tflite"
|
"model": "hey_mycroft_v0.1.tflite",
|
||||||
|
"openWakeWord": {
|
||||||
|
"probability_cutoff": 0.7
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"type": "openWakeWord",
|
"type": "openWakeWord",
|
||||||
"wake_word": "Hey Rhasspy",
|
"wake_word": "Hey Rhasspy",
|
||||||
"model": "hey_rhasspy_v0.1.tflite"
|
"model": "hey_rhasspy_v0.1.tflite",
|
||||||
|
"openWakeWord": {
|
||||||
|
"probability_cutoff": 0.7
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"type": "openWakeWord",
|
"type": "openWakeWord",
|
||||||
"wake_word": "Okay Nabu",
|
"wake_word": "Ok Nabu",
|
||||||
"model": "ok_nabu_v0.1.tflite"
|
"model": "ok_nabu_v0.1.tflite",
|
||||||
|
"openWakeWord": {
|
||||||
|
"probability_cutoff": 0.7
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user