From cb81f7f02d6f8bd9b57e637551d095201acf1a17 Mon Sep 17 00:00:00 2001 From: George Joseph Date: Sat, 27 Dec 2025 12:32:06 -0700 Subject: [PATCH] Train from the command line The files in the `cli` directory allow you to train wake words from the command line without needing to use the Jupyter notebook or a web browser. Basically, the logic from the notebook has been placed in separate shell scripts and python files wrapped by 3 high-level scripts that do the following: * setup_python_venv: Creates a Python virtual environment with all the packages needed to train. The venv is created in the container's /data directory and is therefore stored on the host, not in the container's root docker volume. * setup_training_datasets: Downloads, extracts and converts the MIT RIR, FMA, Audioset and Negative training reference datasets. Also stored in /data. * train_wake_word: Generates the wake word samples, augments them with the audio from the training datasets, and finally runs the microwakeword training. The resulting model tflite and json files are placed in the /data/output directory. See the README.md file for much more information. --- cli/.bashrc | 135 +++++++++ cli/Dockerfile | 27 ++ cli/README.md | 507 +++++++++++++++++++++++++++++++++ cli/cudainfo | 53 ++++ cli/requirements.txt | 10 + cli/setup_audioset | 175 ++++++++++++ cli/setup_fma | 131 +++++++++ cli/setup_mit_audio | 124 ++++++++ cli/setup_negative_datasets | 85 ++++++ cli/setup_python_venv | 183 ++++++++++++ cli/setup_training_datasets | 48 ++++ cli/shell.functions | 150 ++++++++++ cli/system_summary | 18 ++ cli/tensorboard1.png | Bin 0 -> 20767 bytes cli/tensorboard2.png | Bin 0 -> 33126 bytes cli/tensorboard3.png | Bin 0 -> 43799 bytes cli/test_python | 129 +++++++++ cli/train_wake_word | 125 ++++++++ cli/wake_word_sample_augmenter | 215 ++++++++++++++ cli/wake_word_sample_generator | 112 ++++++++ cli/wake_word_sample_trainer | 241 ++++++++++++++++ 21 files changed, 2468 insertions(+) create mode 100644 cli/.bashrc create mode 100644 cli/Dockerfile create mode 100644 cli/README.md create mode 100755 cli/cudainfo create mode 100644 cli/requirements.txt create mode 100755 cli/setup_audioset create mode 100755 cli/setup_fma create mode 100755 cli/setup_mit_audio create mode 100755 cli/setup_negative_datasets create mode 100755 cli/setup_python_venv create mode 100755 cli/setup_training_datasets create mode 100644 cli/shell.functions create mode 100755 cli/system_summary create mode 100644 cli/tensorboard1.png create mode 100644 cli/tensorboard2.png create mode 100644 cli/tensorboard3.png create mode 100755 cli/test_python create mode 100755 cli/train_wake_word create mode 100755 cli/wake_word_sample_augmenter create mode 100755 cli/wake_word_sample_generator create mode 100755 cli/wake_word_sample_trainer diff --git a/cli/.bashrc b/cli/.bashrc new file mode 100644 index 0000000..922c4c1 --- /dev/null +++ b/cli/.bashrc @@ -0,0 +1,135 @@ +# ~/.bashrc: executed by bash(1) for non-login shells. +# see /usr/share/doc/bash/examples/startup-files (in the package bash-doc) +# for examples + +# If not running interactively, don't do anything +[ -z "$PS1" ] && return + +# don't put duplicate lines in the history. See bash(1) for more options +# ... or force ignoredups and ignorespace +HISTCONTROL=ignoredups:ignorespace + +# append to the history file, don't overwrite it +shopt -s histappend + +# for setting history length see HISTSIZE and HISTFILESIZE in bash(1) +HISTSIZE=1000 +HISTFILESIZE=2000 + +# check the window size after each command and, if necessary, +# update the values of LINES and COLUMNS. +shopt -s checkwinsize + +# make less more friendly for non-text input files, see lesspipe(1) +[ -x /usr/bin/lesspipe ] && eval "$(SHELL=/bin/sh lesspipe)" + +# set variable identifying the chroot you work in (used in the prompt below) +if [ -z "$debian_chroot" ] && [ -r /etc/debian_chroot ]; then + debian_chroot=$(cat /etc/debian_chroot) +fi + +# set a fancy prompt (non-color, unless we know we "want" color) +case "$TERM" in + xterm-color) color_prompt=yes;; +esac + +# uncomment for a colored prompt, if the terminal has the capability; turned +# off by default to not distract the user: the focus in a terminal window +# should be on the output of commands, not on the prompt +#force_color_prompt=yes + +if [ -n "$force_color_prompt" ]; then + if [ -x /usr/bin/tput ] && tput setaf 1 >&/dev/null; then + # We have color support; assume it's compliant with Ecma-48 + # (ISO/IEC-6429). (Lack of such support is extremely rare, and such + # a case would tend to support setf rather than setaf.) + color_prompt=yes + else + color_prompt= + fi +fi + +if [ "$color_prompt" = yes ]; then + PS1='${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ ' +else + PS1='${debian_chroot:+($debian_chroot)}\u@\h:\w\$ ' +fi +unset color_prompt force_color_prompt + +# If this is an xterm set the title to user@host:dir +case "$TERM" in +xterm*|rxvt*) + PS1="\[\e]0;${debian_chroot:+($debian_chroot)}\u@\h: \w\a\]$PS1" + ;; +*) + ;; +esac + +# enable color support of ls and also add handy aliases +if [ -x /usr/bin/dircolors ]; then + test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)" + alias ls='ls --color=auto' + #alias dir='dir --color=auto' + #alias vdir='vdir --color=auto' + + alias grep='grep --color=auto' + alias fgrep='fgrep --color=auto' + alias egrep='egrep --color=auto' +fi + +# some more ls aliases +alias ll='ls -alF' +alias la='ls -A' +alias l='ls -CF' + +# Alias definitions. +# You may want to put all your additions into a separate file like +# ~/.bash_aliases, instead of adding them here directly. +# See /usr/share/doc/bash-doc/examples in the bash-doc package. + +if [ -f ~/.bash_aliases ]; then + . ~/.bash_aliases +fi + +# enable programmable completion features (you don't need to enable +# this, if it's already enabled in /etc/bash.bashrc and /etc/profile +# sources /etc/bash.bashrc). +#if [ -f /etc/bash_completion ] && ! shopt -oq posix; then +# . /etc/bash_completion +#fi + +if [ -f /data/.bashrc ]; then + . /data/.bashrc +fi + +if ! mountpoint -q /data ; then + cat <<-EOF >&2 + ======================================================= + WARNING: The /data directory is NOT mounted. + Running the training process without /data mounted + could add over 140Gb of python packages and training + files to this container's storage which is probably + NOT what you want. + + You should remove this container and re-create it with + a 'docker run' option like '-v :/data' + making sure the host directory is on a device that has + enough free space. + ======================================================= +EOF +fi + +if [ -d /data/.venv ]; then + . /data/.venv/bin/activate +else + cat <<-EOF >&2 + ======================================================= + WARNING: A python virtual environment wasn't found + at /data/.venv. You'll need to run 'setup_python_venv' + before you'll be able to use this container for + training. + ======================================================= +EOF + +fi +alias venv='[ -d /data/.venv ] && source /data/.venv/bin/activate || echo "/data/.venv does not exist yet"' diff --git a/cli/Dockerfile b/cli/Dockerfile new file mode 100644 index 0000000..c460d93 --- /dev/null +++ b/cli/Dockerfile @@ -0,0 +1,27 @@ +# Since this is a pure python environment, we don't need to start +# with a huge CUDA image. A standard Ubuntu image will do. +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_ROOT_USER_ACTION=ignore \ + HF_HUB_DISABLE_SYMLINKS_WARNING=1 \ + PATH="/root/mww-scripts:${PATH}" + +# System deps +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3.12 python3.12-venv python3.12-dev python3-pip python-is-python3 \ + git wget curl unzip ca-certificates nano less \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /data + +COPY --chown=root:root --chmod=0755 .bashrc /root/ +COPY --chown=root:root --chmod=0755 setup_* wake_word_sample* train_wake_word \ + test_python cudainfo system_summary shell.functions requirements.txt /root/mww-scripts/ + +# Docker and Podman send the CMD a SIGTERM when you "stop" the container. Unfortunately, bash +# normally doesn't exit when it recieves a SIGTERM so docker/podman has to wait for the "stop" +# to timeout then SIGKILL the container. +# This little scriptlet causes bash to exit immediately when it receives the SIGTERM. +CMD ["/usr/bin/bash", "-c", "exec /usr/bin/bash --rcfile <(echo '[ -f ~/.bashrc ] && source ~/.bashrc ; trap exit SIGTERM ;')" ] diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..359d73d --- /dev/null +++ b/cli/README.md @@ -0,0 +1,507 @@ +# Run training from the command line + +## Overview + +With these scripts and Dockerfile, you can train new wake words from the +command line without using a Jupyter notebook. + +Differences between this Docker image and the Jupyter notebook image: + +* The Python training environment isn't included in the image. Instead, a + "virtual environment" (venv) is created in the `/data` directory which you + will have mounted to a host directory. This cuts about 7gb from the image + and allows the virtualenv to persist across container instances. + +* The logic from the Jupyter notebook is contained in individual Python + and shell scripts + +* No ports need to be exposed since the Jupyter notebook server isn't being + run. + +## TL;DR + +For the impatient among you... + +```shell +$ mkdir /some/work/directory # On a device with more than 150GB free space +$ docker build -t microwakeword-cli:latest . +$ docker run -it --rm --gpus=all -v /some/work/directory:/data --name=mww-cli microwakeword-cli:latest +root@mww-cli:/# cd /data +root@mww-cli:/data# setup_python_venv +##### You have about 4 minutes to drink coffee + +root@mww-cli:/data# setup_training_datasets --cleanup-archives --cleanup-intermediate-files +##### You have about 25 minutes for a quick lunch (on a 1gb/sec internet connection) + +root@mww-cli:/data# train_wake_word --cleanup-work-dir "wake_word" "Wake Word" +##### You have about 30-45 minutes for a nap depending on available system resources. +##### You'll be informed of where to find your trained model. +``` + +Load the trained model on your device and give it a try but don't be surprized +if you get a lot of missed or false activations. Read on to find out why. + +## Get Started + +Good, you stuck around! Now read the rest of the document before doing +anything. + +### Using a GPU + +Having an Nvidia GPU available can cut the training time by up to half. The +open-source nouveau driver shipped with Linux kernels doesn't support CUDA +however so if you have an Nvidia GPU and want to use it for training, you'll +need to install the official Nvidia driver from +https://www.nvidia.com/en-in/drivers/unix/ + +### Build the image + +You can use either Docker or Podman as your container management tool. +`docker` is used in the examples but if you have podman, just substitute +the command. + +Start by navigating to the directory that contains this README file and +the accompanying Dockerfile. Then... + + +```shell +docker build -t microwakeword-cli:latest . +``` + +This should be fairly quick and result in an image that's about 320mb in size +as it's basically a standard Ubunbtu24.04 image with a few added tools. + +So why isn't a pre-built image available for download? Because it'll probably +take longer to download a pre-built image than for you to create it locally. +GitHub's container registry is notoriously erratic when it comes to download +throughput. + +### Create a host work directory + +This directory will contain the Python virtual environment plus all of the +downloaded and generated data needed for training and the final trained +models. A full environment will need about 150gb of free space but read +further to see how to reduce this. + +Your `` will be mounted inside the container as `/data`. + +The training container will start a Bash shell so if you have Bash +aliases or Bashy things you like, create a `.bashrc` file in your +`` and put them in there. It'll automatically be included +any time you enter the container. + +### Create and start the container + +There are lots of options that control container creation. The simplest example +will create the container and give you an interactive shell. When you exit the +shell, the container will be stopped and removed leaving your `` +intact. + +```shell +$ docker run -it --rm --gpus=all -v :/data microwakeword-cli:latest +``` + +Options: + +* Remove the `--gpus=all` option if you don't have an Nvidia GPU or don't want to use it. +* Remove the `--rm` and add a `--name=mww-cli` option to keep the container + around and give it a name for training more than one wake word. You + can stop and remove it when you're ready. +* Add a `-d` option to start the container in the background and use `docker + attach mww-cli` or `docker exec -it mww-cli /bin/bash` to connect to it. + +When the container starts, you'll see: + +```text +======================================================= +WARNING: A python virtual environment wasn't found +at /data/.venv. You'll need to run setup_python_venv +before you'll be able to use this container for +training. +======================================================= +root@mww-cli:/# +``` + +Don't worry about the python WARNING right now. You'll be creating the +virtualenv in the next step. + +If you've forgotton to create and/or mount your host data directory, you'll +see an additional warning: + +```text +======================================================= +WARNING: The /data directory is NOT mounted. +Running the training process without /data mounted +could add over 140Gb of python packages and training +files to this container's storage which is probably +NOT what you want. + +You should remove this container and re-create it with +a 'docker run' option like '-v :/data' +making sure the host directory is on a device that has +enough free space. +======================================================= +``` + +You can certainly continue but it's a "really bad idea"™ because your +container storage could grow from a few hundred mb to over 140gb. + +At this point, you're in a Bash shell. + +### Create the Python virtual environment + +The Python virtual environment will contain all the software needed to train. +It gets created as `/data/.venv` and will take up about 11gb of disk space. + +The scripts that do all the work will be in the container's PATH so to setup +the virtual environment and install all of the packages, just run: + +```text +setup_python_venv [ --verbose ] + +Options: + +--verbose: Print the detailed "pip install" output. + +``` + +When the installation is finished, a test of the major components will be +run. + +Once the process is done, you should change to the `/data` directory and +activate the virtual environment with: + +```shell +root@mww-cli:/# cd /data +root@mww-cli:/data# source .venv/bin/activate +(.venv) root@mww-cli:/data# +``` + +Technically, you don't need to do either of these since the scripts +are in the PATH and they know to use the `/data` directory for everything. +It's more of an "if you're interested" thing. + +At this point, you have a container with all software installed. + +## Get the reference data + +The training process itself relies on a significant amount of audio reference +data that creates a simulated "audio environment" that your wake word will be +trained in. These "training datasets" include things like varying amounts of +reverberation, background music, background conversations, background noise, +etc. All said and done, it amounts to about 30gb of audio but with the +downloaded archives and extracted intermediate files, you'll need about 85gb +of free space. Thankfully, you only need to download the files once no +matter how many wake words you want to train and since it's stored in +`/data`, you can even remove the docker container and recreate it without +losing any of it. There are 4 datasets that are required. + +This is a three stage process... + +1. Download zipfiles or tarballs. (about 30gb) +2. Extract them. (about 50gb) +3. Convert them into the final form. (about 31gb) + +NOTE: The sizes add up to more than the 85gb stated earlier because one +of the datasets doesn't need to be covnerted and is counted in both +steps 2 and 3. You really do only need 85gb. + +To download the archives, unpack them, and convert the audio to what's needed +by the training process, run: + +```text +setup_training_datasets [ --cleanup-archives ] [ --cleanup-intermediate-files ] + +Options: +--cleanup-archives: Automatically delete the tarballs or zipfiles after + they've been extracted. + +--cleanup-intermediate-files: Automatically delete the intermediate files + after they've been converted. + +``` + +On a 1gb/sec Internet connection, this will take about 25 minutes. + +The script detects if the datasets have already been downloaded, extracted +and/or converted and skips those steps as appropriate so if you've run the +script without the cleanup options, you can just run it again with those +options to clean them up. + +Now you're ready to train a wake word. Almost. + +## Train a Wake Word + +Training is done in 3 stages. + +1. Generate thousands of samples of the wake word with various voices, +pitches, speeds, inflections, etc. +2. Augment the samples with the training datasets to add background noise, etc. +3. Run the Tensorflow training. + +### Generate a sample for verification + +Before you start the full process, you're going to want to generate a single +wake word sample and play it back to ensure it sounds right. The wake word +should be spelled phonetically to give the sample generator the best chance +of success. + +```text +root@mww-cli:/# wake_word_sample_generator --samples=1 "hey buster" +===== Generating 1 sample of 'hey buster' ===== + Loading /data/tools/piper-sample-generator/models/en_US-libritts_r-medium.pt + Successfully loaded the model + Batch 1/0 complete + Done +Sample available at /data/work/test_sample/hey_buster.wav +Play it from your host. +``` + +You should then play that file from your host. The reason I used "hey buster" +as the wake word is to demonstrate why it's important to generate and listen +to a sample. If you try that exact input and play it back, you'll notice +that the generator didn't capture the "er" at the end very well. To get it to +do so, I had to add a period on the end as a "spacer". +"hey buster." worked much better. + +When you're happy with the sample, you can run the full process. + +### Run the full training process + +```text +train_wake_word [ --samples= ] [ --batch-size= ] + [ --training-steps= ] [ --cleanup-work-dir ] + [ ] + +Options: +--samples: The number of samples to generate for the wake word. + Default: 20000 + +--batch-size: How many samples should be generated at a time. The more + samples, the more memory is needed. + Default: 100 + +--training-steps: Number of training steps. More training steps means better + detection and false positive rates but also more time to train. + Default: 25000 + +--cleanup-work-dir: Delete the /data/work directory after successful training. + Default: false + + The word to train spelled phonetically. + Required. + + An optional pretty name to save to the json metadata file. + Default: The wake word with individual words capitalized + and punctuation removed. + +``` + +By default, the training process creates 20,000 samples of your wake word and +runs 25,000 training steps. See [Tensorboard Results](#tensorboard-results) +in the [Extra Credit](#extra-credit) section below for +why these are the defaults. Depending on resources available, this could take +between 30 and 60 minutes. + +The resulting tflite model files and logs will be placed in the +`/data/output/---` directory +and will therefore be available from your host in the directory you mapped +`/data` to. File names will have non-filename-friendly characters in your +wake word changed to underscores to make things easier. You'll need both the +tflite and json files to load on your device. Exactly how you load them +depends on the device and is beyond the scope of this project. + +The only real measure of success is how well the resulting model works +on a real device. If you encounter too many missed or false activations, +increasing the number of samples would probably improve the results more +than increasing the number of training steps. See +[Tensorboard Results](#tensorboard-results) in the [Extra Credit](#extra-credit) section below. + +The output from the last step is filtered some by the script but still quite +verbose. The full log will be available in the output directory as +`training.log` if you're interested. Intepreting the log is beyond the scope +of this project however. + +You can train additional wake words or change the number of samples and +training steps by simply running `train_wake_word` again. No need to repeat +any of the earlier setup steps. If you change the wake word or the number of +wake word samples, the work directory will be deleted and all 3 steps re-run. +If you only change the number of training steps, the data from the first two +steps is still valid and only the 3rd step is run. + +All of the intermediate data is stored in the `/data/work` directory which will +grow to about 17gb with 20,000 wake word samples. Once the tflite model is +successfully generated and you're happy with the results, you can delete the +`/data/work` directory. + +### Training more than one wake word + +Once you have a container running, you +can easily train multiple wake words from your host: + +```shell +for wp in "hey_alexa" "hey_jenkins" ; do + docker exec -it mww-cli train_wake_word --cleanup-work-dir "$wp" +done +``` + +### Training time examples + +Training times depend on lots of things. These are examples only. +Your Mileage May Vary!!! + +```text +=============================================================================== + Training Summary + +CPU: Intel(R) Core(TM) i7-6950X CPU @ 3.00GHz (20 cores) Memory: 64195 mb +GPU: N/A + + Generate 10000 samples, 100/batch Elapsed time: 0:06:17 + Augment 10000 samples Elapsed time: 0:04:05 + 10000 training steps Elapsed time: 0:15:04 + ================================================== + Total Elapsed time: 0:25:26 +================================================================================ + +================================================================================ + Training Summary + +CPU: Intel(R) Core(TM) i7-6950X CPU @ 3.00GHz (20 cores) Memory: 64195 mb +GPU: NVIDIA GeForce RTX 3060 (3584 cores) Memory: 11909 mb + + Generate 10000 samples, 100/batch Elapsed time: 0:00:29 + Augment 10000 samples Elapsed time: 0:03:40 + 10000 training steps Elapsed time: 0:08:00 + ====================================================== + Total Elapsed time: 0:12:09 +================================================================================ + +================================================================================ + Training Summary + +CPU: Intel(R) Core(TM) i7-6950X CPU @ 3.00GHz (20 cores) Memory: 64195 mb +GPU: N/A + + Generate 20000 samples, 100/batch Elapsed time: 0:10:38 + Augment 20000 samples Elapsed time: 0:07:04 + 25000 training steps Elapsed time: 0:25:21 + ====================================================== + Total Elapsed time: 0:43:03 +================================================================================ + +================================================================================ + Training Summary + +CPU: Intel(R) Core(TM) i7-6950X CPU @ 3.00GHz (20 cores) Memory: 64195 mb +GPU: NVIDIA GeForce RTX 3060 (3584 cores) Memory: 11909 mb + + Generate 20000 samples, 100/batch Elapsed time: 0:00:53 + Augment 20000 samples Elapsed time: 0:07:05 + 25000 training steps Elapsed time: 0:19:13 + ====================================================== + Total Elapsed time: 0:27:11 +================================================================================ + +================================================================================ + Training Summary + +CPU: Intel(R) Core(TM) i7-6950X CPU @ 3.00GHz (20 cores) Memory: 64195 mb +GPU: N/A + + Generate 50000 samples, 100/batch Elapsed time: 0:30:47 + Augment 50000 samples Elapsed time: 0:20:22 + 40000 training steps Elapsed time: 1:01:51 + ================================================== + Total Elapsed time: 1:53:00 +================================================================================ + +================================================================================ + Training Summary + +CPU: Intel(R) Core(TM) i7-6950X CPU @ 3.00GHz (20 cores) Memory: 64195 mb +GPU: NVIDIA GeForce RTX 3060 (3584 cores) Memory: 11909 mb + + Generate 50000 samples, 100/batch Elapsed time: 0:02:08 + Augment 50000 samples Elapsed time: 0:19:13 + 40000 training steps Elapsed time: 0:42:23 + ====================================================== + Total Elapsed time: 1:03:44 +================================================================================ + + +``` + +The sample generation process is really the only one that uses multiple CPUs so +having fewer CPU threads available will probably make little difference. + +## Extra Credit + +### Training defaults + +If you plan on training multiple wake words, you can set your own default +training parameters by creating a `/data/.defaults.env` file with the +following contents: + +```shell +# Variable names follow the command line parameters converted to upper case +# and with the dashes ('-') converted to underscores ('_'). +export SAMPLES=10000 +export TRAINING_STEPS=10000 + +# Don't use the GPU for any operations. Stick with the CPU only. +##export CUDA_VISIBLE_DEVICES=-1 + +``` + +### Examine your model with Tensorboard + +Tensorboard is a web-based graphical model viewer. You can use it to get an +idea of how many training steps are needed before accuracy results stop +improving. To use it, you'll have to expose port 6006 by adding `-p +6006:6006` to your `docker run` command line. If you didn't, don't worry. +Remember, the /data directory is mapped to a directory on your host so you +can simply stop and delete the current container and recreate it with the new +`docker run` command. No need to re-run any of the setup or training steps. + +To start Tensorboard, run: + +```shell +root@mww-cli:/# cd /data +root@mww-cli:/data# source .venv/bin/activate +(.venv) root@mww-cli:/data# tensorboard --bind_all --logdir ./output +``` + +Now on your host, point your browser at `http://localhost:6006/`, +click "SCALARS" at the top and take a look at the various charts. You'll see +a "train" and "validation" item for each training run you've performed. It's +the "train" items you're interested in. + + + +You have to be a Tensorflow expert to decipher most of the charts but +the "Accuracy" chart for this particular wake word and 50,000 samples would +seem to idicate that there's very little improvement after about 20,000 +training steps. + +![Accuracy Chart, 50000 samples](tensorboard1.png) + +In contrast, with only 5,000 wake word samples, there's still improvement to be had after +20,000 training steps. + +![Accuracy Chart, 5000 samples](tensorboard2.png) + +Given that it's faster to generate wake word samples than it is to train, +20,000 samples and 25,000 training steps seems like a good compromise. This +chart has a bit less smoothing to show a bit more detail and includes the +50,000 sample run as well. This run took only 27 minutes as opposed to the +63 minutes it took for the 50,000 sample run. Now you know why 20,000 and +25,000 are the defaults for these scripts. + +![Accuracy Chart, 25000 samples](tensorboard3.png) + + + + + + diff --git a/cli/cudainfo b/cli/cudainfo new file mode 100755 index 0000000..5c3164b --- /dev/null +++ b/cli/cudainfo @@ -0,0 +1,53 @@ +#!/usr/bin/env python + +import sys, glob + +devices = glob.glob("/dev/nvidia[0-9]") +if len(devices) == 0: + print("CUDA not available or no CUDA-capable GPU found.") + sys.exit(0) + + +cc_cores_per_SM_dict = { + (2,0) : 32, + (2,1) : 48, + (3,0) : 192, + (3,5) : 192, + (3,7) : 192, + (5,0) : 128, + (5,2) : 128, + (6,0) : 64, + (6,1) : 128, + (7,0) : 64, + (7,5) : 64, + (8,0) : 64, + (8,6) : 128, + (8,9) : 128, + (9,0) : 128, + (10,0) : 128, + (12,0) : 128 + } + +try: + from numba import cuda + device = cuda.get_current_device() + ctx = cuda.current_context() + meminfo = ctx.get_memory_info() + compute_capability = device.compute_capability + sms = getattr(device, 'MULTIPROCESSOR_COUNT') + cores_per_sm = cc_cores_per_SM_dict.get(compute_capability) + if not cores_per_sm: + cores_per_sm = "unknown" + total_cores = "unknown" + else: + total_cores = cores_per_sm * sms + + print(f" GPU Name: {device.name if type(device.name) is str else device.name.decode()}") + print(f" Compute Capability: {'.'.join(list(map(str, compute_capability))):>7}") + print(f"Streaming Multiprocessors: {sms:>7}") + print(f" CUDA Cores per SM: {cores_per_sm:>7}") + print(f" Total CUDA Cores: {total_cores:>7}") + print(f" Total Memory: {meminfo.total / 1024 / 1024:>7.0f} mb") + print(f" Free Memory: {meminfo.free / 1024 / 1024:>7.0f} mb") +except Exception as e: + print("CUDA not available or no CUDA-capable GPU found.") diff --git a/cli/requirements.txt b/cli/requirements.txt new file mode 100644 index 0000000..a0e801b --- /dev/null +++ b/cli/requirements.txt @@ -0,0 +1,10 @@ +# --- Packages needed by our scripts --- + +numpy==1.26.4 +scipy==1.12.0 +librosa==0.10.2.post1 +soundfile==0.12.1 +tqdm==4.67.1 +scikit-learn==1.6.0 +numba==0.63.1 +PyYAML==6.0.3 diff --git a/cli/setup_audioset b/cli/setup_audioset new file mode 100755 index 0000000..d92552d --- /dev/null +++ b/cli/setup_audioset @@ -0,0 +1,175 @@ +#!/bin/bash +set -euo pipefail + +PROGPATH=$(realpath "$0") +PROGDIR=$(dirname "${PROGPATH}") + +source "${PROGDIR}/shell.functions" + +if [ "${HELP}" == "true" ] ; then + cat <&2 +Usage: $0 [ --cleanup-archives ] [ --cleanup-input-files ] [ --data-dir= ] + + --cleanup-archives : Automatically clean up any downloaded archvies after + extraction. + --cleanup-intermediate-files + : Automatically clean up the intermediate files after they've + : converted to 16k. + : Path to the data directory. + : Default: ${DATA_DIR} + +EOF + exit 1 +fi + +mkdir -p "${DATA_DIR}/training_datasets/downloads" || : +cd "${DATA_DIR}/training_datasets" + +echo "***** Checking audioset *****" + +AUDIO_URL="https://huggingface.co/datasets/agkphysics/AudioSet/resolve" +AUDIO_DIR="./audioset" +mkdir -p "${AUDIO_DIR}" +AUDIO16K_DIR="./audioset_16k" +mkdir -p "${AUDIO16K_DIR}" +AUDIO_FILECOUNT="./downloads/audioset_filecount" +AUDIO_IN_GLOB="*.flac" + +declare -A filecounts +for i in {0..9} ; do + fname="bal_train0${i}.tar" + filecounts[${fname}]=0 +done + +get_filecounts filecounts "${AUDIO_FILECOUNT}" + + +REV_CANDIDATES=( + "6762f044d1c88619c7f2006486036192128fb07e" + "0049167e89f259a010c3f070fe3666d9e5242836" + "ceb9eaaa7844c9ad7351e659c84a572e376ad06d" + "main" +) + +TAR_PATTERNS=( + "data/bal_train0" + "data/bal_train/bal_train0" +) + +find_rev() { + for rev in "${REV_CANDIDATES[@]}" ; do + for pattern in "${TAR_PATTERNS[@]}" ; do + url="https://huggingface.co/datasets/agkphysics/AudioSet/resolve/${rev}/${pattern}0.tar" + curl -I -L --fail -s "${url}" > /dev/null && echo "${rev},${pattern}" + done + done + echo "" +} + +converter() { + source ${DATA_DIR}/.venv/bin/activate + python - "${AUDIO_DIR}" "${AUDIO16K_DIR}" <<-EOF +import os, sys, subprocess, scipy.io.wavfile, numpy as np +from pathlib import Path +import soundfile as sf +import librosa +from tqdm import tqdm + +def write_wav(dst: Path, data: np.ndarray, sr: int): + x = np.clip(data, -1.0, 1.0) + scipy.io.wavfile.write(dst, sr, (x * 32767).astype(np.int16)) + +audioset_dir = Path(sys.argv[1]) +audioset_out = Path(sys.argv[2]) + +# convert FLAC → 16k mono WAV +flacs = list(audioset_dir.rglob("*.flac")) +print(f" FLAC files: {len(flacs)}") +audioset_bad = [] +ok = 0 +for p in tqdm(flacs, desc=" AudioSet→WAV (resample 16k mono)"): + try: + outfile = Path(audioset_out / (p.stem + ".wav")) + if outfile.exists(): + continue + y, _ = librosa.load(p, sr=16000, mono=True) + if y.size == 0: + raise ValueError("empty audio") + write_wav(outfile, y, 16000) + ok += 1 + except Exception as e: + audioset_bad.append(f"{p}:{e}") + +if audioset_bad: + (audioset_out / "audioset_corrupted_files.log").write_text("\n".join(audioset_bad)) +print(f" AudioSet complete ({ok} ok, {len(audioset_bad)} failed)") +EOF +} + +expected_filecount=$(get_total_filecount filecounts) +actual_filecount=$(find "${AUDIO16K_DIR}" -name "*.wav" 2>/dev/null | wc -l) || : +write_filecount=false + +if [ "${actual_filecount}" -ne 0 ] && [ "${actual_filecount}" -eq "${expected_filecount}" ] ; then + echo " Existing Audioset valid" +else + dl=$(find_rev) + [ -n "$dl" ] || { echo " Could not locate an AudioSet revision with FLAC tarballs still present on HF." ; exit 1 ; } + rev=${dl%%,*} + pattern=${dl##*,} + echo " Checking 10 tarballs" + for i in {0..9} ; do + fname="downloads/bal_train0${i}.tar" + if [ ! -f "${fname}" ] ; then + echo " Downloading bal_train0${i}.tar" + url="${AUDIO_URL}/${rev}/${pattern}${i}.tar" + curl -L -s --fail "${url}" -o "${fname}" || { echo "Could not fetch ${fname} at rev ${rev}; continuing." ; continue ; } + fi + + tarball_filecount=$(tar -tvf "${fname}" | wc -l ) + filecounts["bal_train0${i}.tar"]=${tarball_filecount} + write_filecount=true + + echo " Untarring bal_train0${i}.tar" + tar -xf "${fname}" -C "${AUDIO_DIR}" + if "${CLEANUP_ARCHIVES}" && [ -f "${fname}" ] ; then + echo " Cleaning up bal_train0${i}.tar" + rm -rf "${fname}" + fi + done + rm -rf "${AUDIO16K_DIR}/audioset_corrupted_files.log" || : + converter + if [ -f "${AUDIO16K_DIR}/audioset_corrupted_files.log" ] ; then + failed=$(cat "${AUDIO16K_DIR}/audioset_corrupted_files.log" | wc -l) + filecounts[failed]=-${failed} + fi + expected_filecount=$(get_total_filecount filecounts) + actual_filecount=$(find ${AUDIO16K_DIR} -name "*.wav" 2>/dev/null | wc -l) || : + if [ "${actual_filecount}" -ne "${expected_filecount}" ] ; then + echo " Converted file count(${actual_filecount}) != expected file count(${expected_filecount})" >&2 + exit 1 + fi +fi + +if ${write_filecount} ; then + write_filecounts filecounts "${AUDIO_FILECOUNT}" +fi + +if "${CLEANUP_ARCHIVES}" ; then + for i in {0..9} ; do + fname="downloads/bal_train0${i}.tar" + if [ -f "${fname}" ] ; then + echo " Cleaning up bal_train0${i}.tar" + rm -rf "${fname}" + fi + done +fi + +if "${CLEANUP_INTERMEDIATE_FILES}" && [ -d "${AUDIO_DIR}" ] ; then + echo " Cleaning up ${AUDIO_DIR}" + rm -rf "${AUDIO_DIR}" +fi + +echo " Audioset complete" +exit 0 + diff --git a/cli/setup_fma b/cli/setup_fma new file mode 100755 index 0000000..fe7f090 --- /dev/null +++ b/cli/setup_fma @@ -0,0 +1,131 @@ +#!/bin/bash +set -euo pipefail + +PROGPATH=$(realpath "$0") +PROGDIR=$(dirname "${PROGPATH}") + +source "${PROGDIR}/shell.functions" + +if [ "${HELP}" == "true" ] ; then + cat <&2 +Usage: $0 [ --cleanup-archives ] [ --cleanup-input-files ] [ --data-dir= ] + + --cleanup-archives : Automatically clean up any downloaded archvies after + extraction. + --cleanup-intermediate-files + : Automatically clean up the intermediate files after they've + : converted to 16k. + : Path to the data directory. + : Default: ${DATA_DIR} + +EOF + exit 1 +fi + +mkdir -p "${DATA_DIR}/training_datasets/downloads" || : +cd "${DATA_DIR}/training_datasets" + +echo "***** Checking FMA *****" + +AUDIO_URL="https://huggingface.co/datasets/mchl914/fma_xsmall/resolve/main/fma_xs.zip" +AUDIO_ZIPFILE="fma_xs.zip" +AUDIO_ZIP="./downloads/${AUDIO_ZIPFILE}" +AUDIO_DIR="fma" +mkdir -p "${AUDIO_DIR}" || : +AUDIO16K_DIR="fma_16k" +mkdir -p "${AUDIO16K_DIR}" || : +AUDIO_FILECOUNT="./downloads/fma_filecount" +AUDIO_IN_GLOB="*.mp3" + +declare -A filecounts=( [${AUDIO_ZIPFILE}]=0 ) +get_filecounts filecounts "${AUDIO_FILECOUNT}" + +converter() { + source ${DATA_DIR}/.venv/bin/activate + python - "${AUDIO_DIR}" "${AUDIO16K_DIR}" <<-EOF +import os, sys, subprocess, scipy.io.wavfile, numpy as np +from pathlib import Path +import soundfile as sf +import librosa +from tqdm import tqdm + +def write_wav(dst: Path, data: np.ndarray, sr: int): + x = np.clip(data, -1.0, 1.0) + scipy.io.wavfile.write(dst, sr, (x * 32767).astype(np.int16)) + +fma_dir = Path(sys.argv[1]) +fma_out = Path(sys.argv[2]) + +# convert MP3 → 16k mono WAV +mp3s = list(fma_dir.rglob("*.mp3")) +print(f" MP3 files: {len(mp3s)}") +fma_bad = [] +ok = 0 +for p in tqdm(mp3s, desc=" FMA→WAV (resample 16k mono)"): + try: + outfile = Path(fma_out / (p.stem + ".wav")) + if outfile.exists(): + continue + y, _ = librosa.load(p, sr=16000, mono=True) + if y.size == 0: + raise ValueError("empty audio") + write_wav(outfile, y, 16000) + ok += 1 + except Exception as e: + fma_bad.append(f"{p}:{e}") + +if fma_bad: + (fma_out / "fma_corrupted_files.log").write_text("\n".join(fma_bad)) +print(f" FMA complete ({ok} ok, {len(fma_bad)} failed)") +EOF + +} + +expected_filecount=${filecounts[${AUDIO_ZIPFILE}]} +actual_filecount=$(find ${AUDIO16K_DIR} -name '*.wav' 2>/dev/null | wc -l) || : +write_filecount=false + +if [ "${actual_filecount}" -ne 0 ] && [ "${actual_filecount}" -eq "${expected_filecount}" ] ; then + echo " Existing FMA valid" +else + actual_filecount=$(find "${AUDIO_DIR}" -name "${AUDIO_IN_GLOB}" 2>/dev/null | wc -l) || : + if [ "${actual_filecount}" -eq 0 ] || [ "${actual_filecount}" -ne "${expected_filecount}" ] ; then + if [ ! -f "${AUDIO_ZIP}" ] ; then + echo " Downloading ${AUDIO_ZIPFILE}" + curl -sfL "${AUDIO_URL}" -o "${AUDIO_ZIP}" + fi + + rm -rf "${AUDIO_DIR}" || : + mkdir "${AUDIO_DIR}" + echo " Unzipping ${AUDIO_ZIPFILE}" + unzip -q -d "${AUDIO_DIR}" "${AUDIO_ZIP}" + fi + if "${CLEANUP_ARCHIVES}" && [ -f "${AUDIO_ZIP}" ] ; then + echo " Cleaning up ${AUDIO_ZIPFILE}" + rm -rf "${AUDIO_ZIP}" + fi + + converter + + actual_filecount=$(find "${AUDIO16K_DIR}" -name "*.wav" 2>/dev/null | wc -l) || : + filecounts[${AUDIO_ZIPFILE}]="${actual_filecount}" + write_filecount=true +fi + +if ${write_filecount} ; then + write_filecounts filecounts "${AUDIO_FILECOUNT}" +fi + +if "${CLEANUP_ARCHIVES}" && [ -f "${AUDIO_ZIP}" ] ; then + echo " Cleaning up ${AUDIO_ZIPFILE}" + rm -rf "${AUDIO_ZIP}" +fi + +if "${CLEANUP_INTERMEDIATE_FILES}" && [ -d "${AUDIO_DIR}" ]; then + echo " Cleaning up ${AUDIO_DIR}" + rm -rf "${AUDIO_DIR}" +fi + +echo " FMA complete" +exit 0 + diff --git a/cli/setup_mit_audio b/cli/setup_mit_audio new file mode 100755 index 0000000..e5a1f23 --- /dev/null +++ b/cli/setup_mit_audio @@ -0,0 +1,124 @@ +#!/bin/bash +set -euo pipefail + +PROGPATH=$(realpath "$0") +PROGDIR=$(dirname "${PROGPATH}") + +source "${PROGDIR}/shell.functions" + +if [ "${HELP}" == "true" ] ; then + cat <&2 +Usage: $0 [ --cleanup-archives ] [ --cleanup-input-files ] [ --data-dir= ] + + --cleanup-archives : Automatically clean up any downloaded archvies after + extraction. + --cleanup-intermediate-files + : Automatically clean up the intermediate files after they've + : converted to 16k. + : Path to the data directory. + : Default: ${DATA_DIR} + +EOF + exit 1 +fi + +mkdir -p "${DATA_DIR}/training_datasets/downloads" || : +cd "${DATA_DIR}/training_datasets" + +AUDIO_URL="https://mcdermottlab.mit.edu/Reverb/IRMAudio/Audio.zip" +AUDIO_ZIPFILE="MIT_RIR_Audio.zip" +AUDIO_ZIP="./downloads/${AUDIO_ZIPFILE}" +AUDIO_DIR="./mit_rirs" +mkdir -p "${AUDIO_DIR}" || : +AUDIO16K_DIR="./mit_rirs_16k" +mkdir -p "${AUDIO16K_DIR}" || : +AUDIO_FILECOUNT="./downloads/mit_rir_filecount" +AUDIO_IN_GLOB="*.wav" + +declare -A filecounts=( [${AUDIO_ZIPFILE}]=0 ) +get_filecounts filecounts "${AUDIO_FILECOUNT}" + +echo "===== Checking MIT_RIR =====" + +converter() { + source ${DATA_DIR}/.venv/bin/activate + python - "${AUDIO_DIR}" "${AUDIO16K_DIR}" <<-EOF +import os, sys, subprocess, scipy.io.wavfile, numpy as np +from pathlib import Path +import soundfile as sf +import librosa +from tqdm import tqdm + +def write_wav(dst: Path, data: np.ndarray, sr: int): + x = np.clip(data, -1.0, 1.0) + scipy.io.wavfile.write(dst, sr, (x * 32767).astype(np.int16)) + +rir_in = Path(sys.argv[1]) +rir_out = Path(sys.argv[2]) + +waves = list(rir_in.rglob("*.wav")) +try: + print(" MIT RIR normalizing to 16k…") + # Normalize to 16k mono + for p in tqdm(waves, desc=" MIT_RIR (resample 16k mono)"): + outfile = Path(rir_out / p.name) + if outfile.exists(): + continue + a, sr = sf.read(p, always_2d=False) + if a.ndim > 1: + a = a[:, 0] + if sr != 16000: + a, _ = librosa.load(p, sr=16000, mono=True) + write_wav(outfile, a, 16000) + print(" MIT RIR normalization complete") +except Exception as e2: + print(f" MIT RIR fallback failed: {e2}") + raise +EOF +} + +expected_filecount=${filecounts[${AUDIO_ZIPFILE}]} +actual_filecount=$(find "${AUDIO16K_DIR}" -name '*.wav' 2>/dev/null | wc -l) || : +write_filecount=false + +if [ "${actual_filecount}" -ne 0 ] && [ "${actual_filecount}" -eq "${expected_filecount}" ] ; then + echo " Existing ${AUDIO16K_DIR} valid" +else + actual_filecount=$(find "${AUDIO_DIR}" -name "${AUDIO_IN_GLOB}" 2>/dev/null | wc -l) || : + if [ "${actual_filecount}" -eq 0 ] || [ "${actual_filecount}" -ne "${expected_filecount}" ] ; then + if [ ! -f "${AUDIO_ZIP}" ] ; then + echo " Downloading ${AUDIO_ZIPFILE}" + curl -sfL "${AUDIO_URL}" -o "${AUDIO_ZIP}" + fi + + rm -rf "${AUDIO_DIR}" || : + echo " Unzipping ${AUDIO_ZIPFILE}" + unzip -u -q -d "${AUDIO_DIR}" "${AUDIO_ZIP}" + fi + if "${CLEANUP_ARCHIVES}" && [ -f "${AUDIO_ZIP}" ] ; then + echo " Cleaning up ${AUDIO_ZIPFILE}" + rm -rf "${AUDIO_ZIP}" + fi + + converter + actual_filecount=$(find "${AUDIO16K_DIR}" -name "*.wav" 2>/dev/null | wc -l) || : + filecounts[${AUDIO_ZIPFILE}]="${actual_filecount}" + write_filecount=true +fi + +if ${write_filecount} ; then + write_filecounts filecounts "${AUDIO_FILECOUNT}" +fi + +if "${CLEANUP_ARCHIVES}" && [ -f "${AUDIO_ZIP}" ] ; then + echo " Cleaning up ${AUDIO_ZIPFILE}" + rm -rf "${AUDIO_ZIP}" +fi + +if "${CLEANUP_INTERMEDIATE_FILES}" && [ -d "${AUDIO_DIR}" ]; then + echo " Cleaning up ${AUDIO_DIR}" + rm -rf "${AUDIO_DIR}" +fi + +echo " MIT_RIR complete" +exit 0 diff --git a/cli/setup_negative_datasets b/cli/setup_negative_datasets new file mode 100755 index 0000000..eec7fee --- /dev/null +++ b/cli/setup_negative_datasets @@ -0,0 +1,85 @@ +#!/bin/bash +set -euo pipefail + +PROGPATH=$(realpath "$0") +PROGDIR=$(dirname "${PROGPATH}") + +source "${PROGDIR}/shell.functions" + +if [ "${HELP}" == "true" ] ; then + cat <&2 +Usage: $0 [ --cleanup-archives ] [ --data-dir= ] + + --cleanup-archives : Automatically clean up any downloaded archvies after + extraction. + : Path to the data directory. + : Default: ${DATA_DIR} + +EOF + exit 1 +fi + +mkdir -p "${DATA_DIR}/training_datasets/downloads" || : +cd "${DATA_DIR}/training_datasets" + +mkdir -p ./negative_datasets || : + +NEGATIVE_DATASET_URL="https://huggingface.co/datasets/kahrendt/microwakeword/resolve/main" +declare -a NEGATIVE_DATASETS=( dinner_party dinner_party_eval no_speech speech ) +AUDIO_FILECOUNT="./downloads/negative_filecount" + +declare -A filecounts=( [dinner_party.zip]=0 [dinner_party_eval.zip]=0 [no_speech.zip]=0 [speech.zip]=0 ) +get_filecounts filecounts "${AUDIO_FILECOUNT}" + +echo "===== Checking negative datasets: ${NEGATIVE_DATASETS[*]} =====" +write_filecount=false + +for ds in "${NEGATIVE_DATASETS[@]}" ; do + AUDIO_ZIPFILE="${ds}.zip" + AUDIO_ZIP="./downloads/${AUDIO_ZIPFILE}" + AUDIO_DIR="./negative_datasets/${ds}" + mkdir -p "${AUDIO_DIR}" || : + + expected_filecount=${filecounts[${AUDIO_ZIPFILE}]} + actual_filecount=$(find "${AUDIO_DIR}" -name '*.ninja' 2>/dev/null | wc -l) || : + + if [ "${actual_filecount}" -ne 0 ] && [ "${actual_filecount}" -eq "${expected_filecount}" ] ; then + echo " Existing ${ds} valid" + continue + fi + + if [ ! -f "${AUDIO_ZIP}" ] ; then + echo " Downloading ${AUDIO_ZIPFILE}" + curl -sfL "${NEGATIVE_DATASET_URL}/${ds}.zip" -o "${AUDIO_ZIP}" + fi + + rm -rf "${AUDIO_DIR}" || : + echo " Unzipping ${AUDIO_ZIPFILE}" + unzip -q -d "./negative_datasets" "${AUDIO_ZIP}" + actual_filecount=$(find "${AUDIO_DIR}" -name '*.ninja' 2>/dev/null | wc -l) || : + filecounts[${AUDIO_ZIPFILE}]="${actual_filecount}" + write_filecount=true + + if "${CLEANUP_ARCHIVES}" && [ -f "${AUDIO_ZIP}" ] ; then + echo " Cleaning up ${AUDIO_ZIPFILE}" + rm -rf "${AUDIO_ZIP}" + fi +done + +if ${write_filecount} ; then + write_filecounts filecounts "${AUDIO_FILECOUNT}" +fi + +if "${CLEANUP_ARCHIVES}" ; then + for ds in "${NEGATIVE_DATASETS[@]}" ; do + AUDIO_ZIPFILE="${ds}.zip" + AUDIO_ZIP="./downloads/${AUDIO_ZIPFILE}" + if [ -f "${AUDIO_ZIP}" ] ; then + echo " Cleaning up ${AUDIO_ZIPFILE}" + rm -rf "${AUDIO_ZIP}" + fi + done +fi + +echo " Negative datasets complete" + diff --git a/cli/setup_python_venv b/cli/setup_python_venv new file mode 100755 index 0000000..153d43d --- /dev/null +++ b/cli/setup_python_venv @@ -0,0 +1,183 @@ +#!/bin/bash +PROGDIR="$(dirname $(realpath $0))" + +KNOWN_ARGS=( data-dir python gpu no-gpu ) +source "${PROGDIR}/shell.functions" + +if [ ${#UNKNOWN_ARGS[@]} -gt 0 ] ; then + echo "Unknown argument(s): ${UNKNOWN_ARGS[*]}" >&2 + HELP=true +fi + +if [ "${HELP}" == "true" ] ; then + cat <&2 +Usage: setup_python_venv [ --gpu | --no-gpu ] [ --verbose ] + +Options: +--gpu: Install the GPU-capable versions of packages if available. This + is the default if the script detects that a GPU is available. + +--no-gpu: Install the non-GPU-capable versions of packages even if + GPU-capable packages are available. This is the default if the script + detects that a GPU is NOT available. + +--verbose: Print the detailed "pip install" output. + +EOF + exit 1 +fi + +[ -n "${DATA_DIR}" ] && DATA_DIR="$(realpath ${DATA_DIR})" +[ -d "${DATA_DIR}" ] || { + echo "Data directory '${DATA_DIR}' doesn't exist." >&2 + exit 1 +} + +cd "${DATA_DIR}" + +[ -z "${GPU}" ] && { + GPU=false + [ -c /dev/nvidiactl ] && { + GPU=true + echo " Nvidia GPU detected" + } +} + +"${GPU}" || export CUDA_VISIBLE_DEVICES=-1 + +VENV="${DATA_DIR}/.venv" +[ -n "${VIRTUAL_ENV}" ] && deactivate + +if [ -n "${PYTHON}" ] ; then + PYTHONS=( "${PYTHON}" ) + unset PYTHON +else + PYTHONS=( python3.12 python3.10 ) +fi + +for p in "${PYTHONS[@]}" ; do + "${p}" --version &>/dev/null && { PYTHON="${p}" ; break ; } +done + +[ -n "${PYTHON}" ] || { + echo "A python 3.12 or 3.10 interpreter wasn't found. You 'll need to install one before proceeding." >&2 + exit 1 +} + +if [ -d "${VENV}" ] ; then + if [ -f "${DATA_DIR}/.mww-data-dir" ] ; then + source "${VENV}/bin/activate" || { + echo "Unable to activate existing virtualenv '${VENV}'. You should delete it and try again." >&2 + exit 1 + } + else + rm -rf "${VENV}" + fi +fi + +echo "===== Setting up Python environment ${VENV} =====" + +if [ -z "$VIRTUAL_ENV" ] ; then + echo " ===== Creating new virtualenv at '${VENV}' =====" +else + echo " ===== Updating virtualenv at '${VENV}' =====" +fi +${PYTHON} -m venv --upgrade-deps "${VENV}" +source "${VENV}/bin/activate" + +set -euo pipefail + +declare -a progfiles=( $(find ${PROGDIR} -mindepth 1 -maxdepth 1 -executable -type f) ) +progfiles+=( "${PROGDIR}/shell.functions" ) + +for f in "${progfiles[@]}" ; do + ln -sfr "${f}" ".venv/bin/$(basename ${f})" +done + +# +# Pip doesn't process packages from requirements.txt in +# order but order is important because tensorflow, torch, +# onnxruntime and micro-wake-word all depend on CUDA packages +# at various versions. They need to be installed in this specific +# order or they may not be able to use the GPU. +# +export PIP_PROGRESS_BAR=off +export PIP_NO_COLOR=1 +export PIP_QUIET=0 + +pip_install() { + if $VERBOSE ; then + pip install "$@" || return 1 + else + { pip install "$@" || return 1 ; } | stdbuf -i0 -o0 tr -d '[:print:]' | stdbuf -i0 -o0 tr '\n' '.' + fi + echo +} + +START_TS=$EPOCHSECONDS + +echo " ===== Installing common requirements =====" +pip_install -r "${PROGDIR}/requirements.txt" + +${GPU} && tfgpu='[and-cuda]' || tfgpu="" +echo " ===== Installing Tensorflow${tfgpu} =====" +pip_install ai_edge_litert "tensorflow${tfgpu}==2.20.0" "tensorboard==2.20.0" \ + "tensorboard-data-server==0.7.2" + +${GPU} && torchgpu='--index-url https://download.pytorch.org/whl/cu129' || torchgpu="" +echo " ===== Installing torch and torchaudio ${torchgpu:+[cuda]} =====" +pip_install "torch==2.9.1" "torchaudio==2.9.1" ${torchgpu} + +echo " ===== Checking microwakeword =====" +MWW="${DATA_DIR}/tools/microWakeWord" +if [ ! -d "${MWW}" ] || [ -n "$(git -C "${MWW}" status --porcelain)" ] ; then + rm -rf "${MWW}" || : + echo " Cloning micro-wake-word to ${DATA_DIR}/tools" + git clone https://github.com/TaterTotterson/micro-wake-word "${MWW}" &>/dev/null +fi +echo " Installing microwakeword" +pip_install -e "${MWW}" + +echo " ===== Checking piper-sample-generator =====" +PSG="${DATA_DIR}/tools/piper-sample-generator" +if [ ! -d "${PSG}" ] || [ -n "$(git -C ${PSG} status --porcelain)" ] ; then + rm -rf "${PSG}" || : + echo " Cloning piper-sample-generator to ${DATA_DIR}/tools" + git clone https://github.com/rhasspy/piper-sample-generator "${PSG}" &>/dev/null +fi +echo " Installing piper-sample-generator" +pip_install -e "${PSG}" +git -C tools/piper-sample-generator clean -fd &>/dev/null + +MODELS_DIR="${PSG}/models" +MODEL_NAME="en_US-libritts_r-medium.pt" +MODEL_FILE="${MODELS_DIR}/${MODEL_NAME}" +MODEL_URL="https://github.com/rhasspy/piper-sample-generator/releases/download/v2.0.0/${MODEL_NAME}" +if [ ! -f "${MODEL_FILE}" ] ; then + echo " Downloading ${MODEL_NAME} for piper-sample-generator" + curl -sfL "${MODEL_URL}" -o "${MODEL_FILE}" +fi + +if [ ! -f "${MODEL_FILE}.json" ] ; then + echo " Downloading ${MODEL_NAME}.json for piper-sample-generator" + curl -sfL "${MODEL_URL}.json" -o "${MODEL_FILE}.json" +fi + +${GPU} && onnxgpu='-gpu[cuda]' || onnxgpu="" +echo " ===== Installing onnxruntime${onnxgpu} =====" +pip_install "onnxruntime${onnxgpu}>=1.16.0" + +echo " ===== Installing keras =====" +# keras 3.13 has "issues" so we need to back down to 3.12. +pip_install "keras==3.12.0" + +${PROGDIR}/test_python --data-dir="${DATA_DIR}" + +touch .mww-data-dir +END_TS=$EPOCHSECONDS + +echo "Run 'source ${VENV}/bin/activate' to activate the new virtualenv in the current shell." + +print_elapsed_time "${START_TS}" "${END_TS}" "Python package installation complete" + + diff --git a/cli/setup_training_datasets b/cli/setup_training_datasets new file mode 100755 index 0000000..fc6e280 --- /dev/null +++ b/cli/setup_training_datasets @@ -0,0 +1,48 @@ +#!/bin/bash +set -euo pipefail + +PROGPATH=$(realpath "$0") +PROGDIR=$(dirname "${PROGPATH}") + +KNOWN_ARGS=( data-dir cleanup-archives cleanup-intermediate-files ) +source "${PROGDIR}/shell.functions" + +if [ ${#UNKNOWN_ARGS[@]} -gt 0 ] ; then + echo "Unknown argument(s): ${UNKNOWN_ARGS[*]}" >&2 + HELP=true +fi + +if [ "${HELP}" == "true" ] ; then + cat <&2 +Usage: setup_training_datasets [ --cleanup-archives ] [ --cleanup-intermediate-files ] + +Options: +--cleanup-archives: Automatically delete the tarballs or zipfiles after + they've been extracted. + +--cleanup-intermediate-files: Automatically delete the intermediate files + after they've been converted. + +EOF + exit 1 +fi + +cd "${DATA_DIR}" + +START_TS=$EPOCHSECONDS +echo -e "\n===== Setting up Training Datasets =====\n" + +${PROGDIR}/setup_negative_datasets --cleanup-archives=${CLEANUP_ARCHIVES} \ + --cleanup-intermediate-files=${CLEANUP_INTERMEDIATE_FILES} --data-dir="${DATA_DIR}" + +${PROGDIR}/setup_mit_audio --cleanup-archives=${CLEANUP_ARCHIVES} \ + --cleanup-intermediate-files=${CLEANUP_INTERMEDIATE_FILES} --data-dir="${DATA_DIR}" + +${PROGDIR}/setup_audioset --cleanup-archives=${CLEANUP_ARCHIVES} \ + --cleanup-intermediate-files=${CLEANUP_INTERMEDIATE_FILES} --data-dir="${DATA_DIR}" + +${PROGDIR}/setup_fma --cleanup-archives=${CLEANUP_ARCHIVES} \ + --cleanup-intermediate-files=${CLEANUP_INTERMEDIATE_FILES} --data-dir="${DATA_DIR}" + +END_TS=$(date +%s.%N) +print_elapsed_time "${START_TS}" "${END_TS}" "Training dataset setup" diff --git a/cli/shell.functions b/cli/shell.functions new file mode 100644 index 0000000..07b3b02 --- /dev/null +++ b/cli/shell.functions @@ -0,0 +1,150 @@ + +if [ "$0" == "${BASH_SOURCE[0]}" ] ; then + echo "${BASH_SOURCE[0]} is meant to be 'sourced' not run directly" >&2 + exit 1 +fi + +if [ ! -v DATA_DIR ] ; then + [ -f .mww-data-dir ] && DATA_DIR="${PWD}" || DATA_DIR="/data" +fi + +DEFAULT_SAMPLES=20000 +DEFAULT_BATCH_SIZE=100 +DEFAULT_TRAINING_STEPS=25000 + +[ -f "${DATA_DIR}/.defaults.env" ] && source "${DATA_DIR}/.defaults.env" || : + +: "${SAMPLES:=${DEFAULT_SAMPLES}}" +: "${BATCH_SIZE:=${DEFAULT_BATCH_SIZE}}" +: "${TRAINING_STEPS:=${DEFAULT_TRAINING_STEPS}}" +: "${CLEANUP_WORK_DIR:=false}" +: "${CLEANUP_ARCHIVES:=false}" +: "${CLEANUP_INTERMEDIATE_FILES:=false}" +: "${QUIET:=false}" +: "${VERBOSE:=false}" + +HELP=false + +if [ -v KNOWN_ARGS ] ; then + KNOWN_ARGS+=( help verbose quiet h v q ) +fi +declare -gi OPTION_COUNT=0 +declare -ga POSITIONAL_ARGS=() +declare -ga EXTRA_ARGS=() +declare -ga UNKNOWN_ARGS=() +declare -i __stop_parsing=0 +for a in "$@"; do + if [ "$a" == "--" ] ; then + __stop_parsing=1 + shift + continue + fi + if [ $__stop_parsing == 1 ] ; then + EXTRA_ARGS+=( "$a" ) + shift + continue + fi + + if [ -v KNOWN_ARGS ] && [[ "${a}" =~ ^--?([^=]+)=?.* ]] ; then + _arg=${BASH_REMATCH[1]} + known=false + for _k in "${KNOWN_ARGS[@]}" ; do + [ "${_arg}" == "${_k}" ] && { known=true ; break ; } || : + done + $known || UNKNOWN_ARGS+=( "${a}" ) + fi + OPTION_COUNT+=1 + case "$a" in + -h | --help) + HELP=true + break + ;; + -q | --quiet) + QUIET=true + break + ;; + -v | --verbose) + VERBOSE=true + break + ;; + --*=*) + [[ $a =~ --([^=]+)=(.*) ]] + l=${BASH_REMATCH[1]//-/_} + declare -n var="${l^^}" + var="${BASH_REMATCH[2]}" + ;; + --no-*) + [[ $a =~ --no-(.+) ]] + l=${BASH_REMATCH[1]//-/_} + declare -n var="${l^^}" + var=false + ;; + --*) + [[ $a =~ --(.+) ]] + l=${BASH_REMATCH[1]//-/_} + declare -n var="${l^^}" + var=true + ;; + *) + POSITIONAL_ARGS+=( "$a" ) + ;; + esac +done + + +print_elapsed_time() { + print_seps=True + if [ "$1" == "--no-separators" ] ; then + shift + print_seps=False + fi + local START_TS=${1:?"Usage: $0 "} + local END_TS=${2:?"Usage: $0 "} + message="${3}" + python <> "${af}" + done +} diff --git a/cli/system_summary b/cli/system_summary new file mode 100755 index 0000000..da4f7c1 --- /dev/null +++ b/cli/system_summary @@ -0,0 +1,18 @@ +#!/bin/bash +PROGPATH=$(realpath "$0") +PROGDIR=$(dirname "${PROGPATH}") + +CUDA_INFO=$("${PROGDIR}/cudainfo") +CUDA_CORES=$(sed -n -r -e "s/\s*Total\s+CUDA\s+Cores:\s+([0-9]+)$/\1/gp" <<<${CUDA_INFO}) +GPU_NAME="$(sed -n -r -e 's/\s*GPU\s+Name:\s+(.+)$/\1/gp' <<<${CUDA_INFO})" +GPU_MEMORY="$(sed -n -r -e 's/\s*Total\s+Memory:\s*([0-9.]+).*/\1/gp' <<<${CUDA_INFO})" +CPU_NAME="$(sed -n -r -e 's/model\s+name\s*:\s*(.+)$/\1/gp' /proc/cpuinfo | head -1)" +CPU_CORES="$(nproc)" +SYS_MEMORY="$(free -m | sed -n -r -e 's/Mem:\s+([0-9.]+)\s+.*/\1/gp')" + +printf "CPU: %s (%d cores) Memory: %s mb\n" "${CPU_NAME}" "${CPU_CORES}" "${SYS_MEMORY}" +if [ -z "${GPU_NAME}" ] ; then + printf "GPU: N/A\n" +else + printf "GPU: %s (%d cores) Memory: %s mb\n" "${GPU_NAME}" "${CUDA_CORES}" "${GPU_MEMORY}" +fi diff --git a/cli/tensorboard1.png b/cli/tensorboard1.png new file mode 100644 index 0000000000000000000000000000000000000000..a7741d97d19234c3bd33331af9fd8c81e547b4f3 GIT binary patch literal 20767 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 literal 0 HcmV?d00001 diff --git a/cli/tensorboard3.png b/cli/tensorboard3.png new file mode 100644 index 0000000000000000000000000000000000000000..6df0306e4e19ff251d1902ca63c1df33be1b31f6 GIT binary patch literal 43799 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/dev/null <= 0: + result = "Available - " + c.device + else: + result = "Not available" + except: + result = "Not available" + + print("GPU:", result) + + try: + with tf.device("/CPU:0"): + a = tf.random.normal([10000, 10000]) + b = tf.random.normal([10000, 10000]) + c = tf.matmul(a, b) + result = "Available - " + c.device + except: + result = "Not available" + + print("CPU:", result) +except: + print("Tensorflow not available") +EOF + + +python - 2>/dev/null </dev/null </dev/null </dev/null && { + echo "piper-sample-generator available" +} || { + echo "piper-sample-generator not available" +} + +echo +echo -e "\n===== Python Environment Testing Complete =====\n" diff --git a/cli/train_wake_word b/cli/train_wake_word new file mode 100755 index 0000000..b52adcf --- /dev/null +++ b/cli/train_wake_word @@ -0,0 +1,125 @@ +#!/bin/bash +set -e + +PROGPATH=$(realpath "$0") +PROGDIR=$(dirname "${PROGPATH}") + +KNOWN_ARGS=( samples batch-size training-steps data-dir cleanup-work-dir ) +source "${PROGDIR}/shell.functions" +WAKE_WORD=${POSITIONAL_ARGS[0]} + +if [ ${#UNKNOWN_ARGS[@]} -gt 0 ] ; then + echo "Unknown argument(s): ${UNKNOWN_ARGS[*]}" >&2 + HELP=true +fi + +if [ "${HELP}" == "true" ] || [ -z "${WAKE_WORD}" ] ; then + cat <&2 +Usage: train_wake_word [ --samples= ] [ --batch-size= ] + [ --training-steps= ] [ --cleanup-work-dir ] + [ ] + +Options: +--samples: The number of samples to generate for the wake word. + Default: ${DEFAULT_SAMPLES} + +--batch-size: How many samples should be generated at a time. The more + samples per batch, the more memory is needed. + Default: ${DEFAULT_BATCH_SIZE} + +--training-steps: Number of training steps. More training steps means better + detection and false positive rates but also more time to train. + Default: ${DEFAULT_TRAINING_STEPS} + +--cleanup-work-dir: Delete the /data/work directory after successful training. + Default: false + + The word to train spelled phonetically. + Required. + + An optional pretty name to save to the json metadata file. + Default: The wake word with individual words capitalized + and punctuation removed. + +EOF + exit 1 +fi + +# shellcheck source=/dev/null +source "${DATA_DIR}/.venv/bin/activate" + +cd "${DATA_DIR}" +mkdir -p "${DATA_DIR}/work" || : + +[ ${#POSITIONAL_ARGS} -eq 2 ] && WAKE_WORD_TITLE="${POSITIONAL_ARGS[1]}" || : + +if [ ! -v WAKE_WORD_TITLE ] ; then + declare -a WWNA=( ${WAKE_WORD//[^a-zA-Z0-9]/ } ) + WAKE_WORD_TITLE="${WWNA[*]^}" +elif [ -z "$WAKE_WORD_TITLE" ] ; then + WAKE_WORD_TITLE="$WAKE_WORD" +fi + +printf "%-80s\n" "=" | tr ' ' "=" +echo "===== Running '${WAKE_WORD}(${WAKE_WORD_TITLE})' generation, augmentation and training =====" +"${PROGDIR}/cudainfo" +echo +START_TS=$EPOCHSECONDS + +export TF_CPP_MIN_LOG_LEVEL=9 +export TF_FORCE_GPU_ALLOW_GROWTH=true +export TF_GPU_ALLOCATOR=cuda_malloc_async +export TF_XLA_FLAGS="--tf_xla_auto_jit=0" +export NVIDIA_TF32_OVERRIDE=1 +export TF_CUDNN_WORKSPACE_LIMIT_IN_MB=512 +export GLOG_minloglevel=2 +export GRPC_VERBOSITY=ERROR + + +"${PROGDIR}/wake_word_sample_generator" \ + --samples=${SAMPLES} \ + --batch-size=${BATCH_SIZE} \ + --data-dir="${DATA_DIR}" "${WAKE_WORD}" + +POST_GEN_TS=$EPOCHSECONDS + +ww="${WAKE_WORD// /_}" +ww="${ww//./}" + +AUGMENT=false +GENERATED_DIR="${DATA_DIR}/work/wake_word_samples" +AUGMENTED_DIR="${DATA_DIR}/work/wake_word_samples_augmented" + +[ -d "${AUGMENTED_DIR}" ] || AUGMENT=true +[ "${GENERATED_DIR}/0.wav" -nt "${AUGMENTED_DIR}/testing/wakeword_mmap/data.ninja" ] && AUGMENT=true || : + +if ${AUGMENT} ; then + rm -rf "${AUGMENTED_DIR}" || : + mkdir -p "${AUGMENTED_DIR}" || : + "${PROGDIR}/wake_word_sample_augmenter" --data-dir="${DATA_DIR}" || { rm -rf "${AUGMENTED_DIR}" ; exit 1 ; } +else + echo "Augmentation not required" + echo +fi + +POST_AUGMENT_TS=$EPOCHSECONDS + +"${PROGDIR}/wake_word_sample_trainer" --samples=${SAMPLES} --training-steps=${TRAINING_STEPS} --data-dir="${DATA_DIR}" \ + "${WAKE_WORD}" "${WAKE_WORD_TITLE}" + +if ${CLEANUP_WORK_DIR} ; then + rm -rf "${DATA_DIR}/work/trained_models" "${DATA_DIR}/work/wake_word_samples" \ + "${DATA_DIR}/work/wake_word_samples_augmented" "${DATA_DIR}/work/last_wake_word" || : +fi +END_TS=$EPOCHSECONDS + +python -c $'print(f"{\'=\' * 80}")' +printf "%44s\n\n" "Training Summary" +"${PROGDIR}/system_summary" +echo +print_elapsed_time --no-separators "${START_TS}" "${POST_GEN_TS}" "Generate ${SAMPLES} samples, ${BATCH_SIZE}/batch" +print_elapsed_time --no-separators "${POST_GEN_TS}" "${POST_AUGMENT_TS}" "Augment ${SAMPLES} samples" +print_elapsed_time --no-separators "${POST_AUGMENT_TS}" "${END_TS}" "${TRAINING_STEPS} training steps" +python -c $'msg="="*54 ; print(f"{msg:>80s}")' +print_elapsed_time --no-separators "${START_TS}" "${END_TS}" "Total" +python -c $'print(f"{\'=\' * 80}")' diff --git a/cli/wake_word_sample_augmenter b/cli/wake_word_sample_augmenter new file mode 100755 index 0000000..3e2e5b8 --- /dev/null +++ b/cli/wake_word_sample_augmenter @@ -0,0 +1,215 @@ +#!/usr/bin/env python + +import sys, os, gc, glob, random +import types, shutil, json +from datetime import datetime, timezone +from pathlib import Path +from argparse import ArgumentParser as ArgParser, ArgumentError + +default_data_dir = os.getcwd() if os.path.exists(".mww-data-dir") else "/data" + +parser = ArgParser(exit_on_error=False) +parser.add_argument("--data-dir", type=str, help=f"Data directory. Default: {default_data_dir}", required=False, default=default_data_dir) +parser.add_argument("--input-dir", type=str, help="Sample input directory. Default: /work/wake_word_samples", required=False) +parser.add_argument("--output-dir", type=str, help="Sample output directory. Default: _augmented", required=False) +parser.add_argument("--mit-rirs-16k-dir", type=str, help="MIT RIR input directory. Default: /training_datasets/mit_rirs_16k", required=False) +parser.add_argument("--fma-16k-dir", type=str, help="FMA input directory. Default: /training_datasets/fma_16k", required=False) +parser.add_argument("--audioset-16k-dir", type=str, help="Audioset input directory. Default: /training_datasets/audioset_16k", required=False) + +try: + args = parser.parse_args() +except ArgumentError: + parser.print_help() + sys.exit(1) + +args.data_dir = os.path.realpath(args.data_dir) +work_dir = args.data_dir + "/work" + +if not args.input_dir: + args.input_dir = work_dir + "/wake_word_samples" +else: + args.input_dir = os.path.realpath(args.input_dir) + +if not args.output_dir: + args.output_dir = args.input_dir + "_augmented" +else: + args.output_dir = os.path.realpath(args.output_dir) + +if not args.mit_rirs_16k_dir: + args.mit_rirs_16k_dir = args.data_dir + "/training_datasets/mit_rirs_16k" +else: + args.mit_rirs_16k_dir = os.path.realpath(args.mit_rirs_16k_dir) + +if not args.fma_16k_dir: + args.fma_16k_dir = args.data_dir + "/training_datasets/fma_16k" +else: + args.fma_16k_dir = os.path.realpath(args.fma_16k_dir) + +if not args.audioset_16k_dir: + args.audioset_16k_dir = args.data_dir + "/training_datasets/audioset_16k" +else: + args.audioset_16k_dir = os.path.realpath(args.audioset_16k_dir) + +out_path = Path(args.output_dir) +out_path.mkdir(exist_ok=True) + +def validate_directories(paths): + for path in paths: + if not os.path.exists(path): + print(f"Error: Directory {path} does not exist. Please ensure preprocessing is complete.") + return False + return True + +paths = [ work_dir, args.input_dir, args.output_dir, args.mit_rirs_16k_dir, args.fma_16k_dir, args.audioset_16k_dir ] +if not validate_directories(paths): + parser.print_help() + sys.exit(1) + +files = glob.glob(args.input_dir + "/*.wav") +if not files: + raise RuntimeError("❌ No WAVs in wake_word_samples.") +max_samples = len(files) + +print(f"\n===== Augmenting {max_samples} wake word samples =====") + +print(" Initializing libraries") + +os.environ["TF_CPP_MIN_LOG_LEVEL"]="3" +os.environ["TF_FORCE_GPU_ALLOW_GROWTH"]="true" +os.environ["TF_GPU_ALLOCATOR"]="cuda_malloc_async" +os.environ["TF_XLA_FLAGS"]="--tf_xla_auto_jit=0" +os.environ["NVIDIA_TF32_OVERRIDE"]="1" +os.environ["TF_CUDNN_WORKSPACE_LIMIT_IN_MB"]="512" +os.environ["GLOG_minloglevel"]="9" +os.environ["GRPC_VERBOSITY"]="ERROR" + +print(" Loading Tensorflow") +import tensorflow as tf + +print(" GPU memory config") +# Per-device memory growth (belt + suspenders) +for g in tf.config.list_physical_devices("GPU"): + try: + tf.config.experimental.set_memory_growth(g, True) + except Exception: + pass +print(f" GPUs: {tf.config.list_physical_devices('GPU')}") +gc.collect() + +import numpy as np +import librosa +from mmap_ninja.ragged import RaggedMmap +from microwakeword.audio.augmentation import Augmentation +from microwakeword.audio.clips import Clips +from microwakeword.audio.spectrograms import SpectrogramGeneration +from microwakeword.audio.audio_utils import save_clip + +START_TIME = datetime.now(timezone.utc).replace(microsecond=0) + +# Paths to augmented data +impulse_paths = [ args.mit_rirs_16k_dir ] +background_paths = [ args.fma_16k_dir, args.audioset_16k_dir] + +clips = Clips( + input_directory=args.input_dir, + file_pattern='*.wav', + max_clip_duration_s=5, + remove_silence=True, + random_split_seed=10, + split_count=0.1, +) + +augmenter = Augmentation( + augmentation_duration_s=3.2, + augmentation_probabilities={ + "SevenBandParametricEQ": 0.1, + "TanhDistortion": 0.05, + "PitchShift": 0.15, + "BandStopFilter": 0.1, + "AddColorNoise": 0.1, + "AddBackgroundNoise": 0.7, + "Gain": 0.8, + "RIR": 0.7, + }, + impulse_paths=impulse_paths, + background_paths=background_paths, + background_min_snr_db=5, + background_max_snr_db=10, + min_jitter_s=0.2, + max_jitter_s=0.3, +) + +# Augment samples and save the training, validation, and testing sets. + +def audio_generator_from_wavs(self, split="train", repeat=1): + """ + Yield 1-D float32 arrays loaded via librosa from input_dir/*.wav. + Deterministic 80/10/10 split with seed 10 to mirror original Clips behavior. + """ + files = sorted(glob.glob(args.input_dir + "/*.wav")) + if not files: + raise RuntimeError("❌ No WAVs in wake_word_samples.") + + rng = random.Random(10) # deterministic shuffling like Clips(random_split_seed=10) + files_shuf = files[:] + rng.shuffle(files_shuf) + + n = len(files_shuf) + n_val = max(1, int(0.10 * n)) + n_test = max(1, int(0.10 * n)) + n_train = max(0, n - n_val - n_test) + splits = { + "train": files_shuf[:n_train], + "validation": files_shuf[n_train:n_train + n_val], + "test": files_shuf[n_train + n_val:], + } + file_list = splits.get(split, []) + if not file_list: + return # nothing to yield + + for _ in range(max(1, int(repeat))): + for p in file_list: + y, sr = librosa.load(p, sr=16000, mono=True) + yield y.astype(np.float32, copy=False) + +# Bind the patched generator to your existing `clips` instance +clips.audio_generator = types.MethodType(audio_generator_from_wavs, clips) + +# ---- Split config (same as before) ---- +split_cfg = { + "training": {"name": "train", "repetition": 2, "slide_frames": 10}, + "validation": {"name": "validation", "repetition": 1, "slide_frames": 10}, + "testing": {"name": "test", "repetition": 1, "slide_frames": 1}, +} + +# ---- Generate features ---- +for split, cfg in split_cfg.items(): + out_dir = out_path / split + out_dir.mkdir(parents=True, exist_ok=True) + print(f" Augmenting {split}") + + print(f" Generating spectrograms") + spectros = SpectrogramGeneration( + clips=clips, # now backed by our WAV loader + augmenter=augmenter, # your existing augmenter + slide_frames=cfg["slide_frames"], + step_ms=10, + ) + + print(f" Generating files") + RaggedMmap.from_generator( + out_dir=str(out_dir / "wakeword_mmap"), + sample_generator=spectros.spectrogram_generator( + split=cfg["name"], repeat=cfg["repetition"] + ), + batch_size=100, + verbose=False, + ) + print(f" {split} augmentation complete") + +END_TIME = datetime.now(timezone.utc).replace(microsecond=0) +et = END_TIME - START_TIME +print(f"\n{'=' * 80}") +msg=f"Augmented {max_samples} wake word samples." +print(f"{msg:>50s} Elapsed time: {et!s}") +print(f"{'=' * 80}\n") diff --git a/cli/wake_word_sample_generator b/cli/wake_word_sample_generator new file mode 100755 index 0000000..3afcd6c --- /dev/null +++ b/cli/wake_word_sample_generator @@ -0,0 +1,112 @@ +#!/bin/bash +set -e + +PROGPATH=$(realpath "$0") +PROGDIR=$(dirname "${PROGPATH}") + +KNOWN_ARGS=( samples batch-size data-dir ) +source "${PROGDIR}/shell.functions" +WAKE_WORD="${POSITIONAL_ARGS[0]}" + +if [ ${#UNKNOWN_ARGS[@]} -gt 0 ] ; then + echo "Unknown argument(s): ${UNKNOWN_ARGS[*]}" >&2 + HELP=true +fi + +if [ "${HELP}" == "true" ] || [ -z "${WAKE_WORD}" ] ; then + cat <&2 +Usage: $0 [ --samples= ] [ --batch-size= ] + +--samples: The number of samples to generate for the wake word. + Default: ${DEFAULT_SAMPLES} + +--batch-size: How many samples should be generated at a time. The more + samples, the more memory is needed. + Default: ${DEFAULT_BATCH_SIZE} + + The word to generate samples for. + Required. + +EOF + exit 1 +fi + +# shellcheck source=/dev/null +source "${DATA_DIR}/.venv/bin/activate" + +WORK_DIR="${DATA_DIR}/work" +mkdir -p "${WORK_DIR}" || : +cd "${WORK_DIR}" + +PSG="${DATA_DIR}/tools/piper-sample-generator" +MODELS_DIR="${PSG}/models" +MODEL_NAME=en_US-libritts_r-medium.pt +MODEL_FILE="${MODELS_DIR}/${MODEL_NAME}" +SAMPLES_DIR="${WORK_DIR}/wake_word_samples" + +mkdir -p "${SAMPLES_DIR}" || : + +REGENERATE=false + +if [ "${SAMPLES}" -eq 1 ] ; then + echo "===== Generating ${SAMPLES} sample of '${WAKE_WORD}' =====" + wake_word_filename="${WAKE_WORD//[ \`~\!\$&*\(\)\{\}\[\]\|\;\'\"<>.?\/]/_}" + + mkdir -p "${WORK_DIR}/test_sample" || : + "${PSG}/generate_samples.py" "${WAKE_WORD}" \ + --model "${MODEL_FILE}" \ + --max-samples ${SAMPLES} \ + --batch-size ${BATCH_SIZE} \ + --output-dir "${WORK_DIR}/test_sample" \ + --max-speakers 100 2>&1 | sed -r -e "s/(DEBUG|INFO):__main__:/ /g" + mv "${WORK_DIR}/test_sample/0.wav" "${WORK_DIR}/test_sample/${wake_word_filename}.wav" + echo "Sample available at ${WORK_DIR}/test_sample/${wake_word_filename}.wav" + echo "Play it from your host." + exit 0 +fi + +grep -q "${WAKE_WORD}:${SAMPLES}:${MODEL_NAME}" "${WORK_DIR}/last_wake_word" &>/dev/null || REGENERATE=true + +# Double check that the number of existing samples matches SAMPLES" +existing_samples=$(find "${SAMPLES_DIR}" -name '*.wav' | wc -l) +[ "${existing_samples}" -eq "${SAMPLES}" ] || REGENERATE=true + +START_TS=$EPOCHSECONDS + +if ! ${REGENERATE} ; then + echo "Sample generation not required" + echo + exit 0 +fi + +echo -e "\n===== Generating ${SAMPLES} wake word samples in batches of ${BATCH_SIZE} =====" +export TF_CPP_MIN_LOG_LEVEL=9 +export TF_FORCE_GPU_ALLOW_GROWTH=true +export TF_GPU_ALLOCATOR=cuda_malloc_async +export TF_XLA_FLAGS="--tf_xla_auto_jit=0" +export NVIDIA_TF32_OVERRIDE=1 +export TF_CUDNN_WORKSPACE_LIMIT_IN_MB=512 +export GLOG_minloglevel=2 +export GRPC_VERBOSITY=ERROR + +echo " Generating samples" +rm -rf "${SAMPLES_DIR}" || : +mkdir -p "${SAMPLES_DIR}" || : +"${PSG}/generate_samples.py" "${WAKE_WORD}" \ + --model "${MODEL_FILE}" \ + --max-samples ${SAMPLES} \ + --batch-size ${BATCH_SIZE} \ + --output-dir "${SAMPLES_DIR}" 2>&1 | sed -r -e "s/(DEBUG|INFO):__main__:/ /g" + +generated_files=$(find "${SAMPLES_DIR}" -name '*.wav' | wc -l) +if [ "${generated_files}" -ne "${SAMPLES}" ] ; then + echo "ERROR: only generated ${generated_files} files" >&2 + exit 1 +fi +END_TS=$(date +%s.%N) +echo "${WAKE_WORD}:${SAMPLES}:${MODEL_NAME}" > "${WORK_DIR}/last_wake_word" +echo +END_TS=$EPOCHSECONDS +print_elapsed_time "${START_TS}" "${END_TS}" "Generated ${SAMPLES} wake word samples." + +exit 0 diff --git a/cli/wake_word_sample_trainer b/cli/wake_word_sample_trainer new file mode 100755 index 0000000..743b3fe --- /dev/null +++ b/cli/wake_word_sample_trainer @@ -0,0 +1,241 @@ +#!/bin/bash +set -e + +PROGPATH=$(realpath "$0") +PROGDIR=$(dirname "${PROGPATH}") + +KNOWN_ARGS=( training-steps samples data-dir ) +source "${PROGDIR}/shell.functions" +WAKE_WORD="${POSITIONAL_ARGS[0]}" + +if [ ${#UNKNOWN_ARGS[@]} -gt 0 ] ; then + echo "Unknown argument(s): ${UNKNOWN_ARGS[*]}" >&2 + HELP=true +fi + +if [ "${HELP}" == "true" ] || [ -z "${WAKE_WORD}" ] ; then + cat <&2 +Usage: $0 [ --samples= ] [ --training-steps= ] + [ ] + + $0 -h/--help + +--samples: The number of samples to generate for the wake word. + Used only to generate output file names. + +--training-steps: Number of training steps. + Default: ${DEFAULT_TRAINING_STEPS} + +: The word to train spelled phonetically. + Required. + +: A pretty name to save to the json metadata file. + Default: The wake word with individual words capitalized. + +EOF + exit 1 +fi + +WORK_DIR="${DATA_DIR}/work" +TRAINING_DS="${DATA_DIR}/training_datasets" + +[ ${#POSITIONAL_ARGS} -eq 2 ] && WAKE_WORD_TITLE="${POSITIONAL_ARGS[1]}" + +if [ ! -v WAKE_WORD_TITLE ] ; then + declare -a WWNA=( ${WAKE_WORD//[^a-zA-Z0-9]/ } ) + WAKE_WORD_TITLE="${WWNA[*]^}" +elif [ -z "$WAKE_WORD_TITLE" ] ; then + WAKE_WORD_TITLE="$WAKE_WORD" +fi + +# shellcheck source=/dev/null +source "${DATA_DIR}/.venv/bin/activate" + +check_directories() { + for d in "$@" ; do + [ -d "$d" ] || { echo "ERROR: Directory $d not found" >&2 ; exit 1 ; } + done +} + +check_directories ${WORK_DIR}/wake_word_samples_augmented \ + ${TRAINING_DS}/negative_datasets/{speech,dinner_party,no_speech,dinner_party_eval} + +cd "${WORK_DIR}" + +echo "===== Starting ${TRAINING_STEPS} training steps =====" + +START_TS=$EPOCHSECONDS + +mkdir -p "${WORK_DIR}/trained_models" || : +cat <"${WORK_DIR}/trained_models/training_parameters.yaml" +batch_size: 16 +clip_duration_ms: 1500 +eval_step_interval: 500 +features: +- features_dir: ${WORK_DIR}/wake_word_samples_augmented + penalty_weight: 1.0 + sampling_weight: 2.0 + truncation_strategy: truncate_start + truth: true + type: mmap +- features_dir: ${TRAINING_DS}/negative_datasets/speech + penalty_weight: 1.0 + sampling_weight: 12.0 + truncation_strategy: random + truth: false + type: mmap +- features_dir: ${TRAINING_DS}/negative_datasets/dinner_party + penalty_weight: 1.0 + sampling_weight: 12.0 + truncation_strategy: random + truth: false + type: mmap +- features_dir: ${TRAINING_DS}/negative_datasets/no_speech + penalty_weight: 1.0 + sampling_weight: 5.0 + truncation_strategy: random + truth: false + type: mmap +- features_dir: ${TRAINING_DS}/negative_datasets/dinner_party_eval + penalty_weight: 1.0 + sampling_weight: 0.0 + truncation_strategy: split + truth: false + type: mmap +freq_mask_count: +- 0 +freq_mask_max_size: +- 0 +learning_rates: +- 0.001 +maximization_metric: average_viable_recall +minimization_metric: null +negative_class_weight: +- 20 +positive_class_weight: +- 1 +target_minimization: 0.9 +time_mask_count: +- 0 +time_mask_max_size: +- 0 +train_dir: ${WORK_DIR}/trained_models/wakeword +training_steps: +- ${TRAINING_STEPS} +window_step_ms: 10 + +EOF + +echo " Wrote training_parameters.yaml" +rm -rf "${WORK_DIR}/trained_models/wakeword" + +export TF_CPP_MIN_LOG_LEVEL=9 +export TF_FORCE_GPU_ALLOW_GROWTH=true +export TF_GPU_ALLOCATOR=cuda_malloc_async +export TF_XLA_FLAGS="--tf_xla_auto_jit=0" +export NVIDIA_TF32_OVERRIDE=1 +export TF_CUDNN_WORKSPACE_LIMIT_IN_MB=512 +export GLOG_minloglevel=9 +export GRPC_VERBOSITY=ERROR + +echo " Loading Tensorflow" + +wake_word_filename="${WAKE_WORD//[ \`~\!\$&*\(\)\{\}\[\]\|\;\'\"<>.?\/]/_}" +OUTPUT_DIR="${DATA_DIR}/output/$(date +'%Y-%m-%d-%H-%M-%S')-${wake_word_filename}-${SAMPLES}-${TRAINING_STEPS}" +mkdir -p "${OUTPUT_DIR}/logs" || : + +python - \ + --training_config="${WORK_DIR}/trained_models/training_parameters.yaml" \ + --train 1 \ + --restore_checkpoint 1 \ + --test_tf_nonstreaming 0 \ + --test_tflite_nonstreaming 0 \ + --test_tflite_nonstreaming_quantized 0 \ + --test_tflite_streaming 0 \ + --test_tflite_streaming_quantized 1 \ + --use_weights "best_weights" \ + mixednet \ + --pointwise_filters "64,64,64,64" \ + --repeat_in_block "1,1,1,1" \ + --mixconv_kernel_sizes "[5], [7,11], [9,15], [23]" \ + --residual_connection "0,0,0,0" \ + --first_conv_filters 32 \ + --first_conv_kernel_size 5 \ + --stride 2 <&1 | tr '\r' '\n' | stdbuf -i0 -o0 sed -r -e "/^Validation Batch/d" |\ + tee "${OUTPUT_DIR}/logs/training.log" | sed -r -e '/^INFO:absl:/!d' \ + -r -e "/None|Sharding|unsupported characters|AUC|fingerprint/d" \ + -r -e 's/INFO:absl:/ /g' \ + -r -e "s/, (recall =|estimated false|average viable recall)/,\n \1/g" + +import sys, os, gc +import runpy +import yaml +print(" Loading Tensorflow") +import tensorflow as tf + +print(" GPU memory config") +# Per-device memory growth (belt + suspenders) +for g in tf.config.list_physical_devices("GPU"): + try: + tf.config.experimental.set_memory_growth(g, True) + except Exception: + pass +print(f"INFO:absl:GPUs: {tf.config.list_physical_devices('GPU')}") +gc.collect() + +print() +try: + runpy.run_module("microwakeword.model_train_eval", run_name="__main__", alter_sys=True) +except Exception as e: + print(e, file=sys.stderr) + sys.exit(1) +EOF + +source_path="${WORK_DIR}/trained_models/wakeword/tflite_stream_state_internal_quant/stream_state_internal_quant.tflite" + +if [ ! -f "${source_path}" ] ; then + echo "Output model not found! Training didn't complete successfully. See ${WORK_DIR}/training.log" + exit 1 +fi + +cp "${WORK_DIR}/trained_models/wakeword/model_summary.txt" "${OUTPUT_DIR}/logs/" +cp -a "${WORK_DIR}/trained_models/wakeword/logs/train" "${OUTPUT_DIR}/logs/" +cp -a "${WORK_DIR}/trained_models/wakeword/logs/validation" "${OUTPUT_DIR}/logs/" + +echo -e "\n Training complete!" +echo " Full log: ${OUTPUT_DIR}/logs/training.log" + +tflite_filename="${wake_word_filename}.tflite" +tflite_path="${OUTPUT_DIR}/${tflite_filename}" + +cp "${source_path}" "${tflite_path}" + +# --- Write JSON metadata file with matching model name --- +json_path="${OUTPUT_DIR}/${wake_word_filename}.json" +cat <<-EOF > "${json_path}" +{ + "type": "micro", + "wake_word": "${WAKE_WORD_TITLE}", + "author": "Tater Totterson", + "website": "https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git", + "model": "${tflite_filename}", + "trained_languages": ["en"], + "version": 2, + "micro": { + "probability_cutoff": 0.97, + "sliding_window_size": 5, + "feature_step_size": 10, + "tensor_arena_size": 30000, + "minimum_esphome_version": "2024.7.0" + } +} +EOF + +echo "Name: ${WAKE_WORD_TITLE}" +echo "Model: ${tflite_path}" +echo "Metadata: ${json_path}" +echo +END_TS=$EPOCHSECONDS +print_elapsed_time "${START_TS}" "${END_TS}" "Training completed." +echo +