From 5bc0f12a7f8ca11b123ac9a0ebd4225077e5d757 Mon Sep 17 00:00:00 2001 From: MasterPhooey Date: Sat, 17 Jan 2026 01:23:51 -0600 Subject: [PATCH] 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