Skip to content

Commit

Permalink
Add snowboy add-on
Browse files Browse the repository at this point in the history
  • Loading branch information
synesthesiam committed Oct 18, 2023
1 parent 4731121 commit eb9d6e2
Show file tree
Hide file tree
Showing 18 changed files with 371 additions and 0 deletions.
5 changes: 5 additions & 0 deletions snowboy/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

## 1.0.0

- Initial release
93 changes: 93 additions & 0 deletions snowboy/DOCS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Home Assistant Add-on: snowboy

## Installation

Follow these steps to get the add-on installed on your system:

1. Navigate in your Home Assistant frontend to **Settings** -> **Add-ons** -> **Add-on store**.
2. Add the store https://github.com/rhasspy/hassio-addons
2. Find the "snowboy" add-on and click it.
3. Click on the "INSTALL" button.

## How to use

After this add-on is installed and running, it will be automatically discovered
by the Wyoming integration in Home Assistant. To finish the setup,
click the following my button:

[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=wyoming)

Alternatively, you can install the Wyoming integration manually, see the
[Wyoming integration documentation](https://www.home-assistant.io/integrations/wyoming/)
for more information.

## Configuration

### Option: `sensitivity`

Activation threshold (0-1), where higher means fewer activations.

### Option: `debug_logging`

Enable debug logging. Useful for seeing satellite connections and each wake word detection in the logs.

## Custom Wake Words

This add-on will train custom wake words on start-up from WAV audio samples placed in `/share/snowboy/train/<language>/<wake_word>`

To get started, first record 3 samples of your wake word:

```sh
arecord -r 16000 -c 1 -f S16_LE -t wav -d 3 sample1.wav
arecord -r 16000 -c 1 -f S16_LE -t wav -d 3 sample2.wav
arecord -r 16000 -c 1 -f S16_LE -t wav -d 3 sample3.wav
```

Ideally, this should be recorded on the same device you plan to use for wake word recognition (same microphone, etc).

After your 3 samples are recorded, you will need to copy them to your Home Assistant server. You can use the [Samba add-on](https://www.home-assistant.io/common-tasks/supervised/#installing-and-using-the-samba-add-on) to do this.

Copy the WAV files to `/share/snowboy/train/<language>/<wake_word>` where `<language>` is either `en` for English or `zh` for Chinese (other languages are not supported). `<wake_word>` should be the name of your wake word, such as `hey_computer` (spaces in the same are not recommended).

Your directory structure should look like this after copying the samples:

- `/share/snowboy/train/`
- `en/`
- `hey_computer/`
- `sample1.wav`
- `sample2.wav`
- `sample3.wav`

Restart the add-on and check the log for a message that your wake word was trained. Enable debug logging in the add-on configuration for more information.

After training, your wake word model (`.pmdl`) will be next to your samples:

- `/share/snowboy/train/`
- `en/`
- `hey_computer/`
- `hey_computer.pmdl`
- `sample1.wav`
- `sample2.wav`
- `sample3.wav`

Copy your wake word model (e.g., `hey_computer.pmdl`) to `/share/snowboy` to start using it immediately.

If you'd like to retrain, delete the `.pmdl` file next to your samples and restart the add-on. You will need to copy the new model to `/share/snowboy` again after training.

## Support

Got questions?

You have several options to get them answered:

- The [Home Assistant Discord Chat Server][discord].
- The Home Assistant [Community Forum][forum].
- Join the [Reddit subreddit][reddit] in [/r/homeassistant][reddit]

In case you've found an bug, please [open an issue on our GitHub][issue].

[discord]: https://discord.gg/c5DvZ4e
[forum]: https://community.home-assistant.io
[issue]: https://github.com/home-assistant/addons/issues
[reddit]: https://reddit.com/r/homeassistant
[repository]: https://github.com/rhasspy/hassio-addons
45 changes: 45 additions & 0 deletions snowboy/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
ARG BUILD_FROM
FROM ${BUILD_FROM}
ARG BUILD_ARCH

# Set shell
SHELL ["/bin/bash", "-o", "pipefail", "-c"]

# Install snowboy
WORKDIR /usr/src
ARG WYOMING_SNOWBOY_VERSION
ARG SNOWMAN_ENROLL_VERSION
ENV PIP_BREAK_SYSTEM_PACKAGES=1

RUN \
apt-get update \
&& apt-get install -y --no-install-recommends \
python3 \
python3-pip \
python3-dev \
build-essential \
swig \
libatlas-base-dev \
curl \
&& pip3 install --no-cache-dir -U \
setuptools \
wheel \
&& pip3 install --no-cache-dir \
"wyoming-snowboy @ https://github.com/rhasspy/wyoming-snowboy/archive/refs/tags/v${WYOMING_SNOWBOY_VERSION}.tar.gz" \
&& curl --location --output - \
"https://github.com/rhasspy/snowman-enroll/releases/download/v${SNOWMAN_ENROLL_VERSION}/snowman_enroll-${BUILD_ARCH}.tar.gz" | \
tar -xzf - \
&& apt-get remove --yes build-essential swig \
&& apt-get autoclean \
&& apt-get purge \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /
COPY src/train.py /usr/src/
COPY rootfs /

HEALTHCHECK --start-period=10m \
CMD echo '{ "type": "describe" }' \
| nc -w 1 localhost 10400 \
| grep -iq "snowboy" \
|| exit 1
17 changes: 17 additions & 0 deletions snowboy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Home Assistant Add-on: snowboy

![Supports aarch64 Architecture][aarch64-shield] ![Supports amd64 Architecture][amd64-shield] ![Supports armv7 Architecture][armv7-shield]

Home Assistant add-on that uses [snowboy](https://github.com/Kitt-AI/snowboy) for wake word detection and [snowman](https://github.com/Thalhammer/snowman/) for custom wake word training.

See the [documentation](DOCS.md) for how to train a custom wake word.

Part of the [Year of Voice](https://www.home-assistant.io/blog/2022/12/20/year-of-voice/).

Requires Home Assistant 2023.9 or later.

[aarch64-shield]: https://img.shields.io/badge/aarch64-yes-green.svg
[amd64-shield]: https://img.shields.io/badge/amd64-yes-green.svg
[armv7-shield]: https://img.shields.io/badge/armv7-yes-green.svg
[armhf-shield]: https://img.shields.io/badge/armhf-no-red.svg
[i386-shield]: https://img.shields.io/badge/i386-no-red.svg
10 changes: 10 additions & 0 deletions snowboy/build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
build_from:
amd64: ghcr.io/home-assistant/amd64-base-debian:bookworm
aarch64: ghcr.io/home-assistant/aarch64-base-debian:bookworm
codenotary:
signer: [email protected]
base_image: [email protected]
args:
WYOMING_SNOWBOY_VERSION: 1.0.0
SNOWMAN_ENROLL_VERSION: 1.0.0
24 changes: 24 additions & 0 deletions snowboy/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
version: 1.0.0-7
slug: snowboy
name: snowboy
description: snowboy wake word detection using the Wyoming protocol
url: https://github.com/rhasspy/hassio-addons/tree/master/snowboy
arch:
- amd64
- aarch64
- armv7
init: false
discovery:
- wyoming
map:
- share:rw
options:
sensitivity: 0.5
debug_logging: false
schema:
sensitivity: float
debug_logging: bool
ports:
"10400/tcp": null
homeassistant: 2023.9.0
Empty file.
24 changes: 24 additions & 0 deletions snowboy/rootfs/etc/s6-overlay/s6-rc.d/discovery/run
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
# ==============================================================================
# Sends discovery information to Home Assistant.
# ==============================================================================
declare config

# Wait for snowboy to become available
bash -c \
"until
echo '{ \"type\": \"describe\" }'
> /dev/tcp/localhost/10400; do sleep 0.5;
done" > /dev/null 2>&1 || true;

config=$(\
bashio::var.json \
uri "tcp://$(hostname):10400" \
)

if bashio::discovery "wyoming" "${config}" > /dev/null; then
bashio::log.info "Successfully sent discovery information to Home Assistant."
else
bashio::log.error "Discovery message to Home Assistant failed!"
fi
1 change: 1 addition & 0 deletions snowboy/rootfs/etc/s6-overlay/s6-rc.d/discovery/type
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
oneshot
1 change: 1 addition & 0 deletions snowboy/rootfs/etc/s6-overlay/s6-rc.d/discovery/up
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/etc/s6-overlay/s6-rc.d/discovery/run
Empty file.
26 changes: 26 additions & 0 deletions snowboy/rootfs/etc/s6-overlay/s6-rc.d/snowboy/finish
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
# ==============================================================================
# Take down the S6 supervision tree when service fails
# s6-overlay docs: https://github.com/just-containers/s6-overlay
# ==============================================================================
declare exit_code
readonly exit_code_container=$(</run/s6-linux-init-container-results/exitcode)
readonly exit_code_service="${1}"
readonly exit_code_signal="${2}"

bashio::log.info \
"Service exited with code ${exit_code_service}" \
"(by signal ${exit_code_signal})"

if [[ "${exit_code_service}" -eq 256 ]]; then
if [[ "${exit_code_container}" -eq 0 ]]; then
echo $((128 + $exit_code_signal)) > /run/s6-linux-init-container-results/exitcode
fi
[[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt
elif [[ "${exit_code_service}" -ne 0 ]]; then
if [[ "${exit_code_container}" -eq 0 ]]; then
echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode
fi
exec /run/s6/basedir/bin/halt
fi
37 changes: 37 additions & 0 deletions snowboy/rootfs/etc/s6-overlay/s6-rc.d/snowboy/run
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
# ==============================================================================
# Start snowboy service
# ==============================================================================
flags=()

if bashio::config.true 'debug_logging'; then
flags+=('--debug')
fi

train_dir='/share/snowboy/train'
# Train models in a directory with the structure:
# <train_dir>/
# <wake_word>/
# sample1.wav
# ...
#
# When trained, a .pmdl file with the same name as the directory will be
# present.
if [ -n "${train_dir}" ]; then
train_flags=()
if bashio::config.true 'debug_logging'; then
train_flags+=('--debug')
fi

pushd /usr/src
python3 train.py \
--train-dir "${train_dir}" \
--snowman-dir . "${train_flags[@]}"
popd
fi

exec python3 -m wyoming_snowboy \
--uri 'tcp://0.0.0.0:10400' \
--custom-model-dir /share/snowboy \
--sensitivity "$(bashio::config 'sensitivity')" ${flags[@]}
1 change: 1 addition & 0 deletions snowboy/rootfs/etc/s6-overlay/s6-rc.d/snowboy/type
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
longrun
Empty file.
Empty file.
75 changes: 75 additions & 0 deletions snowboy/src/train.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
import argparse
import logging
import subprocess
from pathlib import Path

_LOGGER = logging.getLogger()


def main() -> None:
"""Main entry point"""
parser = argparse.ArgumentParser()
parser.add_argument(
"--train-dir",
help="Path to directory with <language>/<wake_word> structure and WAV samples",
)
parser.add_argument(
"--snowman-dir",
help="Path to directory with snowman enroll binary and resources",
)
parser.add_argument(
"--debug", action="store_true", help="Print DEBUG messages to the console"
)
args = parser.parse_args()

logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)

train_dir = Path(args.train_dir)
if not train_dir.is_dir():
_LOGGER.debug("Training directory does not exist: %s", train_dir)
return

snowman_dir = Path(args.snowman_dir)
for lang_dir in train_dir.iterdir():
if not lang_dir.is_dir():
continue

lang = lang_dir.name
for ww_dir in lang_dir.iterdir():
if not ww_dir.is_dir():
continue

wav_files = list(ww_dir.glob("*.wav"))
if not wav_files:
# No WAV files
_LOGGER.debug("No WAV files in %s, skipping", ww_dir)
continue

ww_name = ww_dir.name
ww_model = ww_dir / f"{ww_name}.pmdl"
if ww_model.exists() and (ww_model.stat().st_size > 0):
# Already trained
_LOGGER.debug("Found %s, skipping %s", ww_model, ww_dir)
continue

# WAV -> .pmdl
enroll = [
str((snowman_dir / "enroll").absolute()),
"--language",
lang,
"--output",
str(ww_model),
]

for wav_path in wav_files:
enroll.extend(["--recording", str(wav_path)])

_LOGGER.debug(enroll)
subprocess.check_call(enroll)
_LOGGER.info("Trained %s", ww_model)


if __name__ == "__main__":
main()
12 changes: 12 additions & 0 deletions snowboy/translations/en.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
configuration:
sensitivity:
name: Sensitivity
description: >-
Activation threshold (0-1), where higher means fewer activations.
debug_logging:
name: Debug logging
description: >-
Enable debug logging. Useful for seeing each wake word detection in the logs.
network:
10400/tcp: porcupine1 Wyoming Protocol

0 comments on commit eb9d6e2

Please sign in to comment.