{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "825fe381",
   "metadata": {},
   "source": [
    "# Introduction\n",
    "\n",
    "This notebook demonstrates the process of training a new openWakeWord model, using synthetic speech generated with open-source TTS models, and negative data representing music, noise, and speech. While the process here is complete, only small samples of datasets are utilized so that a new model can be trained on CPUs. In practice, much larger volumes of data (both positive and negitive examples) is needed to produce robust models. See the [documentation](https://github.com/dscripka/openWakeWord/tree/main/docs/models) for the pre-trained openWakeWord models for more information about how these models were trained."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bd8e4597",
   "metadata": {},
   "source": [
    "To start, we'll need to install the requirements needed to train new openWakeWord models."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1ba07a8f",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Install requirements (it's recommended that you do this in a new virtual environment)\n",
    "\n",
    "# !pip install openwakeword\n",
    "# !pip install speechbrain\n",
    "# !pip install datasets\n",
    "# !pip install scipy matplotlib"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "c914b0c9",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:26:26.308309Z",
     "start_time": "2023-02-18T03:26:24.785801Z"
    }
   },
   "outputs": [],
   "source": [
    "# Imports\n",
    "\n",
    "import os\n",
    "import collections\n",
    "import numpy as np\n",
    "from numpy.lib.format import open_memmap\n",
    "from pathlib import Path\n",
    "from tqdm import tqdm\n",
    "import openwakeword\n",
    "import openwakeword.data\n",
    "import openwakeword.utils\n",
    "import openwakeword.metrics\n",
    "\n",
    "import scipy\n",
    "import datasets\n",
    "import matplotlib.pyplot as plt\n",
    "import torch\n",
    "from torch import nn\n",
    "import IPython.display as ipd"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b40a7f25",
   "metadata": {},
   "source": [
    "# Data Preparation"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "aee94c6e",
   "metadata": {},
   "source": [
    "## Download Data"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "00ea736a",
   "metadata": {},
   "source": [
    "Next we'll load the data used for training. For the purposes of this demonstration, we'll use a small set of positive and negative.\n",
    "\n",
    "For the positive data, there are ~3400 synthetic examples of the phrase \"turn on the office lights\" that were produced with the text-to-speech models documented in a [separate repo](https://github.com/dscripka/synthetic_speech_dataset_generation).\n",
    "\n",
    "These positive examples can be downloaded [here](https://f002.backblazeb2.com/file/openwakeword-resources/data/turn_on_the_office_lights.tar.gz).\n",
    "\n",
    "For negative data, we'll use small, already prepared samples of the [fma-large dataset](https://github.com/mdeff/fma) for music, the [FSD50k dataset](https://zenodo.org/record/4060432#.Y-hA2BzMJhE) for noise, and the [Common Voice 11](https://huggingface.co/datasets/mozilla-foundation/common_voice_11_0) dataset for speech.\n",
    "\n",
    "The fma-large sample can be downloaded [here](https://f002.backblazeb2.com/file/openwakeword-resources/data/fma_sample.zip), and then extracted into the working director.\n",
    "\n",
    "The FSD50k sample can be downloaded [here](https://f002.backblazeb2.com/file/openwakeword-resources/data/fsd50k_sample.zip), and then extracted into the working directory.\n",
    "\n",
    "And we'll use the HuggingFace Datasets library to get a portion of the test split of the Common Voice 11 (CV11) corpus.\n",
    "\n",
    "Note the data provided here is intended for non-commerical applications only; you will need to verify the license status of this (and other) data if you intend to use it for commerical purposes."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 381,
   "id": "a31f760c",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-12T16:08:32.045657Z",
     "start_time": "2023-02-12T16:07:24.104475Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "Reading metadata...: 16354it [00:00, 26183.62it/s]\n",
      "100%|██████████| 5000/5000 [00:44<00:00, 112.28it/s]\n"
     ]
    }
   ],
   "source": [
    "# Download CV11 test split from HuggingFace, and convert the audio into 16 khz, 16-bit wav files\n",
    "\n",
    "cv_11 = datasets.load_dataset(\"mozilla-foundation/common_voice_11_0\", \"en\", split=\"test\", streaming=True)\n",
    "cv_11 = cv_11.cast_column(\"audio\", datasets.Audio(sampling_rate=16000, mono=True)) # convert to 16-khz\n",
    "cv_11 = iter(cv_11)\n",
    "\n",
    "# Convert and save clips (only first 5000)\n",
    "limit = 5000\n",
    "for i in tqdm(range(limit)):\n",
    "    example = next(cv_11)\n",
    "    output = os.path.join(\"cv11_test_clips\", example[\"path\"][0:-4] + \".wav\")\n",
    "    os.makedirs(os.path.dirname(output), exist_ok=True)\n",
    "\n",
    "    wav_data = (example[\"audio\"][\"array\"]*32767).astype(np.int16) # convert to 16-bit PCM format\n",
    "    scipy.io.wavfile.write(output, 16000, wav_data)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0a12ab3d",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-12T02:04:47.611837Z",
     "start_time": "2023-02-12T02:04:47.606080Z"
    }
   },
   "source": [
    "## Compute Audio Embeddings"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bfea6a2b",
   "metadata": {},
   "source": [
    "Once all the data is downloaded, we can now get the audio embeddings for the positive and negative clips. As this part of the openWakeWord model is frozen (i.e., not updated during training), it makes sense to pre-compute these features so that they only need to be prepared once."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "473349ce",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:26:45.282504Z",
     "start_time": "2023-02-18T03:26:45.093446Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/home/dscripka/anaconda3/envs/torch_gpu/lib/python3.9/site-packages/onnxruntime/capi/onnxruntime_inference_collection.py:54: UserWarning: Specified provider 'CUDAExecutionProvider' is not in available provider names.Available providers: 'CPUExecutionProvider'\n",
      "  warnings.warn(\n"
     ]
    }
   ],
   "source": [
    "# Create audio pre-processing object to get openWakeWord audio embeddings\n",
    "\n",
    "F = openwakeword.utils.AudioFeatures()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9e757355",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-12T02:14:32.160470Z",
     "start_time": "2023-02-12T02:14:32.154438Z"
    }
   },
   "source": [
    "### Negative Clips"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "ab401215",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:27:23.911209Z",
     "start_time": "2023-02-18T03:26:47.968057Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "200it [00:00, 2779.44it/s]\n",
      "100%|██████████| 200/200 [00:01<00:00, 141.58it/s]\n",
      "1000it [00:00, 2806.48it/s]\n",
      "100%|██████████| 1000/1000 [00:05<00:00, 177.36it/s]\n",
      "5000it [00:01, 2555.99it/s]\n",
      "100%|██████████| 5000/5000 [00:26<00:00, 188.73it/s]"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "6096 negative clips after filtering, representing ~12.0 hours\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\n"
     ]
    }
   ],
   "source": [
    "# Get negative example paths, filtering out clips that are too long or too short\n",
    "\n",
    "negative_clips, negative_durations = openwakeword.data.filter_audio_paths(\n",
    "    [\n",
    "        \"fma_sample\",\n",
    "        \"fsd50k_sample\",\n",
    "        \"cv11_test_clips\"\n",
    "    ],\n",
    "    min_length_secs = 1.0, # minimum clip length in seconds\n",
    "    max_length_secs = 60*30, # maximum clip length in seconds\n",
    "    duration_method = \"header\" # use the file header to calculate duration\n",
    ")\n",
    "\n",
    "print(f\"{len(negative_clips)} negative clips after filtering, representing ~{sum(negative_durations)//3600} hours\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "221d8662",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:28:06.568812Z",
     "start_time": "2023-02-18T03:28:06.524651Z"
    }
   },
   "outputs": [],
   "source": [
    "# Use HuggingFace datasets to load files from disk by batches\n",
    "\n",
    "audio_dataset = datasets.Dataset.from_dict({\"audio\": negative_clips})\n",
    "audio_dataset = audio_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "37ec1163",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:30:38.082245Z",
     "start_time": "2023-02-18T03:29:08.355371Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      " 98%|█████████▊| 94/96 [01:26<00:01,  1.09it/s]\n",
      "15it [00:03,  4.61it/s]                        \n"
     ]
    }
   ],
   "source": [
    "# Get audio embeddings (features) for negative clips and save to .npy file\n",
    "# Process files by batch and save to Numpy memory mapped file so that\n",
    "# an array larger than the available system memory can be created\n",
    "\n",
    "batch_size = 64 # number of files to load, compute features, and write to mmap at a time\n",
    "clip_size = 3  # the desired window size (in seconds) for the trained openWakeWord model\n",
    "N_total = int(sum(negative_durations)//clip_size) # maximum number of rows in mmap file\n",
    "n_feature_cols = F.get_embedding_shape(clip_size)\n",
    "\n",
    "output_file = \"negative_features.npy\"\n",
    "output_array_shape = (N_total, n_feature_cols[0], n_feature_cols[1])\n",
    "fp = open_memmap(output_file, mode='w+', dtype=np.float32, shape=output_array_shape)\n",
    "\n",
    "row_counter = 0\n",
    "for i in tqdm(np.arange(0, audio_dataset.num_rows, batch_size)):\n",
    "    # Load data in batches and shape into rectangular array\n",
    "    wav_data = [(j[\"array\"]*32767).astype(np.int16) for j in audio_dataset[i:i+batch_size][\"audio\"]]\n",
    "    wav_data = openwakeword.data.stack_clips(wav_data, clip_size=16000*clip_size).astype(np.int16)\n",
    "    \n",
    "    # Compute features (increase ncpu argument for faster processing)\n",
    "    features = F.embed_clips(x=wav_data, batch_size=1024, ncpu=8)\n",
    "    \n",
    "    # Save computed features to mmap array file (stopping once the desired size is reached)\n",
    "    if row_counter + features.shape[0] > N_total:\n",
    "        fp[row_counter:min(row_counter+features.shape[0], N_total), :, :] = features[0:N_total - row_counter, :, :]\n",
    "        fp.flush()\n",
    "        break\n",
    "    else:\n",
    "        fp[row_counter:row_counter+features.shape[0], :, :] = features\n",
    "        row_counter += features.shape[0]\n",
    "        fp.flush()\n",
    "        \n",
    "# Trip empty rows from the mmapped array\n",
    "openwakeword.data.trim_mmap(output_file)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c60aa86d",
   "metadata": {},
   "source": [
    "Now we have all of the negative features prepared, and saved to fixed durations clips in a Numpy array. For this data, the array is small at ~160 MB, but in-practice the memory mapping allows the array to be very large (e.g., 100s of GBs)."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1b49b9c6",
   "metadata": {},
   "source": [
    "### Positive Clips"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6f3f9ed0",
   "metadata": {},
   "source": [
    "First, [download](https://f002.backblazeb2.com/file/openwakeword-resources/data/turn_on_the_office_lights.tar.gz) and extract the positive clips into the working directory.\n",
    "\n",
    "Then the positive clips will be prepared in two way:\n",
    "\n",
    "1) Mixing the synthetic positive clips with negative data at random SNRs to simulate noise data\n",
    "\n",
    "2) Aligning the positive clips with background data such that the end of the input window aligns with the end of the positive clip. This way the model will learn to predict the presence of the wakeword/phrase immediately after it is spoken.\n",
    "\n",
    "In practice, there are other possible ways to augment the positive data (e.g., creating reverberation with room impulse response files, mixing with synthetic noise, etc.) but in practice we have observed that mixing with realistic background data provides the best results. Again, see the [documentation](https://github.com/dscripka/openWakeWord/tree/main/docs/models) for the pre-trained openWakeWord models for more information about the types of data augmentation used.\n",
    "\n",
    "After this prepartion, the positive clips will be converted into the openWakeWord features in the same way as the negative files."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "fe1964fb",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:31:01.912793Z",
     "start_time": "2023-02-18T03:30:43.623741Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "3388it [00:01, 2771.26it/s]\n",
      "100%|██████████| 3388/3388 [00:17<00:00, 198.61it/s]"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "3203 positive clips after filtering\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\n"
     ]
    }
   ],
   "source": [
    "# Get positive example paths, filtering out clips that are too long or too short\n",
    "\n",
    "positive_clips, durations = openwakeword.data.filter_audio_paths(\n",
    "    [\n",
    "        \"turn_on_the_office_lights\"\n",
    "    ],\n",
    "    min_length_secs = 1.0, # minimum clip length in seconds\n",
    "    max_length_secs = 2.0, # maximum clip length in seconds\n",
    "    duration_method = \"header\" # use the file header to calculate duration\n",
    ")\n",
    "\n",
    "print(f\"{len(positive_clips)} positive clips after filtering\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "5d9dc47b",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:31:05.710564Z",
     "start_time": "2023-02-18T03:31:05.699618Z"
    }
   },
   "outputs": [],
   "source": [
    "# Define starting point for each positive clip based on its length, so that each one ends \n",
    "# between 0-200 ms from the end of the total window size chosen for the model.\n",
    "# This results in the model being most confident in the prediction right after the\n",
    "# end of the wakeword in the audio stream, reducing latency in operation.\n",
    "\n",
    "# Get start and end positions for the positive audio in the full window\n",
    "sr = 16000\n",
    "total_length_seconds = 3 # must be the some window length as that used for the negative examples\n",
    "total_length = int(sr*total_length_seconds)\n",
    "\n",
    "jitters = (np.random.uniform(0, 0.2, len(positive_clips))*sr).astype(np.int32)\n",
    "starts = [total_length - (int(np.ceil(i*sr))+j) for i,j in zip(durations, jitters)]\n",
    "ends = [int(i*sr) + j for i, j in zip(durations, starts)]\n",
    "\n",
    "# Create generator to mix the positive audio with background audio\n",
    "batch_size = 8\n",
    "mixing_generator = openwakeword.data.mix_clips_batch(\n",
    "    foreground_clips = positive_clips,\n",
    "    background_clips = negative_clips,\n",
    "    combined_size = total_length,\n",
    "    batch_size = batch_size,\n",
    "    snr_low = 5,\n",
    "    snr_high = 15,\n",
    "    start_index = starts,\n",
    "    volume_augmentation=True, # randomly scale the volume of the audio after mixing\n",
    ")\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "70898754",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-12T03:35:32.377177Z",
     "start_time": "2023-02-12T03:35:32.349576Z"
    }
   },
   "outputs": [],
   "source": [
    "# (Optionally) listen to mixed clips to confirm that the mixing appears correct\n",
    "\n",
    "mixed_clips, labels, background_clips = next(mixing_generator)\n",
    "ipd.display(ipd.Audio(mixed_clips[0], rate=16000, normalize=True, autoplay=False))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "621c2ee6",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:33:35.853508Z",
     "start_time": "2023-02-18T03:31:44.655774Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "100%|██████████| 400/400 [01:50<00:00,  3.62it/s]\n",
      "4it [00:00,  5.66it/s]                       \n"
     ]
    }
   ],
   "source": [
    "# Iterate through the mixing generator, computing audio features for positive examples and saving them\n",
    "\n",
    "N_total = len(positive_clips) # maximum number of rows in mmap file\n",
    "n_feature_cols = F.get_embedding_shape(total_length_seconds)\n",
    "\n",
    "output_file = \"turn_on_the_office_lights_features.npy\"\n",
    "output_array_shape = (N_total, n_feature_cols[0], n_feature_cols[1])\n",
    "\n",
    "fp = open_memmap(output_file, mode='w+', dtype=np.float32, shape=output_array_shape)\n",
    "\n",
    "row_counter = 0\n",
    "for batch in tqdm(mixing_generator, total=N_total//batch_size):\n",
    "    batch, lbls, background = batch[0], batch[1], batch[2]\n",
    "    \n",
    "    # Compute audio features\n",
    "    features = F.embed_clips(batch, batch_size=256)\n",
    "\n",
    "    # Save computed features\n",
    "    fp[row_counter:row_counter+features.shape[0], :, :] = features\n",
    "    row_counter += features.shape[0]\n",
    "    fp.flush()\n",
    "    \n",
    "    if row_counter >= N_total:\n",
    "        break\n",
    "\n",
    "# Trip empty rows from the mmapped array\n",
    "openwakeword.data.trim_mmap(output_file)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "77a31736",
   "metadata": {},
   "source": [
    "Alright! At this point the positive and negative features have been pre-computed and saved to disk, and now a model can be trained that takes these features and predicts whether the wakeword/phrase is present."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cab316e2",
   "metadata": {},
   "source": [
    "# Training the Model"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a514773d",
   "metadata": {},
   "source": [
    "At this point, you are free to use any type of model that you like, but in practice we've observed that a simple full-connected neural network can often perform quite well. For this example notebook, we'll create and train this network in Pytorch, but any framework that can export a model to the [ONNX](https://onnx.ai/) format will also work."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f0dab37e",
   "metadata": {},
   "source": [
    "## Loading Data"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "c7ed4de5",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:33:52.884948Z",
     "start_time": "2023-02-18T03:33:51.191681Z"
    }
   },
   "outputs": [],
   "source": [
    "# Load the data prepared in previous steps (it's small enough to load entirely in memory)\n",
    "\n",
    "negative_features = np.load(\"negative_features.npy\")\n",
    "positive_features = np.load(\"turn_on_the_office_lights_features.npy\")\n",
    "\n",
    "X = np.vstack((negative_features, positive_features))\n",
    "y = np.array([0]*len(negative_features) + [1]*len(positive_features)).astype(np.float32)[...,None]\n",
    "\n",
    "# Make Pytorch dataloader\n",
    "batch_size = 512\n",
    "training_data = torch.utils.data.DataLoader(\n",
    "    torch.utils.data.TensorDataset(torch.from_numpy(X), torch.from_numpy(y)),\n",
    "    batch_size = batch_size,\n",
    "    shuffle = True\n",
    ")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1d1ba9e4",
   "metadata": {},
   "source": [
    "## Define Model"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "id": "d7c71798",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:33:54.913238Z",
     "start_time": "2023-02-18T03:33:54.896447Z"
    }
   },
   "outputs": [],
   "source": [
    "# Define fully-connected network in PyTorch\n",
    "\n",
    "layer_dim = 32\n",
    "fcn = nn.Sequential(\n",
    "                    nn.Flatten(),\n",
    "                    nn.Linear(X.shape[1]*X.shape[2], layer_dim), # since the input is flattened, it's timesteps*feature columns\n",
    "                    nn.LayerNorm(layer_dim),\n",
    "                    nn.ReLU(),\n",
    "                    nn.Linear(layer_dim, layer_dim),\n",
    "                    nn.LayerNorm(layer_dim),\n",
    "                    nn.ReLU(),\n",
    "                    nn.Linear(layer_dim, 1),\n",
    "                    nn.Sigmoid(),\n",
    "                )\n",
    "\n",
    "loss_function = torch.nn.functional.binary_cross_entropy\n",
    "optimizer = torch.optim.Adam(fcn.parameters(), lr=0.001)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6bb834c1",
   "metadata": {},
   "source": [
    "## Train Model"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "id": "5bc28f8b",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:33:59.286835Z",
     "start_time": "2023-02-18T03:33:57.795926Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "100%|██████████| 10/10 [00:01<00:00,  6.74it/s]\n"
     ]
    }
   ],
   "source": [
    "# Define training loop, metrics, and logging\n",
    "\n",
    "n_epochs = 10\n",
    "history = collections.defaultdict(list)\n",
    "for i in tqdm(range(n_epochs), total=n_epochs):\n",
    "    for batch in training_data:\n",
    "        # Get data for batch\n",
    "        x, y = batch[0], batch[1]\n",
    "        \n",
    "        # Get weights for classes, and assign 10x higher weight to negative class\n",
    "        # to help the model learn to not have too many false-positives\n",
    "        # As you have more data (both positive and negative), this is less important\n",
    "        weights = torch.ones(y.shape[0])\n",
    "        weights[y.flatten() == 1] = 0.1\n",
    "        \n",
    "        # Zero gradients\n",
    "        optimizer.zero_grad()\n",
    "        \n",
    "        # Run forward pass\n",
    "        predictions = fcn(x)\n",
    "        \n",
    "        # Update model parameters\n",
    "        loss = loss_function(predictions, y, weights[..., None])\n",
    "        loss.backward()\n",
    "        optimizer.step()\n",
    "        \n",
    "        # Log metrics\n",
    "        history['loss'].append(float(loss.detach().numpy()))\n",
    "        \n",
    "        tp = sum(predictions.flatten()[y.flatten() == 1] >= 0.5)\n",
    "        fn = sum(predictions.flatten()[y.flatten() == 1] < 0.5)\n",
    "        history['recall'].append(float(tp/(tp+fn).detach().numpy()))\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "id": "4231dd84",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:34:01.172348Z",
     "start_time": "2023-02-18T03:34:01.043030Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/plain": [
       "(0.0, 1.0)"
      ]
     },
     "execution_count": 14,
     "metadata": {},
     "output_type": "execute_result"
    },
    {
     "data": {
      "image/png": "[encoded data, 34136 characters]",
      "text/plain": [
       "<Figure size 640x480 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Plot training metrics\n",
    "\n",
    "plt.figure()\n",
    "plt.plot(history['loss'], label=\"loss\")\n",
    "plt.plot(history['recall'], label=\"recall\")\n",
    "plt.legend()\n",
    "plt.ylim(0,1)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "03a3ae78",
   "metadata": {},
   "source": [
    "## Try the Model on an Example Clip"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "65c43bb6",
   "metadata": {},
   "source": [
    "To confirm that the model is working as expected, let's test it on an example audio file (obtained from Youtube) of someone talking and then saying the phrase \"turn on the office lights\" at the end of the clip. We'll simulate how the model would be used in production, by predicting every 80 ms (1280 samples) and plotting the predictions over time.\n",
    "\n",
    "This clip is a good sanity test to confirm the model is performing in the right way, as it contains about ~30 seconds of speech that does no contain the target phrase, but does contain related words (e.g., \"lights\") that should not result in an activation. So ideally, the model scores are low up to the end of the recording, where there should then be a spike right after the target spoken phrase."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 16,
   "id": "d6ad350e",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:38:17.596854Z",
     "start_time": "2023-02-18T03:38:16.926888Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "100%|██████████| 394/394 [00:00<00:00, 19403.48it/s]\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 17432 characters]",
      "text/plain": [
       "<Figure size 640x480 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Load data\n",
    "sr, dat = scipy.io.wavfile.read(\"training_tutorial_data/turn_on_the_office_lights_test_clip.wav\")\n",
    "\n",
    "# Pre-compute audio features using helper function\n",
    "features = F._get_embeddings(dat)\n",
    "\n",
    "# Get predictions for each window\n",
    "scores = []\n",
    "for i in tqdm(range(0, features.shape[0]-28)): # 28 is the number of timestep frames for this model\n",
    "    window = features[i:i+28][None,]\n",
    "    with torch.no_grad():\n",
    "        scores.append(float(fcn(torch.from_numpy(window)).detach().numpy()))\n",
    "    \n",
    "plt.figure()\n",
    "_ = plt.plot(scores)\n",
    "_ = plt.ylim(0,1)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6531eefd",
   "metadata": {},
   "source": [
    "Overall, the model is working well on this test clip. There are a few spikes around the word \"lights\" spoken in other contexts, but the clear activation is around the entire phrase.\n",
    "\n",
    "To make this test a little more difficult, let's arbitrarily mix the test clip with some background music from the fma-large dataset at a low signal-to-noise ratio to simulate a more realistic (and challenging) scenario. Listen to the clip below to get a more intuitive feel of what the type of audio environment this represents."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 19,
   "id": "2f72631b",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:40:11.733430Z",
     "start_time": "2023-02-18T03:40:11.721719Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/html": [
       "\n",
       "                <audio  controls=\"controls\" >\n",
       "                    <source src=\"data:audio/wav;base64,[encoded data, 256060 characters]\" type=\"audio/wav\" />\n",
       "                    Your browser does not support the audio element.\n",
       "                </audio>\n",
       "              "
      ],
      "text/plain": [
       "<IPython.lib.display.Audio object>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Load two clips and mix them\n",
    "_, dat = scipy.io.wavfile.read(\"turn_on_the_office_lights_test_clip.wav\")\n",
    "_, dat_music = scipy.io.wavfile.read(\"fma_sample/000182.wav\")\n",
    "dat[-20*16000:] = (dat[-20*16000:] + dat_music[0:20*16000]*.7)/2 #quick manual mixing\n",
    "\n",
    "ipd.display(ipd.Audio(dat[-16000*6:], rate=16000, normalize=True, autoplay=False))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 20,
   "id": "344383c7",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:40:23.392725Z",
     "start_time": "2023-02-18T03:40:22.770119Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "100%|██████████| 394/394 [00:00<00:00, 19051.83it/s]\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 14244 characters]",
      "text/plain": [
       "<Figure size 640x480 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Pre-compute audio features using helper function\n",
    "features = F._get_embeddings(dat)\n",
    "\n",
    "# Get predictions for each window\n",
    "scores = []\n",
    "for i in tqdm(range(0, features.shape[0]-28)): # 28 is the number of timestep frames for this model\n",
    "    window = features[i:i+28][None,]\n",
    "    with torch.no_grad():\n",
    "        scores.append(float(fcn(torch.from_numpy(window)).detach().numpy()))\n",
    "    \n",
    "plt.figure()\n",
    "_ = plt.plot(scores)\n",
    "_ = plt.ylim(0,1)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "078a1bc2",
   "metadata": {},
   "source": [
    "The model is now less confidant in it's prediction than before, but the score is still above a default score of 0.5 which confirms that the model at least represents a good starting point."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0367ee93",
   "metadata": {},
   "source": [
    "# Export the Model"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f4eb66ce",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-12T20:00:58.877229Z",
     "start_time": "2023-02-12T20:00:58.868037Z"
    }
   },
   "source": [
    "Now that the model is trained and passes basic performance validation tests, it can be exported to ONNX so that it can be used by the openWakeWord inference engine. With Torch, this process is quite simple."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 21,
   "id": "75acc3bb",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:40:40.474020Z",
     "start_time": "2023-02-18T03:40:40.401258Z"
    }
   },
   "outputs": [],
   "source": [
    "# Export model to ONNX format\n",
    "\n",
    "output_path = \"turn_on_the_office_lights.onnx\"\n",
    "torch.onnx.export(fcn, args=torch.zeros((1, 28, 96)), f=output_path) # the 'args' is the shape of a single example"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "03bd9f1e",
   "metadata": {},
   "source": [
    "# Evaluate the Model"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8c671f55",
   "metadata": {},
   "source": [
    "Let's now load in the ONNX model with openWakeWord, and use that to run some more rigorous testing. First, let's just confirm that the ONNX model works as expected."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 22,
   "id": "295fd55c",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:41:08.824352Z",
     "start_time": "2023-02-18T03:41:08.615724Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/home/dscripka/anaconda3/envs/torch_gpu/lib/python3.9/site-packages/onnxruntime/capi/onnxruntime_inference_collection.py:54: UserWarning: Specified provider 'CUDAExecutionProvider' is not in available provider names.Available providers: 'CPUExecutionProvider'\n",
      "  warnings.warn(\n"
     ]
    }
   ],
   "source": [
    "# Create openWakeWord instance\n",
    "\n",
    "oww = openwakeword.Model(\n",
    "    wakeword_model_paths=[\"turn_on_the_office_lights.onnx\"],\n",
    "    enable_speex_noise_suppression=True,\n",
    "    vad_threshold=0.5\n",
    ")\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 23,
   "id": "6f009d79",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:41:13.924834Z",
     "start_time": "2023-02-18T03:41:12.995975Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 16876 characters]",
      "text/plain": [
       "<Figure size 640x480 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Do a quick test prediction on the test clip to confirm that the behavior is still as expected\n",
    "\n",
    "scores = oww.predict_clip(\"turn_on_the_office_lights_test_clip.wav\")\n",
    "\n",
    "plt.figure()\n",
    "_ = plt.plot([i[\"turn_on_the_office_lights\"] for i in scores])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d26f14af",
   "metadata": {},
   "source": [
    "Since that looks fine, we can now conduct a more rigorous test to evaluate the false-accept rate in something closer to a production scenario. Specifically, we want the openWakeWord models to respond consistently when a user speaks the target wake word/phrase, but also does not activate even in the presence of many hours of continuous background noise and un-related speech.\n",
    "\n",
    "To test that, we'll use a few clips (for a total of ~ 1 hour) from the [Santa Barbara Corpus of Spoken American English](https://www.linguistics.ucsb.edu/research/santa-barbara-corpus) to produce a more realistic metric for the false-activation rate per hour.\n",
    "\n",
    "The combined clip (already converted to a single-channel, 16khz, 16-bit WAV file) can be downloaded [here](https://f002.backblazeb2.com/file/openwakeword-resources/data/santa_barbara_corpus_test_clip.wav).\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 24,
   "id": "756d7100",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:43:13.392821Z",
     "start_time": "2023-02-18T03:41:57.091857Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 21164 characters]",
      "text/plain": [
       "<Figure size 640x480 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Estimate the false-accept rate on realistic test data (will take up to several minutes an a desktop-grade CPU)\n",
    "\n",
    "scores = oww.predict_clip(\"santa_barbara_corpus_test_clip.wav\")\n",
    "\n",
    "plt.figure()\n",
    "_ = plt.plot([i[\"turn_on_the_office_lights\"] for i in scores])\n",
    "_ = plt.ylim(0,1)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 25,
   "id": "b9e2f282",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:43:20.326550Z",
     "start_time": "2023-02-18T03:43:20.309552Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "False-accept rate per hour: 94.0\n"
     ]
    }
   ],
   "source": [
    "# Calculate the false-accept rate per hour from this result\n",
    "\n",
    "false_accepts = openwakeword.metrics.get_false_positives(\n",
    "    [i[\"turn_on_the_office_lights\"] for i in scores], threshold=0.5\n",
    ")\n",
    "\n",
    "print(f\"False-accept rate per hour: {false_accepts/1}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "04d294cf",
   "metadata": {},
   "source": [
    "It looks like the false-accept rate for this model is very high, and would need to be reduced quite significantly to be viable for a production deployment. Of course, this was expected as the model was trained on a very small amount of positive and negative data."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c758890a",
   "metadata": {},
   "source": [
    "# Create a User-specific Verifier Model"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "362c6812",
   "metadata": {},
   "source": [
    "As we saw, the simple model trained on this dataset performs quite well at detecting the presence of the wakeword/phrase, but often activates when it shouldn't, leading to an unnacceptably high false-accept rate.\n",
    "\n",
    "In practice, there are two ways to improve the performance of the model:\n",
    "\n",
    "1) Train on much larger amounts of positive and negative examples. The models released with openWakeWord are often trained on >100,000 positive examples, and over 30,000 hours of negative data.\n",
    "\n",
    "2) Create a user-specific \"verifier\" model based on examples of a specific person speaking the both the wake word/phrase and unrelated speech. The openWakeWord inference engine uses this verifier model to filter out likely false activations by focusing on only known speakers.\n",
    "\n",
    "We'll demonstrate the 2nd option here, as it's a very quick way to significantly improve performance at the cost of making the model far less likely to work well with other voices. The approach behind this verifier model are relatively simple, and are discussed in more detail in the openWakeWord documentation [here](https://github.com/dscripka/openWakeWord/docs/custom_verifier_models.md).\n",
    "\n",
    "For test data, we'll use 20 examples of the wake phrase generated with the [Tortoise](https://github.com/neonbjb/tortoise-tts) TTS model. While this is also synthetic data, it's very high quality and trained on different data than the TTS models used to generate training data. For unrelated speech, an English [phonetic pangram](https://www.liquisearch.com/list_of_pangrams/english_phonetic_pangrams) sentence was generated with the same TTS voice.\n",
    "\n",
    "This example wake phrase clips and reference negative speech (phonetic pangram) are included in the openWakeWord repo (in the `notebooks/training_tutorial_data` directory)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 26,
   "id": "f6b88046",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:45:18.538259Z",
     "start_time": "2023-02-18T03:45:18.525190Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/html": [
       "\n",
       "                <audio  controls=\"controls\" >\n",
       "                    <source src=\"data:audio/x-wav;base64,[encoded data, 82888 characters]\" type=\"audio/x-wav\" />\n",
       "                    Your browser does not support the audio element.\n",
       "                </audio>\n",
       "              "
      ],
      "text/plain": [
       "<IPython.lib.display.Audio object>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Provide paths to positive and negative speech from the target speaker for training a custom\n",
    "# verifier model\n",
    "\n",
    "reference_clips = [str(i) for i in Path(\"training_tutorial_data/positive/\").glob(\"*.wav\")]\n",
    "negative_clips = [str(i) for i in Path(\"training_tutorial_data/negative/\").glob(\"*.wav\")]\n",
    "\n",
    "# Listen to one of the clips\n",
    "ipd.display(ipd.Audio(reference_clips[0], rate=16000, normalize=True, autoplay=False))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "937e0df5",
   "metadata": {},
   "source": [
    "Now that we have the data (note that all of the clips *must* be 16 khz, 16-bit PCM WAV files), we can train a custom verifier model. This is simply a scikit-learn logistic regression model, using the same audio features from the normal openWakeWord pre-processor, so it is very fast to train and adds negligble time to the openWakeWord inference engine."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 27,
   "id": "7f965293",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:45:57.606472Z",
     "start_time": "2023-02-18T03:45:56.587146Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "Processing positive reference clips: 100%|██████████| 3/3 [00:00<00:00,  4.77it/s]\n",
      "Processing negative reference clips: 100%|██████████| 1/1 [00:00<00:00,  5.53it/s]\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Training and saving verifier model...\n",
      "Done!\n"
     ]
    }
   ],
   "source": [
    "# Train verifier model on the reference clips\n",
    "\n",
    "output_model_path = \"turn_on_the_office_lights_verifier.pkl\"\n",
    "openwakeword.train_custom_verifier(\n",
    "    positive_reference_clips = reference_clips[0:3], # use 3 reference examples for the wake phrase\n",
    "    negative_reference_clips = negative_clips,\n",
    "    output_path = output_model_path,\n",
    "    model_name = \"turn_on_the_office_lights.onnx\"\n",
    ")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4ad4d2ec",
   "metadata": {},
   "source": [
    "After the model is trained, we can instantiate a new openWakeWord instance and include the path to the trained verifier model, as well as set the threshold score from the base model required to invoke the verifier. In practice, you can set this threshold score a bit lower than normal, though as usual actual testing in the deployment environment is recommended."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 28,
   "id": "50b99677",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:46:01.357976Z",
     "start_time": "2023-02-18T03:46:01.146646Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/home/dscripka/anaconda3/envs/torch_gpu/lib/python3.9/site-packages/onnxruntime/capi/onnxruntime_inference_collection.py:54: UserWarning: Specified provider 'CUDAExecutionProvider' is not in available provider names.Available providers: 'CPUExecutionProvider'\n",
      "  warnings.warn(\n"
     ]
    }
   ],
   "source": [
    "# Create openWakeWord instance with verifier model\n",
    "\n",
    "oww = openwakeword.Model(\n",
    "    wakeword_model_paths=[\"turn_on_the_office_lights.onnx\"],\n",
    "    enable_speex_noise_suppression=True,\n",
    "    vad_threshold=0.5,\n",
    "    custom_verifier_models={\"turn_on_the_office_lights\": \"turn_on_the_office_lights_verifier.pkl\"},\n",
    "    custom_verifier_threshold=0.3,\n",
    ")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6666dadb",
   "metadata": {},
   "source": [
    "Finally, we can run the model on our test clip from the Santa Barbara corpus and see if the false activation rate has decreased to an acceptable level."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 29,
   "id": "19fd5f51",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:47:25.076640Z",
     "start_time": "2023-02-18T03:46:07.021368Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 20460 characters]",
      "text/plain": [
       "<Figure size 640x480 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Run false-accept rate test again, now with the verifier model\n",
    "\n",
    "scores = oww.predict_clip(\"santa_barbara_corpus_test_clip.wav\")\n",
    "\n",
    "plt.figure()\n",
    "plt.plot([i[\"turn_on_the_office_lights\"] for i in scores])\n",
    "_ = plt.ylim(0,1)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 30,
   "id": "c59007d2",
   "metadata": {
    "ExecuteTime": {
     "end_time": "2023-02-18T03:47:29.856142Z",
     "start_time": "2023-02-18T03:47:29.836354Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "False-accept rate per hour: 0.0\n"
     ]
    }
   ],
   "source": [
    "# Calculate the false-accept rate per hour from this new result\n",
    "\n",
    "false_accepts = openwakeword.metrics.get_false_positives(\n",
    "    [i[\"turn_on_the_office_lights\"] for i in scores], threshold=0.5\n",
    ")\n",
    "\n",
    "print(f\"False-accept rate per hour: {false_accepts/1}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7c7375c6",
   "metadata": {},
   "source": [
    "Sucess! Now the false-activation rate is at most <1 per hour given that there weren't any false-positives in our ~1 hour test clip, which is an orders of magnitude decrease! This model is now much closer to being ready for a production deployment, assuming that each user is known and can provide the neccessary data to train the verifier model."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "torch_gpu",
   "language": "python",
   "name": "torch_gpu"
  },
  "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.9.13"
  },
  "toc": {
   "base_numbering": 1,
   "nav_menu": {},
   "number_sections": true,
   "sideBar": true,
   "skip_h1_title": false,
   "title_cell": "Table of Contents",
   "title_sidebar": "Contents",
   "toc_cell": false,
   "toc_position": {
    "height": "calc(100% - 180px)",
    "left": "10px",
    "top": "150px",
    "width": "384px"
   },
   "toc_section_display": true,
   "toc_window_display": true
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
