From cb81f7f02d6f8bd9b57e637551d095201acf1a17 Mon Sep 17 00:00:00 2001 From: George Joseph Date: Sat, 27 Dec 2025 12:32:06 -0700 Subject: [PATCH 01/11] 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 + From 5bc0f12a7f8ca11b123ac9a0ebd4225077e5d757 Mon Sep 17 00:00:00 2001 From: MasterPhooey Date: Sat, 17 Jan 2026 01:23:51 -0600 Subject: [PATCH 02/11] cli + web recorder ui --- .DS_Store | Bin 0 -> 10244 bytes cli/.bashrc => .bashrc | 0 LICENSE | 201 ----- README.md | 540 ++++++++++-- cli/.DS_Store | Bin 0 -> 8196 bytes cli/Dockerfile | 27 - cli/README.md | 507 ----------- cli/requirements.txt | 10 - cli/setup_python_venv | 43 +- cli/setup_training_datasets | 39 +- cli/tensorboard1.png | Bin 20767 -> 0 bytes cli/tensorboard2.png | Bin 33126 -> 0 bytes cli/tensorboard3.png | Bin 43799 -> 0 bytes cli/wake_word_sample_augmenter | 0 cli/wake_word_sample_trainer | 0 dockerfile | 76 +- microWakeWord_training_notebook.ipynb | 1073 ------------------------ mmw.png | Bin 11478 -> 0 bytes recorder_server.py | 593 +++++++++++++ requirements.txt | 24 +- run_recorder.sh | 64 ++ startup.sh | 23 - static/index.html | 782 +++++++++++++++++ cli/train_wake_word => train_wake_word | 33 +- 24 files changed, 2002 insertions(+), 2033 deletions(-) create mode 100644 .DS_Store rename cli/.bashrc => .bashrc (100%) delete mode 100644 LICENSE create mode 100644 cli/.DS_Store delete mode 100644 cli/Dockerfile delete mode 100644 cli/README.md delete mode 100644 cli/requirements.txt delete mode 100644 cli/tensorboard1.png delete mode 100644 cli/tensorboard2.png delete mode 100644 cli/tensorboard3.png mode change 100755 => 100644 cli/wake_word_sample_augmenter mode change 100755 => 100644 cli/wake_word_sample_trainer delete mode 100644 microWakeWord_training_notebook.ipynb delete mode 100644 mmw.png create mode 100644 recorder_server.py create mode 100644 run_recorder.sh delete mode 100644 startup.sh create mode 100644 static/index.html rename cli/train_wake_word => train_wake_word (83%) mode change 100755 => 100644 diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..545370aade608462522b6c10a2fd51bda2b12294 GIT binary patch literal 10244 zcmeHMU2GIp6uxI#=nNw;EiHdC?8*vMC}CSEh$w8k+t@-2lx^t`lx22jY$r@-mYvyM zYEx666akHi(Ws~~CPsZgjY7o4Xrcs7j1L5f@*>8>Ctm!+2NUDDb7$M`w)mo$hRjXw zJ@=k_&Yd}DzB6a;9YP2+XVrQ_f`kwrE2YvM*vwGqJgw6T=W(i#0_qbxoiwZ@j?O33 z)1-Zd?*ZQfz6X2{_#W^*FdIC8Ihz(Fm?~1H3f>k2m^$)U{x_2)lDPj;knV-6UH+0(xhpzxvRz7EL1s1VlAU%SaabN^-xhNuq32q zqoa-WTkC=in;ORIf}{2ITf%k0#;xJ8F@diMZQQmmal{xltrN@w@ZJJseu+gVC(Tjq zl##^SqY|El#$bIsWera~n<&iKxhOlkD2pf4%=W9E?YuBg6z9u5y?y-$4kjciYis*7 zLvi$+7E>I}PDl%FbVXarGWTY*9J-(och;dW}74s%-DqGB#2{IJ00$;O^QD4Oq$fFQ|y#xs=7JYo!3=os2{y=I{L%9mJx(XsOvJ7VeN9& z(q+rTlHN^dr)AI<8{h!o;^b`5z|NZ_Tj2URq(!&2#rsyidgtJd=T0m_eN zG;A4Ll-Jjxg3@K9$=+>->jgowyN)14n5x*gnHMCr+fb64;j!-DhOAvZIcBlPtl7Z} zz4%Or(HNWYY6Sov?<@tVlNXck>@(9p^Mmx{Dj}iG)rA5^p zE8}`&_bOTLqf0fZs-~jR%a<;zlH+nWFI_?%NjGykh6FwX78!h~0Lf5f9qWcZ)egHo z*TlFB_EKpZ8Nun9Ip6xyK!c0)g;Km`p3!GJ8}AP*z(C_D~Nz;QSM&%lfD z5}bxt;0<^O-i7zzBlrwHhYN5KuE6(j4Ss;@@H^aqKRAJ_;#P1nw~|}S)pF~&_1t!D z2iMAVa{XL_8)7zag{T~uu*vOkBS>kc zLMOzb3kDzoharh5H^2l7jzR`TVGJ=o4v)c;a1u_zQ}8^zfJi?LFC)(1gty>rcpuKf zIrtFHyNLe^zJaUoV+n>kiZKkoO=5W1ip9KxZocB#kDq?+)WxQ$v+6(0riX5pf^zYU zX;|hUu0-Q#F0qM26s$T)-7cxQ$f!bso@VU~X}UJpR(Q0 zT0dn^`z)R9DZ2>CQxkXVqRvlVXNkK+W-@)>OlF28UbIF@D#vU-D@z%>s~|o9Z@KyZ z|5{@mMpiRoK6mE=$kagIEt^rG?6kGwP~fQ}iPM2lP0;7AuwY k&O6+G#N&-K>Z+rRtB&-4`Wf(V4%+ - MicroWakeWord Trainer Logo -

microWakeWord Trainer Docker

- +# Run training from the command line -# 🥔 MicroWakeWord Trainer – Tater Approved +## Overview -**✅ Tater Totterson tested & working on an NVIDIA RTX 3070 Laptop GPU (8 GB VRAM).** -Easily train microWakeWord detection models with this pre-built Docker image and JupyterLab notebook. +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: -## 🚀 Quick Start +* 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. -Follow these steps to get up and running: +* The logic from the Jupyter notebook is contained in individual Python + and shell scripts -### 1️⃣ Pull the Pre-Built Docker Image +* No ports need to be exposed since the Jupyter notebook server isn't being + run. -```bash -docker pull ghcr.io/tatertotterson/microwakeword:latest +## 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. -### 2️⃣ Run the Docker Container +## Get Started -```bash -docker run --rm -it \ - --gpus all \ - -p 8888:8888 \ - -v $(pwd):/data \ - ghcr.io/tatertotterson/microwakeword:latest +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 . ``` -**What these flags do:** -- `--gpus all` → Enables GPU acceleration -- `-p 8888:8888` → Exposes JupyterLab on port 8888 -- `-v $(pwd):/data` → Saves your work in the current folder +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. -### 3️⃣ Open JupyterLab +### Create a host work directory -Visit [http://localhost:8888](http://localhost:8888) in your browser — the notebook UI will open. +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`. -### 4️⃣ Set Your Wake Word +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. -At the **top of the notebook**, find this line: +### Create and start the container -```bash -TARGET_WORD = "hey_tater" # Change this to your desired wake word +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 ``` -Change `"hey_tater"` to your desired wake word (phonetic spellings often work best). +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. -### 5️⃣ Run the Notebook +When the container starts, you'll see: -Run all cells in the notebook. This process will: -- Generate wake word samples -- Train a detection model -- Output a quantized `.tflite` model ready for on-device use +```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. -### 6️⃣ Retrieve the Trained Model & JSON +If you've forgotton to create and/or mount your host data directory, you'll +see an additional warning: -When training finishes, download links for both the `.tflite` model and its `.json` manifest will be displayed in the last cell. +```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. +======================================================= +``` -## 🔄 Resetting to a Clean State +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. -If you need to start fresh: +At this point, you're in a Bash shell. -1. Delete the `data` folder that was mapped to your Docker container. -2. Restart the container using the steps above. -3. A fresh copy of the notebook will be placed into the `data` directory. +### 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. -## 🎤 Optional: Personal Voice Samples +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: -In addition to synthetic TTS samples, the trainer can optionally use your own real voice recordings to significantly improve accuracy for your voice and environment. +```text +setup_python_venv [ --verbose ] -### How it works -- If a folder named personal_samples/ exists and contains .wav files, the trainer will: - - Automatically extract features from those recordings - - Include them during training alongside the synthetic TTS data - - Up-weight your personal samples during training for better real-world performance +Options: -No extra flags or configuration are required — it is detected automatically. +--verbose: Print the detailed "pip install" output. -### How to use it -1. Create a folder in the repo root: - mkdir personal_samples +``` -2. Record yourself saying the wake word naturally and save the files as .wav: - personal_samples/ - hey_tater_01.wav - hey_tater_02.wav - hey_tater_03.wav - ... +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) -3. Run the training script as normal: -If personal samples are found, you’ll see a message during training indicating they are being included. -### Recording tips -- 10–30 recordings is usually enough to see a noticeable improvement -- Vary distance, volume, and tone slightly -- Record in the same environment where the wake word will be used (room noise matters) -- Use 16-bit WAV files if possible (most recorders do this by default) ---- -## 🙌 Credits -This project builds upon the excellent work of [kahrendt/microWakeWord](https://github.com/kahrendt/microWakeWord). -Huge thanks to the original authors for their contributions to the open-source community! diff --git a/cli/.DS_Store b/cli/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..81f16e00b173ed44a5b322427e2687074b8d876f GIT binary patch literal 8196 zcmeHLU2GIp6u#fIz$_DBC@mCZ*_9QjP{OuS5K-8++gP9l%C_`}pR&6%wiBi^WoLFv zZEEV1KLrzCG%D(YiBTU=qY&{$6D4S3d>}}a7cnM2@!}som>AEUJ6mWAnh-S*=O*`_ zx#ym9XU^I0o4aQiV`#{0HH=j<#$@VTstOG^XndXbX-)Dqd=cc&n8C7{A!jgS+L>wA zfg;F2kbxirK?Z^h1R1y;GC*fGZ}LsfeX$yhK?Z^h{4X=W-w#phTqb-uCZzZ3pusBv zNS2b?YjjO@faeqSWx|(ZLVB*Wr>Gv_yTUIqAlzv^%FRh8d^skhaAy$i4E~+rS19oA zPJU6(oFO4(Fa{Y2GB7;@oHjF=!(5i8KDmBBlNmG($BoA?LMkbpF>@9*W!8aIzde+4 z({A2rNayzPS-WkUnc{Uiv)9mvde!nq+j7%}rRO{cL(|D*f2Uz-_E3YJw=~D|O^JmN zwO)1c$Vgqyrs`~RqX{C5GHGbOHL zQ-d=5YEoHyVDLJ-NkN-Vp;3IaC`lEAzT^ijhh{6voN#AXchA24y{ejb^gX(nc8!9b zOuM?%tIl&M2ivl?wL7O5DE7{*ZRQ6oTA80so7p@W>-DCYG9DGy*_>^fyKTpC4cqGO za`mGwU*j81TI%X??3~-^ZPsPb{=%w^-(%MxcXsybIfrB@baL*Z$URkSYd1caXxi4= zc43}6zf4go7Iqd4$Hh#hS>@s- zcP$Nvh0Qge;x1kJjBzAyxWj5pmc>27%VQx~J+)t>AX{yX@2Ghz;qdZRvb>M$8*@61 zLal_?RFgxx-Jy{E$Bfs6LaNh#n6M#SmAVabNYy&bbVfIQ)dQPJwWG5jBKDOPTjfv} z{jviTj3ad6#-uE(pB>~kZb>`tu0h?Jc%;T=S?+QA(>Zb9p%2&9|!8G+m>hmsBn;3#Y;zvU;9;RKqG5#0B~d1SRBH5o58$YH=C+SU==l zc{z?H;zC_6#Kg!?z%Yxm2DY6YWP@y&jkD+2tL%Mtik)U>*_Z4hyUc!IKeC_L&+IDu z9i^C!axBFPtU?v;MJ?{bdTc-=TF{D}=s^}5bo9YQ9t8|x7?0rzJc*|;j%V>AUcw2y zg4gjj-od;05TD>voW(g@#J9MN?{Edb;wpZVLQPWq*YRtv|3stZIQN0%~G4x zBlSxCA_l2Q#{&~Fc^A?!iK%q+zY$6&Z%nty!_C{a?`XMg>GsWBPOt^#6$=+dSFB#Q z{{DyRCtxU0TLkL$1n@9Fi76iDJzPx1tH(rTS)_wVp&z|QMM2FXA=O!&yz&KZeYX;e zs56K*V!BePil{S*GGe+WvNobh#8fejM`{(dgm@V8eCr}>6&13WY*IEWYAG>NOt&b@ z6qSX!QB$^1_yU~Ue+HnBA zID`yg-Gl`jN07q^MhWR-cpOjT7>?r^JdYO$?Z84jESvmj zw=ydcPL;g=Z@T&S|6AEQ@Ch;yWZ(|T07{doWCK0y&0a0M){as?M4dNoHzuU#LW5V1 sll01Ql72bv*+$8f`y_lhCM0R7{pTM7{*k}i!TBGY|GZkhbI<=j0hFY)qW}N^ literal 0 HcmV?d00001 diff --git a/cli/Dockerfile b/cli/Dockerfile deleted file mode 100644 index c460d93..0000000 --- a/cli/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -# 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 deleted file mode 100644 index 359d73d..0000000 --- a/cli/README.md +++ /dev/null @@ -1,507 +0,0 @@ -# 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/requirements.txt b/cli/requirements.txt deleted file mode 100644 index a0e801b..0000000 --- a/cli/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -# --- 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_python_venv b/cli/setup_python_venv index 153d43d..4a77557 100755 --- a/cli/setup_python_venv +++ b/cli/setup_python_venv @@ -1,5 +1,6 @@ #!/bin/bash -PROGDIR="$(dirname $(realpath $0))" +PROGDIR="$(dirname "$(realpath "$0")")" +ROOTDIR="$(dirname "${PROGDIR}")" KNOWN_ARGS=( data-dir python gpu no-gpu ) source "${PROGDIR}/shell.functions" @@ -27,7 +28,7 @@ EOF exit 1 fi -[ -n "${DATA_DIR}" ] && DATA_DIR="$(realpath ${DATA_DIR})" +[ -n "${DATA_DIR}" ] && DATA_DIR="$(realpath "${DATA_DIR}")" [ -d "${DATA_DIR}" ] || { echo "Data directory '${DATA_DIR}' doesn't exist." >&2 exit 1 @@ -52,7 +53,8 @@ if [ -n "${PYTHON}" ] ; then PYTHONS=( "${PYTHON}" ) unset PYTHON else - PYTHONS=( python3.12 python3.10 ) + # Add 3.11 as a common middle-ground (especially outside Ubuntu 24.04) + PYTHONS=( python3.12 python3.11 python3.10 ) fi for p in "${PYTHONS[@]}" ; do @@ -60,14 +62,14 @@ for p in "${PYTHONS[@]}" ; do done [ -n "${PYTHON}" ] || { - echo "A python 3.12 or 3.10 interpreter wasn't found. You 'll need to install one before proceeding." >&2 + echo "A python 3.12/3.11/3.10 interpreter wasn't found. You'll need to install one before proceeding." >&2 exit 1 } -if [ -d "${VENV}" ] ; then +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 + echo "Unable to activate existing virtualenv '${VENV}'. You should delete it and try again." >&2 exit 1 } else @@ -82,24 +84,28 @@ if [ -z "$VIRTUAL_ENV" ] ; then 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) ) +# Symlink CLI scripts into .venv/bin +declare -a progfiles=( $(find "${PROGDIR}" -mindepth 1 -maxdepth 1 -executable -type f) ) progfiles+=( "${PROGDIR}/shell.functions" ) +# Also symlink the top-level entrypoint if present +[ -x "${ROOTDIR}/train_wake_word" ] && progfiles+=( "${ROOTDIR}/train_wake_word" ) + for f in "${progfiles[@]}" ; do - ln -sfr "${f}" ".venv/bin/$(basename ${f})" + 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. +# 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 @@ -117,7 +123,8 @@ pip_install() { START_TS=$EPOCHSECONDS echo " ===== Installing common requirements =====" -pip_install -r "${PROGDIR}/requirements.txt" +# requirements.txt lives in repo root now +pip_install -r "${ROOTDIR}/requirements.txt" ${GPU} && tfgpu='[and-cuda]' || tfgpu="" echo " ===== Installing Tensorflow${tfgpu} =====" @@ -140,7 +147,7 @@ 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 +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 @@ -171,13 +178,11 @@ 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}" +"${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" - - +print_elapsed_time "${START_TS}" "${END_TS}" "Python package installation complete" \ No newline at end of file diff --git a/cli/setup_training_datasets b/cli/setup_training_datasets index fc6e280..c343e95 100755 --- a/cli/setup_training_datasets +++ b/cli/setup_training_datasets @@ -1,8 +1,9 @@ #!/bin/bash set -euo pipefail -PROGPATH=$(realpath "$0") -PROGDIR=$(dirname "${PROGPATH}") +PROGPATH="$(realpath "$0")" +PROGDIR="$(dirname "${PROGPATH}")" +ROOTDIR="$(dirname "${PROGDIR}")" # repo root (train_wake_word, requirements.txt, etc.) KNOWN_ARGS=( data-dir cleanup-archives cleanup-intermediate-files ) source "${PROGDIR}/shell.functions" @@ -27,22 +28,38 @@ EOF exit 1 fi +# Normalize + validate DATA_DIR (shell.functions typically sets a default, +# but this makes the script standalone-safe) +[ -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}" 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_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_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_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}" +"${PROGDIR}/setup_fma" \ + --cleanup-archives="${CLEANUP_ARCHIVES}" \ + --cleanup-intermediate-files="${CLEANUP_INTERMEDIATE_FILES}" \ + --data-dir="${DATA_DIR}" -END_TS=$(date +%s.%N) +END_TS=$EPOCHSECONDS print_elapsed_time "${START_TS}" "${END_TS}" "Training dataset setup" diff --git a/cli/tensorboard1.png b/cli/tensorboard1.png deleted file mode 100644 index a7741d97d19234c3bd33331af9fd8c81e547b4f3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20767 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 diff --git a/cli/tensorboard3.png b/cli/tensorboard3.png deleted file mode 100644 index 6df0306e4e19ff251d1902ca63c1df33be1b31f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43799 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.29.36" \ - && pip install -r /tmp/requirements.txt \ - && pip install "onnxruntime-gpu[cuda]>=1.16.0" \ - && pip install "tensorflow[and-cuda]==2.18.0" \ - "tensorboard==2.18.0" \ - "tensorboard-data-server==0.7.2" \ - "tensorflow-io-gcs-filesystem==0.37.1" \ - && pip install \ - torch==2.7.1 \ - torchaudio==2.7.1 \ - --index-url https://download.pytorch.org/whl/cu128 +# Bash environment +COPY --chown=root:root --chmod=0755 .bashrc /root/ -# Workspace + notebook fallback -RUN mkdir -p /data -WORKDIR /data -COPY microWakeWord_training_notebook.ipynb /root/ +# Root-level entrypoints +COPY --chown=root:root --chmod=0755 \ + train_wake_word \ + run_recorder.sh \ + recorder_server.py \ + requirements.txt \ + /root/mww-scripts/ -# Startup script (copies default notebook if missing) -COPY startup.sh /usr/local/bin/startup.sh -RUN chmod +x /usr/local/bin/startup.sh +# CLI folder (THIS IS THE IMPORTANT CHANGE) +COPY --chown=root:root cli/ /root/mww-scripts/cli/ -EXPOSE 8888 +# Static UI for recorder +COPY --chown=root:root --chmod=0644 static/index.html /root/mww-scripts/static/index.html -CMD ["/bin/bash", "-lc", "/usr/local/bin/startup.sh && \ - exec jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root \ - --ServerApp.token='' --ServerApp.password='' --ServerApp.root_dir=/data"] +# recorder server +CMD ["/bin/bash", "-lc", "/root/mww-scripts/run_recorder.sh"] diff --git a/microWakeWord_training_notebook.ipynb b/microWakeWord_training_notebook.ipynb deleted file mode 100644 index 19b54dc..0000000 --- a/microWakeWord_training_notebook.ipynb +++ /dev/null @@ -1,1073 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# 🥔 MicroWakeWord Trainer — Tater Totterson Edition\n", - "# ==================================================\n", - "# Welcome, friend! 👋 This notebook will help you train your very own wake word model.\n", - "# Think of it like teaching Tater Totterson to recognize when you say a special word.\n", - "#\n", - "# By the end, you'll have:\n", - "# ✅ A trained TensorFlow Lite model ready for on-device detection.\n", - "# ✅ A matching JSON manifest you can drop straight into ESPHome.\n", - "#\n", - "# This flow is optimized for Python 3.10 and NVIDIA GPUs (but should work elsewhere too).\n", - "# You can customize the wake word, play with training parameters, and experiment with\n", - "# different datasets until you get something that feels just right. 💪\n", - "#\n", - "# ⚡ Quick Tips:\n", - "# • Change TARGET_WORD below to whatever you want your wake word to be.\n", - "# • Rerun the notebook from the top if you change it (to regenerate everything).\n", - "# • Expect to experiment — tweaking hyperparameters is part of the fun!\n", - "#\n", - "# When you’re done, you’ll get two files:\n", - "# 1️⃣ .tflite — your trained model.\n", - "# 2️⃣ .json — a manifest for ESPHome integration.\n", - "#\n", - "# More info & examples:\n", - "# 🔗 https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker\n", - "\n", - "# --- Set your wake word here ---\n", - "TARGET_WORD = \"tater\" # 🗣️ Change this to whatever phrase you want!\n", - "print(f\"🥔 Tater Totterson is listening for: '{TARGET_WORD}'\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "BFf6511E65ff" - }, - "outputs": [], - "source": [ - "import platform\n", - "import sys\n", - "import os\n", - "\n", - "# mac-only helper deps\n", - "if platform.system() == \"Darwin\":\n", - " !\"{sys.executable}\" -m pip install 'git+https://github.com/puddly/pymicro-features@puddly/minimum-cpp-version' --root-user-action=ignore\n", - "\n", - "!\"{sys.executable}\" -m pip install 'git+https://github.com/whatsnowplaying/audio-metadata@d4ebb238e6a401bb1a5aaaac60c9e2b3cb30929f' --root-user-action=ignore\n", - "\n", - "# 👇 use the actual location in the container\n", - "repo_path = \"/data/microWakeWord\"\n", - "\n", - "if not os.path.exists(repo_path):\n", - " print(\"⬇️ Cloning microWakeWord repository to /data…\")\n", - " !git clone https://github.com/TaterTotterson/micro-wake-word.git {repo_path}\n", - "\n", - "# optional: pin to a commit\n", - "# !cd /data/microWakeWord && git checkout ac6502bf48b5e372c47ed509f5f5ca181e6d50bb\n", - "\n", - "if os.path.exists(repo_path):\n", - " print(\"📦 Installing microWakeWord...\")\n", - " !\"{sys.executable}\" -m pip install -e {repo_path} --root-user-action=ignore\n", - "else:\n", - " print(f\"❌ Repository not found at {repo_path}. Clone might have failed.\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "BFf6511E65ff" - }, - "outputs": [], - "source": [ - "# --- GPU Check (Torch + ONNX Runtime) ---\n", - "\n", - "import torch\n", - "import onnxruntime as ort\n", - "\n", - "print(\"🔧 Torch CUDA Available:\", torch.cuda.is_available())\n", - "if torch.cuda.is_available():\n", - " print(\" • Device count:\", torch.cuda.device_count())\n", - " print(\" • Current device:\", torch.cuda.current_device())\n", - " print(\" • Device name:\", torch.cuda.get_device_name(torch.cuda.current_device()))\n", - "else:\n", - " print(\"⚠️ Torch cannot see a GPU — check Docker runtime (--gpus all) and nvidia-container-toolkit\")\n", - "\n", - "print(\"\\n🔧 ONNX Runtime Providers:\")\n", - "try:\n", - " providers = ort.get_available_providers()\n", - " print(\" •\", providers)\n", - " if \"CUDAExecutionProvider\" not in providers:\n", - " print(\"⚠️ CUDAExecutionProvider not available — ONNX will fall back to CPU.\")\n", - "except Exception as e:\n", - " print(\"⚠️ Could not query ONNX Runtime providers:\", e)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "dEluu7nL7ywd" - }, - "outputs": [], - "source": [ - "# NVIDIA Linux Docker: generate 1 sample of the target word (robust + CUDA check)\n", - "\n", - "import os, sys, shutil, subprocess, time, platform\n", - "from pathlib import Path\n", - "from IPython.display import Audio, display\n", - "\n", - "REPO_URL = \"https://github.com/rhasspy/piper-sample-generator\"\n", - "REPO_DIR = Path.cwd() / \"piper-sample-generator\"\n", - "MODELS_DIR = REPO_DIR / \"models\"\n", - "MODEL_NAME = \"en_US-libritts_r-medium.pt\"\n", - "MODEL_URL = f\"https://github.com/rhasspy/piper-sample-generator/releases/download/v2.0.0/{MODEL_NAME}\"\n", - "AUDIO_OUT_DIR = Path.cwd() / \"generated_samples\"\n", - "AUDIO_PATH = AUDIO_OUT_DIR / \"0.wav\"\n", - "\n", - "def run(cmd, check=True):\n", - " print(\"→\", \" \".join(cmd))\n", - " proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)\n", - " for line in proc.stdout:\n", - " print(line, end=\"\")\n", - " rc = proc.wait()\n", - " if check and rc != 0:\n", - " raise RuntimeError(f\"Command failed with exit code {rc}: {' '.join(cmd)}\")\n", - " return rc\n", - "\n", - "def pip_install(*pkgs):\n", - " run([sys.executable, \"-m\", \"pip\", \"install\", \"--upgrade\", \"pip\"], check=False)\n", - " run([sys.executable, \"-m\", \"pip\", \"install\", *pkgs])\n", - "\n", - "def safe_clone(repo_url, branch=None, dest=REPO_DIR, retries=2):\n", - " if dest.exists() and not (dest / \".git\").exists():\n", - " print(\"⚠️ Found partial clone. Removing…\")\n", - " shutil.rmtree(dest, ignore_errors=True)\n", - " if not dest.exists():\n", - " for i in range(retries + 1):\n", - " try:\n", - " cmd = [\"git\", \"clone\", \"--depth\", \"1\", repo_url, str(dest)]\n", - " if branch:\n", - " cmd = [\"git\", \"clone\", \"--depth\", \"1\", \"--branch\", branch, repo_url, str(dest)]\n", - " run(cmd)\n", - " break\n", - " except Exception as e:\n", - " if i == retries:\n", - " raise\n", - " print(f\"Clone failed ({i+1}/{retries+1}). Retrying in 2s… [{e}]\")\n", - " time.sleep(2)\n", - "\n", - "def ensure_model():\n", - " MODELS_DIR.mkdir(parents=True, exist_ok=True)\n", - " mp = MODELS_DIR / MODEL_NAME\n", - " if not mp.exists() or mp.stat().st_size == 0:\n", - " import urllib.request\n", - " print(f\"Downloading model to {mp} …\")\n", - " with urllib.request.urlopen(MODEL_URL) as r, open(mp, \"wb\") as f:\n", - " shutil.copyfileobj(r, f)\n", - " if mp.stat().st_size < 100 * 1024:\n", - " raise RuntimeError(\"Downloaded model looks too small; download may have failed.\")\n", - " print(f\"✅ Model ready: {mp}\")\n", - "\n", - "# 1) Clone main repo (Linux/NVIDIA)\n", - "print(\"Linux/NVIDIA detected — using main piper-sample-generator repo.\")\n", - "safe_clone(REPO_URL)\n", - "\n", - "# 2) Install deps\n", - "# - piper-tts provides the `piper` module (required by generate_samples.py)\n", - "# - piper-phonemize-cross does the phonemization\n", - "# - onnxruntime-gpu enables CUDA (container must have NVIDIA runtime)\n", - "deps = [\n", - " \"piper-tts>=1.2.0\",\n", - " \"piper-phonemize-cross==1.2.1\",\n", - " \"soundfile\",\n", - " \"numpy\",\n", - " \"onnxruntime-gpu>=1.16.0\",\n", - "]\n", - "pip_install(*deps)\n", - "\n", - "# 3) Verify CUDA provider is available\n", - "try:\n", - " import onnxruntime as ort\n", - " providers = ort.get_available_providers()\n", - " print(f\"ONNX Runtime providers: {providers}\")\n", - " if \"CUDAExecutionProvider\" not in providers:\n", - " print(\"⚠️ CUDAExecutionProvider not available. \"\n", - " \"The sample will still run on CPU, but check your NVIDIA container setup \"\n", - " \"(nvidia-container-toolkit, runtime, and driver).\")\n", - "except Exception as e:\n", - " print(\"⚠️ Could not import onnxruntime to verify providers:\", e)\n", - "\n", - "# 4) Ensure model present\n", - "ensure_model()\n", - "\n", - "# 5) Generate one sample\n", - "AUDIO_OUT_DIR.mkdir(parents=True, exist_ok=True)\n", - "gen_script = REPO_DIR / \"generate_samples.py\"\n", - "if not gen_script.exists():\n", - " raise FileNotFoundError(f\"Missing generator: {gen_script}\")\n", - "\n", - "cmd = [\n", - " sys.executable, str(gen_script),\n", - " TARGET_WORD,\n", - " \"--model\", str(MODELS_DIR / MODEL_NAME), # ← pass the generator .pt explicitly\n", - " \"--max-samples\", \"1\",\n", - " \"--batch-size\", \"1\",\n", - " \"--output-dir\", str(AUDIO_OUT_DIR),\n", - "]\n", - "run(cmd)\n", - "\n", - "# 6) Play the audio (if the notebook frontend supports it)\n", - "if AUDIO_PATH.exists():\n", - " print(f\"🎧 Playing {AUDIO_PATH}\")\n", - " display(Audio(str(AUDIO_PATH), autoplay=True))\n", - "else:\n", - " print(f\"Audio file not found at {AUDIO_PATH}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "-SvGtCCM9akR" - }, - "outputs": [], - "source": [ - "# Generate a large number of wake word samples for training (with length-scale sweep)\n", - "import sys, subprocess\n", - "from pathlib import Path\n", - "\n", - "REPO_DIR = Path.cwd() / \"piper-sample-generator\"\n", - "MODELS_DIR = REPO_DIR / \"models\"\n", - "MODEL_NAME = \"en_US-libritts_r-medium.pt\"\n", - "\n", - "MAX_SAMPLES = 50000\n", - "BATCH_SIZE = 100\n", - "\n", - "# Piper \"speed\" control via piper-sample-generator is length_scale(s)\n", - "LENGTH_SCALES = [\"0.85\", \"0.95\", \"1.00\", \"1.05\", \"1.15\"]\n", - "\n", - "cmd = [\n", - " sys.executable,\n", - " str(REPO_DIR / \"generate_samples.py\"),\n", - " TARGET_WORD,\n", - " \"--model\", str(MODELS_DIR / MODEL_NAME),\n", - " \"--max-samples\", str(MAX_SAMPLES),\n", - " \"--batch-size\", str(BATCH_SIZE),\n", - " \"--output-dir\", \"generated_samples\",\n", - " \"--length-scales\", *LENGTH_SCALES,\n", - "]\n", - "\n", - "print(\"→\", \" \".join(cmd))\n", - "subprocess.run(cmd, check=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "YJRG4Qvo9nXG" - }, - "outputs": [], - "source": [ - "# NVIDIA/Linux dataset prep to match the Apple behavior, but with pinned AudioSet\n", - "# MIT RIR -> resample to 16 kHz\n", - "# AudioSet -> fetch from a working HF revision, convert to 16 kHz mono, skip bad\n", - "# FMA -> resample to 16 kHz mono\n", - "\n", - "import os, sys, subprocess, scipy.io.wavfile, numpy as np\n", - "from pathlib import Path\n", - "from tqdm import tqdm\n", - "import soundfile as sf\n", - "import librosa\n", - "from datasets import load_dataset\n", - "\n", - "# -------------------------------------------------\n", - "# small shell helpers (for curl/tar probing)\n", - "# -------------------------------------------------\n", - "def sh(cmd: str) -> int:\n", - " return subprocess.call(cmd, shell=True)\n", - "\n", - "def curl(url: str, out: Path) -> int:\n", - " # -L follow, -s silent, --fail to get nonzero on 404\n", - " return subprocess.call(f\"curl -L -s --fail '{url}' -o '{out}'\", shell=True)\n", - "\n", - "def write_wav(dst: Path, data: np.ndarray, sr: int):\n", - " x = np.clip(data, -1.0, 1.0)\n", - " scipy.io.wavfile.write(dst, sr, (x * 32767).astype(np.int16))\n", - "\n", - "# -----------------------------\n", - "# MIT RIR (resample to 16 kHz)\n", - "# -----------------------------\n", - "print(\"=== MIT RIR ===\")\n", - "rir_out = Path(\"mit_rirs\")\n", - "rir_out.mkdir(exist_ok=True)\n", - "if not any(rir_out.rglob(\"*.wav\")):\n", - " ok = 0\n", - " try:\n", - " # Avoid datasets.Audio to keep TorchCodec out:\n", - " # Use streaming=True + manual decode with librosa\n", - " print(\"⬇️ MIT RIR (streaming + manual decode)…\")\n", - " ds = load_dataset(\n", - " \"davidscripka/MIT_environmental_impulse_responses\",\n", - " split=\"train\",\n", - " streaming=True\n", - " )\n", - " for i, row in enumerate(tqdm(ds)):\n", - " try:\n", - " audio_path = row[\"audio\"][\"path\"]\n", - " y, sr = librosa.load(audio_path, sr=16000, mono=True)\n", - " write_wav(rir_out / f\"rir_{i:04d}.wav\", y, 16000)\n", - " ok += 1\n", - " except Exception:\n", - " pass\n", - " print(f\"✅ MIT RIR saved: {ok} files\")\n", - " except Exception as e:\n", - " print(f\"⚠️ MIT RIR download failed: {e}\")\n", - " # Fallback ZIP route\n", - " try:\n", - " print(\"⬇️ MIT RIR (fallback ZIP)…\")\n", - " zip_url = \"https://mcdermottlab.mit.edu/Reverb/IRMAudio/Audio.zip\"\n", - " zip_path = rir_out.parent / \"MIT_RIR_Audio.zip\"\n", - " if not zip_path.exists():\n", - " os.system(f\"wget -q -O '{zip_path}' '{zip_url}'\")\n", - " os.system(f'unzip -q -o \"{zip_path}\" -d \"{rir_out}\"')\n", - " # Normalize to 16k mono\n", - " for p in tqdm(list(rir_out.rglob(\"*.wav\")), desc=\"Normalize MIT RIR\"):\n", - " a, sr = sf.read(p, always_2d=False)\n", - " if a.ndim > 1:\n", - " a = a[:, 0]\n", - " if sr != 16000:\n", - " a, _ = librosa.load(p, sr=16000, mono=True)\n", - " write_wav(p, a, 16000)\n", - " print(\"✅ MIT RIR fallback complete\")\n", - " except Exception as e2:\n", - " print(f\"❌ MIT RIR fallback failed: {e2}\")\n", - "else:\n", - " print(\"✅ mit_rirs exists; skipping.\")\n", - "\n", - "# ============================================================\n", - "# AudioSet (pinned FLAC .tar → 16k mono, skip bad files)\n", - "# ============================================================\n", - "print(\"\\n=== AudioSet subset (pinned FLAC .tar → 16k mono) ===\")\n", - "audioset_dir = Path(\"audioset\"); audioset_dir.mkdir(exist_ok=True)\n", - "audioset_out = Path(\"audioset_16k\"); audioset_out.mkdir(exist_ok=True)\n", - "\n", - "if any(audioset_out.rglob(\"*.wav\")):\n", - " print(\"✅ audioset_16k exists; skipping.\")\n", - "else:\n", - " # commits / refs we know about — we’ll probe them\n", - " REV_CANDIDATES = [\n", - " \"6762f044d1c88619c7f2006486036192128fb07e\",\n", - " \"0049167e89f259a010c3f070fe3666d9e5242836\",\n", - " \"ceb9eaaa7844c9ad7351e659c84a572e376ad06d\",\n", - " \"main\", # last resort\n", - " ]\n", - " # possible folder layouts\n", - " TAR_PATTERNS = [\n", - " \"data/bal_train0{idx}.tar\",\n", - " \"data/bal_train/bal_train0{idx}.tar\",\n", - " ]\n", - "\n", - " def find_working_rev():\n", - " for rev in REV_CANDIDATES:\n", - " for pat in TAR_PATTERNS:\n", - " probe = f\"https://huggingface.co/datasets/agkphysics/AudioSet/resolve/{rev}/{pat.format(idx=0)}\"\n", - " rc = sh(f\"curl -I -L --fail -s '{probe}' > /dev/null\")\n", - " if rc == 0:\n", - " return rev, pat\n", - " return None, None\n", - "\n", - " rev, pattern = find_working_rev()\n", - " if rev is None:\n", - " raise RuntimeError(\"Could not locate an AudioSet revision with FLAC tarballs still present on HF.\")\n", - "\n", - " print(f\"📌 Using AudioSet revision: {rev}\")\n", - " print(f\"🗂️ Tar layout pattern: {pattern}\")\n", - "\n", - " # download + extract bal_train00..09\n", - " for i in range(10):\n", - " rel = pattern.format(idx=i)\n", - " url = f\"https://huggingface.co/datasets/agkphysics/AudioSet/resolve/{rev}/{rel}\"\n", - " fname = rel.split(\"/\")[-1]\n", - " out_tar = audioset_dir / fname\n", - " if not out_tar.exists():\n", - " print(f\"⬇️ {fname}\")\n", - " rc = curl(url, out_tar)\n", - " if rc != 0:\n", - " print(f\"⚠️ Could not fetch {fname} at rev {rev}; continuing.\")\n", - " continue\n", - " print(f\"📦 Extract {fname}\")\n", - " rc = sh(f\"tar -xf '{out_tar}' -C '{audioset_dir}'\")\n", - " if rc != 0:\n", - " print(f\"⚠️ tar extract failed for {fname}; continuing.\")\n", - "\n", - " # convert FLAC → 16k mono WAV\n", - " flacs = list(audioset_dir.rglob(\"*.flac\"))\n", - " print(f\"🔎 FLAC files: {len(flacs)}\")\n", - " audioset_bad = []\n", - " ok = 0\n", - " for p in tqdm(flacs, desc=\"AudioSet→WAV (resample 16k mono)\"):\n", - " try:\n", - " y, _ = librosa.load(p, sr=16000, mono=True)\n", - " if y.size == 0:\n", - " raise ValueError(\"empty audio\")\n", - " write_wav(audioset_out / (p.stem + \".wav\"), y, 16000)\n", - " ok += 1\n", - " except Exception as e:\n", - " audioset_bad.append(f\"{p}:{e}\")\n", - "\n", - " if audioset_bad:\n", - " (audioset_out / \"audioset_corrupted_files.log\").write_text(\"\\n\".join(audioset_bad))\n", - " print(f\"✅ AudioSet complete ({ok} ok, {len(audioset_bad)} failed)\")\n", - "\n", - "# -----------------------------\n", - "# FMA xsmall (resample to 16 kHz mono)\n", - "# -----------------------------\n", - "print(\"\\n=== FMA xsmall ===\")\n", - "fma_zip_dir = Path(\"fma\"); fma_zip_dir.mkdir(exist_ok=True)\n", - "fma_out = Path(\"fma_16k\"); fma_out.mkdir(exist_ok=True)\n", - "\n", - "zipname = \"fma_xs.zip\"\n", - "zipurl = f\"https://huggingface.co/datasets/mchl914/fma_xsmall/resolve/main/{zipname}\"\n", - "zipout = fma_zip_dir / zipname\n", - "if not zipout.exists():\n", - " os.system(f\"wget -q -O '{zipout}' '{zipurl}'\")\n", - " os.system(f\"cd fma && unzip -q '{zipname}'\")\n", - "\n", - "mp3s = list(Path(\"fma/fma_small\").rglob(\"*.mp3\"))\n", - "print(f\"🎵 FMA mp3 count: {len(mp3s)}\")\n", - "corrupt = []\n", - "for p in tqdm(mp3s, desc=\"FMA→16k WAV\"):\n", - " try:\n", - " y, sr = librosa.load(p, sr=16000, mono=True)\n", - " if y.size == 0:\n", - " raise ValueError(\"empty audio\")\n", - " write_wav(fma_out / (p.stem + \".wav\"), y, 16000)\n", - " except Exception as e:\n", - " corrupt.append(f\"{p}:{e}\")\n", - "if corrupt:\n", - " Path(\"fma_corrupted_files.log\").write_text(\"\\n\".join(corrupt))\n", - "\n", - "print(\"\\n✅ Dataset prep complete!\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "XW3bmbI5-JAz" - }, - "outputs": [], - "source": [ - "# Sets up the augmentations.\n", - "# To improve your model, experiment with these settings and use more sources of\n", - "# background clips.\n", - "import sys, os\n", - "from pathlib import Path\n", - "\n", - "# try the common places we’ve used\n", - "candidates = [\n", - " \"/data/microWakeWord\", # what the last install log showed\n", - " \"/data/microwakeword\", # lowercase variant\n", - " \"./microwakeword\", # local clone\n", - " \"./microWakeWord\", # camel case\n", - "]\n", - "\n", - "for base in candidates:\n", - " if os.path.isdir(base):\n", - " # add the repo root\n", - " sys.path.insert(0, base)\n", - " # add the actual package dir inside the repo\n", - " if os.path.isdir(os.path.join(base, \"microwakeword\")):\n", - " sys.path.insert(0, os.path.join(base, \"microwakeword\"))\n", - " break\n", - "from microwakeword.audio.augmentation import Augmentation\n", - "from microwakeword.audio.clips import Clips\n", - "from microwakeword.audio.spectrograms import SpectrogramGeneration\n", - "\n", - "def validate_directories(paths):\n", - " for path in paths:\n", - " if not os.path.exists(path):\n", - " print(f\"Error: Directory {path} does not exist. Please ensure preprocessing is complete.\")\n", - " return False\n", - " return True\n", - "\n", - "# Paths to augmented data\n", - "impulse_paths = ['mit_rirs']\n", - "background_paths = ['fma_16k', 'audioset_16k']\n", - "\n", - "if not validate_directories(impulse_paths + background_paths):\n", - " raise ValueError(\"One or more required directories are missing.\")\n", - "\n", - "# Process TTS generated samples (default)\n", - "clips_tts = Clips(\n", - " input_directory='./generated_samples',\n", - " file_pattern='*.wav',\n", - " max_clip_duration_s=5,\n", - " remove_silence=True,\n", - " random_split_seed=10,\n", - " split_count=0.1,\n", - ")\n", - "\n", - "# Process personal recordings if available (optional)\n", - "clips_personal = None\n", - "if os.path.exists(\"./personal_samples\") and any(Path(\"./personal_samples\").glob(\"*.wav\")):\n", - " clips_personal = Clips(\n", - " input_directory=\"./personal_samples\",\n", - " file_pattern=\"*.wav\",\n", - " max_clip_duration_s=5,\n", - " remove_silence=True,\n", - " random_split_seed=10,\n", - " split_count=0.1,\n", - " )\n", - " print(\"✅ Found personal samples, will create separate feature set\")\n", - "\n", - "augmenter = Augmentation(\n", - " augmentation_duration_s=3.2,\n", - " augmentation_probabilities={\n", - " \"SevenBandParametricEQ\": 0.1,\n", - " \"TanhDistortion\": 0.05,\n", - " \"PitchShift\": 0.15,\n", - " \"BandStopFilter\": 0.1,\n", - " \"AddColorNoise\": 0.1,\n", - " \"AddBackgroundNoise\": 0.7,\n", - " \"Gain\": 0.8,\n", - " \"RIR\": 0.7,\n", - " },\n", - " impulse_paths=impulse_paths,\n", - " background_paths=background_paths,\n", - " background_min_snr_db=5,\n", - " background_max_snr_db=10,\n", - " min_jitter_s=0.2,\n", - " max_jitter_s=0.3,\n", - ")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "V5UsJfKKD1k9" - }, - "outputs": [], - "source": [ - "# Augment a random generated-sample WAV and play it back (pass ndarray to augmenter)\n", - "from pathlib import Path\n", - "from IPython.display import Audio, display\n", - "import numpy as np\n", - "import soundfile as sf\n", - "import librosa, random, glob\n", - "\n", - "output_dir = Path(\"./augmented_clips\")\n", - "output_dir.mkdir(exist_ok=True)\n", - "\n", - "# 1) Pick a random WAV from the Piper outputs\n", - "candidates = glob.glob(\"generated_samples/*.wav\")\n", - "if not candidates:\n", - " raise SystemExit(\"No files in generated_samples/. Run the TTS sample cell first.\")\n", - "src_path = random.choice(candidates)\n", - "\n", - "# 2) Load as 16 kHz mono float32\n", - "y, sr = librosa.load(src_path, sr=16000, mono=True)\n", - "y = y.astype(np.float32, copy=False)\n", - "\n", - "# 3) Augment — microwakeword Augmentation expects a 1-D numpy array\n", - "try:\n", - " y_aug = augmenter.augment_clip(y)\n", - "except Exception as e:\n", - " # some versions accept (samples, sr) — try that as a fallback\n", - " try:\n", - " y_aug = augmenter.augment_clip((y, sr))\n", - " except Exception:\n", - " raise\n", - "\n", - "# 4) Save and play\n", - "out_path = output_dir / \"augmented_clip.wav\"\n", - "sf.write(str(out_path), y_aug.astype(np.float32, copy=False), sr, subtype=\"PCM_16\")\n", - "print(f\"Augmented clip saved to {out_path}\")\n", - "display(Audio(str(out_path), autoplay=True))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "D7BHcY1mEGbK" - }, - "outputs": [], - "source": [ - "# Augment samples and save the training, validation, and testing sets.\n", - "# This version avoids datasets.Audio entirely by driving Clips from local WAVs.\n", - "\n", - "import os, glob, random\n", - "from pathlib import Path\n", - "import types\n", - "import numpy as np\n", - "import librosa\n", - "from mmap_ninja.ragged import RaggedMmap\n", - "from microwakeword.audio.spectrograms import SpectrogramGeneration\n", - "\n", - "# ---- Patch: drive clips from generated_samples/*.wav (no datasets.Audio, no torchcodec) ----\n", - "def audio_generator_from_wavs(self, split=\"train\", repeat=1, source_dir=\"generated_samples\"):\n", - " \"\"\"\n", - " Yield 1-D float32 arrays loaded via librosa from source_dir/*.wav.\n", - " Deterministic 80/10/10 split with seed 10 to mirror original Clips behavior.\n", - " \"\"\"\n", - " files = sorted(glob.glob(f\"{source_dir}/*.wav\"))\n", - " if not files:\n", - " raise SystemExit(f\"❌ No WAVs in {source_dir}/. Generate samples first.\")\n", - "\n", - " rng = random.Random(10) # deterministic shuffling like Clips(random_split_seed=10)\n", - " files_shuf = files[:]\n", - " rng.shuffle(files_shuf)\n", - "\n", - " n = len(files_shuf)\n", - " n_val = max(1, int(0.10 * n))\n", - " n_test = max(1, int(0.10 * n))\n", - " n_train = max(0, n - n_val - n_test)\n", - " splits = {\n", - " \"train\": files_shuf[:n_train],\n", - " \"validation\": files_shuf[n_train:n_train + n_val],\n", - " \"test\": files_shuf[n_train + n_val:],\n", - " }\n", - " file_list = splits.get(split, [])\n", - " if not file_list:\n", - " return # nothing to yield\n", - "\n", - " for _ in range(max(1, int(repeat))):\n", - " for p in file_list:\n", - " y, sr = librosa.load(p, sr=16000, mono=True)\n", - " yield y.astype(np.float32, copy=False)\n", - "\n", - "# Bind the patched generator to clips_tts instance\n", - "def audio_generator_tts(self, split=\"train\", repeat=1):\n", - " return audio_generator_from_wavs(self, split, repeat, \"generated_samples\")\n", - "\n", - "clips_tts.audio_generator = types.MethodType(audio_generator_tts, clips_tts)\n", - "print(\"✅ Patched clips_tts.audio_generator to stream from generated_samples/*.wav (no torchcodec).\")\n", - "\n", - "# Bind the patched generator to clips_personal if it exists\n", - "if clips_personal is not None:\n", - " def audio_generator_personal(self, split=\"train\", repeat=1):\n", - " return audio_generator_from_wavs(self, split, repeat, \"personal_samples\")\n", - " clips_personal.audio_generator = types.MethodType(audio_generator_personal, clips_personal)\n", - " print(\"✅ Patched clips_personal.audio_generator to stream from personal_samples/*.wav (no torchcodec).\")\n", - "\n", - "# ---- Validate augmentation asset folders exist ----\n", - "def validate(paths):\n", - " for p in paths:\n", - " if not Path(p).exists():\n", - " raise SystemExit(f\"❌ Missing directory: {p}. Run dataset prep first.\")\n", - "\n", - "impulse_paths = [\"mit_rirs\"]\n", - "background_paths = [\"fma_16k\", \"audioset_16k\"]\n", - "validate(impulse_paths + background_paths)\n", - "\n", - "# ---- Output root ----\n", - "out_root = Path(\"generated_augmented_features\")\n", - "out_root.mkdir(exist_ok=True)\n", - "\n", - "# ---- Split config (same as before) ----\n", - "split_cfg = {\n", - " \"training\": {\"name\": \"train\", \"repetition\": 2, \"slide_frames\": 10},\n", - " \"validation\": {\"name\": \"validation\", \"repetition\": 1, \"slide_frames\": 10},\n", - " \"testing\": {\"name\": \"test\", \"repetition\": 1, \"slide_frames\": 1},\n", - "}\n", - "\n", - "# ---- Generate features for TTS samples ----\n", - "for split, cfg in split_cfg.items():\n", - " out_dir = out_root / split\n", - " out_dir.mkdir(parents=True, exist_ok=True)\n", - " print(f\"🧪 Processing {split} (TTS) …\")\n", - "\n", - " spectros = SpectrogramGeneration(\n", - " clips=clips_tts, # now backed by our WAV loader\n", - " augmenter=augmenter, # your existing augmenter\n", - " slide_frames=cfg[\"slide_frames\"],\n", - " step_ms=10,\n", - " )\n", - "\n", - " RaggedMmap.from_generator(\n", - " out_dir=str(out_dir / \"wakeword_mmap\"),\n", - " sample_generator=spectros.spectrogram_generator(\n", - " split=cfg[\"name\"], repeat=cfg[\"repetition\"]\n", - " ),\n", - " batch_size=100,\n", - " verbose=True,\n", - " )\n", - "\n", - "# ---- Generate features for personal samples if available ----\n", - "if clips_personal is not None:\n", - " out_root_personal = Path(\"personal_augmented_features\")\n", - " out_root_personal.mkdir(exist_ok=True)\n", - " for split, cfg in split_cfg.items():\n", - " out_dir = out_root_personal / split\n", - " out_dir.mkdir(parents=True, exist_ok=True)\n", - " print(f\"🧪 Processing {split} (personal) …\")\n", - " spectros = SpectrogramGeneration(\n", - " clips=clips_personal,\n", - " augmenter=augmenter,\n", - " slide_frames=cfg[\"slide_frames\"],\n", - " step_ms=10,\n", - " )\n", - " RaggedMmap.from_generator(\n", - " out_dir=str(out_dir / \"wakeword_mmap\"),\n", - " sample_generator=spectros.spectrogram_generator(split=cfg[\"name\"], repeat=cfg[\"repetition\"]),\n", - " batch_size=100,\n", - " verbose=True,\n", - " )\n", - "\n", - "print(\"✅ Features ready (generated_augmented_features/*/wakeword_mmap)\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "1pGuJDPyp3ax" - }, - "outputs": [], - "source": [ - "# Downloads pre-generated spectrogram features (made for microWakeWord in\n", - "# particular) for various negative datasets. This can be slow!\n", - "\n", - "import os\n", - "import requests\n", - "import zipfile\n", - "from pathlib import Path\n", - "from tqdm import tqdm\n", - "\n", - "# Function to download a file with progress bar\n", - "def download_file(url, output_path):\n", - " response = requests.get(url, stream=True)\n", - " total_size = int(response.headers.get('content-length', 0))\n", - " with open(output_path, \"wb\") as f, tqdm(\n", - " desc=f\"Downloading {output_path.name}\",\n", - " total=total_size,\n", - " unit=\"B\",\n", - " unit_scale=True,\n", - " unit_divisor=1024,\n", - " ) as bar:\n", - " for chunk in response.iter_content(chunk_size=1024):\n", - " f.write(chunk)\n", - " bar.update(len(chunk))\n", - " print(f\"Downloaded: {output_path}\")\n", - "\n", - "# Function to extract ZIP files\n", - "def extract_zip(zip_path, extract_to):\n", - " with zipfile.ZipFile(zip_path, 'r') as zip_ref:\n", - " zip_ref.extractall(extract_to)\n", - " print(f\"Extracted: {zip_path} to {extract_to}\")\n", - "\n", - "# Directory for negative datasets\n", - "output_dir = Path('./negative_datasets')\n", - "output_dir.mkdir(exist_ok=True)\n", - "\n", - "# Negative dataset URLs\n", - "link_root = \"https://huggingface.co/datasets/kahrendt/microwakeword/resolve/main/\"\n", - "filenames = ['dinner_party.zip', 'dinner_party_eval.zip', 'no_speech.zip', 'speech.zip']\n", - "\n", - "# Download and extract files\n", - "for fname in filenames:\n", - " link = link_root + fname\n", - " zip_path = output_dir / fname\n", - "\n", - " # Download only if the file doesn't already exist\n", - " if not zip_path.exists():\n", - " try:\n", - " download_file(link, zip_path)\n", - " except Exception as e:\n", - " print(f\"Error downloading {fname}: {e}\")\n", - " continue\n", - "\n", - " # Extract the ZIP file\n", - " try:\n", - " extract_zip(zip_path, output_dir)\n", - " except Exception as e:\n", - " print(f\"Error extracting {fname}: {e}\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "Ii1A14GsGVQT" - }, - "outputs": [], - "source": [ - "# --- Save a yaml config that controls the training process ---\n", - "\n", - "import os, sys, yaml\n", - "from pathlib import Path\n", - "\n", - "config = {}\n", - "\n", - "config[\"window_step_ms\"] = 10\n", - "config[\"train_dir\"] = \"trained_models/wakeword\"\n", - "\n", - "config[\"features\"] = [\n", - " {\"features_dir\":\"generated_augmented_features\",\"sampling_weight\":2.0,\"penalty_weight\":1.0,\"truth\":True,\"truncation_strategy\":\"truncate_start\",\"type\":\"mmap\"},\n", - " {\"features_dir\":\"negative_datasets/speech\",\"sampling_weight\":12.0,\"penalty_weight\":1.0,\"truth\":False,\"truncation_strategy\":\"random\",\"type\":\"mmap\"},\n", - " {\"features_dir\":\"negative_datasets/dinner_party\",\"sampling_weight\":12.0,\"penalty_weight\":1.0,\"truth\":False,\"truncation_strategy\":\"random\",\"type\":\"mmap\"},\n", - " {\"features_dir\":\"negative_datasets/no_speech\",\"sampling_weight\":5.0,\"penalty_weight\":1.0,\"truth\":False,\"truncation_strategy\":\"random\",\"type\":\"mmap\"},\n", - " {\"features_dir\":\"negative_datasets/dinner_party_eval\",\"sampling_weight\":0.0,\"penalty_weight\":1.0,\"truth\":False,\"truncation_strategy\":\"split\",\"type\":\"mmap\"},\n", - "]\n", - "\n", - "# Add personal features if they exist\n", - "if os.path.exists(\"personal_augmented_features/training\"):\n", - " config[\"features\"].insert(1, {\"features_dir\": \"personal_augmented_features\", \"sampling_weight\": 3.0, \"penalty_weight\": 1.0, \"truth\": True, \"truncation_strategy\": \"truncate_start\", \"type\": \"mmap\"})\n", - " print(\"✅ Added personal features with higher weight (3.0)\")\n", - "\n", - "config[\"training_steps\"] = [40000]\n", - "config[\"positive_class_weight\"] = [1]\n", - "config[\"negative_class_weight\"] = [20]\n", - "config[\"learning_rates\"] = [0.001]\n", - "\n", - "# Smaller batch to avoid GPU copy/alloc failures on 3070 laptop VRAM\n", - "config[\"batch_size\"] = 16\n", - "\n", - "# SpecAugment off (as before)\n", - "config[\"time_mask_max_size\"] = [0]\n", - "config[\"time_mask_count\"] = [0]\n", - "config[\"freq_mask_max_size\"] = [0]\n", - "config[\"freq_mask_count\"] = [0]\n", - "\n", - "config[\"eval_step_interval\"] = 500\n", - "config[\"clip_duration_ms\"] = 1500\n", - "config[\"target_minimization\"] = 0.9\n", - "config[\"minimization_metric\"] = None\n", - "config[\"maximization_metric\"] = \"average_viable_recall\"\n", - "\n", - "with open(\"training_parameters.yaml\", \"w\") as f:\n", - " yaml.dump(config, f)\n", - "\n", - "print(\"✅ Wrote training_parameters.yaml (batch_size=16)\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "WoEXJBaiC9mf" - }, - "outputs": [], - "source": [ - "# Train + export with GPU first, then automatic CPU fallback on GPU/VRAM errors\n", - "# (LIVE streaming output + full log capture for error detection)\n", - "# NOTE: Suppress ONLY the noisy \"Validation Batch #...\" lines (everything else still streams)\n", - "import os, sys, subprocess, textwrap\n", - "\n", - "# ---- Common TF env (applies to BOTH attempts) ----\n", - "base_env = os.environ.copy()\n", - "base_env.setdefault(\"TF_CPP_MIN_LOG_LEVEL\", \"2\")\n", - "base_env.setdefault(\"TF_XLA_FLAGS\", \"--tf_xla_auto_jit=0\") # disable XLA JIT (more stable mem)\n", - "base_env.setdefault(\"NVIDIA_TF32_OVERRIDE\", \"1\") # allow TF32 (perf/VRAM win on Ampere+)\n", - "\n", - "# These only matter when a GPU is visible:\n", - "base_env.setdefault(\"TF_FORCE_GPU_ALLOW_GROWTH\", \"true\")\n", - "base_env.setdefault(\"TF_GPU_ALLOCATOR\", \"cuda_malloc_async\")\n", - "# Optional (uncomment if you want a smaller cuDNN workspace):\n", - "# base_env.setdefault(\"TF_CUDNN_WORKSPACE_LIMIT_IN_MB\", \"256\")\n", - "\n", - "# ---- Training argv (same as your runpy args) ----\n", - "train_args = [\n", - " \"-m\", \"microwakeword.model_train_eval\",\n", - " \"--training_config\", \"training_parameters.yaml\",\n", - " \"--train\", \"1\",\n", - " \"--restore_checkpoint\", \"1\",\n", - " \"--test_tf_nonstreaming\", \"0\",\n", - " \"--test_tflite_nonstreaming\", \"0\",\n", - " \"--test_tflite_nonstreaming_quantized\", \"0\",\n", - " \"--test_tflite_streaming\", \"0\",\n", - " \"--test_tflite_streaming_quantized\", \"1\",\n", - " \"--use_weights\", \"best_weights\",\n", - " \"mixednet\",\n", - " \"--pointwise_filters\", \"64,64,64,64\",\n", - " \"--repeat_in_block\", \"1,1,1,1\",\n", - " \"--mixconv_kernel_sizes\", \"[5], [7,11], [9,15], [23]\",\n", - " \"--residual_connection\", \"0,0,0,0\",\n", - " \"--first_conv_filters\", \"32\",\n", - " \"--first_conv_kernel_size\", \"5\",\n", - " \"--stride\", \"2\",\n", - "]\n", - "\n", - "OOM_MARKERS = (\n", - " \"resourceexhaustederror\",\n", - " \"resource exhausted\",\n", - " \"oom\",\n", - " \"out of memory\",\n", - " \"cuda_error_out_of_memory\",\n", - " \"cudnn\",\n", - " \"failed to allocate\",\n", - " \"blas xgemm\",\n", - " \"cublas\",\n", - " \"internalerror: cuda\",\n", - " \"failed call to cuinit\",\n", - ")\n", - "\n", - "class RunResult:\n", - " def __init__(self, returncode: int, stdout: str):\n", - " self.returncode = returncode\n", - " self.stdout = stdout\n", - "\n", - "def run_training(label: str, extra_env: dict) -> RunResult:\n", - " env = base_env.copy()\n", - " env.update(extra_env or {})\n", - "\n", - " print(f\"\\n🚀 {label}\")\n", - " print(\"→\", \" \".join([sys.executable] + train_args))\n", - "\n", - " proc = subprocess.Popen(\n", - " [sys.executable] + train_args,\n", - " env=env,\n", - " text=True,\n", - " stdout=subprocess.PIPE,\n", - " stderr=subprocess.STDOUT,\n", - " bufsize=1, # line-buffered (best effort)\n", - " universal_newlines=True,\n", - " )\n", - "\n", - " full_log = []\n", - " try:\n", - " # Stream lines live AND capture them for OOM detection / error messages\n", - " assert proc.stdout is not None\n", - " for line in proc.stdout:\n", - " full_log.append(line)\n", - "\n", - " # Hide ONLY the per-minibatch validation spam\n", - " if line.startswith(\"Validation Batch #\"):\n", - " continue\n", - "\n", - " # Everything else streams live\n", - " print(line, end=\"\")\n", - " finally:\n", - " returncode = proc.wait()\n", - "\n", - " return RunResult(returncode, \"\".join(full_log))\n", - "\n", - "# Attempt 1: GPU (normal visibility)\n", - "cp = run_training(\n", - " \"Attempt 1/2: GPU training (with allow_growth + cuda_malloc_async)\",\n", - " extra_env={}, # no override\n", - ")\n", - "\n", - "if cp.returncode == 0:\n", - " print(\"✅ Training and testing complete (GPU path).\")\n", - "else:\n", - " out_l = (cp.stdout or \"\").lower()\n", - " looks_like_gpu_oom = any(m in out_l for m in OOM_MARKERS)\n", - "\n", - " if looks_like_gpu_oom:\n", - " # Attempt 2: CPU fallback (hide GPUs completely)\n", - " cp2 = run_training(\n", - " \"Attempt 2/2: CPU fallback (GPU hidden via CUDA_VISIBLE_DEVICES='')\",\n", - " extra_env={\n", - " \"CUDA_VISIBLE_DEVICES\": \"\", # hard-disable GPU\n", - " # (Optional) makes TF less chatty about GPU init on some builds:\n", - " \"TF_CPP_MIN_LOG_LEVEL\": \"2\",\n", - " },\n", - " )\n", - " if cp2.returncode == 0:\n", - " print(\"✅ Training and testing complete (CPU fallback).\")\n", - " else:\n", - " raise RuntimeError(\n", - " \"Training failed on BOTH GPU and CPU.\\n\\n\"\n", - " + textwrap.indent(cp2.stdout or \"(no output)\", prefix=\" \")\n", - " )\n", - " else:\n", - " # Not an OOM-style failure: surface the original error\n", - " raise RuntimeError(\n", - " \"Training failed (does not look like a VRAM/OOM issue).\\n\\n\"\n", - " + textwrap.indent(cp.stdout or \"(no output)\", prefix=\" \")\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "ex_UIWvwtjAN" - }, - "outputs": [], - "source": [ - "import shutil\n", - "import json\n", - "from IPython.display import display, HTML\n", - "\n", - "# Use the wake word from Cell 3\n", - "wake_word = TARGET_WORD\n", - "\n", - "# --- Copy TFLite file to working dir with wake word name ---\n", - "source_path = \"trained_models/wakeword/tflite_stream_state_internal_quant/stream_state_internal_quant.tflite\"\n", - "tflite_filename = f\"{wake_word}.tflite\"\n", - "tflite_path = f\"./{tflite_filename}\"\n", - "shutil.copy(source_path, tflite_path)\n", - "\n", - "# --- Write JSON metadata file with matching model name ---\n", - "json_data = {\n", - " \"type\": \"micro\",\n", - " \"wake_word\": wake_word,\n", - " \"author\": \"Tater Totterson\",\n", - " \"website\": \"https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git\",\n", - " \"model\": tflite_filename,\n", - " \"trained_languages\": [\"en\"],\n", - " \"version\": 2,\n", - " \"micro\": {\n", - " \"probability_cutoff\": 0.97,\n", - " \"sliding_window_size\": 5,\n", - " \"feature_step_size\": 10,\n", - " \"tensor_arena_size\": 30000,\n", - " \"minimum_esphome_version\": \"2024.7.0\"\n", - " }\n", - "}\n", - "json_filename = f\"{wake_word}.json\"\n", - "json_path = f\"./{json_filename}\"\n", - "with open(json_path, \"w\") as json_file:\n", - " json.dump(json_data, json_file, indent=2)\n", - "\n", - "# --- Display nice download links ---\n", - "html = f\"\"\"\n", - "

Download your files:

\n", - "\n", - "\"\"\"\n", - "display(HTML(html))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "accelerator": "GPU", - "colab": { - "gpuType": "T4", - "provenance": [] - }, - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.12" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/mmw.png b/mmw.png deleted file mode 100644 index ba38f86a826442e9211a4724b46d011d2db6cb12..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11478 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 O($$o;m8ujhQ2!q~6Kpa7 diff --git a/recorder_server.py b/recorder_server.py new file mode 100644 index 0000000..e469104 --- /dev/null +++ b/recorder_server.py @@ -0,0 +1,593 @@ +# recorder_server.py +import os +import re +import subprocess +import threading +from pathlib import Path +from typing import Dict, Any, List, Optional, Tuple + +from fastapi import FastAPI, UploadFile, File, Form, Query +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +ROOT_DIR = Path(__file__).resolve().parent + +# In Docker CLI world, DATA_DIR should be /data +DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")).resolve() + +# UI files live next to this script by default +STATIC_DIR = Path(os.environ.get("STATIC_DIR", str(ROOT_DIR / "static"))).resolve() + +# Personal samples MUST land in /data/personal_samples for your CLI pipeline +PERSONAL_DIR = Path(os.environ.get("PERSONAL_DIR", str(DATA_DIR / "personal_samples"))).resolve() + +# CLI folder inside repo +CLI_DIR = Path(os.environ.get("CLI_DIR", str(ROOT_DIR / "cli"))).resolve() + +# If you want cleanup defaults for auto dataset setup, set these env vars: +# REC_DATASET_CLEANUP_ARCHIVES=true/false +# REC_DATASET_CLEANUP_INTERMEDIATE_FILES=true/false +DATASET_CLEANUP_ARCHIVES = os.environ.get("REC_DATASET_CLEANUP_ARCHIVES", "false").lower() in ("1", "true", "yes", "y") +DATASET_CLEANUP_INTERMEDIATE = os.environ.get("REC_DATASET_CLEANUP_INTERMEDIATE_FILES", "false").lower() in ("1", "true", "yes", "y") + +# We want "Start training" to trigger your CLI entrypoint, using the existing venv +# (train_wake_word should be in /data/.venv/bin via setup_python_venv) +TRAIN_CMD = os.environ.get( + "TRAIN_CMD", + f"source '{DATA_DIR}/.venv/bin/activate' && train_wake_word --data-dir '{DATA_DIR}'" +) + +TAKES_PER_SPEAKER_DEFAULT = int(os.environ.get("REC_TAKES_PER_SPEAKER", "10")) +SPEAKERS_TOTAL_DEFAULT = int(os.environ.get("REC_SPEAKERS_TOTAL", "1")) + +# How many lines to show in WebUI (tail) +TRAIN_LOG_TAIL_LINES = int(os.environ.get("REC_TRAIN_LOG_TAIL_LINES", "400")) +# If you prefer bytes-based tailing (fast), keep this non-zero. +TRAIN_LOG_MAX_BYTES = int(os.environ.get("REC_TRAIN_LOG_MAX_BYTES", str(512 * 1024))) # 512KB + +app = FastAPI(title="microWakeWord Personal Recorder") + +# Serve static UI +STATIC_DIR.mkdir(parents=True, exist_ok=True) +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + +def safe_name(raw: str) -> str: + s = (raw or "").strip().lower() + s = re.sub(r"\s+", "_", s) + s = re.sub(r"[^a-z0-9_]+", "", s) + s = re.sub(r"^_+|_+$", "", s) + return s or "wakeword" + + +# -------------------- In-memory session state -------------------- +STATE: Dict[str, Any] = { + "raw_phrase": None, + "safe_word": None, + + "speakers_total": SPEAKERS_TOTAL_DEFAULT, + "takes_per_speaker": TAKES_PER_SPEAKER_DEFAULT, + + "takes_received": 0, + "takes": [], + + "training": { + "running": False, + "exit_code": None, + "log_lines": [], # legacy in-memory tail (still maintained) + "log_path": None, # path to recorder_training.log + "safe_word": None, + + # NEW: byte offset for efficient log tailing + "log_offset": 0, + }, +} + +STATE_LOCK = threading.Lock() + + +def _reset_personal_samples_dir(): + PERSONAL_DIR.mkdir(parents=True, exist_ok=True) + for p in PERSONAL_DIR.glob("*.wav"): + try: + p.unlink() + except Exception: + pass + + +def _append_train_log(line: str): + line = (line or "").rstrip("\n") + with STATE_LOCK: + buf: List[str] = STATE["training"]["log_lines"] + buf.append(line) + if len(buf) > 250: + del buf[: (len(buf) - 250)] + + +def _title_from_phrase(raw_phrase: str) -> str: + # Keep it human-friendly for the optional argument + s = re.sub(r"[^a-zA-Z0-9 ]+", " ", raw_phrase or "").strip() + s = re.sub(r"\s+", " ", s) + return s.title() if s else "" + + +def _run_streamed( + cmd: List[str], + cwd: Path, + log_path: Path, + header: Optional[str] = None, + env: Optional[Dict[str, str]] = None, +) -> int: + """ + Run a command streaming stdout/stderr to both: + - recorder_training.log (disk) + - STATE["training"]["log_lines"] (UI) [best-effort] + Returns process exit code. + """ + if header: + _append_train_log(header) + + _append_train_log("→ " + " ".join(cmd)) + + with open(log_path, "a", encoding="utf-8") as lf: + lf.write("\n" + ("=" * 80) + "\n") + if header: + lf.write(header + "\n") + lf.write("→ " + " ".join(cmd) + "\n") + lf.flush() + + proc = subprocess.Popen( + cmd, + cwd=str(cwd), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=env, + ) + + assert proc.stdout is not None + for line in proc.stdout: + lf.write(line) + lf.flush() + _append_train_log(line) + + return proc.wait() + + +def _ensure_training_venv(log_path: Path) -> None: + """ + Ensure /data/.venv exists by running cli/setup_python_venv if needed. + """ + activate = DATA_DIR / ".venv" / "bin" / "activate" + if activate.exists(): + _append_train_log("✅ Training venv found (skipping setup_python_venv)") + return + + setup = CLI_DIR / "setup_python_venv" + if not setup.exists(): + raise RuntimeError(f"Missing setup_python_venv at: {setup}") + + rc = _run_streamed( + ["bash", "-lc", f"cd '{DATA_DIR}' && '{setup}' --data-dir='{DATA_DIR}'"], + cwd=DATA_DIR, + log_path=log_path, + header="===== Ensuring Python venv (/data/.venv) =====", + ) + + if rc != 0: + raise RuntimeError(f"setup_python_venv failed (exit_code={rc})") + + if not activate.exists(): + raise RuntimeError(f"setup_python_venv finished, but {activate} is still missing") + + +def _ensure_training_datasets(log_path: Path) -> None: + """ + Always run setup_training_datasets before training. + The underlying scripts should skip work when already done. + """ + setup = CLI_DIR / "setup_training_datasets" + if not setup.exists(): + raise RuntimeError(f"Missing setup_training_datasets at: {setup}") + + cleanup_arch = "true" if DATASET_CLEANUP_ARCHIVES else "false" + cleanup_inter = "true" if DATASET_CLEANUP_INTERMEDIATE else "false" + + cmd = [ + "bash", + "-lc", + ( + f"cd '{DATA_DIR}' && " + f"'{setup}' " + f"--cleanup-archives='{cleanup_arch}' " + f"--cleanup-intermediate-files='{cleanup_inter}' " + f"--data-dir='{DATA_DIR}'" + ), + ] + + rc = _run_streamed( + cmd, + cwd=DATA_DIR, + log_path=log_path, + header="===== Ensuring training datasets (setup_training_datasets) =====", + ) + + if rc != 0: + raise RuntimeError(f"setup_training_datasets failed (exit_code={rc})") + + +def _read_log_tail_by_bytes(log_path: Path, max_bytes: int) -> str: + """ + Read up to the last max_bytes from a file (UTF-8 best effort). + """ + if not log_path.exists(): + return "" + + try: + size = log_path.stat().st_size + start = max(0, size - max_bytes) + with open(log_path, "rb") as f: + f.seek(start) + data = f.read() + # If we started in the middle of a line, it's ok; UI will show partial. + return data.decode("utf-8", errors="replace") + except Exception: + return "" + + +def _read_log_tail_by_lines(log_path: Path, max_lines: int) -> str: + """ + Read last N lines of a file (simple, may be slower on huge files). + """ + if not log_path.exists(): + return "" + try: + # Read by bytes limited first, then line-tail + raw = _read_log_tail_by_bytes(log_path, TRAIN_LOG_MAX_BYTES) + if not raw: + return "" + lines = raw.splitlines() + if len(lines) <= max_lines: + return "\n".join(lines) + return "\n".join(lines[-max_lines:]) + except Exception: + return "" + + +def _read_log_since_offset(log_path: Path, offset: int, max_bytes: int = 256 * 1024) -> Tuple[str, int]: + """ + Read log file incrementally starting from `offset`. + Returns (new_text, new_offset). Caps bytes read per call. + """ + if not log_path.exists(): + return ("", offset) + + try: + size = log_path.stat().st_size + # If file rotated/truncated, reset offset + if offset > size: + offset = 0 + + with open(log_path, "rb") as f: + f.seek(offset) + data = f.read(max_bytes) + + new_offset = offset + len(data) + text = data.decode("utf-8", errors="replace") + return (text, new_offset) + except Exception: + return ("", offset) + + +def _run_training_background(safe_word: str, allow_no_personal: bool): + with STATE_LOCK: + raw_phrase = STATE.get("raw_phrase") or "" + + wake_word_title = _title_from_phrase(raw_phrase) + + with STATE_LOCK: + STATE["training"]["running"] = True + STATE["training"]["exit_code"] = None + STATE["training"]["log_lines"] = [] + STATE["training"]["safe_word"] = safe_word + log_path = Path(str(DATA_DIR / "recorder_training.log")) + STATE["training"]["log_path"] = str(log_path) + STATE["training"]["log_offset"] = 0 + + # fresh header at the start of a run + _append_train_log("================================================================================") + _append_train_log("===== Recorder Training Run =====") + _append_train_log("================================================================================") + + # Ensure the log exists and starts cleanly with a header separator for this run + try: + with open(log_path, "a", encoding="utf-8") as lf: + lf.write("\n" + ("=" * 80) + "\n") + lf.write("===== Recorder Training Run =====\n") + lf.write(("=" * 80) + "\n") + lf.flush() + except Exception: + pass + + try: + # 1) Ensure venv (auto-installs) + _ensure_training_venv(log_path) + + # 2) Ensure datasets (auto-installs / skips if already present) + _ensure_training_datasets(log_path) + + # 3) Run training + if wake_word_title: + cmd_str = f"{TRAIN_CMD} '{safe_word}' '{wake_word_title}'" + else: + cmd_str = f"{TRAIN_CMD} '{safe_word}'" + + env = os.environ.copy() + env["MWW_ALLOW_NO_PERSONAL"] = "true" if allow_no_personal else "false" + + _append_train_log("===== Training (train_wake_word) =====") + _append_train_log(f"→ Running: {cmd_str}") + + with open(log_path, "a", encoding="utf-8") as lf: + proc = subprocess.Popen( + ["bash", "-lc", cmd_str], + cwd=str(DATA_DIR), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=env, + ) + assert proc.stdout is not None + for line in proc.stdout: + lf.write(line) + lf.flush() + _append_train_log(line) + + rc = proc.wait() + + _append_train_log(f"✓ Training finished (exit_code={rc})") + with STATE_LOCK: + STATE["training"]["exit_code"] = rc + + except Exception as e: + _append_train_log(f"✗ Training crashed: {e!r}") + with STATE_LOCK: + STATE["training"]["exit_code"] = 999 + + finally: + with STATE_LOCK: + STATE["training"]["running"] = False + + +# -------------------- Routes -------------------- +@app.get("/", response_class=HTMLResponse) +def index(): + html_path = STATIC_DIR / "index.html" + if not html_path.exists(): + return HTMLResponse( + "

Missing UI

Create static/index.html.

", + status_code=500, + ) + return HTMLResponse(html_path.read_text(encoding="utf-8")) + + +@app.post("/api/start_session") +def start_session(payload: Dict[str, Any]): + raw = (payload.get("phrase") or "").strip() + if not raw: + return JSONResponse({"ok": False, "error": "phrase is required"}, status_code=400) + + safe = safe_name(raw) + + speakers_total = int(payload.get("speakers_total") or SPEAKERS_TOTAL_DEFAULT) + takes_per_speaker = int(payload.get("takes_per_speaker") or TAKES_PER_SPEAKER_DEFAULT) + + speakers_total = max(1, min(10, speakers_total)) + takes_per_speaker = max(1, min(50, takes_per_speaker)) + + with STATE_LOCK: + STATE["raw_phrase"] = raw + STATE["safe_word"] = safe + STATE["speakers_total"] = speakers_total + STATE["takes_per_speaker"] = takes_per_speaker + STATE["takes_received"] = 0 + STATE["takes"] = [] + # do not interrupt training if running + + _reset_personal_samples_dir() + + return { + "ok": True, + "raw_phrase": raw, + "safe_word": safe, + "speakers_total": speakers_total, + "takes_per_speaker": takes_per_speaker, + "takes_total": speakers_total * takes_per_speaker, + "personal_dir": str(PERSONAL_DIR), + "data_dir": str(DATA_DIR), + } + + +@app.get("/api/session") +def get_session(): + with STATE_LOCK: + return { + "ok": True, + "raw_phrase": STATE["raw_phrase"], + "safe_word": STATE["safe_word"], + "speakers_total": STATE["speakers_total"], + "takes_per_speaker": STATE["takes_per_speaker"], + "takes_received": STATE["takes_received"], + "takes": list(STATE["takes"]), + "training": dict(STATE["training"]), + } + + +@app.post("/api/upload_take") +async def upload_take( + speaker_index: int = Form(...), + take_index: int = Form(...), + file: UploadFile = File(...), +): + with STATE_LOCK: + safe_word = STATE["safe_word"] + speakers_total = int(STATE["speakers_total"]) + takes_per_speaker = int(STATE["takes_per_speaker"]) + + if not safe_word: + return JSONResponse({"ok": False, "error": "No active session. Call /api/start_session first."}, status_code=400) + + if speaker_index < 1 or speaker_index > speakers_total: + return JSONResponse({"ok": False, "error": f"speaker_index must be 1..{speakers_total}"}, status_code=400) + + if take_index < 1 or take_index > takes_per_speaker: + return JSONResponse({"ok": False, "error": f"take_index must be 1..{takes_per_speaker}"}, status_code=400) + + PERSONAL_DIR.mkdir(parents=True, exist_ok=True) + + out_name = f"speaker{speaker_index:02d}_take{take_index:02d}.wav" + out_path = PERSONAL_DIR / out_name + + data = await file.read() + if not data or len(data) < 44: + return JSONResponse({"ok": False, "error": "Empty/invalid file"}, status_code=400) + + out_path.write_bytes(data) + + with STATE_LOCK: + if out_name not in STATE["takes"]: + STATE["takes"].append(out_name) + STATE["takes_received"] = len(STATE["takes"]) + + return {"ok": True, "saved_as": out_name, "takes_received": STATE["takes_received"]} + + +@app.post("/api/train") +def train_now(payload: Dict[str, Any] = None): + payload = payload or {} + allow_no_personal = bool(payload.get("allow_no_personal", False)) + + with STATE_LOCK: + safe_word = STATE["safe_word"] + takes_received = int(STATE["takes_received"]) + speakers_total = int(STATE["speakers_total"]) + takes_per_speaker = int(STATE["takes_per_speaker"]) + training_running = bool(STATE["training"]["running"]) + + takes_total = speakers_total * takes_per_speaker + + if training_running: + return JSONResponse({"ok": False, "error": "Training already running"}, status_code=400) + + if not safe_word: + return JSONResponse({"ok": False, "error": "No active session"}, status_code=400) + + min_required = max(1, min(3, takes_total)) + + if takes_received == 0 and not allow_no_personal: + return JSONResponse( + { + "ok": False, + "error": f"No personal voice samples recorded (0/{takes_total}).", + "code": "NO_PERSONAL_SAMPLES", + "message": "You can train without personal voices, or record samples first.", + "takes_total": takes_total, + }, + status_code=400, + ) + + if 0 < takes_received < min_required: + return JSONResponse( + { + "ok": False, + "error": f"Not enough takes yet ({takes_received}/{takes_total}).", + "code": "NOT_ENOUGH_TAKES", + "min_required": min_required, + "takes_total": takes_total, + }, + status_code=400, + ) + + t = threading.Thread(target=_run_training_background, args=(safe_word, allow_no_personal), daemon=True) + t.start() + + return { + "ok": True, + "started": True, + "safe_word": safe_word, + "personal_samples_used": takes_received >= min_required, + "allow_no_personal": allow_no_personal, + } + + +@app.get("/api/train_status") +def train_status( + offset: int = Query(0, ge=0), + max_bytes: int = Query(65536, ge=1024, le=262144), + last_size: int = Query(0, ge=0), + last_mtime: float = Query(0.0, ge=0.0), +): + """ + Stream training output from the log file on disk. + + Robust to log overwrite/truncation: + - UI passes offset + last_size + last_mtime + - If file shrinks or mtime goes backwards/changes weirdly, reset offset to 0 + """ + with STATE_LOCK: + tr = dict(STATE["training"]) + log_path_str = tr.get("log_path") + + log_text = "" + next_offset = offset + log_size = 0 + log_mtime = 0.0 + + if log_path_str: + p = Path(log_path_str) + if p.exists(): + try: + st = p.stat() + log_size = int(st.st_size) + log_mtime = float(st.st_mtime) + + # Detect overwrite/truncate/reset: + # - file shrank + # - file mtime moved "backwards" (rare) or changed while size reset + # If anything indicates a reset, restart from beginning. + if (log_size < last_size) or (last_mtime and log_mtime < last_mtime): + offset = 0 + + # Clamp offset to current file size + if offset > log_size: + offset = log_size + + # Read incrementally from the file + with p.open("rb") as f: + f.seek(offset) + chunk = f.read(max_bytes) + + log_text = chunk.decode("utf-8", errors="replace") + next_offset = offset + len(chunk) + + except Exception as e: + log_text = f"\n[log read error: {e!r}]\n" + next_offset = offset + + tr["log_text"] = log_text + tr["next_offset"] = next_offset + tr["log_size"] = log_size + tr["log_mtime"] = log_mtime + + return {"ok": True, "training": tr} + + +@app.post("/api/reset_recordings") +def reset_recordings(): + _reset_personal_samples_dir() + with STATE_LOCK: + STATE["takes_received"] = 0 + STATE["takes"] = [] + return {"ok": True} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 438e424..a0e801b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,28 +1,10 @@ -# --- Core training (Microwakeword) --- +# --- Packages needed by our scripts --- numpy==1.26.4 scipy==1.12.0 librosa==0.10.2.post1 soundfile==0.12.1 -soxr==0.5.0.post1 -audiomentations==0.38.0 -webrtcvad==2.0.10 tqdm==4.67.1 scikit-learn==1.6.0 -numba==0.60.0 -joblib==1.4.2 -pandas==2.2.3 -pymicro_features @ git+https://github.com/puddly/pymicro-features@e1d3f88183e12bb8af2df9e399ea157af7393762 -audio-metadata @ git+https://github.com/whatsnowplaying/audio-metadata@d4ebb238e6a401bb1a5aaaac60c9e2b3cb30929f -bitstruct==8.19.0 - -# --- Piper sample generation --- -piper-tts>=1.2.0 -piper-phonemize-cross==1.2.1 - -# --- Notebook / tooling --- -ipykernel==6.29.5 -jupyterlab==4.3.4 -ipywidgets==8.1.5 -matplotlib-inline==0.1.7 -rich==13.9.4 +numba==0.63.1 +PyYAML==6.0.3 diff --git a/run_recorder.sh b/run_recorder.sh new file mode 100644 index 0000000..9ac94c5 --- /dev/null +++ b/run_recorder.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOTDIR="$(dirname "$(realpath "$0")")" + +# Training convention +DATA_DIR="${DATA_DIR:-/data}" +HOST="${REC_HOST:-0.0.0.0}" +PORT="${REC_PORT:-8888}" + +# Keep recorder deps separate from training venv +VENV_DIR="${DATA_DIR}/.recorder-venv" +PY="${VENV_DIR}/bin/python" +PIP="${PY} -m pip" +PIN_FILE="${VENV_DIR}/.pinned_installed" + +FASTAPI_VERSION="${REC_FASTAPI_VERSION:-0.115.6}" +UVICORN_VERSION="${REC_UVICORN_VERSION:-0.30.6}" +PY_MULTIPART_VERSION="${REC_PY_MULTIPART_VERSION:-0.0.9}" + +echo "microWakeWord Recorder (Docker)" +echo "-> ROOTDIR: ${ROOTDIR}" +echo "-> DATA_DIR: ${DATA_DIR}" +echo "-> URL: http://localhost:${PORT}/" + +mkdir -p "${DATA_DIR}" + +# ----------------------------- +# Recorder venv (separate) +# ----------------------------- +if [[ ! -x "${PY}" ]]; then + echo "Creating recorder venv: ${VENV_DIR}" + python3 -m venv "${VENV_DIR}" +fi + +# shellcheck disable=SC1091 +source "${VENV_DIR}/bin/activate" + +if [[ ! -f "${PIN_FILE}" ]]; then + echo "Installing pinned recorder deps" + ${PIP} install -U pip setuptools wheel + ${PIP} install \ + "fastapi==${FASTAPI_VERSION}" \ + "uvicorn[standard]==${UVICORN_VERSION}" \ + "python-multipart==${PY_MULTIPART_VERSION}" + touch "${PIN_FILE}" +else + echo "Reusing existing recorder venv (no upgrades)" +fi + +# ----------------------------- +# Recorder server env +# ----------------------------- +export DATA_DIR="${DATA_DIR}" +export STATIC_DIR="${ROOTDIR}/static" +export PERSONAL_DIR="${DATA_DIR}/personal_samples" + +# IMPORTANT: leave training venv creation to /api/train inside recorder_server.py +# but still set TRAIN_CMD so the server knows how to invoke training once ready +export TRAIN_CMD="source '${DATA_DIR}/.venv/bin/activate' && train_wake_word --data-dir='${DATA_DIR}'" + +echo "Launching uvicorn on ${HOST}:${PORT}" +cd "${ROOTDIR}" +exec "${VENV_DIR}/bin/uvicorn" recorder_server:app --host "${HOST}" --port "${PORT}" \ No newline at end of file diff --git a/startup.sh b/startup.sh deleted file mode 100644 index bb4f3e2..0000000 --- a/startup.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${NB_UID:=0}" -: "${NB_GID:=0}" -umask 002 - -NOTEBOOK_SRC="/root/microWakeWord_training_notebook.ipynb" -NOTEBOOK_DST="/data/microWakeWord_training_notebook.ipynb" - -mkdir -p /data /data/generated_samples /data/personal_samples - -if [[ ! -f "$NOTEBOOK_DST" ]]; then - echo "No training notebook found in /data; copying default…" - cp -n "$NOTEBOOK_SRC" "$NOTEBOOK_DST" -fi - -# Try to align ownership for convenience (ignore errors if not permitted) -if [[ "$NB_UID" != "0" || "$NB_GID" != "0" ]]; then - chown -R "$NB_UID:$NB_GID" /data || true -fi - -exec "$@" \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..cb77038 --- /dev/null +++ b/static/index.html @@ -0,0 +1,782 @@ + + + + + + microWakeWord Recorder + + + + +
+
+
+ +
+

🎙️ microWakeWord Personal Recorder

+

Enter a wake word, test TTS pronunciation, then record takes. Recording starts when you speak and stops after silence.

+
+
+
+ +
+
+ + + + No session +
+ +
+ + + Speaker: - +
+ +
+ Advanced (if it’s too sensitive / not sensitive enough) +
+ + + +
+
+
+ +
+
+ + + + Idle +
+ +
+
+ Mic level +
+ +
+ +

+ Speaker: - / - + Waiting +

+ +

+ Take: 0 / 10 + Not recording +

+ +
+ +

Training log

+
(no training started)
+
+
+ + + + \ No newline at end of file diff --git a/cli/train_wake_word b/train_wake_word old mode 100755 new mode 100644 similarity index 83% rename from cli/train_wake_word rename to train_wake_word index b52adcf..4faed61 --- a/cli/train_wake_word +++ b/train_wake_word @@ -3,9 +3,10 @@ set -e PROGPATH=$(realpath "$0") PROGDIR=$(dirname "${PROGPATH}") +CLIDIR="${PROGDIR}/cli" KNOWN_ARGS=( samples batch-size training-steps data-dir cleanup-work-dir ) -source "${PROGDIR}/shell.functions" +source "${CLIDIR}/shell.functions" WAKE_WORD=${POSITIONAL_ARGS[0]} if [ ${#UNKNOWN_ARGS[@]} -gt 0 ] ; then @@ -62,7 +63,7 @@ fi printf "%-80s\n" "=" | tr ' ' "=" echo "===== Running '${WAKE_WORD}(${WAKE_WORD_TITLE})' generation, augmentation and training =====" -"${PROGDIR}/cudainfo" +"${CLIDIR}/cudainfo" echo START_TS=$EPOCHSECONDS @@ -75,17 +76,13 @@ export TF_CUDNN_WORKSPACE_LIMIT_IN_MB=512 export GLOG_minloglevel=2 export GRPC_VERBOSITY=ERROR - -"${PROGDIR}/wake_word_sample_generator" \ +"${CLIDIR}/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" @@ -96,7 +93,7 @@ AUGMENTED_DIR="${DATA_DIR}/work/wake_word_samples_augmented" 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 ; } + "${CLIDIR}/wake_word_sample_augmenter" --data-dir="${DATA_DIR}" || { rm -rf "${AUGMENTED_DIR}" ; exit 1 ; } else echo "Augmentation not required" echo @@ -104,22 +101,30 @@ 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}" +"${CLIDIR}/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" || : + rm -rf \ + "${DATA_DIR}/work/trained_models" \ + "${DATA_DIR}/work/wake_word_samples" \ + "${DATA_DIR}/work/wake_word_samples_augmented" \ + "${DATA_DIR}/work/personal_augmented_features" \ + "${DATA_DIR}/work/last_wake_word" || : fi + END_TS=$EPOCHSECONDS python -c $'print(f"{\'=\' * 80}")' printf "%44s\n\n" "Training Summary" -"${PROGDIR}/system_summary" +"${CLIDIR}/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}")' +python -c $'print(f"{\'=\' * 80}")' \ No newline at end of file From b57fcd9b05435df7c48d0103a74944e3ab5686bc Mon Sep 17 00:00:00 2001 From: Tater Totterson Date: Sat, 17 Jan 2026 01:26:40 -0600 Subject: [PATCH 03/11] Delete cli/.DS_Store --- cli/.DS_Store | Bin 8196 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 cli/.DS_Store diff --git a/cli/.DS_Store b/cli/.DS_Store deleted file mode 100644 index 81f16e00b173ed44a5b322427e2687074b8d876f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHLU2GIp6u#fIz$_DBC@mCZ*_9QjP{OuS5K-8++gP9l%C_`}pR&6%wiBi^WoLFv zZEEV1KLrzCG%D(YiBTU=qY&{$6D4S3d>}}a7cnM2@!}som>AEUJ6mWAnh-S*=O*`_ zx#ym9XU^I0o4aQiV`#{0HH=j<#$@VTstOG^XndXbX-)Dqd=cc&n8C7{A!jgS+L>wA zfg;F2kbxirK?Z^h1R1y;GC*fGZ}LsfeX$yhK?Z^h{4X=W-w#phTqb-uCZzZ3pusBv zNS2b?YjjO@faeqSWx|(ZLVB*Wr>Gv_yTUIqAlzv^%FRh8d^skhaAy$i4E~+rS19oA zPJU6(oFO4(Fa{Y2GB7;@oHjF=!(5i8KDmBBlNmG($BoA?LMkbpF>@9*W!8aIzde+4 z({A2rNayzPS-WkUnc{Uiv)9mvde!nq+j7%}rRO{cL(|D*f2Uz-_E3YJw=~D|O^JmN zwO)1c$Vgqyrs`~RqX{C5GHGbOHL zQ-d=5YEoHyVDLJ-NkN-Vp;3IaC`lEAzT^ijhh{6voN#AXchA24y{ejb^gX(nc8!9b zOuM?%tIl&M2ivl?wL7O5DE7{*ZRQ6oTA80so7p@W>-DCYG9DGy*_>^fyKTpC4cqGO za`mGwU*j81TI%X??3~-^ZPsPb{=%w^-(%MxcXsybIfrB@baL*Z$URkSYd1caXxi4= zc43}6zf4go7Iqd4$Hh#hS>@s- zcP$Nvh0Qge;x1kJjBzAyxWj5pmc>27%VQx~J+)t>AX{yX@2Ghz;qdZRvb>M$8*@61 zLal_?RFgxx-Jy{E$Bfs6LaNh#n6M#SmAVabNYy&bbVfIQ)dQPJwWG5jBKDOPTjfv} z{jviTj3ad6#-uE(pB>~kZb>`tu0h?Jc%;T=S?+QA(>Zb9p%2&9|!8G+m>hmsBn;3#Y;zvU;9;RKqG5#0B~d1SRBH5o58$YH=C+SU==l zc{z?H;zC_6#Kg!?z%Yxm2DY6YWP@y&jkD+2tL%Mtik)U>*_Z4hyUc!IKeC_L&+IDu z9i^C!axBFPtU?v;MJ?{bdTc-=TF{D}=s^}5bo9YQ9t8|x7?0rzJc*|;j%V>AUcw2y zg4gjj-od;05TD>voW(g@#J9MN?{Edb;wpZVLQPWq*YRtv|3stZIQN0%~G4x zBlSxCA_l2Q#{&~Fc^A?!iK%q+zY$6&Z%nty!_C{a?`XMg>GsWBPOt^#6$=+dSFB#Q z{{DyRCtxU0TLkL$1n@9Fi76iDJzPx1tH(rTS)_wVp&z|QMM2FXA=O!&yz&KZeYX;e zs56K*V!BePil{S*GGe+WvNobh#8fejM`{(dgm@V8eCr}>6&13WY*IEWYAG>NOt&b@ z6qSX!QB$^1_yU~Ue+HnBA zID`yg-Gl`jN07q^MhWR-cpOjT7>?r^JdYO$?Z84jESvmj zw=ydcPL;g=Z@T&S|6AEQ@Ch;yWZ(|T07{doWCK0y&0a0M){as?M4dNoHzuU#LW5V1 sll01Ql72bv*+$8f`y_lhCM0R7{pTM7{*k}i!TBGY|GZkhbI<=j0hFY)qW}N^ From c52f92d3c9224b450a2b14679057b3e9b33f48fd Mon Sep 17 00:00:00 2001 From: MasterPhooey Date: Sat, 17 Jan 2026 16:17:21 -0600 Subject: [PATCH 04/11] cli + web recorder ui --- cli/setup_audioset | 74 ++++++++---- cli/shell.functions | 4 +- cli/wake_word_sample_augmenter | 50 ++++---- cli/wake_word_sample_trainer | 173 ++++++++++++++++---------- dockerfile | 5 +- recorder_server.py | 214 +++++++++++++-------------------- static/index.html | 83 ++++++++----- train_wake_word | 2 +- 8 files changed, 332 insertions(+), 273 deletions(-) diff --git a/cli/setup_audioset b/cli/setup_audioset index d92552d..00c62a1 100755 --- a/cli/setup_audioset +++ b/cli/setup_audioset @@ -67,42 +67,66 @@ find_rev() { } converter() { - source ${DATA_DIR}/.venv/bin/activate + # shellcheck source=/dev/null + source "${DATA_DIR}/.venv/bin/activate" + python - "${AUDIO_DIR}" "${AUDIO16K_DIR}" <<-EOF -import os, sys, subprocess, scipy.io.wavfile, numpy as np +import os, sys from pathlib import Path -import soundfile as sf +from datetime import datetime, timezone + +import numpy as np +import scipy.io.wavfile import librosa -from tqdm import tqdm def write_wav(dst: Path, data: np.ndarray, sr: int): + dst.parent.mkdir(parents=True, exist_ok=True) 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)}") +total = len(flacs) +print(f" FLAC files: {total}") +print(" Converting AudioSet → 16k mono WAV") +print(" Sit tight — this step can take a while.") +print("") + audioset_bad = [] ok = 0 -for p in tqdm(flacs, desc=" AudioSet→WAV (resample 16k mono)"): +skipped = 0 + +START = datetime.now(timezone.utc).replace(microsecond=0) + +# Heartbeat interval (prints every N files) +HEARTBEAT_EVERY = 500 + +for idx, p in enumerate(flacs, start=1): try: - outfile = Path(audioset_out / (p.stem + ".wav")) + outfile = 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 + skipped += 1 + else: + 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 idx == 1 or (idx % HEARTBEAT_EVERY) == 0 or idx == total: + print(f" Progress: {idx}/{total} (ok={ok}, skipped={skipped}, failed={len(audioset_bad)})") + 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)") + +END = datetime.now(timezone.utc).replace(microsecond=0) +elapsed = END - START +print("") +print(f" AudioSet complete ({ok} ok, {skipped} skipped, {len(audioset_bad)} failed) Elapsed: {elapsed}") EOF } @@ -110,13 +134,15 @@ 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" +# Option B behavior: if we already have output WAVs, don't re-download/re-extract/re-convert +if [ "${actual_filecount}" -ne 0 ] ; then + echo " Existing ${AUDIO16K_DIR} present (${actual_filecount} wav); skipping extract/convert" 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" @@ -137,17 +163,16 @@ else 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 + + # Recompute counts and warn (but do not fail) expected_filecount=$(get_total_filecount filecounts) - actual_filecount=$(find ${AUDIO16K_DIR} -name "*.wav" 2>/dev/null | wc -l) || : + 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 + echo " WARNING: mismatch is expected if some AudioSet files are corrupted; continuing." >&2 fi fi @@ -171,5 +196,4 @@ if "${CLEANUP_INTERMEDIATE_FILES}" && [ -d "${AUDIO_DIR}" ] ; then fi echo " Audioset complete" -exit 0 - +exit 0 \ No newline at end of file diff --git a/cli/shell.functions b/cli/shell.functions index 07b3b02..f933d46 100644 --- a/cli/shell.functions +++ b/cli/shell.functions @@ -8,9 +8,9 @@ if [ ! -v DATA_DIR ] ; then [ -f .mww-data-dir ] && DATA_DIR="${PWD}" || DATA_DIR="/data" fi -DEFAULT_SAMPLES=20000 +DEFAULT_SAMPLES=50000 DEFAULT_BATCH_SIZE=100 -DEFAULT_TRAINING_STEPS=25000 +DEFAULT_TRAINING_STEPS=40000 [ -f "${DATA_DIR}/.defaults.env" ] && source "${DATA_DIR}/.defaults.env" || : diff --git a/cli/wake_word_sample_augmenter b/cli/wake_word_sample_augmenter index 3e2e5b8..115a028 100644 --- a/cli/wake_word_sample_augmenter +++ b/cli/wake_word_sample_augmenter @@ -71,17 +71,16 @@ if not files: 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" +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 @@ -98,6 +97,7 @@ gc.collect() import numpy as np import librosa +from tqdm import tqdm from mmap_ninja.ragged import RaggedMmap from microwakeword.audio.augmentation import Augmentation from microwakeword.audio.clips import Clips @@ -108,7 +108,7 @@ 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] +background_paths = [ args.fma_16k_dir, args.audioset_16k_dir ] clips = Clips( input_directory=args.input_dir, @@ -139,8 +139,6 @@ augmenter = Augmentation( 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. @@ -175,7 +173,7 @@ def audio_generator_from_wavs(self, split="train", repeat=1): # 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 config ---- split_cfg = { "training": {"name": "train", "repetition": 2, "slide_frames": 10}, "validation": {"name": "validation", "repetition": 1, "slide_frames": 10}, @@ -188,28 +186,34 @@ for split, cfg in split_cfg.items(): out_dir.mkdir(parents=True, exist_ok=True) print(f" Augmenting {split}") - print(f" Generating spectrograms") + print(" Generating spectrograms") spectros = SpectrogramGeneration( - clips=clips, # now backed by our WAV loader - augmenter=augmenter, # your existing augmenter + clips=clips, + augmenter=augmenter, slide_frames=cfg["slide_frames"], step_ms=10, ) - print(f" Generating files") + print(" Generating files") + print(" Sit tight — this step can take a while.") + + gen = spectros.spectrogram_generator( + split=cfg["name"], + repeat=cfg["repetition"], + ) + RaggedMmap.from_generator( out_dir=str(out_dir / "wakeword_mmap"), - sample_generator=spectros.spectrogram_generator( - split=cfg["name"], repeat=cfg["repetition"] - ), + sample_generator=gen, batch_size=100, - verbose=False, + verbose=False, # keep mmap quiet ) + 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." +msg = f"Augmented {max_samples} wake word samples." print(f"{msg:>50s} Elapsed time: {et!s}") -print(f"{'=' * 80}\n") +print(f"{'=' * 80}\n") \ No newline at end of file diff --git a/cli/wake_word_sample_trainer b/cli/wake_word_sample_trainer index 743b3fe..19007ec 100644 --- a/cli/wake_word_sample_trainer +++ b/cli/wake_word_sample_trainer @@ -129,88 +129,136 @@ 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//[ \`~\!\$&*\(\)\{\}\[\]\|\;\'\"<>.?\/]/_}" +wake_word_filename="${WAKE_WORD//[ \`~\!\$&*$begin:math:text$$end:math:text$\{\}$begin:math:display$$end:math:display$\|\;\'\"<>.?\/]/_}" 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" +TRAIN_LOG="${OUTPUT_DIR}/logs/training.log" -import sys, os, gc -import runpy -import yaml -print(" Loading Tensorflow") -import tensorflow as tf +# ------------------------------------------------------------------ +# Training args (same as before) +# ------------------------------------------------------------------ +TRAIN_ARGS=( + -m microwakeword.model_train_eval + --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 +) -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() +# ------------------------------------------------------------------ +# GPU failure markers that should trigger CPU fallback +# (OOM + known GPU runtime/copy/init failures) +# ------------------------------------------------------------------ +GPU_FALLBACK_MARKERS=( + "resourceexhaustederror" + "resource exhausted" + "oom" + "out of memory" + "cuda_error_out_of_memory" + "failed to allocate" + "cudnn" + "cublas" + "internalerror: cuda" + "failed call to cuinit" + "dst tensor is not initialized" + "failed copying input tensor" + "_eagerconst" +) -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 +run_attempt() { + local label="$1" + shift + echo + echo "================================================================================" + echo "===== ${label} =====" + echo "================================================================================" + echo "→ ${PYTHON_BIN:-python} ${TRAIN_ARGS[*]}" + echo + + # stream everything except validation minibatch spam + "${PYTHON_BIN:-python}" "${TRAIN_ARGS[@]}" 2>&1 \ + | tr '\r' '\n' \ + | stdbuf -i0 -o0 sed -r -e "/^Validation Batch/d" \ + | tee "${TRAIN_LOG}" \ + | sed -r -e "/^Validation Batch/d" -e "s/^INFO:absl:/ /g" + + return ${PIPESTATUS[0]} +} + +# ---- Common TF env (mirrors your notebook) ---- +export TF_CPP_MIN_LOG_LEVEL="${TF_CPP_MIN_LOG_LEVEL:-2}" +export TF_XLA_FLAGS="${TF_XLA_FLAGS:---tf_xla_auto_jit=0}" +export NVIDIA_TF32_OVERRIDE="${NVIDIA_TF32_OVERRIDE:-1}" +export TF_FORCE_GPU_ALLOW_GROWTH="${TF_FORCE_GPU_ALLOW_GROWTH:-true}" +export TF_GPU_ALLOCATOR="${TF_GPU_ALLOCATOR:-cuda_malloc_async}" + +# Attempt 1: GPU +if run_attempt "Attempt 1/2: GPU training (allow_growth + cuda_malloc_async)" ; then + echo "✅ Training complete (GPU path)." +else + echo "⚠️ GPU attempt failed. Checking whether this looks like a GPU/OOM/runtime failure…" + + # Check log for GPU/OOM/runtime markers + log_lc="$(tr '[:upper:]' '[:lower:]' < "${TRAIN_LOG}" || true)" + looks_like_gpu_fail="false" + for m in "${GPU_FALLBACK_MARKERS[@]}"; do + if echo "${log_lc}" | grep -qF "${m}"; then + looks_like_gpu_fail="true" + break + fi + done + + if [ "${looks_like_gpu_fail}" = "true" ]; then + echo "↪️ Detected GPU/OOM/runtime failure markers. Falling back to CPU." + + # Attempt 2: CPU (hide GPU completely) + export CUDA_VISIBLE_DEVICES="" + unset TF_GPU_ALLOCATOR + if run_attempt "Attempt 2/2: CPU fallback (CUDA_VISIBLE_DEVICES='')" ; then + echo "✅ Training complete (CPU fallback)." + else + echo "❌ Training failed on BOTH GPU and CPU. See: ${TRAIN_LOG}" >&2 + exit 1 + fi + else + echo "❌ Training failed (does not look GPU/OOM/runtime). See: ${TRAIN_LOG}" >&2 + exit 1 + fi +fi 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" + echo "Output model not found! Training didn't complete successfully. See ${TRAIN_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/" +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" +echo " Full log: ${TRAIN_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}" { @@ -237,5 +285,4 @@ echo "Metadata: ${json_path}" echo END_TS=$EPOCHSECONDS print_elapsed_time "${START_TS}" "${END_TS}" "Training completed." -echo - +echo \ No newline at end of file diff --git a/dockerfile b/dockerfile index eb4694c..5778ead 100644 --- a/dockerfile +++ b/dockerfile @@ -27,9 +27,12 @@ COPY --chown=root:root --chmod=0755 \ requirements.txt \ /root/mww-scripts/ -# CLI folder (THIS IS THE IMPORTANT CHANGE) +# CLI folder COPY --chown=root:root cli/ /root/mww-scripts/cli/ +# Make all CLI scripts executable (avoids "Permission denied") +RUN chmod -R a+x /root/mww-scripts/cli + # Static UI for recorder COPY --chown=root:root --chmod=0644 static/index.html /root/mww-scripts/static/index.html diff --git a/recorder_server.py b/recorder_server.py index e469104..cdfebe5 100644 --- a/recorder_server.py +++ b/recorder_server.py @@ -4,9 +4,9 @@ import re import subprocess import threading from pathlib import Path -from typing import Dict, Any, List, Optional, Tuple +from typing import Dict, Any, List, Optional -from fastapi import FastAPI, UploadFile, File, Form, Query +from fastapi import FastAPI, UploadFile, File, Form from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles @@ -24,14 +24,9 @@ PERSONAL_DIR = Path(os.environ.get("PERSONAL_DIR", str(DATA_DIR / "personal_samp # CLI folder inside repo CLI_DIR = Path(os.environ.get("CLI_DIR", str(ROOT_DIR / "cli"))).resolve() -# If you want cleanup defaults for auto dataset setup, set these env vars: -# REC_DATASET_CLEANUP_ARCHIVES=true/false -# REC_DATASET_CLEANUP_INTERMEDIATE_FILES=true/false DATASET_CLEANUP_ARCHIVES = os.environ.get("REC_DATASET_CLEANUP_ARCHIVES", "false").lower() in ("1", "true", "yes", "y") DATASET_CLEANUP_INTERMEDIATE = os.environ.get("REC_DATASET_CLEANUP_INTERMEDIATE_FILES", "false").lower() in ("1", "true", "yes", "y") -# We want "Start training" to trigger your CLI entrypoint, using the existing venv -# (train_wake_word should be in /data/.venv/bin via setup_python_venv) TRAIN_CMD = os.environ.get( "TRAIN_CMD", f"source '{DATA_DIR}/.venv/bin/activate' && train_wake_word --data-dir '{DATA_DIR}'" @@ -40,14 +35,13 @@ TRAIN_CMD = os.environ.get( TAKES_PER_SPEAKER_DEFAULT = int(os.environ.get("REC_TAKES_PER_SPEAKER", "10")) SPEAKERS_TOTAL_DEFAULT = int(os.environ.get("REC_SPEAKERS_TOTAL", "1")) -# How many lines to show in WebUI (tail) +# Tail lines shown to UI TRAIN_LOG_TAIL_LINES = int(os.environ.get("REC_TRAIN_LOG_TAIL_LINES", "400")) -# If you prefer bytes-based tailing (fast), keep this non-zero. +# Safety cap for reads (bytes) to avoid giant file reads TRAIN_LOG_MAX_BYTES = int(os.environ.get("REC_TRAIN_LOG_MAX_BYTES", str(512 * 1024))) # 512KB app = FastAPI(title="microWakeWord Personal Recorder") -# Serve static UI STATIC_DIR.mkdir(parents=True, exist_ok=True) app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") @@ -60,7 +54,6 @@ def safe_name(raw: str) -> str: return s or "wakeword" -# -------------------- In-memory session state -------------------- STATE: Dict[str, Any] = { "raw_phrase": None, "safe_word": None, @@ -74,12 +67,13 @@ STATE: Dict[str, Any] = { "training": { "running": False, "exit_code": None, - "log_lines": [], # legacy in-memory tail (still maintained) - "log_path": None, # path to recorder_training.log + "log_lines": [], # legacy in-memory tail (kept, but not relied on) + "log_path": None, # path to recorder_training.log "safe_word": None, - # NEW: byte offset for efficient log tailing - "log_offset": 0, + # NEW: prevent UI duplication when UI appends: + "last_sent_tail": [], # last tail snapshot (list of lines) + "last_log_size": 0, # detect truncation }, } @@ -95,6 +89,26 @@ def _reset_personal_samples_dir(): pass +def _clear_training_log(): + """ + Truncate recorder_training.log for a fresh session. + """ + log_path = DATA_DIR / "recorder_training.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + + with open(log_path, "w", encoding="utf-8") as lf: + lf.write("================================================================================\n") + lf.write("===== New recorder session started =====\n") + lf.write("================================================================================\n") + lf.flush() + + with STATE_LOCK: + STATE["training"]["log_path"] = str(log_path) + STATE["training"]["log_lines"] = [] + STATE["training"]["last_sent_tail"] = [] + STATE["training"]["last_log_size"] = 0 + + def _append_train_log(line: str): line = (line or "").rstrip("\n") with STATE_LOCK: @@ -105,7 +119,6 @@ def _append_train_log(line: str): def _title_from_phrase(raw_phrase: str) -> str: - # Keep it human-friendly for the optional argument s = re.sub(r"[^a-zA-Z0-9 ]+", " ", raw_phrase or "").strip() s = re.sub(r"\s+", " ", s) return s.title() if s else "" @@ -118,12 +131,6 @@ def _run_streamed( header: Optional[str] = None, env: Optional[Dict[str, str]] = None, ) -> int: - """ - Run a command streaming stdout/stderr to both: - - recorder_training.log (disk) - - STATE["training"]["log_lines"] (UI) [best-effort] - Returns process exit code. - """ if header: _append_train_log(header) @@ -156,9 +163,6 @@ def _run_streamed( def _ensure_training_venv(log_path: Path) -> None: - """ - Ensure /data/.venv exists by running cli/setup_python_venv if needed. - """ activate = DATA_DIR / ".venv" / "bin" / "activate" if activate.exists(): _append_train_log("✅ Training venv found (skipping setup_python_venv)") @@ -183,10 +187,6 @@ def _ensure_training_venv(log_path: Path) -> None: def _ensure_training_datasets(log_path: Path) -> None: - """ - Always run setup_training_datasets before training. - The underlying scripts should skip work when already done. - """ setup = CLI_DIR / "setup_training_datasets" if not setup.exists(): raise RuntimeError(f"Missing setup_training_datasets at: {setup}") @@ -217,67 +217,45 @@ def _ensure_training_datasets(log_path: Path) -> None: raise RuntimeError(f"setup_training_datasets failed (exit_code={rc})") -def _read_log_tail_by_bytes(log_path: Path, max_bytes: int) -> str: +def _read_tail_lines(log_path: Path, max_lines: int) -> List[str]: """ - Read up to the last max_bytes from a file (UTF-8 best effort). + Read the last N lines, bounded by TRAIN_LOG_MAX_BYTES. + Returns list of lines (no trailing newlines). """ if not log_path.exists(): - return "" + return [] try: size = log_path.stat().st_size - start = max(0, size - max_bytes) + start = max(0, size - TRAIN_LOG_MAX_BYTES) with open(log_path, "rb") as f: f.seek(start) data = f.read() - # If we started in the middle of a line, it's ok; UI will show partial. - return data.decode("utf-8", errors="replace") - except Exception: - return "" - - -def _read_log_tail_by_lines(log_path: Path, max_lines: int) -> str: - """ - Read last N lines of a file (simple, may be slower on huge files). - """ - if not log_path.exists(): - return "" - try: - # Read by bytes limited first, then line-tail - raw = _read_log_tail_by_bytes(log_path, TRAIN_LOG_MAX_BYTES) - if not raw: - return "" - lines = raw.splitlines() - if len(lines) <= max_lines: - return "\n".join(lines) - return "\n".join(lines[-max_lines:]) - except Exception: - return "" - - -def _read_log_since_offset(log_path: Path, offset: int, max_bytes: int = 256 * 1024) -> Tuple[str, int]: - """ - Read log file incrementally starting from `offset`. - Returns (new_text, new_offset). Caps bytes read per call. - """ - if not log_path.exists(): - return ("", offset) - - try: - size = log_path.stat().st_size - # If file rotated/truncated, reset offset - if offset > size: - offset = 0 - - with open(log_path, "rb") as f: - f.seek(offset) - data = f.read(max_bytes) - - new_offset = offset + len(data) text = data.decode("utf-8", errors="replace") - return (text, new_offset) + lines = text.splitlines() + if len(lines) <= max_lines: + return lines + return lines[-max_lines:] except Exception: - return ("", offset) + return [] + + +def _compute_new_lines(prev_tail: List[str], new_tail: List[str]) -> List[str]: + """ + Given previous and current tail snapshots, return only the newly-added lines. + Works even if the tail window shifts. + """ + if not prev_tail: + return new_tail + + # Try to find the largest suffix of prev_tail that matches a prefix of new_tail + max_k = min(len(prev_tail), len(new_tail)) + for k in range(max_k, 0, -1): + if prev_tail[-k:] == new_tail[:k]: + return new_tail[k:] + + # If no overlap, just return full new_tail (probably truncation or big jump) + return new_tail def _run_training_background(safe_word: str, allow_no_personal: bool): @@ -291,16 +269,15 @@ def _run_training_background(safe_word: str, allow_no_personal: bool): STATE["training"]["exit_code"] = None STATE["training"]["log_lines"] = [] STATE["training"]["safe_word"] = safe_word + STATE["training"]["last_sent_tail"] = [] + STATE["training"]["last_log_size"] = 0 log_path = Path(str(DATA_DIR / "recorder_training.log")) STATE["training"]["log_path"] = str(log_path) - STATE["training"]["log_offset"] = 0 - # fresh header at the start of a run _append_train_log("================================================================================") _append_train_log("===== Recorder Training Run =====") _append_train_log("================================================================================") - # Ensure the log exists and starts cleanly with a header separator for this run try: with open(log_path, "a", encoding="utf-8") as lf: lf.write("\n" + ("=" * 80) + "\n") @@ -311,13 +288,9 @@ def _run_training_background(safe_word: str, allow_no_personal: bool): pass try: - # 1) Ensure venv (auto-installs) _ensure_training_venv(log_path) - - # 2) Ensure datasets (auto-installs / skips if already present) _ensure_training_datasets(log_path) - # 3) Run training if wake_word_title: cmd_str = f"{TRAIN_CMD} '{safe_word}' '{wake_word_title}'" else: @@ -361,7 +334,6 @@ def _run_training_background(safe_word: str, allow_no_personal: bool): STATE["training"]["running"] = False -# -------------------- Routes -------------------- @app.get("/", response_class=HTMLResponse) def index(): html_path = STATIC_DIR / "index.html" @@ -394,10 +366,12 @@ def start_session(payload: Dict[str, Any]): STATE["takes_per_speaker"] = takes_per_speaker STATE["takes_received"] = 0 STATE["takes"] = [] - # do not interrupt training if running _reset_personal_samples_dir() + # Always wipe log on start_session (even if same wakeword) + _clear_training_log() + return { "ok": True, "raw_phrase": raw, @@ -523,64 +497,42 @@ def train_now(payload: Dict[str, Any] = None): @app.get("/api/train_status") -def train_status( - offset: int = Query(0, ge=0), - max_bytes: int = Query(65536, ge=1024, le=262144), - last_size: int = Query(0, ge=0), - last_mtime: float = Query(0.0, ge=0.0), -): +def train_status(): """ - Stream training output from the log file on disk. - - Robust to log overwrite/truncation: - - UI passes offset + last_size + last_mtime - - If file shrinks or mtime goes backwards/changes weirdly, reset offset to 0 + Return only NEW lines since last poll (prevents UI duplication spam even if UI appends). """ with STATE_LOCK: tr = dict(STATE["training"]) log_path_str = tr.get("log_path") + prev_tail = list(STATE["training"].get("last_sent_tail") or []) + prev_size = int(STATE["training"].get("last_log_size") or 0) - log_text = "" - next_offset = offset - log_size = 0 - log_mtime = 0.0 + new_lines: List[str] = [] + full_tail: List[str] = [] + size_now = 0 if log_path_str: p = Path(log_path_str) if p.exists(): try: - st = p.stat() - log_size = int(st.st_size) - log_mtime = float(st.st_mtime) + size_now = int(p.stat().st_size) + except Exception: + size_now = 0 - # Detect overwrite/truncate/reset: - # - file shrank - # - file mtime moved "backwards" (rare) or changed while size reset - # If anything indicates a reset, restart from beginning. - if (log_size < last_size) or (last_mtime and log_mtime < last_mtime): - offset = 0 + # If file was truncated/cleared, reset history + if size_now < prev_size: + prev_tail = [] - # Clamp offset to current file size - if offset > log_size: - offset = log_size + full_tail = _read_tail_lines(p, TRAIN_LOG_TAIL_LINES) + new_lines = _compute_new_lines(prev_tail, full_tail) - # Read incrementally from the file - with p.open("rb") as f: - f.seek(offset) - chunk = f.read(max_bytes) - - log_text = chunk.decode("utf-8", errors="replace") - next_offset = offset + len(chunk) - - except Exception as e: - log_text = f"\n[log read error: {e!r}]\n" - next_offset = offset - - tr["log_text"] = log_text - tr["next_offset"] = next_offset - tr["log_size"] = log_size - tr["log_mtime"] = log_mtime + # Save snapshot for next poll + with STATE_LOCK: + STATE["training"]["last_sent_tail"] = full_tail + STATE["training"]["last_log_size"] = size_now + tr["log_text"] = "\n".join(new_lines) # ONLY new lines + tr["log_tail_preview"] = "\n".join(full_tail) # optional: handy for debugging return {"ok": True, "training": tr} diff --git a/static/index.html b/static/index.html index cb77038..ef9e449 100644 --- a/static/index.html +++ b/static/index.html @@ -250,6 +250,10 @@ } async function api(path, opts) { + opts = opts || {}; + // Always try to avoid cache for polling endpoints + if (!opts.cache) opts.cache = "no-store"; + const res = await fetch(path, opts); const ct = res.headers.get("content-type") || ""; const data = ct.includes("application/json") ? await res.json() : await res.text(); @@ -268,10 +272,9 @@ return (el.scrollHeight - el.scrollTop - el.clientHeight) <= px; } - function appendLogChunkAutoScroll(el, chunk) { - if (!chunk) return; + function setLogTextAutoScroll(el, text) { const stick = isNearBottom(el); - el.textContent += chunk; + el.textContent = text || ""; if (stick) el.scrollTop = el.scrollHeight; } // -------------------------------------------------------------------------- @@ -296,12 +299,21 @@ let currentTake = 0; let takesPerSpeaker = 10; - // --- incremental log streaming state --- - // Polls /api/train_status?offset= and appends training.log_text (reads /data/recorder_training.log) - let trainOffset = 0; + // --- training poll (append mode; scrollback works) --- let trainingPollRunning = false; let trainingPollAbort = false; + let logBuffer = ""; // full text we’ve shown in the browser + let lastChunk = ""; // last chunk we received (for de-dupe) + let seenAnyOutput = false; + + function appendLogAutoScroll(el, chunk) { + if (!chunk) return; + const stick = isNearBottom(el); + el.textContent += chunk; + if (stick) el.scrollTop = el.scrollHeight; + } + function startThreshold() { return parseFloat($("startThresh").value); } function silenceStopMs() { return parseInt($("silenceMs").value, 10); } function minTakeMs() { return parseInt($("minTakeMs").value, 10); } @@ -585,9 +597,11 @@ setPill($("status"), auto ? "Auto-starting training…" : "Preparing training environment…", "warn"); - // reset streaming log state (we show recorder_training.log from the start of this run) - trainOffset = 0; + // Reset log state for a fresh run trainingPollAbort = false; + logBuffer = ""; + lastChunk = ""; + seenAnyOutput = false; const logEl = $("trainLog"); logEl.textContent = "(preparing…)\n"; @@ -603,7 +617,7 @@ // Only start polling AFTER training was successfully kicked off if (!trainingPollRunning) { trainingPollRunning = true; - pollTrainingIncremental(); + pollTrainingTail(); } setPill($("status"), "Training running…", "warn"); @@ -636,9 +650,7 @@ } }); - // Polls /api/train_status?offset= - // Expects JSON: { ok: true, training: { running, exit_code, log_text, next_offset } } - async function pollTrainingIncremental() { + async function pollTrainingTail() { const logEl = $("trainLog"); for (;;) { @@ -648,22 +660,37 @@ } try { - const st = await api(`/api/train_status?offset=${trainOffset}`, { method:"GET" }); + const st = await api(`/api/train_status?ts=${Date.now()}`, { method:"GET", cache:"no-store" }); const tr = st.training || {}; - const chunk = tr.log_text || ""; - const next = (typeof tr.next_offset === "number") ? tr.next_offset : trainOffset; + // NOTE: this assumes /api/train_status returns NEW output chunks (not full tail snapshots) + const chunkRaw = tr.log_text || ""; + const chunk = chunkRaw; // keep exact newlines from server - // If we got real output, replace the "(preparing…)" placeholder - if (chunk && logEl.textContent.startsWith("(preparing…)")) { - logEl.textContent = ""; + if (chunk) { + // wipe placeholder once + if (!seenAnyOutput) { + logEl.textContent = ""; + logBuffer = ""; + lastChunk = ""; + seenAnyOutput = true; + } + + // simple de-dupe: if server repeats the same chunk, skip it + if (chunk !== lastChunk) { + lastChunk = chunk; + logBuffer += chunk; + appendLogAutoScroll(logEl, chunk); + } + } else { + // before first output, show waiting message but do NOT overwrite later scrollback + if (!seenAnyOutput) { + if (!logEl.textContent || logEl.textContent.includes("(no training") || logEl.textContent.startsWith("(preparing…")) { + logEl.textContent = "Waiting for training output…\n"; + } + } } - if (chunk) appendLogChunkAutoScroll(logEl, chunk); - - trainOffset = next; - - // Stop polling only when training has ended and exit_code is set const exitCodeIsSet = (tr.exit_code !== null && tr.exit_code !== undefined); if (!tr.running && exitCodeIsSet) { @@ -681,7 +708,7 @@ // ignore transient polling errors } - await new Promise(r => setTimeout(r, 1500)); + await new Promise(r => setTimeout(r, 1000)); } } @@ -717,11 +744,12 @@ $("takesList").textContent = ""; $("trainLog").textContent = "(no training started)"; - trainOffset = 0; - - // If a previous training poll loop is running, ask it to stop + // Stop any previous poll loop cleanly trainingPollAbort = true; trainingPollRunning = false; + logBuffer = ""; + lastChunk = ""; + seenAnyOutput = false; refreshUI(); @@ -741,6 +769,7 @@ setPill($("sessionPill"), "Session failed", "err"); alert("Start session failed: " + e.message); } finally { + // allow a new poll loop to start later trainingPollAbort = false; } }); diff --git a/train_wake_word b/train_wake_word index 4faed61..73a9110 100644 --- a/train_wake_word +++ b/train_wake_word @@ -93,7 +93,7 @@ AUGMENTED_DIR="${DATA_DIR}/work/wake_word_samples_augmented" if ${AUGMENT} ; then rm -rf "${AUGMENTED_DIR}" || : mkdir -p "${AUGMENTED_DIR}" || : - "${CLIDIR}/wake_word_sample_augmenter" --data-dir="${DATA_DIR}" || { rm -rf "${AUGMENTED_DIR}" ; exit 1 ; } + python -u "${CLIDIR}/wake_word_sample_augmenter" --data-dir="${DATA_DIR}" || { rm -rf "${AUGMENTED_DIR}" ; exit 1 ; } else echo "Augmentation not required" echo From 38f9821d3092e37f6a46cba673aea3e6c1b1b323 Mon Sep 17 00:00:00 2001 From: Tater Totterson Date: Sat, 17 Jan 2026 16:25:06 -0600 Subject: [PATCH 05/11] Delete .DS_Store --- .DS_Store | Bin 10244 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 545370aade608462522b6c10a2fd51bda2b12294..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10244 zcmeHMU2GIp6uxI#=nNw;EiHdC?8*vMC}CSEh$w8k+t@-2lx^t`lx22jY$r@-mYvyM zYEx666akHi(Ws~~CPsZgjY7o4Xrcs7j1L5f@*>8>Ctm!+2NUDDb7$M`w)mo$hRjXw zJ@=k_&Yd}DzB6a;9YP2+XVrQ_f`kwrE2YvM*vwGqJgw6T=W(i#0_qbxoiwZ@j?O33 z)1-Zd?*ZQfz6X2{_#W^*FdIC8Ihz(Fm?~1H3f>k2m^$)U{x_2)lDPj;knV-6UH+0(xhpzxvRz7EL1s1VlAU%SaabN^-xhNuq32q zqoa-WTkC=in;ORIf}{2ITf%k0#;xJ8F@diMZQQmmal{xltrN@w@ZJJseu+gVC(Tjq zl##^SqY|El#$bIsWera~n<&iKxhOlkD2pf4%=W9E?YuBg6z9u5y?y-$4kjciYis*7 zLvi$+7E>I}PDl%FbVXarGWTY*9J-(och;dW}74s%-DqGB#2{IJ00$;O^QD4Oq$fFQ|y#xs=7JYo!3=os2{y=I{L%9mJx(XsOvJ7VeN9& z(q+rTlHN^dr)AI<8{h!o;^b`5z|NZ_Tj2URq(!&2#rsyidgtJd=T0m_eN zG;A4Ll-Jjxg3@K9$=+>->jgowyN)14n5x*gnHMCr+fb64;j!-DhOAvZIcBlPtl7Z} zz4%Or(HNWYY6Sov?<@tVlNXck>@(9p^Mmx{Dj}iG)rA5^p zE8}`&_bOTLqf0fZs-~jR%a<;zlH+nWFI_?%NjGykh6FwX78!h~0Lf5f9qWcZ)egHo z*TlFB_EKpZ8Nun9Ip6xyK!c0)g;Km`p3!GJ8}AP*z(C_D~Nz;QSM&%lfD z5}bxt;0<^O-i7zzBlrwHhYN5KuE6(j4Ss;@@H^aqKRAJ_;#P1nw~|}S)pF~&_1t!D z2iMAVa{XL_8)7zag{T~uu*vOkBS>kc zLMOzb3kDzoharh5H^2l7jzR`TVGJ=o4v)c;a1u_zQ}8^zfJi?LFC)(1gty>rcpuKf zIrtFHyNLe^zJaUoV+n>kiZKkoO=5W1ip9KxZocB#kDq?+)WxQ$v+6(0riX5pf^zYU zX;|hUu0-Q#F0qM26s$T)-7cxQ$f!bso@VU~X}UJpR(Q0 zT0dn^`z)R9DZ2>CQxkXVqRvlVXNkK+W-@)>OlF28UbIF@D#vU-D@z%>s~|o9Z@KyZ z|5{@mMpiRoK6mE=$kagIEt^rG?6kGwP~fQ}iPM2lP0;7AuwY k&O6+G#N&-K>Z+rRtB&-4`Wf(V4%+ Date: Sat, 17 Jan 2026 18:23:46 -0600 Subject: [PATCH 06/11] model name --- recorder_server.py | 119 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/recorder_server.py b/recorder_server.py index cdfebe5..b0f82f0 100644 --- a/recorder_server.py +++ b/recorder_server.py @@ -1,10 +1,13 @@ # recorder_server.py import os import re +import json +import shutil import subprocess import threading +from datetime import datetime from pathlib import Path -from typing import Dict, Any, List, Optional +from typing import Dict, Any, List, Optional, Tuple from fastapi import FastAPI, UploadFile, File, Form from fastapi.responses import HTMLResponse, JSONResponse @@ -71,7 +74,7 @@ STATE: Dict[str, Any] = { "log_path": None, # path to recorder_training.log "safe_word": None, - # NEW: prevent UI duplication when UI appends: + # prevent UI duplication when UI appends: "last_sent_tail": [], # last tail snapshot (list of lines) "last_log_size": 0, # detect truncation }, @@ -258,6 +261,114 @@ def _compute_new_lines(prev_tail: List[str], new_tail: List[str]) -> List[str]: return new_tail +# -------------------- output artifact normalization -------------------- + +def _find_latest_output_pair(output_dir: Path) -> Tuple[Optional[Path], Optional[Path]]: + """ + Find the most recently modified .tflite and its matching .json (same basename) + in output_dir. Falls back to newest .json if an exact match doesn't exist. + Returns (tflite_path, json_path) or (None, None). + """ + if not output_dir.exists(): + return (None, None) + + tflites = sorted(output_dir.glob("*.tflite"), key=lambda p: p.stat().st_mtime, reverse=True) + if not tflites: + return (None, None) + + tfl = tflites[0] + js = tfl.with_suffix(".json") + if js.exists(): + return (tfl, js) + + jsons = sorted(output_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True) + return (tfl, jsons[0] if jsons else None) + + +def _deep_replace_strings(obj: Any, old: str, new: str) -> Any: + """ + Recursively replace occurrences of old in any string values with new. + """ + if isinstance(obj, str): + return obj.replace(old, new) + if isinstance(obj, list): + return [_deep_replace_strings(x, old, new) for x in obj] + if isinstance(obj, dict): + return {k: _deep_replace_strings(v, old, new) for k, v in obj.items()} + return obj + + +def _normalize_output_artifacts(safe_word: str, log_path: Path) -> None: + """ + Rename output artifacts to .tflite / .json + and patch the JSON so it references the renamed tflite. + + Handles weird trainer names like ____r_.tflite by normalizing post-run. + """ + out_dir = DATA_DIR / "output" + tfl, js = _find_latest_output_pair(out_dir) + + if not tfl: + _append_train_log(f"⚠️ No .tflite found in {out_dir}") + return + + new_tfl = out_dir / f"{safe_word}.tflite" + new_js = out_dir / f"{safe_word}.json" + old_tfl_name = tfl.name + + # Already normalized + if tfl.name == new_tfl.name and (js and js.name == new_js.name): + _append_train_log(f"✅ Output names already normalized: {new_tfl.name}") + return + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + + def backup_if_exists(p: Path, suffix: str) -> None: + if p.exists(): + bk = out_dir / f"{safe_word}.{ts}.bak{suffix}" + shutil.move(str(p), str(bk)) + _append_train_log(f"↪️ Backed up existing {p.name} → {bk.name}") + + # Avoid clobbering existing target files (back them up) + if new_tfl.exists() and new_tfl.resolve() != tfl.resolve(): + backup_if_exists(new_tfl, ".tflite") + if new_js.exists() and (not js or new_js.resolve() != js.resolve()): + backup_if_exists(new_js, ".json") + + # Rename tflite + if tfl.resolve() != new_tfl.resolve(): + new_tfl.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(tfl), str(new_tfl)) + _append_train_log(f"✅ Renamed model: {old_tfl_name} → {new_tfl.name}") + + # Rename + patch json if present + if js and js.exists(): + # Read JSON before move (safer if we want the old name) + try: + data = json.loads(js.read_text(encoding="utf-8")) + except Exception: + data = None + + if js.resolve() != new_js.resolve(): + shutil.move(str(js), str(new_js)) + _append_train_log(f"✅ Renamed metadata: {js.name} → {new_js.name}") + + if data is not None: + patched = _deep_replace_strings(data, old_tfl_name, new_tfl.name) + + # Patch common keys if present + for key in ("model", "model_file", "model_filename", "tflite", "tflite_file", "tflite_filename"): + if isinstance(patched, dict) and key in patched and isinstance(patched[key], str): + patched[key] = new_tfl.name + + new_js.write_text(json.dumps(patched, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + _append_train_log(f"✅ Patched JSON to reference: {new_tfl.name}") + else: + _append_train_log("⚠️ No .json found to patch (model renamed only)") + + +# -------------------- training worker -------------------- + def _run_training_background(safe_word: str, allow_no_personal: bool): with STATE_LOCK: raw_phrase = STATE.get("raw_phrase") or "" @@ -324,6 +435,10 @@ def _run_training_background(safe_word: str, allow_no_personal: bool): with STATE_LOCK: STATE["training"]["exit_code"] = rc + # Normalize output artifact names on success + if rc == 0: + _normalize_output_artifacts(safe_word, log_path) + except Exception as e: _append_train_log(f"✗ Training crashed: {e!r}") with STATE_LOCK: From 6392548333a139715d8cd199737213f5fc64d69c Mon Sep 17 00:00:00 2001 From: MasterPhooey Date: Sat, 17 Jan 2026 18:26:42 -0600 Subject: [PATCH 07/11] model name --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 263d9e5..9223777 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ personal_samples/* - +.DS_Store \ No newline at end of file From 2b9aa95903323a4c67a984846eddb7eca2e6cb5a Mon Sep 17 00:00:00 2001 From: MasterPhooey Date: Sat, 17 Jan 2026 20:24:43 -0600 Subject: [PATCH 08/11] personal samples --- cli/wake_word_sample_augmenter | 224 ++++++++++++++++++--------------- cli/wake_word_sample_trainer | 75 +++++++---- 2 files changed, 175 insertions(+), 124 deletions(-) diff --git a/cli/wake_word_sample_augmenter b/cli/wake_word_sample_augmenter index 115a028..99535fb 100644 --- a/cli/wake_word_sample_augmenter +++ b/cli/wake_word_sample_augmenter @@ -1,7 +1,7 @@ #!/usr/bin/env python import sys, os, gc, glob, random -import types, shutil, json +import types from datetime import datetime, timezone from pathlib import Path from argparse import ArgumentParser as ArgParser, ArgumentError @@ -9,12 +9,20 @@ 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) +parser.add_argument("--data-dir", type=str, help=f"Data directory. Default: {default_data_dir}", required=False, default=default_data_dir) + +# Wake word (TTS/generated) inputs/outputs +parser.add_argument("--input-dir", type=str, help="Wake word input dir. Default: /work/wake_word_samples", required=False) +parser.add_argument("--output-dir", type=str, help="Wake word output dir. Default: _augmented", required=False) + +# Personal inputs/outputs (NEW) +parser.add_argument("--personal-dir", type=str, help="Personal WAV dir. Default: /personal_samples", required=False) +parser.add_argument("--personal-output-dir", type=str, help="Personal features output dir. Default: /work/personal_augmented_features", required=False) + +# Dataset dirs +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() @@ -23,10 +31,11 @@ except ArgumentError: sys.exit(1) args.data_dir = os.path.realpath(args.data_dir) -work_dir = args.data_dir + "/work" +work_dir = os.path.join(args.data_dir, "work") +# Wake word defaults if not args.input_dir: - args.input_dir = work_dir + "/wake_word_samples" + args.input_dir = os.path.join(work_dir, "wake_word_samples") else: args.input_dir = os.path.realpath(args.input_dir) @@ -35,24 +44,33 @@ if not args.output_dir: else: args.output_dir = os.path.realpath(args.output_dir) +# Personal defaults (NEW) +if not args.personal_dir: + args.personal_dir = os.path.join(args.data_dir, "personal_samples") +else: + args.personal_dir = os.path.realpath(args.personal_dir) + +if not args.personal_output_dir: + args.personal_output_dir = os.path.join(work_dir, "personal_augmented_features") +else: + args.personal_output_dir = os.path.realpath(args.personal_output_dir) + +# Dataset defaults if not args.mit_rirs_16k_dir: - args.mit_rirs_16k_dir = args.data_dir + "/training_datasets/mit_rirs_16k" + args.mit_rirs_16k_dir = os.path.join(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" + args.fma_16k_dir = os.path.join(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" + args.audioset_16k_dir = os.path.join(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): @@ -60,17 +78,12 @@ def validate_directories(paths): 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): +required = [work_dir, args.input_dir, args.mit_rirs_16k_dir, args.fma_16k_dir, args.audioset_16k_dir] +if not validate_directories(required): 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 =====") +# -------------------- TF + libs -------------------- print(" Initializing libraries") os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" @@ -86,7 +99,6 @@ 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) @@ -97,27 +109,15 @@ gc.collect() import numpy as np import librosa -from tqdm import tqdm 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, -) +impulse_paths = [args.mit_rirs_16k_dir] +background_paths = [args.fma_16k_dir, args.audioset_16k_dir] augmenter = Augmentation( augmentation_duration_s=3.2, @@ -139,81 +139,107 @@ augmenter = Augmentation( max_jitter_s=0.3, ) -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 ---- 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}") +def bind_wav_generator(clips_obj: Clips, wav_dir: str): + """ + Patch clips.audio_generator so we load WAVs directly (deterministic 80/10/10 split, seed=10). + Matches the notebook behavior you posted. + """ + def audio_generator_from_wavs(self, split="train", repeat=1): + files = sorted(glob.glob(os.path.join(wav_dir, "*.wav"))) + if not files: + return - print(" Generating spectrograms") - spectros = SpectrogramGeneration( - clips=clips, - augmenter=augmenter, - slide_frames=cfg["slide_frames"], - step_ms=10, + rng = random.Random(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 + + 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) + + clips_obj.audio_generator = types.MethodType(audio_generator_from_wavs, clips_obj) + +def generate_feature_set(input_wav_dir: str, out_root_dir: str, label: str): + files = glob.glob(os.path.join(input_wav_dir, "*.wav")) + if not files: + print(f"ℹ️ No WAVs found for {label} in: {input_wav_dir} (skipping)") + return False + + max_samples = len(files) + print(f"\n===== Augmenting {max_samples} wake word samples ({label}) =====") + + clips = Clips( + input_directory=input_wav_dir, + file_pattern="*.wav", + max_clip_duration_s=5, + remove_silence=True, + random_split_seed=10, + split_count=0.1, ) - print(" Generating files") - print(" Sit tight — this step can take a while.") + bind_wav_generator(clips, input_wav_dir) - gen = spectros.spectrogram_generator( - split=cfg["name"], - repeat=cfg["repetition"], - ) + out_root = Path(out_root_dir) + out_root.mkdir(parents=True, exist_ok=True) - RaggedMmap.from_generator( - out_dir=str(out_dir / "wakeword_mmap"), - sample_generator=gen, - batch_size=100, - verbose=False, # keep mmap quiet - ) + for split, cfg in split_cfg.items(): + out_dir = out_root / split + out_dir.mkdir(parents=True, exist_ok=True) + print(f" Augmenting {split} ({label})") - print(f" {split} augmentation complete") + spectros = SpectrogramGeneration( + clips=clips, + augmenter=augmenter, + slide_frames=cfg["slide_frames"], + step_ms=10, + ) + + gen = spectros.spectrogram_generator( + split=cfg["name"], + repeat=cfg["repetition"], + ) + + RaggedMmap.from_generator( + out_dir=str(out_dir / "wakeword_mmap"), + sample_generator=gen, + batch_size=100, + verbose=False, + ) + + print(f" {split} augmentation complete ({label})") + + print(f"✅ Features ready: {out_root_dir}/*/wakeword_mmap") + return True + +# Wake word generated/TTS features (existing behavior) +generate_feature_set(args.input_dir, args.output_dir, "generated") + +# Personal features (NEW) +generate_feature_set(args.personal_dir, args.personal_output_dir, "personal") 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"{'Augmentation completed.':>50s} Elapsed time: {et!s}") print(f"{'=' * 80}\n") \ No newline at end of file diff --git a/cli/wake_word_sample_trainer b/cli/wake_word_sample_trainer index 19007ec..f05e3c1 100644 --- a/cli/wake_word_sample_trainer +++ b/cli/wake_word_sample_trainer @@ -60,43 +60,57 @@ check_directories() { check_directories ${WORK_DIR}/wake_word_samples_augmented \ ${TRAINING_DS}/negative_datasets/{speech,dinner_party,no_speech,dinner_party_eval} +# Personal features are optional, but if present they MUST have /training +PERSONAL_FEATURES_DIR="${WORK_DIR}/personal_augmented_features" +HAS_PERSONAL="false" +if [ -d "${PERSONAL_FEATURES_DIR}/training" ] ; then + HAS_PERSONAL="true" + echo "✅ Found personal features: ${PERSONAL_FEATURES_DIR}/training (will weight sampling_weight=3.0)" +else + echo "ℹ️ No personal features found at ${PERSONAL_FEATURES_DIR}/training (continuing without personal weighting)" +fi + 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" + +# We write a YAML with a marker, then splice personal feature block in if it exists. +YAML_PATH="${WORK_DIR}/trained_models/training_parameters.yaml" + +cat <<'EOF' > "${YAML_PATH}" batch_size: 16 clip_duration_ms: 1500 eval_step_interval: 500 features: -- features_dir: ${WORK_DIR}/wake_word_samples_augmented +- features_dir: __WAKEWORD_FEATURES__ penalty_weight: 1.0 sampling_weight: 2.0 truncation_strategy: truncate_start truth: true type: mmap -- features_dir: ${TRAINING_DS}/negative_datasets/speech +__PERSONAL_FEATURE_MARKER__ +- features_dir: __NEG_SPEECH__ penalty_weight: 1.0 sampling_weight: 12.0 truncation_strategy: random truth: false type: mmap -- features_dir: ${TRAINING_DS}/negative_datasets/dinner_party +- features_dir: __NEG_DINNER__ penalty_weight: 1.0 sampling_weight: 12.0 truncation_strategy: random truth: false type: mmap -- features_dir: ${TRAINING_DS}/negative_datasets/no_speech +- features_dir: __NEG_NOSPEECH__ penalty_weight: 1.0 sampling_weight: 5.0 truncation_strategy: random truth: false type: mmap -- features_dir: ${TRAINING_DS}/negative_datasets/dinner_party_eval +- features_dir: __NEG_DINNER_EVAL__ penalty_weight: 1.0 sampling_weight: 0.0 truncation_strategy: split @@ -119,25 +133,46 @@ time_mask_count: - 0 time_mask_max_size: - 0 -train_dir: ${WORK_DIR}/trained_models/wakeword +train_dir: __TRAIN_DIR__ training_steps: -- ${TRAINING_STEPS} +- __TRAINING_STEPS__ window_step_ms: 10 - EOF +# Replace placeholders (portable) +sed -i \ + -e "s|__WAKEWORD_FEATURES__|${WORK_DIR}/wake_word_samples_augmented|g" \ + -e "s|__NEG_SPEECH__|${TRAINING_DS}/negative_datasets/speech|g" \ + -e "s|__NEG_DINNER__|${TRAINING_DS}/negative_datasets/dinner_party|g" \ + -e "s|__NEG_NOSPEECH__|${TRAINING_DS}/negative_datasets/no_speech|g" \ + -e "s|__NEG_DINNER_EVAL__|${TRAINING_DS}/negative_datasets/dinner_party_eval|g" \ + -e "s|__TRAIN_DIR__|${WORK_DIR}/trained_models/wakeword|g" \ + -e "s|__TRAINING_STEPS__|${TRAINING_STEPS}|g" \ + "${YAML_PATH}" + +# Insert/remove personal block +if [ "${HAS_PERSONAL}" = "true" ]; then + # Insert directly after the wakeword feature block (matches your notebook: insert(1, ...)) + perl -0777 -i -pe 's/__PERSONAL_FEATURE_MARKER__/\n- features_dir: '"${PERSONAL_FEATURES_DIR}"'\n penalty_weight: 1.0\n sampling_weight: 3.0\n truncation_strategy: truncate_start\n truth: true\n type: mmap\n/g' "${YAML_PATH}" +else + # Remove marker line entirely + sed -i -e "/__PERSONAL_FEATURE_MARKER__/d" "${YAML_PATH}" +fi + echo " Wrote training_parameters.yaml" rm -rf "${WORK_DIR}/trained_models/wakeword" -wake_word_filename="${WAKE_WORD//[ \`~\!\$&*$begin:math:text$$end:math:text$\{\}$begin:math:display$$end:math:display$\|\;\'\"<>.?\/]/_}" +wake_word_filename="$( + echo "${WAKE_WORD}" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/_/g; s/^_+//; s/_+$//' +)" +[ -n "${wake_word_filename}" ] || wake_word_filename="wakeword" + OUTPUT_DIR="${DATA_DIR}/output/$(date +'%Y-%m-%d-%H-%M-%S')-${wake_word_filename}-${SAMPLES}-${TRAINING_STEPS}" mkdir -p "${OUTPUT_DIR}/logs" || : - TRAIN_LOG="${OUTPUT_DIR}/logs/training.log" -# ------------------------------------------------------------------ -# Training args (same as before) -# ------------------------------------------------------------------ TRAIN_ARGS=( -m microwakeword.model_train_eval --training_config "${WORK_DIR}/trained_models/training_parameters.yaml" @@ -159,10 +194,6 @@ TRAIN_ARGS=( --stride 2 ) -# ------------------------------------------------------------------ -# GPU failure markers that should trigger CPU fallback -# (OOM + known GPU runtime/copy/init failures) -# ------------------------------------------------------------------ GPU_FALLBACK_MARKERS=( "resourceexhaustederror" "resource exhausted" @@ -189,7 +220,6 @@ run_attempt() { echo "→ ${PYTHON_BIN:-python} ${TRAIN_ARGS[*]}" echo - # stream everything except validation minibatch spam "${PYTHON_BIN:-python}" "${TRAIN_ARGS[@]}" 2>&1 \ | tr '\r' '\n' \ | stdbuf -i0 -o0 sed -r -e "/^Validation Batch/d" \ @@ -199,20 +229,17 @@ run_attempt() { return ${PIPESTATUS[0]} } -# ---- Common TF env (mirrors your notebook) ---- export TF_CPP_MIN_LOG_LEVEL="${TF_CPP_MIN_LOG_LEVEL:-2}" export TF_XLA_FLAGS="${TF_XLA_FLAGS:---tf_xla_auto_jit=0}" export NVIDIA_TF32_OVERRIDE="${NVIDIA_TF32_OVERRIDE:-1}" export TF_FORCE_GPU_ALLOW_GROWTH="${TF_FORCE_GPU_ALLOW_GROWTH:-true}" export TF_GPU_ALLOCATOR="${TF_GPU_ALLOCATOR:-cuda_malloc_async}" -# Attempt 1: GPU if run_attempt "Attempt 1/2: GPU training (allow_growth + cuda_malloc_async)" ; then echo "✅ Training complete (GPU path)." else echo "⚠️ GPU attempt failed. Checking whether this looks like a GPU/OOM/runtime failure…" - # Check log for GPU/OOM/runtime markers log_lc="$(tr '[:upper:]' '[:lower:]' < "${TRAIN_LOG}" || true)" looks_like_gpu_fail="false" for m in "${GPU_FALLBACK_MARKERS[@]}"; do @@ -225,7 +252,6 @@ else if [ "${looks_like_gpu_fail}" = "true" ]; then echo "↪️ Detected GPU/OOM/runtime failure markers. Falling back to CPU." - # Attempt 2: CPU (hide GPU completely) export CUDA_VISIBLE_DEVICES="" unset TF_GPU_ALLOCATOR if run_attempt "Attempt 2/2: CPU fallback (CUDA_VISIBLE_DEVICES='')" ; then @@ -256,7 +282,6 @@ echo " Full log: ${TRAIN_LOG}" tflite_filename="${wake_word_filename}.tflite" tflite_path="${OUTPUT_DIR}/${tflite_filename}" - cp "${source_path}" "${tflite_path}" json_path="${OUTPUT_DIR}/${wake_word_filename}.json" From d5e8d187a10fd30d4eb155654311c8ca88c42abe Mon Sep 17 00:00:00 2001 From: MasterPhooey Date: Sat, 17 Jan 2026 23:01:50 -0600 Subject: [PATCH 09/11] personal samples --- cli/wake_word_sample_trainer | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/cli/wake_word_sample_trainer b/cli/wake_word_sample_trainer index f05e3c1..433d044 100644 --- a/cli/wake_word_sample_trainer +++ b/cli/wake_word_sample_trainer @@ -152,8 +152,18 @@ sed -i \ # Insert/remove personal block if [ "${HAS_PERSONAL}" = "true" ]; then - # Insert directly after the wakeword feature block (matches your notebook: insert(1, ...)) - perl -0777 -i -pe 's/__PERSONAL_FEATURE_MARKER__/\n- features_dir: '"${PERSONAL_FEATURES_DIR}"'\n penalty_weight: 1.0\n sampling_weight: 3.0\n truncation_strategy: truncate_start\n truth: true\n type: mmap\n/g' "${YAML_PATH}" + # Insert directly after the wakeword feature block (matches notebook: insert(1, ...)) + personal_block="$(cat < Date: Sun, 18 Jan 2026 07:45:57 -0600 Subject: [PATCH 10/11] logging update --- cli/wake_word_sample_augmenter | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/wake_word_sample_augmenter b/cli/wake_word_sample_augmenter index 99535fb..0ac08ef 100644 --- a/cli/wake_word_sample_augmenter +++ b/cli/wake_word_sample_augmenter @@ -206,7 +206,10 @@ def generate_feature_set(input_wav_dir: str, out_root_dir: str, label: str): for split, cfg in split_cfg.items(): out_dir = out_root / split out_dir.mkdir(parents=True, exist_ok=True) + print(f" Augmenting {split} ({label})") + print(" Sit tight this can take awhile ...") + print() spectros = SpectrogramGeneration( clips=clips, @@ -229,7 +232,7 @@ def generate_feature_set(input_wav_dir: str, out_root_dir: str, label: str): print(f" {split} augmentation complete ({label})") - print(f"✅ Features ready: {out_root_dir}/*/wakeword_mmap") + print(f"\n✅ Features ready: {out_root_dir}/*/wakeword_mmap\n") return True # Wake word generated/TTS features (existing behavior) From 6eff5ed0d49d6e65f50ccb95652a5af6c72322a8 Mon Sep 17 00:00:00 2001 From: MasterPhooey Date: Sun, 18 Jan 2026 08:07:49 -0600 Subject: [PATCH 11/11] readme update --- README.md | 558 +++++++++--------------------------------------------- 1 file changed, 92 insertions(+), 466 deletions(-) diff --git a/README.md b/README.md index 359d73d..cd77b60 100644 --- a/README.md +++ b/README.md @@ -1,507 +1,133 @@ -# Run training from the command line +# microWakeWord Nvidia Trainer & Recorder -## Overview +Train **microWakeWord** detection models using a simple **web-based recorder + trainer UI**, packaged in a Docker container. -With these scripts and Dockerfile, you can train new wake words from the -command line without using a Jupyter notebook. +No Jupyter notebooks required. No manual cell execution. Just record your voice (optional) and train. -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. +## 🚀 Quick Start -* The logic from the Jupyter notebook is contained in individual Python - and shell scripts +### 1️⃣ Pull the Docker Image -* 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. +```bash +docker pull ghcr.io/tatertotterson/microwakeword:latest ``` -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 +### 2️⃣ Run the Container -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 . +```bash +docker run --rm -it \ + --gpus all \ + -p 8888:8888 \ + -v $(pwd):/data \ + ghcr.io/tatertotterson/microwakeword: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. +**What these flags do:** +- `--gpus all` → Enables GPU acceleration +- `-p 8888:8888` → Exposes the Recorder + Trainer WebUI +- `-v $(pwd):/data` → Persists all models, datasets, and cache -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 +### 3️⃣ Open the Recorder WebUI -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. +Open your browser and go to: -Your `` will be mounted inside the container as `/data`. +👉 **http://localhost:8888** -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. +You’ll see the **microWakeWord Recorder & Trainer UI**. -### 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. +## 🎤 Recording Voice Samples (Optional) -```shell -$ docker run -it --rm --gpus=all -v :/data microwakeword-cli:latest -``` +Personal voice recordings are **optional**. -Options: +- You may **record your own voice** for better accuracy +- Or simply **click “Train” without recording anything** -* 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. +If no recordings are present, training will proceed using **synthetic TTS samples only**. -When the container starts, you'll see: +### Remote systems (important) +If you are running this on a **remote PC / server**, browser-based recording will not work unless: +- You use a **reverse proxy** (HTTPS + mic permissions), **or** +- You access the UI via **localhost** on the same machine + +Training itself works fine remotely — only recording requires local microphone access. + +--- + +## 🧠 Training Behavior (Important Notes) + +### ⏬ First training run +The **first time you click Train**, the system will download **large training datasets** (background noise, speech corpora, etc.). + +- This can take **several minutes** +- This happens **only once** +- Data is cached inside `/data` + +You **will NOT need to download these again** unless you delete `/data`. + +--- + +### 🔁 Re-training is safe and incremental + +- You can train **multiple wake words** back-to-back +- You do **NOT** need to clear any folders between runs +- Old models are preserved in timestamped output directories +- All required cleanup and reuse logic is handled automatically + +--- + +## 📦 Output Files + +When training completes, you’ll get: +- `.tflite` – quantized streaming model +- `.json` – ESPHome-compatible metadata + +Both are saved under: ```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:/# +/data/output/ ``` -Don't worry about the python WARNING right now. You'll be creating the -virtualenv in the next step. +Each run is placed in its own timestamped folder. -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. +## 🎤 Optional: Personal Voice Samples (Advanced) -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. -======================================================= -``` +If you record personal samples: +- They are automatically augmented +- They are **up-weighted during training** +- This significantly improves real-world accuracy -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. +No configuration required — detection is automatic. -At this point, you're in a Bash shell. +--- -### Create the Python virtual environment +## 🔄 Resetting Everything (Optional) -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. +If you want a **completely clean slate**: -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) +Delete the /data folder +Then restart the container. +⚠️ This will: +- Remove cached datasets +- Require re-downloading training data +- Delete trained models +--- +## 🙌 Credits +Built on top of the excellent +**https://github.com/kahrendt/microWakeWord** +Huge thanks to the original authors ❤️ \ No newline at end of file