diff --git a/.dockerignore b/.dockerignore index dc7dac1..600cdd1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,15 +1,10 @@ * -!/linux_voice_assistant/*.py - !/pyproject.toml -!/README.md -!/script/setup -!/script/run - -!/sounds/*.flac - -!/wakewords/*.json -!/wakewords/*.tflite -!/wakewords/openWakeWord/*.json -!/wakewords/openWakeWord/*.tflite +!/setup.cfg +!/script/* +!/docker-entrypoint.sh +!/linux_voice_assistant/* +!/sounds/* +!/wakewords/* +!/wakewords/openWakeWord/* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..31621b3 --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# 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 +LVA_USER_ID="1000" +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/${LVA_USER_ID}/pulse/native" +LVA_XDG_RUNTIME_DIR="/run/user/${LVA_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): +# AUDIO_INPUT_DEVICE="default" + +### Audio output device (optional): +# AUDIO_OUTPUT_DEVICE="default" + +# Enable thinking sound (optional): +# ENABLE_THINKING_SOUND="1" + +# Wake model (optional): +# WAKE-MODEL="okay_nabu" + +# Refactory seconds (optional): +# REFACTORY_SECONDS="2" + +# Sound files (optional): +# WAKEUP_SOUND="sounds/wake_word_triggered.flac" +# TIMER_FINISHED_SOUND="sounds/timer_finished.flac" +# PROCESSING_SOUND="sounds/processing.wav" +# MUTE_SOUND="sounds/mute_switch_on.flac" +# UNMUTE_SOUND="sounds/mute_switch_off.flac" diff --git a/.github/workflow.md b/.github/workflow.md new file mode 100644 index 0000000..f834732 --- /dev/null +++ b/.github/workflow.md @@ -0,0 +1,77 @@ +# GitHub Actions Workflows + +This project uses three separate Docker build workflows that automatically build and publish Docker images to GitHub Container Registry (ghcr.io). + +## Workflows Overview + +| Workflow | Trigger | Image Tag(s) | +|----------|---------|--------------| +| [docker-build-release.yml](.github/workflows/docker-build-release.yml) | Tags: `v1.2.0`, `v1.2.0-alpha1` | `1.2.0`, `1.2`, `latest` | +| [docker-build-nightly.yml](.github/workflows/docker-build-nightly.yml) | Push to `main` | `nightly` | +| [docker-build-branch.yml](.github/workflows/docker-build-branch.yml) | All other branches | Branch name | + +## Detailed Description + +### Release Workflow (`docker-build-release.yml`) + +Publishes versioned images for new releases. + +**Triggers:** +- Tags matching `v[0-9]+.[0-9]+.[0-9]+` (e.g., `v1.0.0`) +- Tags matching `v[0-9]+.[0-9]+.[0-9]+-[a-zA-Z0-9]+` (e.g., `v1.0.0-alpha1`) + +**Image Tags:** +``` +ghcr.io/ohf-voice/linux-voice-assistant:1.0.0 +ghcr.io/ohf-voice/linux-voice-assistant:1.0 +ghcr.io/ohf-voice/linux-voice-assistant:1 +ghcr.io/ohf-voice/linux-voice-assistant:latest +``` + +### Nightly Workflow (`docker-build-nightly.yml`) + +Automatically builds nightly images from the main branch. + +**Triggers:** +- Push to `main` branch + +**Image Tag:** +``` +ghcr.io/ohf-voice/linux-voice-assistant:nightly +``` + +### Branch Workflow (`docker-build-branch.yml`) + +Builds images for all other branches. + +**Triggers:** +- Push to all branches except `main` +- No tags + +**Image Tag:** +``` +ghcr.io/ohf-voice/linux-voice-assistant: +``` + +## Multi-Architecture Support + +All workflows build for multiple architectures: +- `linux/amd64` (x86_64) +- `linux/aarch64` (ARM 64-bit, e.g., Raspberry Pi 4) + +## Setup Requirements + +**No additional setup needed!** The workflows use `GITHUB_TOKEN` which is automatically provided by GitHub Actions. + +## Creating a Release + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +This creates images with the following tags: +- `ghcr.io/ohf-voice/linux-voice-assistant:1.0.0` +- `ghcr.io/ohf-voice/linux-voice-assistant:1.0` +- `ghcr.io/ohf-voice/linux-voice-assistant:1` +- `ghcr.io/ohf-voice/linux-voice-assistant:latest` diff --git a/.github/workflows/docker-build-branch.yml b/.github/workflows/docker-build-branch.yml new file mode 100644 index 0000000..b62184f --- /dev/null +++ b/.github/workflows/docker-build-branch.yml @@ -0,0 +1,64 @@ +name: Build and publish the docker image + +on: + push: + branches-ignore: + - "main" + tags-ignore: + - "**" + +env: + REGISTRY: ghcr.io + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Extract clean branch name + id: extract_branch + run: | + echo "CLEAN_BRANCH_NAME=$(echo ${{ github.head_ref || github.ref_name }} | sed 's|.*/||')" >> $GITHUB_ENV + echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + + - name: Log in to the Container registry + uses: docker/login-action@v3.1.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ github.token }} + + # Build Docker Images (amd64 and arm64) + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v5.3.0 + with: + context: . + platforms: linux/amd64,linux/aarch64 + push: true + tags: | + ${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}:${{ env.CLEAN_BRANCH_NAME }} + labels: | + branch=${{ env.CLEAN_BRANCH_NAME }} + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1.4.4 + with: + subject-name: ${{ env.REGISTRY }}/${{ github.repository }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/docker-build-nightly.yml b/.github/workflows/docker-build-nightly.yml new file mode 100644 index 0000000..4403790 --- /dev/null +++ b/.github/workflows/docker-build-nightly.yml @@ -0,0 +1,53 @@ +--- +name: Build and publish the docker image + +on: + push: + branches: + - "main" + +env: + REGISTRY: ghcr.io + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set lowercase registry and repository + run: | + echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + - name: Log in to the Container registry + uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ github.token }} + # Build Docker Images (amd64 and arm64) + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push Docker image + id: push + uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0 + with: + context: . + platforms: linux/amd64,linux/aarch64 + push: true + tags: ${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}:nightly + labels: "nightly" + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1.4.4 + with: + subject-name: ${{ env.REGISTRY }}/${{ github.repository }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/docker-build-release.yml b/.github/workflows/docker-build-release.yml new file mode 100644 index 0000000..8006a4a --- /dev/null +++ b/.github/workflows/docker-build-release.yml @@ -0,0 +1,63 @@ +--- +name: Build and publish the docker image + +on: + push: + 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 + +env: + REGISTRY: ghcr.io + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Log in to the Container registry + uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ github.token }} + # Build Docker Images (amd64 and arm64) + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1 + with: + images: ${{ env.REGISTRY }}/${{ github.repository }} + # E.g. tag v1.2.0 produces 3 tags: '1.2.0', '1.2', and 'latest' + # https://github.com/docker/metadata-action?tab=readme-ov-file#semver + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + - name: Build and push Docker image + id: push + uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0 + with: + context: . + platforms: linux/amd64,linux/aarch64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ github.repository }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + diff --git a/.gitignore b/.gitignore index 26a59d7..e5462da 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ venv/ /dist/ /preferences.json /local/ + +# Docker +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..110688e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +FROM python:3.13-slim-trixie + +ENV LANG C.UTF-8 +ENV DEBIAN_FRONTEND=noninteractive +ENV PYTHONUNBUFFERED=1 + +LABEL \ + org.opencontainers.image.authors="Open Home Foundation" \ + org.opencontainers.image.description="Voice assistant for Home Assistant" \ + org.opencontainers.image.documentation="https://github.com/OHF-Voice/linux-voice-assistant/blob/main/README.md" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.source="https://github.com/OHF-Voice/linux-voice-assistant" \ + org.opencontainers.image.title="Linux-Voice-Assistant" \ + org.opencontainers.image.url="https://github.com/OHF-Voice/linux-voice-assistant" + +### Install packages: +# - avahi-utils: For zeroconf/mDNS discovery by Home Assistant +# - pulseaudio-utils: Required by soundcard library for audio I/O +# - alsa-utils: ALSA tools for audio device management +# - pipewire-bin: Required for pipewire support +# - pipewire-alsa: Required for pipewire support +# - pipewire-pulse: Required for pipewire support +# - build-essential: Required to compile pymicro-features +# - libmpv-dev: Required by python-mpv for audio playback +# - libasound2-plugins: Required by python-mpv for audio playback +# - ca-certificates: For encrypted connections +# - iproute2: For ss command in entrypoint (port check) +# - procps: For pgrep in healthcheck +RUN apt-get update && \ +apt-get install --yes --no-install-recommends \ + avahi-utils \ + pulseaudio-utils \ + alsa-utils \ + pipewire-bin \ + pipewire-alsa \ + pipewire-pulse \ + build-essential \ + libmpv-dev \ + libasound2-plugins \ + ca-certificates \ + iproute2 \ + procps && \ +apt-get clean + +### Set workdir: +WORKDIR /app + +### Copy all application files: +COPY script/ ./script/ +COPY pyproject.toml ./ +COPY setup.cfg ./ +COPY sounds/ ./sounds/ +COPY ../wakewords/ ./wakewords/ +COPY linux_voice_assistant/ ./linux_voice_assistant/ +COPY docker-entrypoint.sh ./ + +### Run installation: +RUN chmod +x docker-entrypoint.sh +RUN ./script/setup + +### Set ports for ESPHome API: +EXPOSE 6053 + +### Set start script: +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/README.md b/README.md index 87d387b..db38727 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,79 @@ -# Linux Voice Assistant +# Linux-Voice-Assistant -Experimental Linux voice assistant for [Home Assistant][homeassistant] that uses the [ESPHome][esphome] protocol. +[![CI](https://github.com/OHF-Voice/linux-voice-assistant/actions/workflows/docker-build-release.yml/badge.svg)](https://github.com/OHF-Voice/linux-voice-assistant/actions/workflows/docker-build-release.yml) [![GitHub Package Version](https://img.shields.io/github/v/tag/OHF-Voice/linux-voice-assistant?label=version)](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant) [![GitHub License](https://img.shields.io/github/license/OHF-Voice/linux-voice-assistant)](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/LICENSE.md) [![GitHub last commit](https://img.shields.io/github/last-commit/OHF-Voice/linux-voice-assistant)](https://github.com/OHF-Voice/linux-voice-assistant/commits) [![GitHub Container Registry](https://img.shields.io/badge/Container%20Registry-GHCR-blue)](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant) -Runs on Linux `aarch64` and `x86_64` platforms. Tested with Python 3.13 and Python 3.11. -Supports announcments, start/continue conversation, and timers. +Experimental Linux-Voice-Assistant for [Home Assistant](https://www.home-assistant.io/) that uses the [ESPHome](https://esphome.io/) protocol. -## Installation -Install system dependencies (`apt-get`): +## Features: -* `libportaudio2` or `portaudio19-dev` (for `sounddevice`) -* `build-essential` (for `pymicro-features`) -* `libmpv-dev` (for `python-mpv`) +- Works with [Home Assistant](https://www.home-assistant.io/integrations/esphome/) +- Local wake word detection using integrated [OpenWakeWord](https://github.com/dscripka/openWakeWord) or [MicroWakeWord](https://github.com/kahrendt/microWakeWord) +- Supports multiple architectures (linux/amd64 and linux/aarch64) +- Automated builds with artifact attestation for security +- Supports multiple wake words and languages +- Supports announcments, start/continue conversation, and timers +- Tested with Python 3.13 and Python 3.11. +- 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 -Clone and install project: + +## Usage: + +### Hardware: + +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. + +### Software: + +#### Installation: + +We have different installation methods available (Docker, systemd), each with its own dedicated instructions. + +See [Linux-Voice-Assistant - Installation](docs/install.md). + +#### Parameter overview: ``` sh -git clone https://github.com/OHF-Voice/linux-voice-assistant.git -cd linux-voice-assistant -script/setup +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] [--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] [--port PORT] + [--enable-thinking-sound] [--debug] ``` -## Running +| Parameter | Description | Default | +|-----------|-------------|---------| +| `--name` | Name of the voice assistant device (required) | - | +| `--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` | Address for ESPHome server | `0.0.0.0` | +| `--port` | Port for ESPHome server | 6053 | +| `--enable-thinking-sound` | Enable thinking sound on startup | False | +| `--debug` | Print DEBUG messages to console | False | -Use `script/run` or `python3 -m linux_voice_assistant` +## Build Information: -You must specify `--name ` with a name that will be available in Home Assistant. +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. -See `--help` for more options. +The Docker images are built using GitHub Actions, which provides: -### Microphone +- Automated builds for different architectures +- Artifact attestation for build provenance verification +- Regular updates and maintenance -Use `--audio-input-device` to change the microphone device. Use `--list-input-devices` to see the available microphones. - -The microphone device **must** support 16Khz mono audio. - -### Speaker - -Use `--audio-output-device` to change the speaker device. Use `--list-output-devices` to see the available speakers. - -### Sounds - -Customize wake word and timer sounds (defaults are used if not specified): -``` sh -python3 -m linux_voice_assistant ... \ - --wakeup-sound sounds/wake_word_triggered_old.wav \ - --timer-finished-sound sounds/timer_finished.flac -``` - -Available sounds: -* **Wake sounds**: `wake_word_triggered.flac` (default), `wake_word_triggered_old.wav` -* **Timer sounds**: `timer_finished.flac` (default), `timer_finished_old.wav` - -The optional "thinking" sound plays while the assistant is processing. Enable it on startup with: -``` sh -python3 -m linux_voice_assistant ... \ - --enable-thinking-sound -``` - -This enables the thinking sound by default and sets the Home Assistant switch to ON. The switch can be toggled at any time from the device page. - -## Wake Word - -Change the default wake word with `--wake-model ` where `` is the name of a model in the `wakewords` directory. For example, `--wake-model hey_jarvis` will load `wakewords/hey_jarvis.tflite` by default. - -You can include more wakeword directories by adding `--wake-word-dir ` where `` 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. - -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: - -``` json -{ - "type": "openWakeWord", - "wake_word": "GLaDOS", - "model": "glados.tflite" -} -``` - -Add `--wake-word-dir ` with the directory containing `glados.tflite` and `glados.json` to your command-line. - -## Connecting to Home Assistant - -1. In Home Assistant, go to "Settings" -> "Device & services" -2. Click the "Add integration" button -3. Choose "ESPHome" and then "Set up another instance of ESPHome" -4. Enter the IP address of your voice satellite with port 6053 -5. Click "Submit" - -## 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' -``` - - -[homeassistant]: https://www.home-assistant.io/ -[esphome]: https://esphome.io/ -[microWakeWord]: https://github.com/kahrendt/microWakeWord -[openWakeWord]: https://github.com/dscripka/openWakeWord -[wakewords-collection]: https://github.com/fwartner/home-assistant-wakewords-collection -[glados]: https://github.com/fwartner/home-assistant-wakewords-collection/blob/main/en/glados/glados.tflite +The documentation for the build process can be found in the [GitHub Actions Workflows](.github/workflow.md) file. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0965ad4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +--- +services: + # Fix permissions for wakeword_data and configuration volume + fix-permissions: + image: "ghcr.io/ohf-voice/linux-voice-assistant:latest" + entrypoint: [] + command: "chown -R ${LVA_USER_ID}:${LVA_USER_GROUP} /app/local /app/configuration" + env_file: .env + group_add: + - audio + volumes: + # Wakeword data and configuration + - wakeword_data:/app/local + - configuration:/app/configuration + restart: "no" + + # Linux Voice Assistant + linux-voice-assistant: + container_name: linux-voice-assistant + image: "ghcr.io/ohf-voice/linux-voice-assistant:latest" + restart: unless-stopped + network_mode: "host" + user: "${LVA_USER_ID}:${LVA_USER_GROUP}" + environment: + # Pulseaudio + - XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR} + - PULSE_SERVER=${LVA_PULSE_SERVER} + env_file: .env + group_add: + - audio + # Priority for audio process + cap_add: + - SYS_NICE + volumes: + # Wakeword data and configuration + - wakeword_data:/app/local + - configuration:/app/configuration + # Time and zone + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + # Pulseaudio + # /run/user/1000:/run/user/1000 + - ${LVA_XDG_RUNTIME_DIR}:${LVA_XDG_RUNTIME_DIR} + depends_on: + - fix-permissions + # Health check for process + healthcheck: + test: ["CMD", "pgrep", "-f", "linux_voice_assistant"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 90s + #command: + # - --additional-parameters-here + # - --but-use-env-if-possible + + +volumes: + wakeword_data: + configuration: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..7fcf605 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,119 @@ +#!/bin/bash +set -e + +### Generate name for this client +# Get active interface +IFACE=$(ip route | grep default | awk '{print $5}' | head -n 1) + +# Check if IFACE is empty +if [ -z "$IFACE" ]; then + echo "No active network interface found." +fi + +# Check if interface directory exists +if [ ! -e "/sys/class/net/$IFACE/address" ]; then + echo "Interface $IFACE does not exist or has no MAC address." +fi + +# Read MAC address and remove ':' +MAC=$(cat /sys/class/net/$IFACE/address | tr -d ':') + +if [ -z "$MAC" ]; then + echo "Could not read MAC address." +fi + +# Generate CLIENT_NAME from it +TEMP_CLIENT_NAME="lva-${MAC}" + + +### Handlers +# Handle parameters +EXTRA_ARGS="" + +if [ "$ENABLE_DEBUG" = "1" ]; then + EXTRA_ARGS="$EXTRA_ARGS --debug" +fi + +CLIENT_NAME=${CLIENT_NAME:-$TEMP_CLIENT_NAME} +if [ -n "${CLIENT_NAME}" ]; then + EXTRA_ARGS="$EXTRA_ARGS --name $CLIENT_NAME" +fi + +PREFERENCES_FILE=${PREFERENCES_FILE:-"/app/configuration/preferences.json"} +if [ -n "${PREFERENCES_FILE}" ]; then + EXTRA_ARGS="$EXTRA_ARGS --preferences-file $PREFERENCES_FILE" +fi + +PORT=${PORT:-6053} +if [ -n "${PORT}" ]; then + EXTRA_ARGS="$EXTRA_ARGS --port $PORT" +fi + +if [ -n "${AUDIO_INPUT_DEVICE}" ]; then + EXTRA_ARGS="$EXTRA_ARGS --audio-input-device $AUDIO_INPUT_DEVICE" +fi + +if [ -n "${AUDIO_OUTPUT_DEVICE}" ]; then + EXTRA_ARGS="$EXTRA_ARGS --audio-output-device $AUDIO_OUTPUT_DEVICE" +fi + +if [ "$ENABLE_THINKING_SOUND" = "1" ]; then + EXTRA_ARGS="$EXTRA_ARGS --enable-thinking-sound" +fi + + +### Wait for PulseAudio +# Wait for PulseAudio to be available before starting the application +CP_MAX_RETRIES=30 +CP_RETRY_DELAY=1 +### while maybe besser? +echo "Checking port $PORT..." +for i in $(seq 1 $CP_MAX_RETRIES); do + # Check if PulseAudio is running + if pactl info >/dev/null 2>&1; then + echo "✅ PulseAudio is running" + break + fi + + if [ $i -eq $CP_MAX_RETRIES ]; then + echo "❌ PulseAudio did not start after $CP_MAX_RETRIES seconds" + exit 2 + fi + + echo "⏳ PulseAudio not running yet, retrying in $CP_RETRY_DELAY s..." + sleep $CP_RETRY_DELAY +done + + +### Check port availability +# PORT variable is used from env +PA_MAX_RETRIES=30 +PA_RETRY_DELAY=2 +echo "Checking port $PORT..." +for i in $(seq 1 $PA_MAX_RETRIES); do + # Wait for port to be free (in case of rapid restarts) + if ! ss -tln | grep -q ":${PORT} "; then + echo "Port $PORT is available" + 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 +if [ "$LIST_DEVICES" = "1" ]; then + echo "list input devices" + ./script/run "$@" $EXTRA_ARGS --list-input-devices + echo "list output devices" + ./script/run "$@" $EXTRA_ARGS --list-output-devices +else + echo "starting application" + exec ./script/run "$@" $EXTRA_ARGS +fi diff --git a/docs/additional_audio_settings.md b/docs/additional_audio_settings.md new file mode 100644 index 0000000..9bfa5d7 --- /dev/null +++ b/docs/additional_audio_settings.md @@ -0,0 +1,36 @@ +# 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' +``` diff --git a/docs/debugging.md b/docs/debugging.md new file mode 100644 index 0000000..27609da --- /dev/null +++ b/docs/debugging.md @@ -0,0 +1,165 @@ + +# Debugging + +## Explanation of the audio system: + +The Linux audio stack consists of multiple layers working together: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ HOST SYSTEM │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ User Applications │ │ +│ │ (Browser, Media Player, Voice Assistant, etc.) │ │ +│ └────────────────┬────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────▼────────────────────────────────────┐ │ +│ │ PipeWire / PulseAudio │ │ +│ │ (Sound Server) │ │ +│ │ - Audio mixing & routing │ │ +│ │ - Device abstraction │ │ +│ │ - Network audio support │ │ +│ │ - Per-application volume control │ │ +│ └────────────────┬────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────▼────────────────────────────────────┐ │ +│ │ ALSA │ │ +│ │ (Advanced Linux Sound Architecture) │ │ +│ │ - Kernel-level audio driver framework │ │ +│ │ - Direct hardware access │ │ +│ │ - Controls: /dev/snd/ │ │ +│ └────────────────┬────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────▼────────────────────────────────────┐ │ +│ │ Hardware (Sound Card) │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌───────────────────┐ │ │ +│ │ │Microphon│ │ Speaker │ │ DSP/Codec │ │ │ +│ │ └─────────┘ └─────────┘ └───────────────────┘ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ LVA_XDG_RUNTIME_DIR=/run/user/1000 │ +│ └── Socket: /run/user/1000/pipewire-0 │ +│ Socket: /run/user/1000/pulse/native │ +└─────────────────────────────────────────────────────────────┘ + │ + │ Device Passthrough + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ DOCKER CONTAINER │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Container Process │ │ +│ │ (Linux Voice Assistant) │ │ +│ │ │ │ +│ │ Accesses audio via: │ │ +│ │ - PipeWire/PulseAudio client library │ │ +│ │ - Uses same LVA_XDG_RUNTIME_DIR socket path │ │ +│ │ │ │ +│ │ Environment Variables: │ │ +│ │ - LVA_XDG_RUNTIME_DIR=/run/user/1000 (host path) │ │ +│ │ - AUDIO_INPUT_DEVICE="default" │ │ +│ │ - AUDIO_OUTPUT_DEVICE="default" │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Device Access: │ +│ - Socket: /run/user/1000/pipewire-0 (volume mount) │ +│ - Socket: /run/user/1000/pulse/native (volume mount) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### How it works: + +1. **Host Layer**: + - ALSA provides kernel drivers for audio hardware + - PipeWire/PulseAudio sits on top of ALSA as a sound server + - Applications connect to PipeWire/PulseAudio, not directly to ALSA, otherwhise the container would need access to the ALSA devices. If you use ALSA only only one application can connect to the device at a time. + +2. **Docker Integration**: + - The LVA_XDG_RUNTIME_DIR socket must be mounted from host to container + - The container user must have matching UID/GID with host user or the audio group is added. + +3. **Communication Flow**: + ``` + Container App → PipeWire Client Library → Host PipeWire/PulseAudio + ↓ ↓ + (via mounted socket) (via ALSA drivers) + ↓ ↓ + ┌─────────────────────────────────────────────────────┐ + │ Audio Data flows to Hardware │ + └─────────────────────────────────────────────────────┘ + ``` + +4. **Key Configuration Points**: + - `LVA_XDG_RUNTIME_DIR` must point to host's runtime directory + - Container user UID/GID should match host user + +### Docker Compose Example: + +```yaml +services: + voice-assistant: + image: ghcr.io/ohf-voice/linux-voice-assistant:latest + environment: + - LVA_XDG_RUNTIME_DIR=/run/user/1000 + - AUDIO_INPUT_DEVICE=default + - AUDIO_OUTPUT_DEVICE=default + volumes: + - /run/user/1000:/run/user/1000:rw + devices: + - /dev/snd:/dev/snd + group_add: + - audio +``` + + +## List available audio devices: + +To list available audio devices, run: + +```bash +# List audio devices +docker run --rm -it -e LIST_DEVICES="1" ghcr.io/florian-asche/linux-voice-assistant:develop-final-docker-version +``` + +Update the `.env` file with your device names: + +``` ini +AUDIO_INPUT_DEVICE="default" +AUDIO_OUTPUT_DEVICE="default" +``` + + +## Pipewire runtime directory (Optional): + +If you work with the root user, you need to: +``` sh +export LVA_XDG_RUNTIME_DIR=/run/user/${USERLVA_USER_ID +``` + +💡 **Note:** Replace `$USERLVA_USER_IDth your actual user id that you want to run the voice assistant. + + +## Troubleshooting: + +### Audio Device Not Found: + +If the container cannot access audio devices, ensure: + +1. The USERLVA_USER_ID`.env` matches your actual users ID (run `id -u $USER` to check) +2. The LVA_XDG_RUNTIME_DIR path exists on the host + +### Permission Denied Errors: + +tbd + +### Low Audio Quality: + +If you experience audio quality issues: + +1. Check the audio device settings with `alsamixer` +2. Try setting `AUDIO_INPUT_DEVICE` and `AUDIO_OUTPUT_DEVICE` explicitly +3. Adjust the microphone gain in your system settings + + +## More debugging documentation: + +See [PiCompose](https://github.com/florian-asche/PiCompose/blob/main/docs/pipewire_debugging.md) for more debugging information. diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..6077735 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,100 @@ +# Linux-Voice-Assistant - Installation + +This guide describes how to install PipeWire, Docker, and then set up the Linux-Voice-Assistant using docker-compose manually. The Linux-Voice-Assistant is a voice assistant that runs locally and supports wake word detection, voice activity detection, and audio playback. + +## Prerequisites: + +- A Linux system (tested on Debian/Raspberry Pi OS/Ubuntu) +- Docker and Docker Compose installed (Optional but preffered) +- A microphone and speaker for audio input/output + +## Overview: + +The installation process can be divided into the following steps: + +``` ++-----------------------------------------------------------------------+ +| LINUX-VOICE-ASSISTANT INSTALLATION | ++-----------------------------------------------------------------------+ + | + v + +-------------------------------+ + | 0) Use Prebuilt Pi Image? | + | +---------+---------+ | + | | YES | NO | | + | +---------+---------+ | + +-------------------------------+ + / \ + / \ + v v + +-------+ +-----------------------+ + | READY | | 1) INSTALL AUDIO SYS | + | | +-----------------------+ + +-------+ | + v + +-------------------------------------------+ + | Audio Service Options | + | +-------------+ +-------------+ | + | | Existing | | Install | | + | | Pipe/Pulse | | Audio Sys | | + | +-------------+ +-------------+ | + | | | | + | v v | + | +-------+ +-----+----------+ | + | |READY | | a) PipeWire | | + | +-------+ | b) PulseAudio | | + | +-----+----------+ | + | | | + +--------------------------+----------------+ + / \ + v v + +---------------------+ +---------------------+ + | Audio Ready | | 2) INSTALL APP | + +---------------------+ +---------------------+ + | + v + +---------------------------+ + | Application Options | + | +-----------------------+ | + | | a) Docker Compose | | + | | b) Bare Metal | | + | +-----------------------+ | + | | | + +-----------+-------#-------+ + | + v + +---------------------------+ + | Application Ready | + +---------------------------+ + +``` + +## Installation Steps: + +### Option A) Use Prebuilt Pi Image: + +The easyiest way to get started is to use the prebuilt image. This image comes with all necessary configurations and drivers preinstalled. + +- Download ready-to-use image from [PiCompose](https://github.com/florian-asche/PiCompose) +- Change configuration in /compose/ directory if you need to. +- You may need to reboot the board like 3 times for everything to be installed. Especially for the 2MicHat drivers. And give docker-compose some time to download the images. You can watch the logs in /var/logs/picompose.log +- More information within the PiCompose project. + +### Option B) Install on your own: + +If you want to setup the software on your own hardware, for example if you want to use LVA on your linux desktop, you can use the docker image. + +#### Step 1: Install Audioservice: + +It is on your own to choose the audio service you want to use. + +- **a) Existing PipeWire/PulseAudio** Use if already installed +- **b) Install PipeWire** (recommended): Install PipeWire and configure. See [Install Audioservice - Pipewire](install_audioserver.md) +- **c) Install PulseAudio** Install PulseAudio as alternative. See [Install Audioservice - PulseAudio](install_audioserver.md) + +#### Step 2: Install Application: + +You can run the application in two ways: + +- **a) Docker Compose** (recommended): Install Docker, download compose files, configure and start. See [Install Application - Docker Compose](install_application.md) +- **b) Bare Metal** Install dependencies, clone repo, setup and create systemd service. See [Install Application - Bare Metal](install_application.md) diff --git a/docs/install_application.md b/docs/install_application.md new file mode 100644 index 0000000..54ea13b --- /dev/null +++ b/docs/install_application.md @@ -0,0 +1,335 @@ +# Install Application + +You can install the application in different ways. We recommend to use Docker Compose if not the prebuilt image. But if you dont want to use Docker you can also install it directly on your system. + +## A) Docker Compose (recommended): + +Install packages: +``` sh +sudo apt-get install -y ca-certificates curl wget gnupg lsb-release git jq vim +``` + +Download and add Docker's official GPG key: +``` sh +mkdir -p /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg +``` + +Set up the Docker repository: +``` sh +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ + https://download.docker.com/linux/debian \ + $(lsb_release -cs) stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null +``` + +Install Docker and Docker Compose: +``` sh +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +``` + +Download the docker-compose.yml and .env.example file from the repository to a folder on your system: +``` sh +mkdir linux-voice-assistant +cd linux-voice-assistant +wget https://raw.githubusercontent.com/OHF-Voice/linux-voice-assistant/refs/tags/v1.0.0/docker-compose.yml +wget https://raw.githubusercontent.com/OHF-Voice/linux-voice-assistant/refs/tags/v1.0.0/.env.example +mv .env.example .env +``` + +💡 **Note:** Use the latest stable version of the files from the repository. We update this documentation only regulary. + +| Tag | Description | Example | +|-----|-------------|---------| +| `latest` | Latest stable release | `ghcr.io/ohf-voice/linux-voice-assistant:latest` | +| `nightly` | Latest development build | `ghcr.io/ohf-voice/linux-voice-assistant:nightly` | +| `x.y.z` | Specific version release | `ghcr.io/ohf-voice/linux-voice-assistant:1.0.0` | +| `x.y` | Major.Minor with auto updates | `ghcr.io/ohf-voice/linux-voice-assistant:1.0` | +| `` | Branch-specific build | `ghcr.io/ohf-voice/linux-voice-assistant:my-branch` | + +Edit the .env file and change the values to your needs: +``` sh +vim .env +``` + +```ini +# Linux-Voice-Assistant - Docker Environment Configuration +# Copy this file to .env and customize for your setup by 'cp .env.example .env' + +### Enable debug mode (optional): +# ENABLE_DEBUG="1" + +### List audio devices (optional): +# if enabled normal startup is disabled +# LIST_DEVICES="1" + +### User ID: +# This is used to set the correct permissions for the accessing the audio device and accessing the PulseAudio socket +USERLVA_USER_ID00" +LVA_USER_GROUP="1000" + +### Name for the client (optional): +# by default it uses the HOSTNAME variable from the piCompose environment which includes the MAC from the network card +# CLIENT_NAME="My Voice Assistant Speaker" + +### PulseAudio socket path on the host: +# PulseAudio Server: /run/user/1000/pulse +# Pipewire Server: /run/user/1000/pulse/native +LVA_PULSE_SERVER="unix:/run/user/${USERLVA_USER_IDlse/native" +LVA_XDG_RUNTIME_DIR="/run/user/${USERLVA_USER_ID + +### Path to the preferences file (optional): +# PREFERENCES_FILE="/app/configuration/preferences.json" + +### Port for the api (optional): +# PORT="6053" + +### Audio input device (optional): +# AUDIO_INPUT_DEVICE="default" + +### Audio output device (optional): +# AUDIO_OUTPUT_DEVICE="default" + +# Enable thinking sound (optional): +# ENABLE_THINKING_SOUND="1" + +# Wake model (optional): +# WAKE-MODEL="okay_nabu" + +# Refactory seconds (optional): +# REFACTORY_SECONDS="2" + +# Sound files (optional): +# WAKEUP_SOUND="sounds/wake_word_triggered.flac" +# TIMER_FINISHED_SOUND="sounds/timer_finished.flac" +# PROCESSING_SOUND="sounds/processing.wav" +# MUTE_SOUND="sounds/mute_switch_on.flac" +# UNMUTE_SOUND="sounds/mute_switch_off.flac" +``` + +You can change various settings here, 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. + +Start the application: +``` sh +docker compose up -d +``` + +💡 **Note:** If you want to use the application with a different user, you need to change the user in the .env file. Dont forget to change the UID from the user. The docker container will run until you stop it. It will restart autiomatically after a reboot. + +Check if the application is running: +``` sh +docker compose ps +``` + +Check the logs: +``` sh +docker compose logs -f +``` + +Stop the service: +``` sh +docker-compose down +``` + +Download the latest image: +``` sh +docker-compose pull +``` + + +## B) Bare Metal: + +Install the required packages for the application: + +``` sh +sudo apt update +sudo apt-get install \ + avahi-utils \ + pulseaudio-utils \ + alsa-utils \ + pipewire-bin \ + pipewire-alsa \ + pipewire-pulse \ + build-essential \ + libmpv-dev \ + libasound2-plugins \ + ca-certificates \ + iproute2 \ + procps \ + git \ + jq \ + curl \ + wget \ + vim \ + python3-venv \ + python3-dev +``` + +Clone the repository: + +``` sh +git clone https://github.com/OHF-Voice/linux-voice-assistant.git +cd linux-voice-assistant +chmod +x docker-entrypoint.sh +``` + +Install the application: + +``` sh +script/setup +``` + +Create a systemd service for the application: + +``` sh +sudo systemctl edit --force --full linux-voice-assistant.service +``` + +Paste the following content into the file: + +``` ini +[Unit] +Description=Linux-Voice-Assistant +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=pi +WorkingDirectory=/home/pi/linux-voice-assistant + +Environment=PATH=/home/pi/linux-voice-assistant/.venv/bin:/usr/bin:/bin +# Environment=ENABLE_DEBUG="1" +# Environment=LIST_DEVICES="1" +Environment=USERLVA_USER_ID00" +Environment=LVA_USER_GROUP="1000" +Environment=CLIENT_NAME="My Voice Assistant Speaker" +Environment=LVA_PULSE_SERVER="unix:/run/user/${USERLVA_USER_IDlse/native" +Environment=LVA_XDG_RUNTIME_DIR="/run/user/${USERLVA_USER_ID +Environment=PREFERENCES_FILE="/home/pi/linux-voice-assistant/preferences.json" +Environment=PORT="6053" +# Environment=AUDIO_INPUT_DEVICE="default" +# Environment=AUDIO_OUTPUT_DEVICE="default" +# Environment=ENABLE_THINKING_SOUND="1" +# Environment=WAKE-MODEL="okay_nabu" +# Environment=REFACTORY_SECONDS="2" +# Environment=WAKEUP_SOUND="sounds/wake_word_triggered.flac" +# Environment=TIMER_FINISHED_SOUND="sounds/timer_finished.flac" +# Environment=PROCESSING_SOUND="sounds/processing.wav" +# Environment=MUTE_SOUND="sounds/mute_switch_on.flac" +# Environment=UNMUTE_SOUND="sounds/mute_switch_off.flac" +# ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh --additional-parameter-if-you-want +ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +💡 **Note:** We are using the `docker-entrypoint.sh` script to start the application. This script is located in the root of the repository. But there is no docker used. Only the start script is used. + +💡 **Note:** Replace `USERLVA_USER_IDth your actual user id that you want to run the voice assistant. Do the same for `LVA_USER_GROUP`. + +Reload the systemd daemon and start the service: + +``` sh +sudo systemctl daemon-reexec +sudo systemctl daemon-reload +sudo systemctl enable linux-voice-assistant +sudo systemctl start linux-voice-assistant +``` + +💡 **Note:** If you want to change settings you can do this with editing the Environment in the service file. + +If you want to check if the service is running you can use the following command: + +``` sh +sudo systemctl status linux-voice-assistant +``` + + +## Connecting to Home Assistant: + +1. In Home Assistant, go to "Settings" -> "Device & services" +2. Click the "Add integration" button +3. Choose "ESPHome" and then "Set up another instance of ESPHome" +4. Enter the IP address of your voice satellite with port 6053 +5. Click "Submit" + + +## Additional Information: + +### Settings: +All optional settings except the audio sounds are disabled by default. You can change them in the UI and they will be saved in the PREFERENCES_FILE. If you set the configuration in the service file, the corresponding settings will be overwritten and you are no longer able to change them in the UI. + +If you want to change the audio sounds you need to change the paths in the service file. + +#### Environment Variables Reference: + +The following variables can be configured in the `.env` or in the service file: + +| Variable | Default | Description | +|----------|---------|-------------| +| `USERLVA_USER_ID`1000` | User ID for the container (usually 1000 for the first user) | +| `GROUP_ID` | `1000` | GROUP ID for the container (usually 1000 for the first users group) | +| `CLIENT_NAME` | (optional) | Custom name for this voice assistant instance | +| `LVA_PULSE_SERVER` | `unix:/run/user/${USERLVA_USER_IDlse/native` | Path to the PulseAudio/PipeWire socket | +| `LVA_XDG_RUNTIME_DIR` | `/run/user/${USERLVA_USER_ID XDG runtime directory | +| `ENABLE_DEBUG` | (optional) | Set to "1" to enable debug mode | +| `LIST_DEVICES` | (optional) | Set to "1" to list audio devices instead of starting | +| `PREFERENCES_FILE` | (optional) | Path to a custom preferences JSON file | +| `PORT` | `6053` | API server port | +| `AUDIO_INPUT_DEVICE` | `default` | Audio input device name | +| `AUDIO_OUTPUT_DEVICE` | `default` | Audio output device name | +| `ENABLE_THINKING_SOUND` | (optional) | Set to "1" to enable thinking sound | +| `WAKE_MODEL` | `okay_nabu` | Wake word model to use | +| `REFACTORY_SECONDS` | `2` | Refractory period in seconds after wake word | +| `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 | +| `PROCESSING_SOUND` | `sounds/processing.wav` | Sound file for processing state | +| `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 + +#### Available Wake Word Models: + +The following wake word models are available: +- `okay_nabu` - Default wake word +- `alexa` - Alexa wake word +- `hey_jarvis` - Jarvis wake word +- `hey_mycroft` - Mycroft wake word +- `hey_luna` - Luna wake word +- `hey_home_assistant` - Home Assistant wake word +- `stop` - Stop wake word +- `okay_computer` - Okay Computer wake word +- `choo_choo_homie` - Choo Choo Homie wake word + + +### Wake Word: + +💡 **Note:** The default value for this parameter is the `wakewords` directory in the repository. + +Change the default wake word with `--wake-model ` where `` is the name of a model in the `wakewords` directory. For example, `--wake-model hey_jarvis` will load `wakewords/hey_jarvis.tflite` by default. + +You can include more wakeword directories by adding `--wake-word-dir ` where `` 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. + +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: + +``` json +{ + "type": "openWakeWord", + "wake_word": "GLaDOS", + "model": "glados.tflite" +} +``` + +Add `--wake-word-dir ` with the directory containing `glados.tflite` and `glados.json` to your command-line. + +#### Additional Wakewords: + +More wakewords can be found in the [wakewords-collection](https://github.com/fwartner/home-assistant-wakewords-collection) repository. diff --git a/docs/install_audioserver.md b/docs/install_audioserver.md new file mode 100644 index 0000000..e5b4055 --- /dev/null +++ b/docs/install_audioserver.md @@ -0,0 +1,135 @@ +# 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. + +## A) Pipewire (recommended): + +PipeWire is a multimedia server that provides low-latency audio/video handling. Install it with the following commands: + +``` sh +# Update package database +sudo apt update + +# Install PipeWire and related packages +sudo apt install -y pipewire wireplumber pipewire-audio-client-libraries libspa-0.2-bluetooth pipewire-audio pipewire-pulse dfu-util +``` + +Link the PipeWire configuration for ALSA applicationssudo ln -sf: + +``` sh +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): + +``` sh +sudo mkdir -p /var/lib/systemd/linger +sudo touch /var/lib/systemd/linger/$USER +``` + +💡 **Note:** Replace `$USER` with your actual username that you want to run the voice assistant. + +### Configure PipeWire (optional): + +Create the PipeWire configuration directory: + +``` sh +mkdir -p "/etc/pipewire" +``` + +Create the file `/etc/pipewire/pipewire.conf` with the following content (minimal configuration for voice assistant use): + +``` ini +# Daemon config file for PipeWire +context.properties = { + link.max-buffers = 16 + mem.warn-mlock = true + log.level = 3 + context.num-data-loops = 1 + core.daemon = true + core.name = pipewire-0 + default.clock.rate = 16000 +} + +context.modules = [ + { name = libpipewire-module-rt + 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 } +] +``` + + +## B) PulseAudio: + +Make sure that you only run Pulseaudio and there is no Pipewire installed. + +sudo apt remove --purge pipewire pipewire-pulse wireplumber +sudo apt autoremove + +Install Pulseaudio + +``` sh +sudo apt install pulseaudio pulseaudio-utils dfu-util +``` + +Enable and start Pulseaudio + +``` sh +systemctl --user enable pulseaudio +systemctl --user start pulseaudio +``` + +Check if Pulseaudio is running + +``` sh +pulseaudio --check +pactl info +``` + + +## Additional Information: + +### Set audio volume: + +If your driver or audiodevice is loaded and you can see the device with `aplay -L` then +set the audio volume from 0 to 100: + +```bash +export LVA_XDG_RUNTIME_DIR=/run/user/${LVA_USER_ID} +sudo amixer -c seeed2micvoicec set Headphone 100% +sudo amixer -c seeed2micvoicec set Speaker 100% +sudo amixer -c Lite set Headphone 100% +sudo amixer -c Lite set Speaker 100% +sudo alsactl store +``` + +💡 **Note:** Replace `$LVA_USER_ID` with your actual user id that you want to run the voice assistant. + +Alternatively you can use the following command to set the volume: + +```bash +export LVA_XDG_RUNTIME_DIR=/run/user/${LVA_USER_ID} +sudo alsamixer +``` + +💡 **Note:** Replace `$LVA_USER_ID` with your actual user id that you want to run the voice assistant. diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index dc584ba..e7d5187 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -104,6 +104,7 @@ async def main() -> None: default="0.0.0.0", help="Address for ESPHome server (default: 0.0.0.0)", ) + # Note that default port is also set in docker-entrypoint.sh parser.add_argument( "--port", type=int, default=6053, help="Port for ESPHome server (default: 6053)" ) diff --git a/linux_voice_assistant/mpv_player.py b/linux_voice_assistant/mpv_player.py index b4762cb..3725e52 100644 --- a/linux_voice_assistant/mpv_player.py +++ b/linux_voice_assistant/mpv_player.py @@ -1,94 +1,108 @@ -"""Media player using mpv in a subprocess.""" - +# mpv_player.py import logging -from collections.abc import Callable -from threading import Lock -from typing import List, Optional, Union +from typing import Union, List, Callable, Optional -from mpv import MPV - -_LOGGER = logging.getLogger(__name__) +from .player.libmpv import LibMpvPlayer class MpvMediaPlayer: - def __init__(self, device: Optional[str] = None) -> None: - self.player = MPV() + """ + Linux Voice Assistant MediaPlayer implementation based on libmpv. - if device: - self.player["audio-device"] = device + This class provides the MediaPlayer interface expected by LVA and + delegates all playback logic to LibMpvPlayer. + """ - self.is_playing = False - - self._playlist: List[str] = [] + def __init__(self, device: str | None = None) -> None: + self._log = logging.getLogger(self.__class__.__name__) + self._player = LibMpvPlayer(device=device) self._done_callback: Optional[Callable[[], None]] = None - self._done_callback_lock = Lock() - self._duck_volume: int = 50 - self._unduck_volume: int = 100 - - self.player.event_callback("end-file")(self._on_end_file) + self._log.debug("MpvMediaPlayer initialized (device=%s)", device) def play( self, url: Union[str, List[str]], done_callback: Optional[Callable[[], None]] = None, - stop_first: bool = True, + stop_first: bool = False, ) -> None: - self.stop() + """ + Play a media URL. - if isinstance(url, str): - self._playlist = [url] - else: - self._playlist = url + Args: + url: Media URL or list of URLs (LVA currently uses a single URL). + done_callback: Optional callback invoked when playback finishes. + stop_first: Kept for API compatibility. + """ + # LVA currently only uses single URLs + if isinstance(url, list): + self._log.debug("Received URL list, using first entry") + url = url[0] - next_url = self._playlist.pop(0) - _LOGGER.debug("Playing %s", next_url) + # Track is changing + if self._done_callback is not None: + self._log.debug( + "Stopping active playback before starting new media" + ) + # Not self.stop() → this would call done_callback + self._player.stop(for_replacement=True) + self._done_callback = None + + self._log.info("Playing media: %s", url) + self._log.debug( + "play(url=%s, stop_first=%s, done_callback=%s)", + url, + stop_first, + bool(done_callback), + ) self._done_callback = done_callback - self.is_playing = True - self.player.play(next_url) + self._player.play(url, done_callback=done_callback, stop_first=stop_first) def pause(self) -> None: - self.player.pause = True - self.is_playing = False + """Pause playback.""" + self._log.debug("pause() called") + self._player.pause() def resume(self) -> None: - self.player.pause = False - if self._playlist: - self.is_playing = True + """Resume playback.""" + self._log.debug("resume() called") + self._player.resume() def stop(self) -> None: - self.player.stop() - self._playlist.clear() + """Stop playback and invoke the done callback if present.""" + self._log.debug("stop() called") - def duck(self) -> None: - self.player.volume = self._duck_volume + self._player.stop() - def unduck(self) -> None: - self.player.volume = self._unduck_volume - - def set_volume(self, volume: int) -> None: - volume = max(0, min(100, volume)) - self.player.volume = volume - - self._unduck_volume = volume - self._duck_volume = volume // 2 - - def _on_end_file(self, event) -> None: - if self._playlist: - self.player.play(self._playlist.pop(0)) - return - - self.is_playing = False - - todo_callback: Optional[Callable[[], None]] = None - with self._done_callback_lock: - if self._done_callback: - todo_callback = self._done_callback + if self._done_callback: + self._log.debug("Invoking done_callback due to stop()") + try: + self._done_callback() + finally: self._done_callback = None - if todo_callback: - try: - todo_callback() - except Exception: - _LOGGER.exception("Unexpected error running done callback") + def set_volume(self, volume: float) -> None: + """ + Set playback volume. + + Args: + volume: Volume in percent (0.0–100.0). + """ + self._log.debug("set_volume(volume=%.2f)", volume) + self._player.set_volume(volume) + + def duck(self, factor: float = 0.5) -> None: + """ + Temporarily reduce volume. + + Args: + factor: Volume multiplier (0.0–1.0). + """ + self._log.debug("duck(factor=%.2f)", factor) + self._player.duck(factor) + + def unduck(self) -> None: + """Restore volume after ducking.""" + self._log.debug("unduck() called") + self._player.unduck() \ No newline at end of file diff --git a/linux_voice_assistant/player/base.py b/linux_voice_assistant/player/base.py new file mode 100644 index 0000000..abfc0eb --- /dev/null +++ b/linux_voice_assistant/player/base.py @@ -0,0 +1,25 @@ +from abc import ABC, abstractmethod +from .state import PlayerState + + +class AudioPlayer(ABC): + + @abstractmethod + def play(self, url: str) -> None: + pass + + @abstractmethod + def pause(self) -> None: + pass + + @abstractmethod + def resume(self) -> None: + pass + + @abstractmethod + def stop(self) -> None: + pass + + @abstractmethod + def state(self) -> PlayerState: + pass diff --git a/linux_voice_assistant/player/libmpv.py b/linux_voice_assistant/player/libmpv.py new file mode 100644 index 0000000..4c7311c --- /dev/null +++ b/linux_voice_assistant/player/libmpv.py @@ -0,0 +1,163 @@ +import threading +from typing import Optional, Callable + +import mpv + +from .base import AudioPlayer +from .state import PlayerState + + +class LibMpvPlayer(AudioPlayer): + """ + AudioPlayer implementation for Linux Voice Assistant using libmpv. + + Responsibilities: + - mpv lifecycle and playback control + - thread-safe state management + - volume handling with ducking support + """ + + def __init__(self, device: Optional[str] = None) -> None: + self._state: PlayerState = PlayerState.IDLE + self._state_lock = threading.Lock() + + # Volume handling + self._user_volume: float = 100.0 # 0.0 – 100.0 + self._duck_factor: float = 1.0 # 0.0 – 1.0 + + # mpv setup + self._mpv = mpv.MPV( + audio_display=False, + log_handler=self._on_mpv_log, + loglevel="error", + ) + + if device: + self._mpv["audio-device"] = device + + # Callback Handling + self._done_callback: Optional[Callable[[], None]] = None + self._suppress_end_event = False + self._mpv.event_callback("end-file")(self._on_end_file) + + # -------- Playback control -------- + + def play( + self, + url: str, + done_callback: Optional[Callable[[], None]] = None, + stop_first: bool = True, + ) -> None: + """ + Start playback of a media URL. + + Args: + url: Media URL or local file path. + done_callback: Optional callback invoked when playback finishes. + stop_first: If True, start playback in paused state. + """ + self._done_callback = done_callback + self._mpv.pause = stop_first + with self._state_lock: + self._set_state(PlayerState.LOADING) + self._mpv.play(url) + + def pause(self) -> None: + """Pause playback.""" + with self._state_lock: + self._mpv.pause = True + self._set_state(PlayerState.PAUSED) + + def resume(self) -> None: + """Resume playback if paused.""" + with self._state_lock: + self._mpv.pause = False + self._set_state(PlayerState.PLAYING) + + def stop(self, for_replacement: bool = False) -> None: + """ + Stop playback. + + If called for track replacement, suppresses end-of-playback handling + (state transition to IDLE and done callback) to allow seamless + playback transitions. + """ + with self._state_lock: + if for_replacement: + self._suppress_end_event = True + self._mpv.stop() + + def state(self) -> PlayerState: + """Return the current player state.""" + with self._state_lock: + return self._state + + # -------- Volume / Ducking -------- + + def set_volume(self, volume: float) -> None: + """ + Set user volume. + + Args: + volume: Volume level (0.0–100.0). + """ + with self._state_lock: + self._user_volume = max(0.0, min(100.0, float(volume))) + self._apply_volume() + + def duck(self, factor: float = 0.5) -> None: + """ + Reduce volume temporarily by a ducking factor. + + Args: + factor: Ducking factor (0.0–1.0). + """ + with self._state_lock: + self._duck_factor = max(0.0, min(1.0, float(factor))) + self._apply_volume() + + def unduck(self) -> None: + """Restore volume to the user-defined level.""" + with self._state_lock: + self._duck_factor = 1.0 + self._apply_volume() + + # -------- Internal helpers -------- + + def _apply_volume(self) -> None: + """Apply effective volume (user volume × duck factor) to mpv.""" + effective = self._user_volume * self._duck_factor + self._mpv.volume = max(0.0, min(100.0, effective)) + + def _on_end_file(self, event) -> None: + callback: Optional[Callable[[], None]] = None + + with self._state_lock: + if self._suppress_end_event: + self._suppress_end_event = False + return + + self._set_state(PlayerState.IDLE) + callback = self._done_callback + self._done_callback = None + + if callback is not None: + try: + callback() + except RuntimeError: + # Callback errors must never break the player + pass + + def _on_mpv_log(self, level: str, prefix: str, text: str) -> None: + """ + Handle mpv log messages. + + Error and fatal messages transition the player into ERROR state. + """ + if level in ("error", "fatal"): + with self._state_lock: + self._set_state(PlayerState.ERROR) + + def _set_state(self, new_state: PlayerState) -> None: + """Update internal player state.""" + self._state = new_state \ No newline at end of file diff --git a/linux_voice_assistant/player/state.py b/linux_voice_assistant/player/state.py new file mode 100644 index 0000000..dfc1e25 --- /dev/null +++ b/linux_voice_assistant/player/state.py @@ -0,0 +1,10 @@ +from enum import Enum, auto + + +class PlayerState(Enum): + IDLE = auto() + LOADING = auto() + PLAYING = auto() + PAUSED = auto() + STOPPING = auto() + ERROR = auto() diff --git a/linux_voice_assistant/zeroconf.py b/linux_voice_assistant/zeroconf.py index 2477f0c..6baeeb4 100644 --- a/linux_voice_assistant/zeroconf.py +++ b/linux_voice_assistant/zeroconf.py @@ -10,7 +10,7 @@ _LOGGER = logging.getLogger(__name__) try: from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf except ImportError: - _LOGGER.fatal("pip install zeroconf") + _LOGGER.fatal("zeroconf not installed. Please install it with: pip install zeroconf") raise MDNS_TARGET_IP = "224.0.0.251" diff --git a/tests/libmpv_smoke_test.py b/tests/libmpv_smoke_test.py new file mode 100644 index 0000000..9eb16e1 --- /dev/null +++ b/tests/libmpv_smoke_test.py @@ -0,0 +1,37 @@ +import time +import mpv + +""" +mpv_smoke_test.py - test playback of media +""" + +def handle_event(event): + print(f"EVENT: {event['event_id']}") + + +player = mpv.MPV( + audio_display=False, + log_handler=print, + loglevel="info", +) + +player.event_callback = handle_event + +print("Loading media...") +player.play("https://icecast.radiofrance.fr/fip-midfi.mp3") + +time.sleep(5) + +print("Pausing...") +player.pause = True +time.sleep(2) + +print("Resuming...") +player.pause = False +time.sleep(5) + +print("Stopping...") +player.stop() + +time.sleep(2) +print("Done.") diff --git a/tests/test_libmpv_player.py b/tests/test_libmpv_player.py new file mode 100644 index 0000000..d031a1a --- /dev/null +++ b/tests/test_libmpv_player.py @@ -0,0 +1,39 @@ +import time +import logging + +from linux_voice_assistant.player.libmpv import LibMpvPlayer + + +logging.basicConfig(level=logging.DEBUG) + +player = LibMpvPlayer() + +player.play("https://icecast.radiofrance.fr/fip-midfi.mp3") +time.sleep(5) + +player.pause() +time.sleep(2) + +player.resume() +time.sleep(5) + +print("Set volume to 80") +player.set_volume(80) +time.sleep(2) + +print("Duck") +player.duck() +time.sleep(2) + +print("Set volume to 40 while ducked") +player.set_volume(40) +time.sleep(2) + +print("Unduck") +player.unduck() +time.sleep(2) + +player.stop() +time.sleep(2) + +print("Final state:", player.state())