Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d84fa0898 | ||
|
|
eb29a00386 | ||
|
|
06f7256ebf | ||
|
|
17e3ee061b | ||
|
|
fc2780d92e | ||
|
|
32f6de6222 | ||
|
|
ec70b3b58d | ||
|
|
b339f76b7d | ||
|
|
4ba509ab41 | ||
|
|
34edd1e67d | ||
|
|
957cd205ef | ||
|
|
2a4a08a55b | ||
|
|
19c4c2337f | ||
|
|
b1e36b0cc3 | ||
|
|
0e9f149cc2 | ||
|
|
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
|
||||||
+38
-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,47 @@ 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):
|
### Microphone volume level (optional):
|
||||||
|
# MIC_VOLUME="100"
|
||||||
|
|
||||||
|
### Mic Gain (optional) passed as an argument to LVA will overide the value in the preferences file on boot but can be changed at runtime in Home Assistant:
|
||||||
|
# MIC_AUTO_GAIN="0"
|
||||||
|
|
||||||
|
### Mic Noise Suppression (optional) passed as an argument to LVA will overide the value in the preferences file on boot but can be changed at runtime in Home Assistant:
|
||||||
|
# MIC_NOISE_SUPPRESSION="0"
|
||||||
|
|
||||||
|
### Number of mic channels to stream
|
||||||
|
# AUDIO_INPUT_CHANNELS=2
|
||||||
|
|
||||||
|
### Enable thinking sound (optional):
|
||||||
# ENABLE_THINKING_SOUND="1"
|
# 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):
|
### Delay before mic opens for continued conversation (optional):
|
||||||
|
# CONTINUE_CONVERSATION_DELAY="0.5"
|
||||||
|
|
||||||
|
### Sound files (optional):
|
||||||
|
# path for custom files in docker is for example "sounds/custom/your_soundfile.flac"
|
||||||
# WAKEUP_SOUND="sounds/wake_word_triggered.flac"
|
# 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
|
|
||||||
+294
-14
@@ -1,18 +1,298 @@
|
|||||||
# Changelog
|
<a id="v1.1.11"></a>
|
||||||
|
# [v1.1.11](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.11) - 2026-06-11
|
||||||
|
|
||||||
## Unreleased
|
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||||
|
|
||||||
- Add support for custom/external wake words
|
## What's Changed
|
||||||
- Add `--download-dir <DIR>` to store downloaded wake word models/configs
|
### Other Changes
|
||||||
- Switch to `soundcard` instead of `sounddevice`
|
* docs(readme): restructure requirements and hardware section by [@florian-asche](https://github.com/florian-asche) in [#321](https://github.com/OHF-Voice/linux-voice-assistant/pull/321)
|
||||||
- Add `--list-input-devices` and `--list-output-devices`
|
* App for HAOS by [@omaramin-2000](https://github.com/omaramin-2000) in [#325](https://github.com/OHF-Voice/linux-voice-assistant/pull/325)
|
||||||
- Use `pymicro-wakeword` for microWakeWord
|
* Add volume arg to parser by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#335](https://github.com/OHF-Voice/linux-voice-assistant/pull/335)
|
||||||
- Add zeroconf/mDNS discovery
|
* Enhance wake word documentation by [@omaramin-2000](https://github.com/omaramin-2000) in [#336](https://github.com/OHF-Voice/linux-voice-assistant/pull/336)
|
||||||
- Support openWakeWord with `pyopen-wakeword`
|
|
||||||
- Support multiple wake words
|
|
||||||
- Save active wake words to preferences JSON file
|
|
||||||
- Refactor main into separate files
|
|
||||||
|
|
||||||
## 1.0.0
|
|
||||||
|
|
||||||
- Initial release
|
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.10...v1.1.11
|
||||||
|
|
||||||
|
[Changes][v1.1.11]
|
||||||
|
|
||||||
|
|
||||||
|
<a id="v1.1.10"></a>
|
||||||
|
# [v1.1.10](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.10) - 2026-04-23
|
||||||
|
|
||||||
|
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
### Exciting New Features 🎉
|
||||||
|
* Add support for WebRTC Auto Gain and Noise Suppression by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#250](https://github.com/OHF-Voice/linux-voice-assistant/pull/250)
|
||||||
|
* Add Instructions for enabling AEC by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#254](https://github.com/OHF-Voice/linux-voice-assistant/pull/254)
|
||||||
|
* Discover project version from Git tags by [@mweinelt](https://github.com/mweinelt) in [#270](https://github.com/OHF-Voice/linux-voice-assistant/pull/270)
|
||||||
|
* Output only satellite by [@jaminh](https://github.com/jaminh) in [#182](https://github.com/OHF-Voice/linux-voice-assistant/pull/182)
|
||||||
|
* Wake word sensitivity by [@florian-asche](https://github.com/florian-asche) in [#207](https://github.com/OHF-Voice/linux-voice-assistant/pull/207)
|
||||||
|
### Other Changes
|
||||||
|
* Revise intro to Linux-Voice-Assistant in README by [@Hedda](https://github.com/Hedda) in [#223](https://github.com/OHF-Voice/linux-voice-assistant/pull/223)
|
||||||
|
* small typo inside the .env file by [@nielsnl68](https://github.com/nielsnl68) in [#235](https://github.com/OHF-Voice/linux-voice-assistant/pull/235)
|
||||||
|
* Move examples by [@jaminh](https://github.com/jaminh) in [#193](https://github.com/OHF-Voice/linux-voice-assistant/pull/193)
|
||||||
|
* Hotfix/build pipeline by [@florian-asche](https://github.com/florian-asche) in [#253](https://github.com/OHF-Voice/linux-voice-assistant/pull/253)
|
||||||
|
* Cleanup mac address by [@nielsnl68](https://github.com/nielsnl68) in [#249](https://github.com/OHF-Voice/linux-voice-assistant/pull/249)
|
||||||
|
* Fix double _tts_finished() call when stop word interrupts TTS by [@interkelstar](https://github.com/interkelstar) in [#256](https://github.com/OHF-Voice/linux-voice-assistant/pull/256)
|
||||||
|
* Add debug logging for stop word detection by [@interkelstar](https://github.com/interkelstar) in [#257](https://github.com/OHF-Voice/linux-voice-assistant/pull/257)
|
||||||
|
* Add maximum seconds for ringing timer by [@omaramin-2000](https://github.com/omaramin-2000) in [#261](https://github.com/OHF-Voice/linux-voice-assistant/pull/261)
|
||||||
|
* Fix lint formatting by [@omaramin-2000](https://github.com/omaramin-2000) in [#262](https://github.com/OHF-Voice/linux-voice-assistant/pull/262)
|
||||||
|
* Update README removing colon signs section headers by [@Hedda](https://github.com/Hedda) in [#265](https://github.com/OHF-Voice/linux-voice-assistant/pull/265)
|
||||||
|
* Update package inclusion to include subpackages: Python Module Hotfix by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#245](https://github.com/OHF-Voice/linux-voice-assistant/pull/245)
|
||||||
|
* Fixed PipeWire setup by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#258](https://github.com/OHF-Voice/linux-voice-assistant/pull/258)
|
||||||
|
* Expose linux-voice-assistant console script by [@mweinelt](https://github.com/mweinelt) in [#268](https://github.com/OHF-Voice/linux-voice-assistant/pull/268)
|
||||||
|
* Start listening after stopping ringing timer using wake word by [@omaramin-2000](https://github.com/omaramin-2000) in [#275](https://github.com/OHF-Voice/linux-voice-assistant/pull/275)
|
||||||
|
* fix(docker): simplify PulseAudio cookie file creation by [@florian-asche](https://github.com/florian-asche) in [#276](https://github.com/OHF-Voice/linux-voice-assistant/pull/276)
|
||||||
|
* docs(config): document timer max ring seconds configuration by [@florian-asche](https://github.com/florian-asche) in [#277](https://github.com/OHF-Voice/linux-voice-assistant/pull/277)
|
||||||
|
* Output only satellite - part 2 by [@florian-asche](https://github.com/florian-asche) in [#278](https://github.com/OHF-Voice/linux-voice-assistant/pull/278)
|
||||||
|
* Add the ability to control microphone volume by [@omaramin-2000](https://github.com/omaramin-2000) in [#287](https://github.com/OHF-Voice/linux-voice-assistant/pull/287)
|
||||||
|
* Fix audio cuts and playback issues by [@omaramin-2000](https://github.com/omaramin-2000) in [#296](https://github.com/OHF-Voice/linux-voice-assistant/pull/296)
|
||||||
|
* docs(install): clarify linger is required on headless systems by [@genericJE](https://github.com/genericJE) in [#299](https://github.com/OHF-Voice/linux-voice-assistant/pull/299)
|
||||||
|
* Fix README.md typo by [@AlecJDavidson](https://github.com/AlecJDavidson) in [#303](https://github.com/OHF-Voice/linux-voice-assistant/pull/303)
|
||||||
|
* Allow option to have docker use nightly build by [@lowlyocean](https://github.com/lowlyocean) in [#302](https://github.com/OHF-Voice/linux-voice-assistant/pull/302)
|
||||||
|
* feat(docker): add custom image and tag env configuration by [@florian-asche](https://github.com/florian-asche) in [#306](https://github.com/OHF-Voice/linux-voice-assistant/pull/306)
|
||||||
|
* Downgrade to Python 3.12 for quicker webrtc builds by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#305](https://github.com/OHF-Voice/linux-voice-assistant/pull/305)
|
||||||
|
|
||||||
|
## New Contributors
|
||||||
|
* [@nielsnl68](https://github.com/nielsnl68) made their first contribution in [#235](https://github.com/OHF-Voice/linux-voice-assistant/pull/235)
|
||||||
|
* [@aryanhasgithub](https://github.com/aryanhasgithub) made their first contribution in [#254](https://github.com/OHF-Voice/linux-voice-assistant/pull/254)
|
||||||
|
* [@interkelstar](https://github.com/interkelstar) made their first contribution in [#256](https://github.com/OHF-Voice/linux-voice-assistant/pull/256)
|
||||||
|
* [@mweinelt](https://github.com/mweinelt) made their first contribution in [#270](https://github.com/OHF-Voice/linux-voice-assistant/pull/270)
|
||||||
|
* [@genericJE](https://github.com/genericJE) made their first contribution in [#299](https://github.com/OHF-Voice/linux-voice-assistant/pull/299)
|
||||||
|
* [@AlecJDavidson](https://github.com/AlecJDavidson) made their first contribution in [#303](https://github.com/OHF-Voice/linux-voice-assistant/pull/303)
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.9...v1.1.10
|
||||||
|
|
||||||
|
[Changes][v1.1.10]
|
||||||
|
|
||||||
|
|
||||||
|
<a id="v1.1.9"></a>
|
||||||
|
# [v1.1.9](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.9) - 2026-03-11
|
||||||
|
|
||||||
|
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
### Other Changes
|
||||||
|
* Cookie file and other default parameters by [@florian-asche](https://github.com/florian-asche) in [#231](https://github.com/OHF-Voice/linux-voice-assistant/pull/231)
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.8...v1.1.9
|
||||||
|
|
||||||
|
[Changes][v1.1.9]
|
||||||
|
|
||||||
|
|
||||||
|
<a id="v1.1.8"></a>
|
||||||
|
# [v1.1.8](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.8) - 2026-03-11
|
||||||
|
|
||||||
|
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
### Other Changes
|
||||||
|
* Add changelog generator based on release information by [@florian-asche](https://github.com/florian-asche) in [#226](https://github.com/OHF-Voice/linux-voice-assistant/pull/226)
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.7...v1.1.8
|
||||||
|
|
||||||
|
[Changes][v1.1.8]
|
||||||
|
|
||||||
|
|
||||||
|
<a id="v1.1.7"></a>
|
||||||
|
# [v1.1.7](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.7) - 2026-03-11
|
||||||
|
|
||||||
|
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
### Exciting New Features 🎉
|
||||||
|
* Add linting scripts and pipeline by [@florian-asche](https://github.com/florian-asche) in [#201](https://github.com/OHF-Voice/linux-voice-assistant/pull/201)
|
||||||
|
### Other Changes
|
||||||
|
* add build flag option for low end devices by [@florian-asche](https://github.com/florian-asche) in [#189](https://github.com/OHF-Voice/linux-voice-assistant/pull/189)
|
||||||
|
* replaced branch build by PR build only by [@florian-asche](https://github.com/florian-asche) in [#203](https://github.com/OHF-Voice/linux-voice-assistant/pull/203)
|
||||||
|
* Change when lint pipeline will start by [@florian-asche](https://github.com/florian-asche) in [#204](https://github.com/OHF-Voice/linux-voice-assistant/pull/204)
|
||||||
|
* fix(script): use os.environ instead of subprocess.environ in setup script by [@florian-asche](https://github.com/florian-asche) in [#205](https://github.com/OHF-Voice/linux-voice-assistant/pull/205)
|
||||||
|
* docs: add documentation for low power device setup and improve setup script by [@florian-asche](https://github.com/florian-asche) in [#206](https://github.com/OHF-Voice/linux-voice-assistant/pull/206)
|
||||||
|
* fix: cookie error ([#127](https://github.com/OHF-Voice/linux-voice-assistant/issues/127)) by [@florian-asche](https://github.com/florian-asche) in [#210](https://github.com/OHF-Voice/linux-voice-assistant/pull/210)
|
||||||
|
* fix(config): remove unix: prefix from default pulseaudio socket path … by [@florian-asche](https://github.com/florian-asche) in [#221](https://github.com/OHF-Voice/linux-voice-assistant/pull/221)
|
||||||
|
* Update the changelog file automatically on each release by [@omaramin-2000](https://github.com/omaramin-2000) in [#224](https://github.com/OHF-Voice/linux-voice-assistant/pull/224)
|
||||||
|
* Enhance hardware compatibility details in README by [@Hedda](https://github.com/Hedda) in [#222](https://github.com/OHF-Voice/linux-voice-assistant/pull/222)
|
||||||
|
* add changelog based on release notes by [@florian-asche](https://github.com/florian-asche) in [#225](https://github.com/OHF-Voice/linux-voice-assistant/pull/225)
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.6...v1.1.7
|
||||||
|
|
||||||
|
[Changes][v1.1.7]
|
||||||
|
|
||||||
|
|
||||||
|
<a id="v1.1.6"></a>
|
||||||
|
# [v1.1.6](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.6) - 2026-03-04
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
* Bugfix: start listening after wakeup sound by [@florian-asche](https://github.com/florian-asche) in [#185](https://github.com/OHF-Voice/linux-voice-assistant/pull/185)
|
||||||
|
* added default sample rate to pulse documentation by [@florian-asche](https://github.com/florian-asche) in [#190](https://github.com/OHF-Voice/linux-voice-assistant/pull/190)
|
||||||
|
* Bugfix: Prevent wake word from interrupting active voice pipeline by [@ekiczek](https://github.com/ekiczek) in [#151](https://github.com/OHF-Voice/linux-voice-assistant/pull/151)
|
||||||
|
* Bugfix: ensure wake word state is set after the wake sound by [@florian-asche](https://github.com/florian-asche) in [#198](https://github.com/OHF-Voice/linux-voice-assistant/pull/198)
|
||||||
|
* Improvement: script/run error handling and return code consistency by [@florian-asche](https://github.com/florian-asche) in [#186](https://github.com/OHF-Voice/linux-voice-assistant/pull/186)
|
||||||
|
* Improvement: move portcheck into python application by [@florian-asche](https://github.com/florian-asche) in [#187](https://github.com/OHF-Voice/linux-voice-assistant/pull/187)
|
||||||
|
* Improvement: help output on application parameters by [@florian-asche](https://github.com/florian-asche) in [#188](https://github.com/OHF-Voice/linux-voice-assistant/pull/188)
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.5...v1.1.6
|
||||||
|
|
||||||
|
[Changes][v1.1.6]
|
||||||
|
|
||||||
|
|
||||||
|
<a id="v1.1.5"></a>
|
||||||
|
# [v1.1.5](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.5) - 2026-02-27
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
* Improve documentation by [@florian-asche](https://github.com/florian-asche) in [#166](https://github.com/OHF-Voice/linux-voice-assistant/pull/166)
|
||||||
|
* Add version and hash to docker container by [@florian-asche](https://github.com/florian-asche) in [#167](https://github.com/OHF-Voice/linux-voice-assistant/pull/167)
|
||||||
|
* Fix timer finished sound not looping by [@ekiczek](https://github.com/ekiczek) in [#159](https://github.com/OHF-Voice/linux-voice-assistant/pull/159)
|
||||||
|
* Volume persistence across restarts by [@omaramin-2000](https://github.com/omaramin-2000) in [#168](https://github.com/OHF-Voice/linux-voice-assistant/pull/168)
|
||||||
|
* docs: add info about reboot by [@florian-asche](https://github.com/florian-asche) in [#175](https://github.com/OHF-Voice/linux-voice-assistant/pull/175)
|
||||||
|
* Enhance play method for playlist support by [@omaramin-2000](https://github.com/omaramin-2000) in [#172](https://github.com/OHF-Voice/linux-voice-assistant/pull/172)
|
||||||
|
* Allow portainer stack to point directly to docker-compose.yml by [@lowlyocean](https://github.com/lowlyocean) in [#177](https://github.com/OHF-Voice/linux-voice-assistant/pull/177)
|
||||||
|
* Fix: Suppress spurious end-file event when starting playback by [@florian-asche](https://github.com/florian-asche) in [#179](https://github.com/OHF-Voice/linux-voice-assistant/pull/179)
|
||||||
|
* Fix: tts.speak and announce by [@florian-asche](https://github.com/florian-asche) in [#179](https://github.com/OHF-Voice/linux-voice-assistant/pull/179)
|
||||||
|
* Fix: duck unduck on timers by [@florian-asche](https://github.com/florian-asche) in [#179](https://github.com/OHF-Voice/linux-voice-assistant/pull/179)
|
||||||
|
|
||||||
|
## New Contributors
|
||||||
|
* [@ekiczek](https://github.com/ekiczek) made their first contribution in [#159](https://github.com/OHF-Voice/linux-voice-assistant/pull/159)
|
||||||
|
* [@lowlyocean](https://github.com/lowlyocean) made their first contribution in [#177](https://github.com/OHF-Voice/linux-voice-assistant/pull/177)
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.4...v1.1.5
|
||||||
|
|
||||||
|
[Changes][v1.1.5]
|
||||||
|
|
||||||
|
|
||||||
|
<a id="v1.1.4"></a>
|
||||||
|
# [v1.1.4](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.4) - 2026-02-20
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
* Documentation: Correct LVA_USER_ID variable name where wrong by [@giobber](https://github.com/giobber) in [#155](https://github.com/OHF-Voice/linux-voice-assistant/pull/155)
|
||||||
|
* Documentation by [@florian-asche](https://github.com/florian-asche) in [#162](https://github.com/OHF-Voice/linux-voice-assistant/pull/162)
|
||||||
|
* Added custom paths to docker for wakeword and sounds by [@florian-asche](https://github.com/florian-asche) in [#165](https://github.com/OHF-Voice/linux-voice-assistant/pull/165)
|
||||||
|
* Changed default pipewire configuration by [@florian-asche](https://github.com/florian-asche) in [#165](https://github.com/OHF-Voice/linux-voice-assistant/pull/165)
|
||||||
|
* Added wake-word-dir variable by [@florian-asche](https://github.com/florian-asche) in [#165](https://github.com/OHF-Voice/linux-voice-assistant/pull/165)
|
||||||
|
* Updated documentation regarding wakeword and sounds and some other things. by [@florian-asche](https://github.com/florian-asche) in [#165](https://github.com/OHF-Voice/linux-voice-assistant/pull/165)
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.3...v1.1.4
|
||||||
|
|
||||||
|
[Changes][v1.1.4]
|
||||||
|
|
||||||
|
|
||||||
|
<a id="v1.1.3"></a>
|
||||||
|
# [v1.1.3](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.3) - 2026-02-14
|
||||||
|
|
||||||
|
## Hotfix:
|
||||||
|
Fixed a bug where the application crashes when the new network-interface parameter is used.
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
* Update documentation, correcting typo by [@giobber](https://github.com/giobber) in [#144](https://github.com/OHF-Voice/linux-voice-assistant/pull/144)
|
||||||
|
* Hotfix: Typo in network-interface output order by [@florian-asche](https://github.com/florian-asche) in [#146](https://github.com/OHF-Voice/linux-voice-assistant/pull/146)
|
||||||
|
|
||||||
|
## New Contributors
|
||||||
|
* [@giobber](https://github.com/giobber) made their first contribution in [#144](https://github.com/OHF-Voice/linux-voice-assistant/pull/144)
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.2...v1.1.3
|
||||||
|
|
||||||
|
[Changes][v1.1.3]
|
||||||
|
|
||||||
|
|
||||||
|
<a id="v1.1.2"></a>
|
||||||
|
# [v1.1.2](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.2) - 2026-02-13
|
||||||
|
|
||||||
|
## Breaking Change
|
||||||
|
* A previous bug in the MAC address detection could have resulted in a device being created with an incorrect address. After installing the new version, it may be necessary to remove and re-add the device in Home Assistant.
|
||||||
|
* The docker-compose.yml and .env.example files were updated. Please review and adapt your configuration if required.
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
* Add open home foundation badge to readme by [@omaramin-2000](https://github.com/omaramin-2000) in [#138](https://github.com/OHF-Voice/linux-voice-assistant/pull/138)
|
||||||
|
* Update README.md by [@Hedda](https://github.com/Hedda) in [#31](https://github.com/OHF-Voice/linux-voice-assistant/pull/31)
|
||||||
|
* Changes EXTRA_ARGS variable to bash array to better handle variable expansion by [@Ziris85](https://github.com/Ziris85) in [#136](https://github.com/OHF-Voice/linux-voice-assistant/pull/136)
|
||||||
|
* change client-name validation (no spaces allowed) and fix example configuration by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
|
||||||
|
* add first version of docker-compose for developers by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
|
||||||
|
* add variable for network-interface with auto-detection enabled by default by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
|
||||||
|
* add mac-address autodetection based on detected or specified network-interface by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
|
||||||
|
* switch from listening on all ip-addresses to binding to the ip-address of the default interface by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
|
||||||
|
* add missing variables and fix variable names by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
|
||||||
|
* fix non-working variables (e.g. sound-files) by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
|
||||||
|
* move auto-name creation from shell to python (name is now optional and auto-generated if not provided) by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
|
||||||
|
* Change download link for docker-compose setup to latest version by [@florian-asche](https://github.com/florian-asche) in [#141](https://github.com/OHF-Voice/linux-voice-assistant/pull/141)
|
||||||
|
|
||||||
|
## New Contributors
|
||||||
|
* [@Hedda](https://github.com/Hedda) made their first contribution in [#31](https://github.com/OHF-Voice/linux-voice-assistant/pull/31)
|
||||||
|
* [@Ziris85](https://github.com/Ziris85) made their first contribution in [#136](https://github.com/OHF-Voice/linux-voice-assistant/pull/136)
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.1...v1.1.2
|
||||||
|
|
||||||
|
[Changes][v1.1.2]
|
||||||
|
|
||||||
|
|
||||||
|
<a id="v1.1.1"></a>
|
||||||
|
# [v1.1.1](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.1) - 2026-02-10
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
* media_player: handle STOP command correctly (fixes [#128](https://github.com/OHF-Voice/linux-voice-assistant/issues/128)) by [@pgrafe](https://github.com/pgrafe) in [#129](https://github.com/OHF-Voice/linux-voice-assistant/pull/129)
|
||||||
|
* changed github workflow naming by [@florian-asche](https://github.com/florian-asche) in [#132](https://github.com/OHF-Voice/linux-voice-assistant/pull/132)
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.0...v1.1.1
|
||||||
|
|
||||||
|
[Changes][v1.1.1]
|
||||||
|
|
||||||
|
|
||||||
|
<a id="v1.1.0"></a>
|
||||||
|
# [v1.1.0](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.0) - 2026-02-10
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
* Fix PEP 508 dependency operator: `=>` -> `>=` by [@ms1design](https://github.com/ms1design) in [#14](https://github.com/OHF-Voice/linux-voice-assistant/pull/14)
|
||||||
|
* Support custom/external wake words by [@synesthesiam](https://github.com/synesthesiam) in [#55](https://github.com/OHF-Voice/linux-voice-assistant/pull/55)
|
||||||
|
* Add an option to play thinking sound by [@omaramin-2000](https://github.com/omaramin-2000) in [#30](https://github.com/OHF-Voice/linux-voice-assistant/pull/30)
|
||||||
|
* Add mute/unmute sounds by [@omaramin-2000](https://github.com/omaramin-2000) in [#82](https://github.com/OHF-Voice/linux-voice-assistant/pull/82)
|
||||||
|
* Fix issues [#98](https://github.com/OHF-Voice/linux-voice-assistant/issues/98) and [#87](https://github.com/OHF-Voice/linux-voice-assistant/issues/87) by [@omaramin-2000](https://github.com/omaramin-2000) in [#100](https://github.com/OHF-Voice/linux-voice-assistant/pull/100)
|
||||||
|
* Merge beta changes by [@omaramin-2000](https://github.com/omaramin-2000) in [#112](https://github.com/OHF-Voice/linux-voice-assistant/pull/112)
|
||||||
|
* Add sound customization by [@omaramin-2000](https://github.com/omaramin-2000) in [#116](https://github.com/OHF-Voice/linux-voice-assistant/pull/116)
|
||||||
|
* Fix some format and linting issues by [@jaminh](https://github.com/jaminh) in [#120](https://github.com/OHF-Voice/linux-voice-assistant/pull/120)
|
||||||
|
* removed disconnected log message in process_packet function by [@florian-asche](https://github.com/florian-asche) in [#124](https://github.com/OHF-Voice/linux-voice-assistant/pull/124)
|
||||||
|
* Handle announcement separately from the media player by [@omaramin-2000](https://github.com/omaramin-2000) in [#125](https://github.com/OHF-Voice/linux-voice-assistant/pull/125)
|
||||||
|
* Don't require name for list commands by [@jaminh](https://github.com/jaminh) in [#67](https://github.com/OHF-Voice/linux-voice-assistant/pull/67)
|
||||||
|
* Fix mpv pause state when loading new media by [@pgrafe](https://github.com/pgrafe) in [#123](https://github.com/OHF-Voice/linux-voice-assistant/pull/123)
|
||||||
|
* Add docker build pipeline and compose files by [@florian-asche](https://github.com/florian-asche) in [#118](https://github.com/OHF-Voice/linux-voice-assistant/pull/118)
|
||||||
|
|
||||||
|
## New Contributors
|
||||||
|
* [@ms1design](https://github.com/ms1design) made their first contribution in [#14](https://github.com/OHF-Voice/linux-voice-assistant/pull/14)
|
||||||
|
* [@synesthesiam](https://github.com/synesthesiam) made their first contribution in [#55](https://github.com/OHF-Voice/linux-voice-assistant/pull/55)
|
||||||
|
* [@omaramin-2000](https://github.com/omaramin-2000) made their first contribution in [#30](https://github.com/OHF-Voice/linux-voice-assistant/pull/30)
|
||||||
|
* [@jaminh](https://github.com/jaminh) made their first contribution in [#120](https://github.com/OHF-Voice/linux-voice-assistant/pull/120)
|
||||||
|
* [@florian-asche](https://github.com/florian-asche) made their first contribution in [#124](https://github.com/OHF-Voice/linux-voice-assistant/pull/124)
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.0.0...v1.1.0
|
||||||
|
|
||||||
|
[Changes][v1.1.0]
|
||||||
|
|
||||||
|
|
||||||
|
<a id="v1.0.0"></a>
|
||||||
|
# [v1.0.0](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.0.0) - 2025-09-16
|
||||||
|
|
||||||
|
Initial release
|
||||||
|
|
||||||
|
[Changes][v1.0.0]
|
||||||
|
|
||||||
|
|
||||||
|
[v1.1.11]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.10...v1.1.11
|
||||||
|
[v1.1.10]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.9...v1.1.10
|
||||||
|
[v1.1.9]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.8...v1.1.9
|
||||||
|
[v1.1.8]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.7...v1.1.8
|
||||||
|
[v1.1.7]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.6...v1.1.7
|
||||||
|
[v1.1.6]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.5...v1.1.6
|
||||||
|
[v1.1.5]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.4...v1.1.5
|
||||||
|
[v1.1.4]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.3...v1.1.4
|
||||||
|
[v1.1.3]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.2...v1.1.3
|
||||||
|
[v1.1.2]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.1...v1.1.2
|
||||||
|
[v1.1.1]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.0...v1.1.1
|
||||||
|
[v1.1.0]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.0.0...v1.1.0
|
||||||
|
[v1.0.0]: https://github.com/OHF-Voice/linux-voice-assistant/tree/v1.0.0
|
||||||
|
|
||||||
|
<!-- Generated by https://github.com/rhysd/changelog-from-release v3.9.1 -->
|
||||||
+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,69 +2,115 @@
|
|||||||
|
|
||||||
[](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 voice satellite software for [Home Assistant](https://www.home-assistant.io/) remote voice control and interaction.
|
||||||
|
|
||||||
|
This project enables you to build a Linux-based voice assistant designed to use [Assist](https://www.home-assistant.io/voice_control/) for Home Assistant. It allows you to create your own smart speaker that runs on any x64 or ARM64 hardware capable of handling local audio processing (using PulseAudio).
|
||||||
|
|
||||||
|
Unlike simpler voice satellites that run on microcontrollers with very limited compute power, this setup can perform local wake word detection (OWW/MWW) and process some data on-device.
|
||||||
|
|
||||||
|
Because it runs on a full Linux system and offers access significantly more local computing resources for additional features and other integrations on the same satellite, this approach also provides greater flexibility for customization (such as for example experiment with using PipeWire).
|
||||||
|
|
||||||
[](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)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
## Usage:
|
- **Microphone:** Device must support 16kHz mono audio
|
||||||
|
- **CPU:** 1Ghz
|
||||||
|
- **Memory:** min. 512MB
|
||||||
|
- **Storage:** The OS and software is around 4GB
|
||||||
|
- **OS:** linux/amd64 or linux/aarch64
|
||||||
|
|
||||||
### Hardware:
|
A more extensive list for possible compatible hardware can be found in the [PiCompose documentation](https://github.com/florian-asche/PiCompose) but basically any microphone that works with [PipeWire (multimedia framework for Linux)](https://pipewire.org/) can in theory be used for voice input with the prebuild image from there, you should however preferably use a far-field microphone-array solution if want better result.
|
||||||
|
|
||||||
You can for example use the Raspberry Pi Zero 2W with the [Satellite1 Hat Board](https://futureproofhomes.net/products/satellite1-top-microphone-board), the [Respeaker Lite](https://wiki.seeedstudio.com/reSpeaker_usb_v3/) or the [Respeaker 2Mic_Hat](https://wiki.seeedstudio.com/ReSpeaker_2_Mics_Pi_HAT/). A list for possible compatible hardware can be found in the [PiCompose documentation](https://github.com/florian-asche/PiCompose) but basically any microphone that works with [Pipewire](https://pipewire.org/) can be used with the prebuild image.
|
Two solutions recommended for setups today is:
|
||||||
|
|
||||||
### Software:
|
- use a Raspberry Pi Zero 2W (Single Board Computer with built-in WiFi) in combination with the [Satellite1 Hat Board](https://futureproofhomes.net/products/satellite1-top-microphone-board)
|
||||||
|
- use at least a Raspberry Pi 3 with the [Respeaker Lite](https://wiki.seeedstudio.com/reSpeaker_usb_v3/). The Respeaker Lite currently has a problem with the Zero 2W.
|
||||||
|
|
||||||
#### Installation:
|
Those mic-boards have microphone-array designed for far-field voice capture with the added benefit of using an onboard XMOS DSP microcontroller with custom firmware which does advanced audio pre-processing for microphone cleanup that result in very good voice recognition capabilities (as it runs algorithms for Noise Suppression, Acoustic Echo Cancellation, Interference Cancellation, and Automatic Gain Control).
|
||||||
|
|
||||||
We have different installation methods available (Docker, systemd), each with its own dedicated instructions.
|
Alternatively if on a lower budget then suggest could use other microphone-array boards like for example the [reSpeaker 2-Mics Pi HAT V2.0](https://wiki.seeedstudio.com/ReSpeaker_2_Mics_Pi_HAT/) (which uses a much more basic audio codec chip).
|
||||||
|
|
||||||
See [Linux-Voice-Assistant - Installation](docs/install.md).
|
## Usage
|
||||||
|
|
||||||
#### Parameter overview:
|
### Installation
|
||||||
|
|
||||||
``` sh
|
**Assist Satellite app for Home Assistant OS**
|
||||||
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]
|
|
||||||
|
For HA OS, we provide a finished [Assist Satellite](https://github.com/OHF-Voice/apps/tree/main/assist_satellite) app (formerly add-on), which uses the Linux Voice Assistant runtime to turn your HA host into a voice satellite.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> For now you first have to add the [OHF-Voice apps](https://github.com/OHF-Voice/apps) repo manually to the App Store repositroy inside Home Assistant before you can install it.
|
||||||
|
|
||||||
|
Later you will be able to install it directly from the official add-on repository (but it is not yet published publicly there):
|
||||||
|
|
||||||
|
[](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https://github.com/home-assistant/addons)
|
||||||
|
|
||||||
|
Once installed, the satellite is automatically discovered by Home Assistant via the ESPHome integration.
|
||||||
|
|
||||||
|
**Raspberry Pi prebuilt image**
|
||||||
|
|
||||||
|
For Raspberry Pi users, we provide a prebuild image that can be flashed to a SD card. See [PiCompose](https://github.com/florian-asche/PiCompose).
|
||||||
|
|
||||||
|
**Docker / bare metal**
|
||||||
|
|
||||||
|
For all other users, we have different installation methods available (Docker, systemd), each with its own dedicated instructions. See [Linux-Voice-Assistant - Installation](docs/install.md).
|
||||||
|
|
||||||
|
### Parameter overview
|
||||||
|
|
||||||
|
💡 **Note:** There is an [environment variable](docs/install_application.md#environment-variables-reference) for each parameter if you use docker or systemd based setup.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE] [--list-input-devices] [--audio-input-block-size AUDIO_INPUT_BLOCK_SIZE] [--audio-output-device AUDIO_OUTPUT_DEVICE] [--list-output-devices] [--wake-word-dir WAKE_WORD_DIR] [--mic-auto-gain] [--mic-noise-suppression]
|
||||||
[--wake-model WAKE_MODEL] [--stop-model STOP_MODEL] [--download-dir DOWNLOAD_DIR] [--refractory-seconds REFRACTORY_SECONDS] [--wakeup-sound WAKEUP_SOUND] [--timer-finished-sound TIMER_FINISHED_SOUND] [--processing-sound PROCESSING_SOUND]
|
[--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]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
|
||||||
|-----------|-------------|---------|
|
|
||||||
| `--name` | Name of the voice assistant device (required) | Autogenerated (`lva-MAC-ADDRESS`) |
|
|
||||||
| `--audio-input-device` | Soundcard name for input device | System default microphone |
|
|
||||||
| `--audio-input-block-size` | Audio input block size in samples | 1024 |
|
|
||||||
| `--audio-output-device` | mpv name for output device | System default speaker |
|
|
||||||
| `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` |
|
|
||||||
| `--wake-model` | ID of active wake word model | `okay_nabu` |
|
|
||||||
| `--stop-model` | ID of stop model | `stop` |
|
|
||||||
| `--download-dir` | Directory to download custom wake word models, etc. | `local/` |
|
|
||||||
| `--refractory-seconds` | Seconds before wake word can be activated again | 2.0 |
|
|
||||||
| `--wakeup-sound` | Sound file played when wake word is detected | `sounds/wake_word_triggered.flac` |
|
|
||||||
| `--timer-finished-sound` | Sound file played when timer finishes | `sounds/timer_finished.flac` |
|
|
||||||
| `--processing-sound` | Sound played while assistant is processing | `sounds/processing.wav` |
|
|
||||||
| `--mute-sound` | Sound played when muting the assistant | `sounds/mute_switch_on.flac` |
|
|
||||||
| `--unmute-sound` | Sound played when unmuting the assistant | `sounds/mute_switch_off.flac` |
|
|
||||||
| `--preferences-file` | Path to preferences JSON file | `preferences.json` |
|
|
||||||
| `--host` | IP-Address for ESPHome server, use 0.0.0.0 for all | Autodetected |
|
|
||||||
| `--network-interface` | Network interface for ESPHome server | Autodetected |
|
|
||||||
| `--port` | Port for ESPHome server | 6053 |
|
|
||||||
| `--enable-thinking-sound` | Enable thinking sound on startup | False |
|
|
||||||
| `--debug` | Print DEBUG messages to console | False |
|
|
||||||
|
|
||||||
## Build Information:
|
| Parameter | Description | Default |
|
||||||
|
| ---------------------------- | --------------------------------------------------------------- | ----------------------------------- |
|
||||||
|
| `--name` | Name of the voice assistant device (required) | Autogenerated (`lva-MAC-ADDRESS`) |
|
||||||
|
| `--audio-input-device` | Soundcard name for input device | Autodetected |
|
||||||
|
| `--audio-input-block-size` | Audio input block size in samples | 1024 |
|
||||||
|
| `--audio-output-device` | mpv name for output device | Autodetected |
|
||||||
|
| `--mic-volume` | Control microphone volume | 100 |
|
||||||
|
| `--mic-auto-gain` | Add WebRTC Gain to Mic | 0 |
|
||||||
|
| `--mic-noise-suppression` | Add WebRTC Noise Suppression to Mic | 0 |
|
||||||
|
| `--audio-input-channels` | Number of microphone audio channels to stream | 2 |
|
||||||
|
| `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` |
|
||||||
|
| `--wake-model` | ID of active wake word model | `okay_nabu` |
|
||||||
|
| `--stop-model` | ID of stop model | `stop` |
|
||||||
|
| `--download-dir` | Directory to download custom wake word models, etc. | `local/` |
|
||||||
|
| `--refractory-seconds` | Seconds before wake word can be activated again | 2.0 |
|
||||||
|
| `--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` |
|
||||||
|
| `--timer-finished-sound` | Sound file played when timer finishes | `sounds/timer_finished.flac` |
|
||||||
|
| `--processing-sound` | Sound played while assistant is processing | `sounds/processing.wav` |
|
||||||
|
| `--mute-sound` | Sound played when muting the assistant | `sounds/mute_switch_on.flac` |
|
||||||
|
| `--unmute-sound` | Sound played when unmuting the assistant | `sounds/mute_switch_off.flac` |
|
||||||
|
| `--preferences-file` | Path to preferences JSON file | `preferences.json` |
|
||||||
|
| `--host` | IP-Address for ESPHome server, use 0.0.0.0 for all | Autodetected |
|
||||||
|
| `--network-interface` | Network interface for ESPHome server | Autodetected |
|
||||||
|
| `--port` | Port for ESPHome server | 6053 |
|
||||||
|
| `--enable-thinking-sound` | Enable thinking sound on startup | False |
|
||||||
|
| `--debug` | Print DEBUG messages to console | False |
|
||||||
|
| `--output-only` | Enable output only mode | False |
|
||||||
|
|
||||||
|
💡 **Note:** There is a detailed explanation on the gain, noise suppression, and wake word sensitivity flags in the [audio options](docs/audio_options.md) file.
|
||||||
|
|
||||||
|
## 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 +121,70 @@ 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:
|
||||||
+44
-23
@@ -40,10 +40,30 @@ 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 [ -n "${AUDIO_INPUT_CHANNELS}" ]; then
|
||||||
|
EXTRA_ARGS+=( "--audio-input-channels" "$AUDIO_INPUT_CHANNELS" )
|
||||||
|
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
|
||||||
@@ -56,6 +76,10 @@ if [ -n "${REFACTORY_SECONDS}" ]; then
|
|||||||
EXTRA_ARGS+=( "--refractory-seconds" "$REFACTORY_SECONDS" )
|
EXTRA_ARGS+=( "--refractory-seconds" "$REFACTORY_SECONDS" )
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -n "${CONTINUE_CONVERSATION_DELAY}" ]; then
|
||||||
|
EXTRA_ARGS+=( "--continue-conversation-delay" "$CONTINUE_CONVERSATION_DELAY" )
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -n "${WAKEUP_SOUND}" ]; then
|
if [ -n "${WAKEUP_SOUND}" ]; then
|
||||||
EXTRA_ARGS+=( "--wakeup-sound" "$WAKEUP_SOUND" )
|
EXTRA_ARGS+=( "--wakeup-sound" "$WAKEUP_SOUND" )
|
||||||
fi
|
fi
|
||||||
@@ -76,13 +100,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 +143,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](https://github.com/OHF-Voice/webrtc-noise-gain) library) automatically adjusts the microphone input volume to keep it at a consistent level. If you're speaking quietly it boosts the signal, if you're speaking loudly it reduces it. Useful in the following ways:-
|
||||||
|
|
||||||
|
- Microphones that are too quiet by default
|
||||||
|
- Environments where you move around relative to the mic
|
||||||
|
- Keeping wake word detection consistent regardless of speaking volume
|
||||||
|
|
||||||
|
### Noise Suppression
|
||||||
|
|
||||||
|
When you change noise suppression, LVA(using the [webrtc](https://github.com/OHF-Voice/webrtc-noise-gain) library) filters out constant background noise from the audio signal. It works by learning what "silence" sounds like in your environment and subtracting that from the audio. Useful in the following ways:
|
||||||
|
|
||||||
|
- Noisy environments
|
||||||
|
- Improving STT accuracy by sending cleaner audio to Home Assistant
|
||||||
|
- Reducing false wake word triggers from background noise
|
||||||
|
|
||||||
|
## Using Gain and Noise Suppression with LVA
|
||||||
|
|
||||||
|
LVA implements gain and Noise Suppression in two ways:
|
||||||
|
|
||||||
|
- [CLI Argument/ENV file](#cli-argumentenv-file)
|
||||||
|
- [Home Assistant Entity](#home-assistant-entity)
|
||||||
|
|
||||||
|
### CLI Argument/ENV file
|
||||||
|
|
||||||
|
#### CLI Argument
|
||||||
|
|
||||||
|
Using docker-entrypoint.sh the following flags can be used to set gain and noise suppression:
|
||||||
|
- `--mic-auto-gain`(ranges from 0-31)
|
||||||
|
- `--mic-noise-suppression`(ranges from 0-4)
|
||||||
|
|
||||||
|
#### ENV file
|
||||||
|
|
||||||
|
You can add/edit these variables in the .env file to set gain and noise suppression:
|
||||||
|
- `MIC_AUTO_GAIN="0"`
|
||||||
|
- `MIC_NOISE_SUPPRESSION="0"`
|
||||||
|
|
||||||
|
|
||||||
|
### Home Assistant Entity
|
||||||
|
|
||||||
|
LVA exposes two slider entities to change these gain and noise suppression from which you can edit the gain and noise suppression at runtime.
|
||||||
|
|
||||||
|
💡 **Note:** Setting the flag and ENV values to 0 turns them off and are not used.
|
||||||
|
|
||||||
|
💡 **Note:** Keep in mind that when the flags are set they will overwrite the previous value in the preferences file and also they will be overridden if the value is changed in Home Assistant(Also applies to the ENV file but the ENV file will always overwrite the last value on startup).
|
||||||
|
|
||||||
|
|
||||||
|
## Wake Word Sensitivity
|
||||||
|
|
||||||
|
LVA exposes three numeric controls in the Home Assistant device page for fine-grained sensitivity tuning. These let you dial in the exact probability threshold that best matches your microphone quality, room acoustics, and false-activation tolerance.
|
||||||
|
|
||||||
|
| Entity | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| **Wake Word 1 Sensitivity** | Probability cutoff for the primary wake word | From model manifest |
|
||||||
|
| **Wake Word 2 Sensitivity** | Probability cutoff for the secondary wake word (if active) | From model manifest |
|
||||||
|
| **Stop Word Sensitivity** | Probability cutoff for the stop word | From model manifest |
|
||||||
|
|
||||||
|
Values range from `0.0` to `1.0`:
|
||||||
|
|
||||||
|
- **Higher value** (e.g. `0.95`) → more selective, fewer false activations, may miss quieter or accented speech
|
||||||
|
- **Lower value** (e.g. `0.50`) → more responsive, but more likely to trigger on similar-sounding words
|
||||||
|
The defaults are read directly from each model's `.json` manifest file (the `probability_cutoff` field), so bundled models like `okay_nabu` (0.85), `hey_jarvis` (0.97), and custom downloaded models all start at their author-tested baseline. Changes made in the Home Assistant UI are persisted in `preferences.json` and survive restarts.
|
||||||
|
|
||||||
|
**Tuning guide:**
|
||||||
|
|
||||||
|
- If the wake word **rarely activates** (misses your voice): lower the value slightly, e.g. by `0.05` steps
|
||||||
|
- If the wake word **activates too often** (false triggers from TV, music, or similar words): raise the value slightly
|
||||||
|
- Start from the model's default and make small adjustments — a change of `0.05–0.10` is usually enough to notice a difference
|
||||||
|
- Far-field microphones with noise cancellation (e.g. ReSpeaker, Satellite1) generally work well at the default values; basic USB microphones may need a lower threshold
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
+65
-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,49 +244,77 @@ 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 | 100 |
|
||||||
|
| `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:
|
||||||
|
|
||||||
The following wake word models are available:
|
The following wake word models are available:
|
||||||
|
|
||||||
|
**microWakeWord:**
|
||||||
- `okay_nabu` - Default wake word
|
- `okay_nabu` - Default wake word
|
||||||
- `alexa` - Alexa wake word
|
- `alexa` - Alexa wake word
|
||||||
- `hey_jarvis` - Jarvis wake word
|
- `hey_jarvis` - Jarvis wake word
|
||||||
- `hey_mycroft` - Mycroft wake word
|
- `hey_mycroft` - Mycroft wake word
|
||||||
|
- `hey_morgan` - Morgan wake word
|
||||||
- `hey_luna` - Luna wake word
|
- `hey_luna` - Luna wake word
|
||||||
- `hey_home_assistant` - Home Assistant wake word
|
- `hey_home_assistant` - Home Assistant wake word
|
||||||
- `stop` - Stop wake word
|
- `stop` - Stop wake word
|
||||||
- `okay_computer` - Okay Computer wake word
|
- `okay_computer` - Okay Computer wake word
|
||||||
- `choo_choo_homie` - Choo Choo Homie wake word
|
- `choo_choo_homie` - Choo Choo Homie wake word
|
||||||
|
|
||||||
|
**openWakeWord:**
|
||||||
|
- `ok_nabu` - Default wake word
|
||||||
|
- `alexa` - Alexa wake word
|
||||||
|
- `hey_jarvis` - Jarvis wake word
|
||||||
|
- `hey_mycroft` - Mycroft wake word
|
||||||
|
- `hey_rhasspy` - Rhasspy wake word
|
||||||
|
|
||||||
### Wake Word:
|
|
||||||
|
|
||||||
💡 **Note:** The default value for this parameter is the `wakewords` directory in the repository.
|
### Custom Wake Word:
|
||||||
|
|
||||||
Change the default wake word with `--wake-model <id>` where `<id>` is the name of a model in the `wakewords` directory. For example, `--wake-model hey_jarvis` will load `wakewords/hey_jarvis.tflite` by default.
|
If you want to use your own wakewords, you can add them to the `wakewords/custom` aka `/var/lib/docker/volumes/lva_wakeword_custom/_data` directory and reference them in the `.env` file.
|
||||||
|
|
||||||
You can include more wakeword directories by adding `--wake-word-dir <DIR>` where `<DIR>` contains either [microWakeWord][] or [openWakeWord][] config files and `.tflite` models. For example, `--wake-word-dir wakewords/openWakeWord` will include the default wake words for openWakeWord.
|
Change the default wake word with `WAKE_MODEL <id>` where `<id>` is the name of a model in the `wakewords` directory. For example, `WAKE_MODEL hey_jarvis` will load `app/wakewords/hey_jarvis.tflite` by default.
|
||||||
|
|
||||||
|
You can include more wakeword directories by adding `WAKE_WORD_DIR <DIR>` where `<DIR>` contains either [microWakeWord][] or [openWakeWord][] config files and `.tflite` models. For example, `WAKE_WORD_DIR app/wakewords/openWakeWord` will include the default wake words for openWakeWord.
|
||||||
|
|
||||||
If you want to add [other wakewords][wakewords-collection], make sure to create a small JSON config file to identify it as an openWakeWord model. For example, download the [GLaDOS][glados] model to `glados.tflite` and create `glados.json` with:
|
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 +326,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.
+335
-139
@@ -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",
|
||||||
@@ -48,39 +57,50 @@ async def main() -> None:
|
|||||||
help="List audio input devices and exit",
|
help="List audio input devices and exit",
|
||||||
)
|
)
|
||||||
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-volume", type=int, default=100, choices=list(range(1, 101)), help="Microphone volume level (1 to 100)")
|
||||||
|
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
|
||||||
|
parser.add_argument("--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4))
|
||||||
|
parser.add_argument(
|
||||||
|
"--audio-input-channels",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
choices=(1, 2),
|
||||||
|
help="Number of mic channels to capture and stream (1=mono, 2=dual-channel voice)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
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",
|
||||||
@@ -89,12 +109,20 @@ async def main() -> None:
|
|||||||
help="Seconds before wake word can be activated again",
|
help="Seconds before wake word can be activated again",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--wakeup-sound",
|
"--continue-conversation-delay",
|
||||||
default=str(_SOUNDS_DIR / "wake_word_triggered.flac")
|
type=float,
|
||||||
|
default=0.5,
|
||||||
|
help="Seconds to wait after TTS finishes before opening the mic for continued conversation (default: 0.5)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--timer-finished-sound",
|
"--wakeup-sound",
|
||||||
default=str(_SOUNDS_DIR / "timer_finished.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(
|
||||||
|
"--timer-finished-sound",
|
||||||
|
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 +139,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",
|
||||||
default=6053,
|
type=int,
|
||||||
help="Port for ESPHome server (default: 6053)"
|
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 +193,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 +203,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 +269,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 +281,44 @@ 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_volume > 0.0:
|
||||||
|
preferences.mic_volume = args.mic_volume
|
||||||
|
if args.mic_auto_gain > 0:
|
||||||
|
preferences.mic_auto_gain = args.mic_auto_gain
|
||||||
|
|
||||||
|
if args.mic_noise_suppression > 0:
|
||||||
|
preferences.mic_noise_suppression = args.mic_noise_suppression
|
||||||
|
|
||||||
# Load wake/stop models
|
# 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 +335,69 @@ 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,
|
||||||
|
continue_conversation_delay=args.continue_conversation_delay,
|
||||||
|
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,
|
||||||
|
audio_input_channels=args.audio_input_channels,
|
||||||
|
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 +406,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:
|
||||||
@@ -347,6 +428,7 @@ async def main() -> None:
|
|||||||
|
|
||||||
def process_audio(state: ServerState, mic, block_size: int):
|
def process_audio(state: ServerState, mic, block_size: int):
|
||||||
"""Process audio chunks from the microphone."""
|
"""Process audio chunks from the microphone."""
|
||||||
|
n_channels = state.audio_input_channels
|
||||||
|
|
||||||
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
|
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
|
||||||
micro_features: Optional[MicroWakeWordFeatures] = None
|
micro_features: Optional[MicroWakeWordFeatures] = None
|
||||||
@@ -357,35 +439,119 @@ 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=n_channels, blocksize=block_size) as mic_in:
|
||||||
while True:
|
while True:
|
||||||
audio_chunk_array = mic_in.record(block_size).reshape(-1)
|
# Shape: (block_size, n_channels) for stereo, (block_size, 1) for mono.
|
||||||
audio_chunk = (
|
raw = mic_in.record(block_size) # float32, range [-1, 1]
|
||||||
(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
|
|
||||||
.tobytes()
|
|
||||||
)
|
|
||||||
|
|
||||||
if state.satellite is None:
|
# Build per-channel byte arrays. Channel 0 is the primary
|
||||||
|
# microphone; channel 1 (when present) is the reference/speaker
|
||||||
|
# feed used for server-side AEC.
|
||||||
|
channel_chunks: list[bytes] = []
|
||||||
|
for ch in range(n_channels):
|
||||||
|
col = raw[:, ch] if n_channels > 1 else raw.reshape(-1)
|
||||||
|
chunk = (np.clip(col * mic_vol_scalar, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
|
||||||
|
channel_chunks.append(chunk)
|
||||||
|
|
||||||
|
# Primary channel drives WebRTC and wake-word detection.
|
||||||
|
audio_chunk = channel_chunks[0]
|
||||||
|
agc = state.preferences.mic_auto_gain or 0
|
||||||
|
ns = state.preferences.mic_noise_suppression or 0
|
||||||
|
|
||||||
|
if agc > 0 or ns > 0:
|
||||||
|
if webrtc is None:
|
||||||
|
webrtc = WebRTCProcessor(agc_level=agc, ns_level=ns)
|
||||||
|
else:
|
||||||
|
webrtc.update_settings(agc, ns)
|
||||||
|
audio_chunk = webrtc.process(audio_chunk)
|
||||||
|
if not audio_chunk:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if state.satellite is None or not hasattr(state.satellite, "_is_streaming_audio"):
|
||||||
continue
|
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()
|
||||||
|
|
||||||
@@ -393,7 +559,9 @@ def process_audio(state: ServerState, mic, block_size: int):
|
|||||||
oww_features = OpenWakeWordFeatures.from_builtin()
|
oww_features = OpenWakeWordFeatures.from_builtin()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
state.satellite.handle_audio(audio_chunk)
|
# Both channels travel in one message: data=ch0 (enhanced), data2=ch1 (raw reference)
|
||||||
|
audio_chunk_2 = channel_chunks[1] if n_channels >= 2 else None
|
||||||
|
state.satellite.handle_audio(audio_chunk, audio_chunk_2)
|
||||||
|
|
||||||
assert micro_features is not None
|
assert micro_features is not None
|
||||||
micro_inputs.clear()
|
micro_inputs.clear()
|
||||||
@@ -404,38 +572,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 +637,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]"
|
||||||
@@ -82,16 +98,36 @@ class ServerState:
|
|||||||
preferences: Preferences
|
preferences: Preferences
|
||||||
preferences_path: Path
|
preferences_path: Path
|
||||||
download_dir: Path
|
download_dir: Path
|
||||||
|
continue_conversation_delay: float = 0.5 # seconds to wait after TTS before opening mic
|
||||||
|
|
||||||
media_player_entity: "Optional[MediaPlayerEntity]" = None
|
media_player_entity: "Optional[MediaPlayerEntity]" = None
|
||||||
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
|
||||||
|
audio_input_channels: int = 2 # number of mic channels to stream
|
||||||
|
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 +135,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")
|
||||||
)
|
self._player.stop(for_replacement=True)
|
||||||
# Not self.stop() → this would call done_callback
|
|
||||||
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)
|
||||||
@@ -105,4 +134,4 @@ class MpvMediaPlayer:
|
|||||||
def unduck(self) -> None:
|
def unduck(self) -> None:
|
||||||
"""Restore volume after ducking."""
|
"""Restore volume after ducking."""
|
||||||
self._log.debug("unduck() called")
|
self._log.debug("unduck() called")
|
||||||
self._player.unduck()
|
self._player.unduck()
|
||||||
@@ -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,12 +19,13 @@ 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()
|
||||||
|
|
||||||
# Volume handling
|
# Volume handling
|
||||||
self._user_volume: float = 100.0 # 0.0 – 100.0
|
self._user_volume: float = 100.0 # 0.0 – 100.0
|
||||||
self._duck_factor: float = 1.0 # 0.0 – 1.0
|
self._duck_factor: float = 1.0 # 0.0 – 1.0
|
||||||
|
|
||||||
# mpv setup
|
# mpv setup
|
||||||
self._mpv = mpv.MPV(
|
self._mpv = mpv.MPV(
|
||||||
@@ -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.
|
||||||
@@ -160,4 +211,4 @@ class LibMpvPlayer(AudioPlayer):
|
|||||||
|
|
||||||
def _set_state(self, new_state: PlayerState) -> None:
|
def _set_state(self, new_state: PlayerState) -> None:
|
||||||
"""Update internal player state."""
|
"""Update internal player state."""
|
||||||
self._state = new_state
|
self._state = new_state
|
||||||
+419
-119
@@ -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
|
||||||
|
|
||||||
@@ -53,6 +63,8 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()}
|
PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()}
|
||||||
|
|
||||||
|
_HAS_AUDIO_DATA2 = "data2" in {f.name for f in VoiceAssistantAudio.DESCRIPTOR.fields}
|
||||||
|
|
||||||
|
|
||||||
class VoiceSatelliteProtocol(APIServer):
|
class VoiceSatelliteProtocol(APIServer):
|
||||||
|
|
||||||
@@ -63,25 +75,31 @@ 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
|
||||||
|
)
|
||||||
|
# Channel 1 carries echo-reference audio; advertise SPEAKER so HA knows to use it for server-side AEC.
|
||||||
|
if state.audio_input_channels >= 2:
|
||||||
|
self.supported_features |= VoiceAssistantFeature.MULTI_CHANNEL_AUDIO # pylint: disable=no-member
|
||||||
|
|
||||||
|
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 +110,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 +141,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 +164,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 +332,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 +341,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 +377,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 +386,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 +421,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 +441,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 +453,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 +473,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 +497,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 +509,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,92 +532,163 @@ 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:
|
|
||||||
# Check external wake words (may require download)
|
|
||||||
external_wake_word = self._external_wake_words.get(wake_word_id)
|
|
||||||
if not external_wake_word:
|
|
||||||
continue
|
|
||||||
|
|
||||||
model_info = self._download_external_wake_word(external_wake_word)
|
|
||||||
if not model_info:
|
if not model_info:
|
||||||
continue
|
# Check external wake words (may require download)
|
||||||
|
external_wake_word = self._external_wake_words.get(wake_word_id)
|
||||||
|
if not external_wake_word:
|
||||||
|
continue
|
||||||
|
|
||||||
self.state.available_wake_words[wake_word_id] = model_info
|
model_info = self._download_external_wake_word(external_wake_word)
|
||||||
|
if not model_info:
|
||||||
|
continue
|
||||||
|
|
||||||
_LOGGER.debug("Loading wake word: %s", model_info.wake_word_path)
|
self.state.available_wake_words[wake_word_id] = model_info
|
||||||
self.state.wake_words[wake_word_id] = model_info.load()
|
|
||||||
|
|
||||||
_LOGGER.info("Wake word set: %s", wake_word_id)
|
_LOGGER.debug("Loading wake word: %s", model_info.wake_word_path)
|
||||||
active_wake_words.add(wake_word_id)
|
self.state.wake_words[wake_word_id] = model_info.load()
|
||||||
break
|
|
||||||
|
_LOGGER.info("Wake word set: %s", wake_word_id)
|
||||||
|
active_wake_words.add(wake_word_id)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
def handle_audio(self, audio_chunk: bytes) -> None:
|
# handle_audio — both channels in ONE message
|
||||||
|
def handle_audio(self, audio_chunk: bytes, audio_chunk_2: Optional[bytes] = None) -> None:
|
||||||
if not self._is_streaming_audio or self.state.muted:
|
if not self._is_streaming_audio or self.state.muted:
|
||||||
return
|
return
|
||||||
|
if _HAS_AUDIO_DATA2 and audio_chunk_2 is not None:
|
||||||
self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
|
self.send_messages([VoiceAssistantAudio(data=audio_chunk, data2=audio_chunk_2)])
|
||||||
|
else:
|
||||||
|
self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
|
||||||
|
|
||||||
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,27 +709,61 @@ 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._continue_conversation = False
|
||||||
self._is_streaming_audio = True
|
# Keep pipeline active during the settle delay so the mic stays closed
|
||||||
_LOGGER.debug("Continuing conversation")
|
# and does not capture the tail end of the TTS audio from the speaker.
|
||||||
|
self._pipeline_active = True
|
||||||
|
_LOGGER.debug("Continuing conversation after %.2fs settle delay", self.state.continue_conversation_delay)
|
||||||
|
|
||||||
|
def _start_continued_conversation() -> None:
|
||||||
|
if self.state.muted:
|
||||||
|
_LOGGER.debug("Skipping continued conversation: muted")
|
||||||
|
self._pipeline_active = False
|
||||||
|
self.unduck()
|
||||||
|
return
|
||||||
|
self.send_messages([VoiceAssistantRequest(start=True)])
|
||||||
|
self._is_streaming_audio = True
|
||||||
|
_LOGGER.debug("Continued conversation started")
|
||||||
|
|
||||||
|
threading.Timer(self.state.continue_conversation_delay, _start_continued_conversation).start()
|
||||||
else:
|
else:
|
||||||
|
self._continue_conversation = False
|
||||||
self.unduck()
|
self.unduck()
|
||||||
|
|
||||||
_LOGGER.debug("TTS response finished")
|
_LOGGER.debug("TTS response finished")
|
||||||
|
|
||||||
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.unduck()
|
||||||
|
self._timer_ring_start = None
|
||||||
return
|
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()
|
||||||
|
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 +776,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 +797,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 +813,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 +869,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
|
|
||||||
+82
-7
@@ -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,15 +16,13 @@ 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==45.3.1",
|
||||||
"netifaces2==0.0.22",
|
"netifaces2==0.0.22",
|
||||||
"soundcard<1",
|
"soundcard<1",
|
||||||
"numpy>=2,<3",
|
"numpy>=2,<3",
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"type": "micro",
|
||||||
|
"wake_word": "Hey Morgan",
|
||||||
|
"author": "John Karabudak",
|
||||||
|
"website": "https://johnthenerd.com",
|
||||||
|
"model": "hey_morgan.tflite",
|
||||||
|
"trained_languages": [
|
||||||
|
"en"
|
||||||
|
],
|
||||||
|
"version": 2,
|
||||||
|
"micro": {
|
||||||
|
"probability_cutoff": 0.9,
|
||||||
|
"sliding_window_size": 8,
|
||||||
|
"feature_step_size": 10,
|
||||||
|
"tensor_arena_size": 30000,
|
||||||
|
"minimum_esphome_version": "2024.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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