From 20db976f819201552e2995972213151a9f412db4 Mon Sep 17 00:00:00 2001 From: dscripka Date: Mon, 22 May 2023 08:05:03 -0400 Subject: [PATCH 001/103] Starting to update code to enable tflite models --- openwakeword/utils.py | 94 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 19 deletions(-) diff --git a/openwakeword/utils.py b/openwakeword/utils.py index 7b5340c..52be608 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -14,7 +14,12 @@ # Imports import os -import onnxruntime as ort +try: + import tflite_runtime.interpreter as tflite + INFERENCE_FRAMEWORK = "tflite" +except ImportError: + import onnxruntime as ort + INFERENCE_FRAMEWORK = "onnx" import numpy as np import pathlib from collections import deque @@ -33,35 +38,86 @@ class AudioFeatures(): `speech_embedding` features. """ def __init__(self, - melspec_onnx_model_path: str = os.path.join( + melspec_model_path: str = os.path.join( pathlib.Path(__file__).parent.resolve(), "resources", "models", "melspectrogram.onnx" ), - embedding_onnx_model_path: str = os.path.join( + embedding_model_path: str = os.path.join( pathlib.Path(__file__).parent.resolve(), "resources", "models", "embedding_model.onnx" ), sr: int = 16000, - ncpu: int = 1 + ncpu: int = 1, + inference_framework: str = "tflite" ): """ Initialize the AudioFeatures object. Args: - melspec_onnx_model_path (str): The path to the ONNX model for computing melspectograms from audio data - embedding_onnx_model_path (str): The path to the ONNX model for Google's `speech_embedding` model + melspec_model_path (str): The path to the model for computing melspectograms from audio data + embedding_model_path (str): The path to the model for Google's `speech_embedding` model sr (int): The sample rate of the audio (default: 16000 khz) ncpu (int): The number of CPUs to use when computing melspectrograms and audio features (default: 1) + inference_framework (str): The inference framework to use when for model prediction. Options are + "tflite" or "onnx". The default is "tflite" as this results in better + efficiency on common platforms (x86, ARM64), but in some deployment + scenarios ONNX models may be preferable. """ - # Initialize the ONNX models - sessionOptions = ort.SessionOptions() - sessionOptions.inter_op_num_threads = ncpu - sessionOptions.intra_op_num_threads = ncpu - self.melspec_model = ort.InferenceSession(melspec_onnx_model_path, sess_options=sessionOptions, - providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) - self.embedding_model = ort.InferenceSession(embedding_onnx_model_path, sess_options=sessionOptions, + # Initialize the models + if INFERENCE_FRAMEWORK == "onnx": + + if melspec_model_path == "": + melspec_model_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "resources", "models", "melspectrogram.onnx") + if embedding_model_path == "": + embedding_model_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "resources", "models", "embedding_model.onnx") + + if ".tflite" in melspec_model_path or ".tflite" in embedding_model_path: + raise ValueError("The onnx inference framework is selected, but tflite models were provided!") + + # Initialize ONNX options + sessionOptions = ort.SessionOptions() + sessionOptions.inter_op_num_threads = ncpu + sessionOptions.intra_op_num_threads = ncpu + + # Melspectrogram model + self.melspec_model = ort.InferenceSession(melspec_model_path, sess_options=sessionOptions, providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) - self.onnx_execution_provider = self.melspec_model.get_providers()[0] + self.onnx_execution_provider = self.melspec_model.get_providers()[0] + self.melspec_model_predict = lambda x: self.melspec_model.run(None, {'input': x}) + + # Audio embedding model + self.embedding_model = ort.InferenceSession(embedding_model_path, sess_options=sessionOptions, + providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) + self.embedding_model_predict = lambda x: self.embedding_model.run(None, {'input_1': x})[0].squeeze() + + elif INFERENCE_FRAMEWORK == "tflite": + + if melspec_model_path == "": + melspec_model_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "resources", "models", "melspectrogram.tflite") + if embedding_model_path == "": + embedding_model_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "resources", "models", "embedding_model.tflite") + + if ".onnx" in melspec_model_path or ".onnx" in embedding_model_path: + raise ValueError("The tflite inference framework is selected, but onnx models were provided!") + + # Melspectrogram model + self.melspec_model = tflite.Interpreter(model_path=melspec_model_path, num_threads=ncpu) + self.melspec_model.resize_tensor_input(0, [1, 1280], strict=True) # initialize with fixed input size + self.melspec_model.allocate_tensors() + + self.tflite_input_details = self.melspec_model.get_input_details() + self.tflite_output_details = self.melspec_model.get_output_details() + + self.melspec_model_predict = lambda x: self.melspec_model.set_tensor(self.tflite_input_details[0]['index'], x) + + # Audio embedding model + self.embedding_model = tflite.Interpreter(model_path=embedding_model_path, num_threads=ncpu) + self.embedding_model.allocate_tensors() + + self.tflite_input_details = self.embedding_model.get_input_details() + self.tflite_output_details = self.embedding_model.get_output_details() + + self.embedding_model_predict = lambda x: self.embedding_model.set_tensor(self.tflite_input_details[0]['index'], x)[0].squeeze() # Create databuffers self.raw_data_buffer: Deque = deque(maxlen=sr*10) @@ -93,7 +149,7 @@ class AudioFeatures(): x = x.astype(np.float32) if x.dtype != np.float32 else x # Get melspectrogram - outputs = self.melspec_model.run(None, {'input': x}) + outputs = self.melspec_model_predict(x) spec = np.squeeze(outputs[0]) # Arbitrary transform of melspectrogram @@ -113,7 +169,7 @@ class AudioFeatures(): """ if melspec.shape[0] != 1: melspec = melspec[None, ] - embedding = self.embedding_model.run(None, {'input_1': melspec})[0].squeeze() + embedding = self.embedding_model_predict(melspec) return embedding def _get_embeddings(self, x: np.ndarray, window_size: int = 76, step_size: int = 8, **kwargs): @@ -126,7 +182,7 @@ class AudioFeatures(): windows.append(window) batch = np.expand_dims(np.array(windows), axis=-1).astype(np.float32) - embedding = self.embedding_model.run(None, {'input_1': batch})[0].squeeze() + embedding = self.embedding_model_predict(batch) return embedding def get_embedding_shape(self, audio_length: float, sr: int = 16000): @@ -229,7 +285,7 @@ class AudioFeatures(): if len(batch) >= batch_size or ndx+1 == x.shape[0]: batch = np.array(batch).astype(np.float32) if "CUDA" in self.onnx_execution_provider: - result = self.embedding_model.run(None, {'input_1': batch})[0].squeeze() + result = self.embedding_model_predict(batch) elif pool: result = np.array(pool.map(self._get_embeddings_from_melspec, @@ -317,7 +373,7 @@ class AudioFeatures(): x = self.melspectrogram_buffer[-76 + ndx:ndx].astype(np.float32)[None, :, :, None] if x.shape[1] == 76: self.feature_buffer = np.vstack((self.feature_buffer, - self.embedding_model.run(None, {'input_1': x})[0].squeeze())) + self.embedding_model_predict(x))) # Reset raw data buffer counter self.accumulated_samples = 0 From 7508a594072994d6b4c0ef5700e09b3c2a476b26 Mon Sep 17 00:00:00 2001 From: dscripka Date: Mon, 5 Jun 2023 08:00:24 -0400 Subject: [PATCH 002/103] tflite integration nearly complete, basic functionality working [skip ci] --- openwakeword/data.py | 2 +- openwakeword/model.py | 117 ++++++++++++++---- .../resources/models/embedding_model.onnx | 4 +- .../resources/models/embedding_model.tflite | 3 + .../resources/models/melspectrogram.tflite | 3 + openwakeword/utils.py | 83 +++++++++---- 6 files changed, 160 insertions(+), 52 deletions(-) create mode 100644 openwakeword/resources/models/embedding_model.tflite create mode 100644 openwakeword/resources/models/melspectrogram.tflite diff --git a/openwakeword/data.py b/openwakeword/data.py index 23bad26..7c34549 100755 --- a/openwakeword/data.py +++ b/openwakeword/data.py @@ -111,7 +111,7 @@ def load_audio_clips(files, clip_size=32000): # Convert clips with sox def _convert_clip(input_file, output_file, backend="ffmpeg"): if backend == "sox": - cmd = f"sox \"{input_file}\" -G -r 16000 -c 1 \"{output_file}\"" + cmd = f"sox \"{input_file}\" -G -r 16000 -c 1 -b 16 \"{output_file}\"" elif backend == "ffmpeg": cmd = f"ffmpeg -y -i \"{input_file}\" -ar 16000 \"{output_file}\"" os.system(cmd) diff --git a/openwakeword/model.py b/openwakeword/model.py index 9d52ddc..153318c 100755 --- a/openwakeword/model.py +++ b/openwakeword/model.py @@ -14,7 +14,6 @@ # Imports import numpy as np -import onnxruntime as ort import openwakeword from openwakeword.utils import AudioFeatures @@ -41,13 +40,15 @@ class Model(): vad_threshold: float = 0, custom_verifier_models: dict = {}, custom_verifier_threshold: float = 0.1, + inference_framework: str = "tflite", **kwargs ): """Initialize the openWakeWord model object. Args: wakeword_model_paths (List[str]): A list of paths of ONNX models to load into the openWakeWord model object. - If not provided, will load all of the pre-trained models. + If not provided, will load all of the pre-trained models. Alternatively, + names of pre-trained models can be provided. class_mapping_dicts (List[dict]): A list of dictionaries with integer to string class mappings for each model in the `wakeword_model_paths` arguments (e.g., {"0": "class_1", "1": "class_2"}) @@ -70,42 +71,96 @@ class Model(): from a model for a given frame is greater than this value, the associated custom verifier model will also predict on that frame, and the verifier score will be returned. + inference_framework (str): The inference framework to use when for model prediction. Options are + "tflite" or "onnx". The default is "tflite" as this results in better + efficiency on common platforms (x86, ARM64), but in some deployment + scenarios ONNX models may be preferable. kwargs (dict): Any other keyword arguments to pass the the preprocessor instance """ - - # Initialize the ONNX models and store them - sessionOptions = ort.SessionOptions() - sessionOptions.inter_op_num_threads = 1 - sessionOptions.intra_op_num_threads = 1 - # Get model paths for pre-trained models if user doesn't provide models to load + pretrained_model_paths = openwakeword.get_pretrained_model_paths() + wakeword_model_names = [] if wakeword_model_paths == []: - wakeword_model_paths = openwakeword.get_pretrained_model_paths() + wakeword_model_paths = pretrained_model_paths wakeword_model_names = list(openwakeword.models.keys()) - else: - wakeword_model_names = [os.path.basename(i[0:-5]) for i in wakeword_model_paths] + elif len(wakeword_model_paths) >= 1: + for ndx, i in enumerate(wakeword_model_paths): + if os.path.exists(i): + wakeword_model_names.append(os.path.splitext(os.path.basename(i))[0]) + else: + # Find pre-trained path by modelname + matching_model = [j for j in pretrained_model_paths if i.replace(" ", "_") in j.split(os.path.sep)[-1]] + if matching_model == []: + raise ValueError("Could not find pretrained model for model name {}".format(i)) + else: + wakeword_model_paths[ndx] = matching_model[0] + wakeword_model_names.append(matching_model.split(os.path.sep)[-1]) # Create attributes to store models and metadata self.models = {} self.model_inputs = {} self.model_outputs = {} + self.model_prediction_function = {} self.class_mapping = {} self.model_input_names = {} self.custom_verifier_models = {} self.custom_verifier_threshold = custom_verifier_threshold + + # Do imports for inference framework + if inference_framework == "onnx": + try: + import onnxruntime as ort + except ImportError: + raise ValueError("Tried to import the onnx runtime, but it was not found. Please install it using `pip install onnxruntime`") + if inference_framework == "tflite": + try: + import tflite_runtime.interpreter as tflite + except ImportError: + raise ValueError("Tried to import the TFLite runtime, but it was not found. Please install it using `pip install tflite-runtime`") + for mdl_path, mdl_name in zip(wakeword_model_paths, wakeword_model_names): # Load openwakeword models - self.models[mdl_name] = ort.InferenceSession(mdl_path, sess_options=sessionOptions, - providers=["CPUExecutionProvider"]) - self.model_inputs[mdl_name] = self.models[mdl_name].get_inputs()[0].shape[1] - self.model_outputs[mdl_name] = self.models[mdl_name].get_outputs()[0].shape[1] + if inference_framework == "onnx": + if ".tflite" in mdl_path: + raise ValueError("The onnx inference framework is selected, but tflite models were provided!") + + sessionOptions = ort.SessionOptions() + sessionOptions.inter_op_num_threads = 1 + sessionOptions.intra_op_num_threads = 1 + + self.models[mdl_name] = ort.InferenceSession(mdl_path, sess_options=sessionOptions, + providers=["CPUExecutionProvider"]) + + self.model_inputs[mdl_name] = self.models[mdl_name].get_inputs()[0].shape[1] + self.model_outputs[mdl_name] = self.models[mdl_name].get_outputs()[0].shape[1] + self.model_prediction_function[mdl_name] = lambda x: self.models[mdl_name].run(None, {self.models[mdl_name].get_inputs()[0].name: x}) + + if inference_framework == "tflite": + if ".onnx" in mdl_path: + raise ValueError("The tflite inference framework is selected, but onnx models were provided!") + + self.models[mdl_name] = tflite.Interpreter(model_path=mdl_path, num_threads=1) + self.models[mdl_name].allocate_tensors() + + self.model_inputs[mdl_name] = self.models[mdl_name].get_input_details()[0]['shape'][1] + self.model_outputs[mdl_name] = self.models[mdl_name].get_output_details()[0]['shape'][1] + + tflite_input_index = self.models[mdl_name].get_input_details()[0]['index'] + tflite_output_index = self.models[mdl_name].get_output_details()[0]['index'] + + def tflite_predict(x): + self.models[mdl_name].set_tensor(tflite_input_index, x) + self.models[mdl_name].invoke() + return self.models[mdl_name].get_tensor(tflite_output_index)[None,] + + self.model_prediction_function[mdl_name] = tflite_predict + if class_mapping_dicts and class_mapping_dicts[wakeword_model_paths.index(mdl_path)].get(mdl_name, None): self.class_mapping[mdl_name] = class_mapping_dicts[wakeword_model_paths.index(mdl_path)] elif openwakeword.model_class_mappings.get(mdl_name, None): self.class_mapping[mdl_name] = openwakeword.model_class_mappings[mdl_name] else: self.class_mapping[mdl_name] = {str(i): str(i) for i in range(0, self.model_outputs[mdl_name])} - self.model_input_names[mdl_name] = self.models[mdl_name].get_inputs()[0].name # Load custom verifier models if isinstance(custom_verifier_models, dict): @@ -136,7 +191,7 @@ class Model(): self.vad = openwakeword.VAD() # Create AudioFeatures object - self.preprocessor = AudioFeatures(**kwargs) + self.preprocessor = AudioFeatures(inference_framework=inference_framework, **kwargs) def get_parent_model_from_label(self, label): """Gets the parent model associated with a given prediction label""" @@ -196,8 +251,6 @@ class Model(): # Get predictions from model(s) predictions = {} for mdl in self.models.keys(): - input_name = self.model_input_names[mdl] - if timing: model_start = time.time() @@ -205,21 +258,31 @@ class Model(): if len(x) > 1280: group_predictions = [] for i in np.arange(len(x)//1280-1, -1, -1): + # group_predictions.extend( + # self.models[mdl].run( + # None, + # {input_name: self.preprocessor.get_features( + # self.model_inputs[mdl], + # start_ndx=-self.model_inputs[mdl] - i + # )} + # ) group_predictions.extend( - self.models[mdl].run( - None, - {input_name: self.preprocessor.get_features( + self.model_prediction_function[mdl]( + self.preprocessor.get_features( self.model_inputs[mdl], start_ndx=-self.model_inputs[mdl] - i - )} + ) ) ) prediction = np.array(group_predictions).max(axis=0)[None, ] else: - prediction = self.models[mdl].run( - None, - {input_name: self.preprocessor.get_features(self.model_inputs[mdl])} - ) + # prediction = self.models[mdl].run( + # None, + # {input_name: self.preprocessor.get_features(self.model_inputs[mdl])} + # ) + prediction = self.model_prediction_function[mdl]( + self.preprocessor.get_features(self.model_inputs[mdl]) + ) if self.model_outputs[mdl] == 1: predictions[mdl] = prediction[0][0][0] diff --git a/openwakeword/resources/models/embedding_model.onnx b/openwakeword/resources/models/embedding_model.onnx index 8582948..2c928ee 100644 --- a/openwakeword/resources/models/embedding_model.onnx +++ b/openwakeword/resources/models/embedding_model.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ba754db3cd768a524c655ea90655ee5e6055a43b8dfd29366a11e93716ae9e51 -size 1328103 +oid sha256:70d164290c1d095d1d4ee149bc5e00543250a7316b59f31d056cff7bd3075c1f +size 1326578 diff --git a/openwakeword/resources/models/embedding_model.tflite b/openwakeword/resources/models/embedding_model.tflite new file mode 100644 index 0000000..52a5336 --- /dev/null +++ b/openwakeword/resources/models/embedding_model.tflite @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0aea21eb84a4ce90a08c870da41b7a7173b45269e6a3207c71d67c40f3a59d8 +size 1330312 diff --git a/openwakeword/resources/models/melspectrogram.tflite b/openwakeword/resources/models/melspectrogram.tflite new file mode 100644 index 0000000..c0f0ab8 --- /dev/null +++ b/openwakeword/resources/models/melspectrogram.tflite @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96fa0adccb6e8cf95cb14465409a1a2898ee4a96a85bb9ed3c7eb0e68bf163e8 +size 1092516 diff --git a/openwakeword/utils.py b/openwakeword/utils.py index 52be608..4fb4633 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -38,17 +38,12 @@ class AudioFeatures(): `speech_embedding` features. """ def __init__(self, - melspec_model_path: str = os.path.join( - pathlib.Path(__file__).parent.resolve(), - "resources", "models", "melspectrogram.onnx" - ), - embedding_model_path: str = os.path.join( - pathlib.Path(__file__).parent.resolve(), - "resources", "models", "embedding_model.onnx" - ), + melspec_model_path: str = "", + embedding_model_path: str = "", sr: int = 16000, ncpu: int = 1, - inference_framework: str = "tflite" + inference_framework: str = "onnx", + device: str = 'cpu' ): """ Initialize the AudioFeatures object. @@ -62,9 +57,18 @@ class AudioFeatures(): "tflite" or "onnx". The default is "tflite" as this results in better efficiency on common platforms (x86, ARM64), but in some deployment scenarios ONNX models may be preferable. + device (str): The device to use when running the models, either "cpu" or "gpu" (default is "cpu".) + Note that depending on the inference framework selected and system configuration, + this setting may not have an effect. For example, to use a GPU with the ONNX + framework the appropriate onnxruntime package must be installed. """ - # Initialize the models - if INFERENCE_FRAMEWORK == "onnx": + # Initialize the models with the appropriate framework + if inference_framework == "onnx": + try: + import onnxruntime as ort + except ImportError: + raise ValueError("Tried to import the onnx runtime, but it was not found. Please install it using `pip install onnxruntime`") + if melspec_model_path == "": melspec_model_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "resources", "models", "melspectrogram.onnx") @@ -81,16 +85,20 @@ class AudioFeatures(): # Melspectrogram model self.melspec_model = ort.InferenceSession(melspec_model_path, sess_options=sessionOptions, - providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) + providers=["CUDAExecutionProvider"] if device=="gpu" else ["CPUExecutionProvider"]) self.onnx_execution_provider = self.melspec_model.get_providers()[0] self.melspec_model_predict = lambda x: self.melspec_model.run(None, {'input': x}) # Audio embedding model self.embedding_model = ort.InferenceSession(embedding_model_path, sess_options=sessionOptions, - providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) + providers=["CUDAExecutionProvider"] if device=="gpu" else ["CPUExecutionProvider"]) self.embedding_model_predict = lambda x: self.embedding_model.run(None, {'input_1': x})[0].squeeze() - elif INFERENCE_FRAMEWORK == "tflite": + elif inference_framework == "tflite": + try: + import tflite_runtime.interpreter as tflite + except ImportError: + raise ValueError("Tried to import the TFLite runtime, but it was not found. Please install it using `pip install tflite-runtime`") if melspec_model_path == "": melspec_model_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "resources", "models", "melspectrogram.tflite") @@ -105,26 +113,57 @@ class AudioFeatures(): self.melspec_model.resize_tensor_input(0, [1, 1280], strict=True) # initialize with fixed input size self.melspec_model.allocate_tensors() - self.tflite_input_details = self.melspec_model.get_input_details() - self.tflite_output_details = self.melspec_model.get_output_details() + melspec_input_index = self.melspec_model.get_input_details()[0]['index'] + melspec_output_index = self.melspec_model.get_output_details()[0]['index'] - self.melspec_model_predict = lambda x: self.melspec_model.set_tensor(self.tflite_input_details[0]['index'], x) + self._tflite_current_melspec_input_size = 1280 + def tflite_melspec_predict(x): + if x.shape[1] != 1280: + self.melspec_model.resize_tensor_input(0, [1, x.shape[1]], strict=True) # initialize with fixed input size + self.melspec_model.allocate_tensors() + self._tflite_current_melspec_input_size = x.shape[1] + elif self._tflite_current_melspec_input_size != 1280: + self.melspec_model.resize_tensor_input(0, [1, 1280], strict=True) # initialize with fixed input size + self.melspec_model.allocate_tensors() + self._tflite_current_melspec_input_size = 1280 + + self.melspec_model.set_tensor(melspec_input_index, x) + self.melspec_model.invoke() + return self.melspec_model.get_tensor(melspec_output_index) + + self.melspec_model_predict = tflite_melspec_predict # Audio embedding model self.embedding_model = tflite.Interpreter(model_path=embedding_model_path, num_threads=ncpu) self.embedding_model.allocate_tensors() - self.tflite_input_details = self.embedding_model.get_input_details() - self.tflite_output_details = self.embedding_model.get_output_details() + embedding_input_index = self.embedding_model.get_input_details()[0]['index'] + embedding_output_index = self.embedding_model.get_output_details()[0]['index'] - self.embedding_model_predict = lambda x: self.embedding_model.set_tensor(self.tflite_input_details[0]['index'], x)[0].squeeze() + self._tflite_current_embedding_batch_size = 1 + def tflite_embedding_predict(x): + if x.shape[0] != 1: + self.embedding_model.resize_tensor_input(0, [x.shape[0], 76, 32, 1], strict=True) # initialize with fixed input size + self.embedding_model.allocate_tensors() + self._tflite_current_embedding_batch_size = x.shape[0] + elif self._tflite_current_embedding_batch_size != 1: + self.embedding_model.resize_tensor_input(0, [1, 76, 32, 1], strict=True) # initialize with fixed input size + self.embedding_model.allocate_tensors() + self._tflite_current_embedding_batch_size = x.shape[0] + + self.embedding_model.set_tensor(embedding_input_index, x) + self.embedding_model.invoke() + return self.embedding_model.get_tensor(embedding_output_index).squeeze() + + self.embedding_model_predict = tflite_embedding_predict # Create databuffers self.raw_data_buffer: Deque = deque(maxlen=sr*10) self.melspectrogram_buffer = np.ones((76, 32)) # n_frames x num_features self.melspectrogram_max_len = 10*97 # 97 is the number of frames in 1 second of 16hz audio self.accumulated_samples = 0 # the samples added to the buffer since the audio preprocessor was last called - self.feature_buffer = self._get_embeddings(np.zeros(160000).astype(np.int16)) # fill with blank data to start + # self.feature_buffer = np.vstack([self._get_embeddings(np.random.randint(-1000, 1000, 1280).astype(np.int16)) for _ in range(10)]) + self.feature_buffer = self._get_embeddings(np.random.randint(-1000,1000, 16000*4).astype(np.int16)) self.feature_buffer_max_len = 120 # ~10 seconds of feature buffer history def _get_melspectrogram(self, x: Union[np.ndarray, List], melspec_transform: Callable = lambda x: x/10 + 2): @@ -362,7 +401,7 @@ class AudioFeatures(): self._buffer_raw_data(x) self.accumulated_samples += len(x) - # Only calculate melspectrogram every ~0.5 seconds to significantly increase efficiency + # Only calculate melspectrogram once minimum samples area accumulated if self.accumulated_samples >= 1280: self._streaming_melspectrogram(self.accumulated_samples) From c94b6374a977d952feb951ba57276bb8e8d8fccb Mon Sep 17 00:00:00 2001 From: dscripka Date: Fri, 9 Jun 2023 14:52:41 -0400 Subject: [PATCH 003/103] Converted onnx models to tflite, replaced hey_mycroft model with corrected version --- openwakeword/resources/models/alexa_v0.1.tflite | 3 +++ openwakeword/resources/models/hey_jarvis_v0.1.tflite | 3 +++ openwakeword/resources/models/hey_mycroft_v0.1.onnx | 4 ++-- openwakeword/resources/models/hey_mycroft_v0.1.tflite | 3 +++ openwakeword/resources/models/hey_rhasspy_v0.1.tflite | 3 +++ openwakeword/resources/models/timer_v0.1.tflite | 3 +++ openwakeword/resources/models/weather_v0.1.tflite | 3 +++ 7 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 openwakeword/resources/models/alexa_v0.1.tflite create mode 100644 openwakeword/resources/models/hey_jarvis_v0.1.tflite create mode 100644 openwakeword/resources/models/hey_mycroft_v0.1.tflite create mode 100644 openwakeword/resources/models/hey_rhasspy_v0.1.tflite create mode 100644 openwakeword/resources/models/timer_v0.1.tflite create mode 100644 openwakeword/resources/models/weather_v0.1.tflite diff --git a/openwakeword/resources/models/alexa_v0.1.tflite b/openwakeword/resources/models/alexa_v0.1.tflite new file mode 100644 index 0000000..5d516e2 --- /dev/null +++ b/openwakeword/resources/models/alexa_v0.1.tflite @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7333a317a790070a7f3432b81d9439c779481cc4ebd67c73da7174ea3cf48397 +size 855312 diff --git a/openwakeword/resources/models/hey_jarvis_v0.1.tflite b/openwakeword/resources/models/hey_jarvis_v0.1.tflite new file mode 100644 index 0000000..d155242 --- /dev/null +++ b/openwakeword/resources/models/hey_jarvis_v0.1.tflite @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14bff778604985e1b5c19f0f7bbe477a69cf281d8db34b232b3b972411f710e2 +size 1278912 diff --git a/openwakeword/resources/models/hey_mycroft_v0.1.onnx b/openwakeword/resources/models/hey_mycroft_v0.1.onnx index f6fe2c6..b9952b3 100644 --- a/openwakeword/resources/models/hey_mycroft_v0.1.onnx +++ b/openwakeword/resources/models/hey_mycroft_v0.1.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:785bdf5655863ae47553b23793aa108c7b0152d4823f7869b41f2d2d765912fc -size 503850 +oid sha256:c2a311e8fa1338de89c31b3b46dc4dffd4af2f9a8d6ddead48893c2d301b1f18 +size 857691 diff --git a/openwakeword/resources/models/hey_mycroft_v0.1.tflite b/openwakeword/resources/models/hey_mycroft_v0.1.tflite new file mode 100644 index 0000000..53b373c --- /dev/null +++ b/openwakeword/resources/models/hey_mycroft_v0.1.tflite @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf9e43136afd3ca323698820a6e32a47f885ef4c30a3b8b577ec71688a9d64d8 +size 860300 diff --git a/openwakeword/resources/models/hey_rhasspy_v0.1.tflite b/openwakeword/resources/models/hey_rhasspy_v0.1.tflite new file mode 100644 index 0000000..4fb9b6c --- /dev/null +++ b/openwakeword/resources/models/hey_rhasspy_v0.1.tflite @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01d2526b45068f565aa3849d6ec2b7abae099154fc1b496f9ef20de9ef241fe9 +size 416140 diff --git a/openwakeword/resources/models/timer_v0.1.tflite b/openwakeword/resources/models/timer_v0.1.tflite new file mode 100644 index 0000000..11a7d50 --- /dev/null +++ b/openwakeword/resources/models/timer_v0.1.tflite @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21d5b0267e97df64870b7aca312e2043ebed248d365698926a115a3694ff9626 +size 1743316 diff --git a/openwakeword/resources/models/weather_v0.1.tflite b/openwakeword/resources/models/weather_v0.1.tflite new file mode 100644 index 0000000..95dab6e --- /dev/null +++ b/openwakeword/resources/models/weather_v0.1.tflite @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4178991c7aeb76670f5a56559eb4129a6f3ae6207886db8bd8094fea7d362c3f +size 1150224 From 3724ef01bc05b4371430586f2a85c756f6a88004 Mon Sep 17 00:00:00 2001 From: dscripka Date: Fri, 9 Jun 2023 16:20:10 -0400 Subject: [PATCH 004/103] tests passing locally [skip ci] --- openwakeword/__init__.py | 17 +++++--- openwakeword/custom_verifier_model.py | 4 +- openwakeword/model.py | 63 +++++++++++++++------------ openwakeword/utils.py | 47 ++++++++++---------- pyproject.toml | 2 +- tests/test_custom_verifier_model.py | 7 +-- tests/test_models.py | 22 ++++++++-- 7 files changed, 92 insertions(+), 70 deletions(-) diff --git a/openwakeword/__init__.py b/openwakeword/__init__.py index 9dd2eaf..37931d8 100755 --- a/openwakeword/__init__.py +++ b/openwakeword/__init__.py @@ -7,19 +7,19 @@ __all__ = ['Model', 'VAD', 'train_custom_verifier'] models = { "alexa": { - "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/alexa_v0.1.onnx") + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/alexa_v0.1.tflite") }, "hey_mycroft": { - "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_mycroft_v0.1.onnx") + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_mycroft_v0.1.tflite") }, "hey_jarvis": { - "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_jarvis_v0.1.onnx") + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_jarvis_v0.1.tflite") }, "timer": { - "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/timer_v0.1.onnx") + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/timer_v0.1.tflite") }, "weather": { - "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/weather_v0.1.onnx") + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/weather_v0.1.tflite") } } @@ -35,5 +35,8 @@ model_class_mappings = { } -def get_pretrained_model_paths(): - return [models[i]["model_path"] for i in models.keys()] +def get_pretrained_model_paths(inference_framework): + if inference_framework == "tflite": + return [models[i]["model_path"] for i in models.keys()] + elif inference_framework == "onnx": + return [models[i]["model_path"].replace(".tflite", ".onnx") for i in models.keys()] diff --git a/openwakeword/custom_verifier_model.py b/openwakeword/custom_verifier_model.py index d2c0211..e6c07d1 100644 --- a/openwakeword/custom_verifier_model.py +++ b/openwakeword/custom_verifier_model.py @@ -139,10 +139,10 @@ def train_custom_verifier( # Load target openWakeWord model if os.path.exists(model_name): oww = openwakeword.Model( - wakeword_model_paths=[model_name], + wakeword_models=[model_name], **kwargs ) - model_name = model_name.split(os.path.sep)[-1][0:-5] + model_name = os.path.splitext(model_name)[0].split(os.path.sep)[-1] else: oww = openwakeword.Model(**kwargs) diff --git a/openwakeword/model.py b/openwakeword/model.py index 153318c..d78b92f 100755 --- a/openwakeword/model.py +++ b/openwakeword/model.py @@ -19,6 +19,7 @@ from openwakeword.utils import AudioFeatures import wave import os +import functools import pickle from collections import deque, defaultdict from functools import partial @@ -34,7 +35,7 @@ class Model(): """ def __init__( self, - wakeword_model_paths: List[str] = [], + wakeword_models: List[str] = [], class_mapping_dicts: List[dict] = [], enable_speex_noise_suppression: bool = False, vad_threshold: float = 0, @@ -46,11 +47,11 @@ class Model(): """Initialize the openWakeWord model object. Args: - wakeword_model_paths (List[str]): A list of paths of ONNX models to load into the openWakeWord model object. + wakeword_models (List[str]): A list of paths of ONNX/tflite models to load into the openWakeWord model object. If not provided, will load all of the pre-trained models. Alternatively, - names of pre-trained models can be provided. + just the names of pre-trained models can be provided to select a subset of models. class_mapping_dicts (List[dict]): A list of dictionaries with integer to string class mappings for - each model in the `wakeword_model_paths` arguments + each model in the `wakeword_models` arguments (e.g., {"0": "class_1", "1": "class_2"}) enable_speex_noise_suppression (bool): Whether to use the noise suppresion from the SpeexDSP library to pre-process all incoming audio. May increase @@ -71,30 +72,30 @@ class Model(): from a model for a given frame is greater than this value, the associated custom verifier model will also predict on that frame, and the verifier score will be returned. - inference_framework (str): The inference framework to use when for model prediction. Options are + inference_framework (str): The inference framework to use when for model prediction. Options are "tflite" or "onnx". The default is "tflite" as this results in better efficiency on common platforms (x86, ARM64), but in some deployment scenarios ONNX models may be preferable. kwargs (dict): Any other keyword arguments to pass the the preprocessor instance """ # Get model paths for pre-trained models if user doesn't provide models to load - pretrained_model_paths = openwakeword.get_pretrained_model_paths() + pretrained_model_paths = openwakeword.get_pretrained_model_paths(inference_framework) wakeword_model_names = [] - if wakeword_model_paths == []: - wakeword_model_paths = pretrained_model_paths + if wakeword_models == []: + wakeword_models = pretrained_model_paths wakeword_model_names = list(openwakeword.models.keys()) - elif len(wakeword_model_paths) >= 1: - for ndx, i in enumerate(wakeword_model_paths): + elif len(wakeword_models) >= 1: + for ndx, i in enumerate(wakeword_models): if os.path.exists(i): wakeword_model_names.append(os.path.splitext(os.path.basename(i))[0]) else: # Find pre-trained path by modelname matching_model = [j for j in pretrained_model_paths if i.replace(" ", "_") in j.split(os.path.sep)[-1]] if matching_model == []: - raise ValueError("Could not find pretrained model for model name {}".format(i)) + raise ValueError("Could not find pretrained model for model name '{}'".format(i)) else: - wakeword_model_paths[ndx] = matching_model[0] - wakeword_model_names.append(matching_model.split(os.path.sep)[-1]) + wakeword_models[ndx] = matching_model[0] + wakeword_model_names.append(matching_model[0].split(os.path.sep)[-1]) # Create attributes to store models and metadata self.models = {} @@ -102,7 +103,6 @@ class Model(): self.model_outputs = {} self.model_prediction_function = {} self.class_mapping = {} - self.model_input_names = {} self.custom_verifier_models = {} self.custom_verifier_threshold = custom_verifier_threshold @@ -111,14 +111,21 @@ class Model(): try: import onnxruntime as ort except ImportError: - raise ValueError("Tried to import the onnx runtime, but it was not found. Please install it using `pip install onnxruntime`") + raise ValueError("Tried to import onnxruntime, but it was not found. Please install it using `pip install onnxruntime`") if inference_framework == "tflite": try: import tflite_runtime.interpreter as tflite - except ImportError: - raise ValueError("Tried to import the TFLite runtime, but it was not found. Please install it using `pip install tflite-runtime`") - for mdl_path, mdl_name in zip(wakeword_model_paths, wakeword_model_names): + def tflite_predict(tflite_interpreter, input_index, output_index, x): + tflite_interpreter.set_tensor(input_index, x) + tflite_interpreter.invoke() + return tflite_interpreter.get_tensor(output_index)[None, ] + + except ImportError: + raise ValueError("Tried to import the TFLite runtime, but it was not found." + "Please install it using `pip install tflite-runtime`") + + for mdl_path, mdl_name in zip(wakeword_models, wakeword_model_names): # Load openwakeword models if inference_framework == "onnx": if ".tflite" in mdl_path: @@ -129,12 +136,14 @@ class Model(): sessionOptions.intra_op_num_threads = 1 self.models[mdl_name] = ort.InferenceSession(mdl_path, sess_options=sessionOptions, - providers=["CPUExecutionProvider"]) + providers=["CPUExecutionProvider"]) self.model_inputs[mdl_name] = self.models[mdl_name].get_inputs()[0].shape[1] self.model_outputs[mdl_name] = self.models[mdl_name].get_outputs()[0].shape[1] - self.model_prediction_function[mdl_name] = lambda x: self.models[mdl_name].run(None, {self.models[mdl_name].get_inputs()[0].name: x}) - + self.model_prediction_function[mdl_name] = lambda x: self.models[mdl_name].run( + None, {self.models[mdl_name].get_inputs()[0].name: x} + ) + if inference_framework == "tflite": if ".onnx" in mdl_path: raise ValueError("The tflite inference framework is selected, but onnx models were provided!") @@ -148,15 +157,11 @@ class Model(): tflite_input_index = self.models[mdl_name].get_input_details()[0]['index'] tflite_output_index = self.models[mdl_name].get_output_details()[0]['index'] - def tflite_predict(x): - self.models[mdl_name].set_tensor(tflite_input_index, x) - self.models[mdl_name].invoke() - return self.models[mdl_name].get_tensor(tflite_output_index)[None,] + foo = functools.partial(tflite_predict, self.models[mdl_name], tflite_input_index, tflite_output_index) + self.model_prediction_function[mdl_name] = foo - self.model_prediction_function[mdl_name] = tflite_predict - - if class_mapping_dicts and class_mapping_dicts[wakeword_model_paths.index(mdl_path)].get(mdl_name, None): - self.class_mapping[mdl_name] = class_mapping_dicts[wakeword_model_paths.index(mdl_path)] + if class_mapping_dicts and class_mapping_dicts[wakeword_models.index(mdl_path)].get(mdl_name, None): + self.class_mapping[mdl_name] = class_mapping_dicts[wakeword_models.index(mdl_path)] elif openwakeword.model_class_mappings.get(mdl_name, None): self.class_mapping[mdl_name] = openwakeword.model_class_mappings[mdl_name] else: diff --git a/openwakeword/utils.py b/openwakeword/utils.py index 4fb4633..69b07cc 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -14,12 +14,6 @@ # Imports import os -try: - import tflite_runtime.interpreter as tflite - INFERENCE_FRAMEWORK = "tflite" -except ImportError: - import onnxruntime as ort - INFERENCE_FRAMEWORK = "onnx" import numpy as np import pathlib from collections import deque @@ -53,7 +47,7 @@ class AudioFeatures(): embedding_model_path (str): The path to the model for Google's `speech_embedding` model sr (int): The sample rate of the audio (default: 16000 khz) ncpu (int): The number of CPUs to use when computing melspectrograms and audio features (default: 1) - inference_framework (str): The inference framework to use when for model prediction. Options are + inference_framework (str): The inference framework to use when for model prediction. Options are "tflite" or "onnx". The default is "tflite" as this results in better efficiency on common platforms (x86, ARM64), but in some deployment scenarios ONNX models may be preferable. @@ -67,8 +61,7 @@ class AudioFeatures(): try: import onnxruntime as ort except ImportError: - raise ValueError("Tried to import the onnx runtime, but it was not found. Please install it using `pip install onnxruntime`") - + raise ValueError("Tried to import onnxruntime, but it was not found. Please install it using `pip install onnxruntime`") if melspec_model_path == "": melspec_model_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "resources", "models", "melspectrogram.onnx") @@ -77,7 +70,7 @@ class AudioFeatures(): if ".tflite" in melspec_model_path or ".tflite" in embedding_model_path: raise ValueError("The onnx inference framework is selected, but tflite models were provided!") - + # Initialize ONNX options sessionOptions = ort.SessionOptions() sessionOptions.inter_op_num_threads = ncpu @@ -85,45 +78,50 @@ class AudioFeatures(): # Melspectrogram model self.melspec_model = ort.InferenceSession(melspec_model_path, sess_options=sessionOptions, - providers=["CUDAExecutionProvider"] if device=="gpu" else ["CPUExecutionProvider"]) + providers=["CUDAExecutionProvider"] if device == "gpu" else ["CPUExecutionProvider"]) self.onnx_execution_provider = self.melspec_model.get_providers()[0] self.melspec_model_predict = lambda x: self.melspec_model.run(None, {'input': x}) # Audio embedding model self.embedding_model = ort.InferenceSession(embedding_model_path, sess_options=sessionOptions, - providers=["CUDAExecutionProvider"] if device=="gpu" else ["CPUExecutionProvider"]) + providers=["CUDAExecutionProvider"] if device == "gpu" + else ["CPUExecutionProvider"]) self.embedding_model_predict = lambda x: self.embedding_model.run(None, {'input_1': x})[0].squeeze() elif inference_framework == "tflite": try: import tflite_runtime.interpreter as tflite except ImportError: - raise ValueError("Tried to import the TFLite runtime, but it was not found. Please install it using `pip install tflite-runtime`") + raise ValueError("Tried to import the TFLite runtime, but it was not found." + "Please install it using `pip install tflite-runtime`") if melspec_model_path == "": - melspec_model_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "resources", "models", "melspectrogram.tflite") + melspec_model_path = os.path.join(pathlib.Path(__file__).parent.resolve(), + "resources", "models", "melspectrogram.tflite") if embedding_model_path == "": - embedding_model_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "resources", "models", "embedding_model.tflite") + embedding_model_path = os.path.join(pathlib.Path(__file__).parent.resolve(), + "resources", "models", "embedding_model.tflite") if ".onnx" in melspec_model_path or ".onnx" in embedding_model_path: raise ValueError("The tflite inference framework is selected, but onnx models were provided!") # Melspectrogram model self.melspec_model = tflite.Interpreter(model_path=melspec_model_path, num_threads=ncpu) - self.melspec_model.resize_tensor_input(0, [1, 1280], strict=True) # initialize with fixed input size + self.melspec_model.resize_tensor_input(0, [1, 1280], strict=True) # initialize with fixed input size self.melspec_model.allocate_tensors() melspec_input_index = self.melspec_model.get_input_details()[0]['index'] melspec_output_index = self.melspec_model.get_output_details()[0]['index'] self._tflite_current_melspec_input_size = 1280 + def tflite_melspec_predict(x): if x.shape[1] != 1280: - self.melspec_model.resize_tensor_input(0, [1, x.shape[1]], strict=True) # initialize with fixed input size + self.melspec_model.resize_tensor_input(0, [1, x.shape[1]], strict=True) # initialize with fixed input size self.melspec_model.allocate_tensors() self._tflite_current_melspec_input_size = x.shape[1] elif self._tflite_current_melspec_input_size != 1280: - self.melspec_model.resize_tensor_input(0, [1, 1280], strict=True) # initialize with fixed input size + self.melspec_model.resize_tensor_input(0, [1, 1280], strict=True) # initialize with fixed input size self.melspec_model.allocate_tensors() self._tflite_current_melspec_input_size = 1280 @@ -141,15 +139,16 @@ class AudioFeatures(): embedding_output_index = self.embedding_model.get_output_details()[0]['index'] self._tflite_current_embedding_batch_size = 1 + def tflite_embedding_predict(x): if x.shape[0] != 1: - self.embedding_model.resize_tensor_input(0, [x.shape[0], 76, 32, 1], strict=True) # initialize with fixed input size + self.embedding_model.resize_tensor_input(0, [x.shape[0], 76, 32, 1], strict=True) # initialize with fixed input size self.embedding_model.allocate_tensors() self._tflite_current_embedding_batch_size = x.shape[0] elif self._tflite_current_embedding_batch_size != 1: - self.embedding_model.resize_tensor_input(0, [1, 76, 32, 1], strict=True) # initialize with fixed input size + self.embedding_model.resize_tensor_input(0, [1, 76, 32, 1], strict=True) # initialize with fixed input size self.embedding_model.allocate_tensors() - self._tflite_current_embedding_batch_size = x.shape[0] + self._tflite_current_embedding_batch_size = x.shape[0] self.embedding_model.set_tensor(embedding_input_index, x) self.embedding_model.invoke() @@ -163,7 +162,7 @@ class AudioFeatures(): self.melspectrogram_max_len = 10*97 # 97 is the number of frames in 1 second of 16hz audio self.accumulated_samples = 0 # the samples added to the buffer since the audio preprocessor was last called # self.feature_buffer = np.vstack([self._get_embeddings(np.random.randint(-1000, 1000, 1280).astype(np.int16)) for _ in range(10)]) - self.feature_buffer = self._get_embeddings(np.random.randint(-1000,1000, 16000*4).astype(np.int16)) + self.feature_buffer = self._get_embeddings(np.random.randint(-1000, 1000, 16000*4).astype(np.int16)) self.feature_buffer_max_len = 120 # ~10 seconds of feature buffer history def _get_melspectrogram(self, x: Union[np.ndarray, List], melspec_transform: Callable = lambda x: x/10 + 2): @@ -435,7 +434,7 @@ class AudioFeatures(): # Bulk prediction function def bulk_predict( file_paths: List[str], - wakeword_model_paths: List[str], + wakeword_models: List[str], prediction_function: str = 'predict_clip', ncpu: int = 1, **kwargs @@ -471,7 +470,7 @@ def bulk_predict( filtered_kwargs = {key: value for key, value in kwargs.items() if key in openwakeword.Model.__init__.__code__.co_varnames} oww = openwakeword.Model( - wakeword_model_paths=wakeword_model_paths, + wakeword_models=wakeword_models, **filtered_kwargs ) mdls.append(oww) diff --git a/pyproject.toml b/pyproject.toml index 7d999a0..8d61186 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [tool.pytest.ini_options] addopts = "--cov=openwakeword --cov-report term-missing --flake8 --mypy --mypy-ignore-missing-imports" -flake8-max-line-length = "120" +flake8-max-line-length = "140" testpaths = [ "tests", "openwakeword" diff --git a/tests/test_custom_verifier_model.py b/tests/test_custom_verifier_model.py index 6ca6d1d..d5665e5 100644 --- a/tests/test_custom_verifier_model.py +++ b/tests/test_custom_verifier_model.py @@ -69,20 +69,21 @@ class TestModels: positive_reference_clips=reference_clips, negative_reference_clips=negative_clips, output_path=os.path.join(tmp_dir, 'verifier_model.pkl'), - model_name=os.path.join("openwakeword", "resources", "models", "hey_mycroft_v0.1.onnx") + model_name=os.path.join("openwakeword", "resources", "models", "hey_mycroft_v0.1.tflite") ) with pytest.raises(ValueError): # Load model with verifier model incorrectly to catch ValueError owwModel = openwakeword.Model( - wakeword_model_paths=[os.path.join("openwakeword", "resources", "models", "hey_mycroft_v0.1.onnx")], + wakeword_models=[os.path.join("openwakeword", "resources", + "models", "hey_mycroft_v0.1.tflite")], custom_verifier_models={"bad_key": os.path.join(tmp_dir, "verifier_model.pkl")}, custom_verifier_threshold=0.3, ) # Load model with verifier model incorrectly to catch ValueError owwModel = openwakeword.Model( - wakeword_model_paths=[os.path.join("openwakeword", "resources", "models", "hey_mycroft_v0.1.onnx")], + wakeword_models=[os.path.join("openwakeword", "resources", "models", "hey_mycroft_v0.1.tflite")], custom_verifier_models={"hey_mycroft_v0.1": os.path.join(tmp_dir, "verifier_model.pkl")}, custom_verifier_threshold=0.3, ) diff --git a/tests/test_models.py b/tests/test_models.py index 9196315..195fd23 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -40,9 +40,13 @@ import platform class TestModels: def test_load_models_by_path(self): # Load model with defaults - owwModel = openwakeword.Model(wakeword_model_paths=[ + owwModel = openwakeword.Model(wakeword_models=[ os.path.join("openwakeword", "resources", "models", "alexa_v0.1.onnx") - ]) + ], inference_framework="onnx") + + owwModel = openwakeword.Model(wakeword_models=[ + os.path.join("openwakeword", "resources", "models", "alexa_v0.1.tflite") + ], inference_framework="tflite") # Prediction on random data owwModel.predict(np.random.randint(-1000, 1000, 1280).astype(np.int16)) @@ -50,12 +54,22 @@ class TestModels: # Prediction on random data with different chunk size owwModel.predict(np.random.randint(-1000, 1000, 1280*2).astype(np.int16)) + def test_load_pretrained_model_by_name(self): + # Load model with defaults + owwModel = openwakeword.Model(wakeword_models=["alexa", "hey mycroft"], inference_framework="onnx") + + owwModel = openwakeword.Model(wakeword_models=["alexa", "hey mycroft"], inference_framework="tflite") + + # Prediction on random data + owwModel.predict(np.random.randint(-1000, 1000, 1280).astype(np.int16)) + def test_custom_model_label_mapping_dict(self): # Load model with model path - owwModel = openwakeword.Model(wakeword_model_paths=[ + owwModel = openwakeword.Model(wakeword_models=[ os.path.join("openwakeword", "resources", "models", "alexa_v0.1.onnx") ], - class_mapping_dicts=[{"alexa_v0.1": {"0": "positive"}}] + class_mapping_dicts=[{"alexa_v0.1": {"0": "positive"}}], + inference_framework="onnx" ) # Prediction on random data From 160ce567905632e60515098959770b8df8e92aca Mon Sep 17 00:00:00 2001 From: dscripka Date: Fri, 9 Jun 2023 16:24:43 -0400 Subject: [PATCH 005/103] Added default arg [skip ci] --- openwakeword/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openwakeword/__init__.py b/openwakeword/__init__.py index 37931d8..514d1e9 100755 --- a/openwakeword/__init__.py +++ b/openwakeword/__init__.py @@ -35,7 +35,7 @@ model_class_mappings = { } -def get_pretrained_model_paths(inference_framework): +def get_pretrained_model_paths(inference_framework="tflite"): if inference_framework == "tflite": return [models[i]["model_path"] for i in models.keys()] elif inference_framework == "onnx": From 105e748da838d3576bb78de668954846073260e1 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sat, 10 Jun 2023 09:00:34 -0400 Subject: [PATCH 006/103] Added warning about deprecated keyword arguments --- .gitattributes | 1 + openwakeword/model.py | 3 ++- openwakeword/utils.py | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 0bb75f7..8343da4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ *.onnx filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text \ No newline at end of file diff --git a/openwakeword/model.py b/openwakeword/model.py index d78b92f..9094597 100755 --- a/openwakeword/model.py +++ b/openwakeword/model.py @@ -15,7 +15,7 @@ # Imports import numpy as np import openwakeword -from openwakeword.utils import AudioFeatures +from openwakeword.utils import AudioFeatures, re_arg import wave import os @@ -33,6 +33,7 @@ class Model(): The main model class for openWakeWord. Creates a model object with the shared audio pre-processer and for arbitrarily many custom wake word/wake phrase models. """ + @re_arg({"wakeword_model_paths": "wakeword_models"}) # temporary handling of keyword argument change def __init__( self, wakeword_models: List[str] = [], diff --git a/openwakeword/utils.py b/openwakeword/utils.py index 69b07cc..3a6411f 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -20,6 +20,7 @@ from collections import deque from multiprocessing.pool import ThreadPool from multiprocessing import Process, Queue import time +import logging import openwakeword from typing import Union, List, Callable, Deque @@ -499,3 +500,18 @@ def bulk_predict( # Consolidate results and return return {list(i.keys())[0]: list(i.values())[0] for i in results} + + +# Handle deprecated arguments and naming (thanks to https://stackoverflow.com/a/74564394) +def re_arg(kwarg_map): + def decorator(func): + def wrapped(*args, **kwargs): + new_kwargs = {} + for k, v in kwargs.items(): + if k in kwarg_map: + logging.warning(f"DEPRECATION: keyword argument '{k}' is no longer valid and " + f"will be removed in future releases. Use '{kwarg_map[k]}' instead.") + new_kwargs[kwarg_map.get(k, k)] = v + return func(*args, **new_kwargs) + return wrapped + return decorator From e561a8a47706277643f2149be79f5c73e9ce4065 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sat, 10 Jun 2023 09:11:55 -0400 Subject: [PATCH 007/103] Updated requirements for tflite --- setup.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 46bc4a9..a9ff492 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,13 @@ def build_additional_requires(): setuptools.setup( name="openwakeword", version="0.4.0", - install_requires=['onnxruntime>=1.10.0,<2', 'tqdm>=4.0,<5.0', 'scipy>=1.3,<2', 'scikit-learn>=1,<2'], + install_requires=[ + 'onnxruntime>=1.10.0,<2', + 'tflite-runtime>=2.8.0,<3' + 'tqdm>=4.0,<5.0', + 'scipy>=1.3,<2', + 'scikit-learn>=1,<2' + ], extras_require={ 'test': [ 'pytest>=7.2.0,<8', From 3f79f3e3c01071cc9f03c590a8eb59723ffab1bc Mon Sep 17 00:00:00 2001 From: dscripka Date: Sat, 10 Jun 2023 09:25:21 -0400 Subject: [PATCH 008/103] typo --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a9ff492..449595a 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ setuptools.setup( version="0.4.0", install_requires=[ 'onnxruntime>=1.10.0,<2', - 'tflite-runtime>=2.8.0,<3' + 'tflite-runtime>=2.8.0,<3', 'tqdm>=4.0,<5.0', 'scipy>=1.3,<2', 'scikit-learn>=1,<2' From 8bf101aa22795948b02ad584fa22e6e5f150e593 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sat, 10 Jun 2023 11:41:01 -0400 Subject: [PATCH 009/103] Fixed bug for onnx models, removed tflite install for windows --- openwakeword/model.py | 37 +++++++++++++++++++++++++------------ setup.py | 2 +- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/openwakeword/model.py b/openwakeword/model.py index 9094597..b49c733 100755 --- a/openwakeword/model.py +++ b/openwakeword/model.py @@ -19,6 +19,7 @@ from openwakeword.utils import AudioFeatures, re_arg import wave import os +import logging import functools import pickle from collections import deque, defaultdict @@ -108,11 +109,6 @@ class Model(): self.custom_verifier_threshold = custom_verifier_threshold # Do imports for inference framework - if inference_framework == "onnx": - try: - import onnxruntime as ort - except ImportError: - raise ValueError("Tried to import onnxruntime, but it was not found. Please install it using `pip install onnxruntime`") if inference_framework == "tflite": try: import tflite_runtime.interpreter as tflite @@ -123,8 +119,26 @@ class Model(): return tflite_interpreter.get_tensor(output_index)[None, ] except ImportError: - raise ValueError("Tried to import the TFLite runtime, but it was not found." - "Please install it using `pip install tflite-runtime`") + logging.warning("Tried to import the tflite runtime, but it was not found. " + "Trying to switching to onnxruntime instead, if appropriate models are available.") + if wakeword_models != [] and all(['.onnx' in i for i in wakeword_models]): + inference_framework = "onnx" + elif wakeword_models != [] and all([os.path.exists(i.replace('.tflite', '.onnx')) for i in wakeword_models]): + inference_framework = "onnx" + wakeword_models = [i.replace('.tflite', '.onnx') for i in wakeword_models] + else: + raise ValueError("Tried to import the tflite runtime for provided tflite models, but it was not found. " + "Please install it using `pip install tflite-runtime`") + + if inference_framework == "onnx": + try: + import onnxruntime as ort + + def onnx_predict(onnx_model, x): + return onnx_model.run(None, {onnx_model.get_inputs()[0].name: x}) + + except ImportError: + raise ValueError("Tried to import onnxruntime, but it was not found. Please install it using `pip install onnxruntime`") for mdl_path, mdl_name in zip(wakeword_models, wakeword_model_names): # Load openwakeword models @@ -141,9 +155,8 @@ class Model(): self.model_inputs[mdl_name] = self.models[mdl_name].get_inputs()[0].shape[1] self.model_outputs[mdl_name] = self.models[mdl_name].get_outputs()[0].shape[1] - self.model_prediction_function[mdl_name] = lambda x: self.models[mdl_name].run( - None, {self.models[mdl_name].get_inputs()[0].name: x} - ) + pred_function = functools.partial(onnx_predict, self.models[mdl_name]) + self.model_prediction_function[mdl_name] = pred_function if inference_framework == "tflite": if ".onnx" in mdl_path: @@ -158,8 +171,8 @@ class Model(): tflite_input_index = self.models[mdl_name].get_input_details()[0]['index'] tflite_output_index = self.models[mdl_name].get_output_details()[0]['index'] - foo = functools.partial(tflite_predict, self.models[mdl_name], tflite_input_index, tflite_output_index) - self.model_prediction_function[mdl_name] = foo + pred_function = functools.partial(tflite_predict, self.models[mdl_name], tflite_input_index, tflite_output_index) + self.model_prediction_function[mdl_name] = pred_function if class_mapping_dicts and class_mapping_dicts[wakeword_models.index(mdl_path)].get(mdl_name, None): self.class_mapping[mdl_name] = class_mapping_dicts[wakeword_models.index(mdl_path)] diff --git a/setup.py b/setup.py index 449595a..6323f9a 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ setuptools.setup( version="0.4.0", install_requires=[ 'onnxruntime>=1.10.0,<2', - 'tflite-runtime>=2.8.0,<3', + 'tflite-runtime>=2.8.0,<3; platform_system == "Linux"', 'tqdm>=4.0,<5.0', 'scipy>=1.3,<2', 'scikit-learn>=1,<2' From c8833f12b27935a3018b03a7b124d8559889b04d Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 11 Jun 2023 14:57:24 -0400 Subject: [PATCH 010/103] Fixed bug in unit test --- tests/test_models.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 195fd23..c5b73e1 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -200,9 +200,10 @@ class TestModels: owwModel.get_parent_model_from_label(target_model_name) def test_get_positive_prediction_frames(self): - owwModel = openwakeword.Model() + owwModel = openwakeword.Model(wakeword_models=[ + os.path.join("openwakeword", "resources", "models", "alexa_v0.1.tflite") + ], inference_framework="tflite") - # Get a clip to use for the test - clip = [str(i) for i in Path(os.path.join("tests", "data")).glob("*.wav")][0] + clip = os.path.join("tests", "data", "alexa_test.wav") features = owwModel._get_positive_prediction_frames(clip) assert list(features.values())[0].shape[0] > 0 From cdd1e529ab1ab177cc19d7d0095211ab637684f5 Mon Sep 17 00:00:00 2001 From: dscripka Date: Tue, 13 Jun 2023 21:23:57 -0400 Subject: [PATCH 011/103] Adjust args microphone stream example, util function bugfix [skip ci] --- examples/detect_from_microphone.py | 25 ++++++++++++++++++++++--- openwakeword/utils.py | 8 +++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/examples/detect_from_microphone.py b/examples/detect_from_microphone.py index f72b084..7c21e10 100644 --- a/examples/detect_from_microphone.py +++ b/examples/detect_from_microphone.py @@ -27,6 +27,20 @@ parser.add_argument( default=1280, required=True ) +parser.add_argument( + "--model_path", + help="The path of a specific model to load", + type=str, + default="", + required=False +) +parser.add_argument( + "--inference_framework", + help="The inference framework to use (either 'onnx' or 'tflite'", + type=str, + default='tflite', + required=False +) args=parser.parse_args() @@ -39,7 +53,12 @@ audio = pyaudio.PyAudio() mic_stream = audio.open(format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK) # Load pre-trained openwakeword models -owwModel = Model() +if args.model_path != "": + owwModel = Model(wakeword_models=[args.model_path], inference_framework=args.inference_framework) +else: + owwModel = Model(inference_framework=args.inference_framework) + +n_models = len(owwModel.models.keys()) # Run capture loop continuosly, checking for wakewords if __name__ == "__main__": @@ -48,7 +67,7 @@ if __name__ == "__main__": print("#"*100) print("Listening for wakewords...") print("#"*100) - print("\n"*13) + print("\n"*(n_models*3)) while True: # Get audio @@ -73,5 +92,5 @@ if __name__ == "__main__": """ # Print results table - print("\033[F"*14) + print("\033[F"*(4*n_models+1)) print(output_string_header, " ", end='\r') diff --git a/openwakeword/utils.py b/openwakeword/utils.py index 3a6411f..fbefd39 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -438,6 +438,7 @@ def bulk_predict( wakeword_models: List[str], prediction_function: str = 'predict_clip', ncpu: int = 1, + inference_framework = "tflite", **kwargs ): """ @@ -445,10 +446,14 @@ def bulk_predict( Args: input_paths (List[str]): The list of input file to predict - wakeword_model_path (List[str])): The paths to the wakeword ONNX model files + wakeword_models (List[str])): The paths to the wakeword model files prediction_function (str): The name of the method used to predict on the input audio files (default is the `predict_clip` method) ncpu (int): How many processes to create (up to max of available CPUs) + inference_framework (str): The inference framework to use when for model prediction. Options are + "tflite" or "onnx". The default is "tflite" as this results in better + efficiency on common platforms (x86, ARM64), but in some deployment + scenarios ONNX models may be preferable. kwargs (dict): Any other keyword arguments to pass to the model initialization or specified prediction function @@ -472,6 +477,7 @@ def bulk_predict( if key in openwakeword.Model.__init__.__code__.co_varnames} oww = openwakeword.Model( wakeword_models=wakeword_models, + inference_framework=inference_framework, **filtered_kwargs ) mdls.append(oww) From f86be164f4bf4b594236ef2b7e90a16ce9ace04f Mon Sep 17 00:00:00 2001 From: dscripka Date: Tue, 13 Jun 2023 21:24:55 -0400 Subject: [PATCH 012/103] flake8 [skip ci] --- openwakeword/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openwakeword/utils.py b/openwakeword/utils.py index fbefd39..ab9c6a8 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -438,7 +438,7 @@ def bulk_predict( wakeword_models: List[str], prediction_function: str = 'predict_clip', ncpu: int = 1, - inference_framework = "tflite", + inference_framework: str = "tflite", **kwargs ): """ From e05ac019e86c2ad5c58f2ac728d06fee15cfe39c Mon Sep 17 00:00:00 2001 From: dscripka Date: Tue, 13 Jun 2023 21:51:45 -0400 Subject: [PATCH 013/103] Added missing onnx model, removed old model [skip ci] --- openwakeword/resources/models/hey_marvin_v0.1.onnx | 3 --- openwakeword/resources/models/hey_rhasspy_v0.1.onnx | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 openwakeword/resources/models/hey_marvin_v0.1.onnx create mode 100644 openwakeword/resources/models/hey_rhasspy_v0.1.onnx diff --git a/openwakeword/resources/models/hey_marvin_v0.1.onnx b/openwakeword/resources/models/hey_marvin_v0.1.onnx deleted file mode 100644 index ed46336..0000000 --- a/openwakeword/resources/models/hey_marvin_v0.1.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b6d4b794ddf2e1d6f29e9f45848e24858e2edd0d810b14e0c1c70dda9a1fcbf0 -size 857691 diff --git a/openwakeword/resources/models/hey_rhasspy_v0.1.onnx b/openwakeword/resources/models/hey_rhasspy_v0.1.onnx new file mode 100644 index 0000000..dea9d9d --- /dev/null +++ b/openwakeword/resources/models/hey_rhasspy_v0.1.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a9b3ed3be2910e35780e097905aa9f35a9c10038df47914cf2b3ec4d670f6ea +size 204081 From 7666ddb25762e68f95ecc620f6d737fb7ddf4192 Mon Sep 17 00:00:00 2001 From: dscripka Date: Wed, 14 Jun 2023 07:58:23 -0400 Subject: [PATCH 014/103] Updates to docs and small fixes in preparation for next release [skip ci] --- CHANGELOG.md | 17 +++++++++++++++++ MANIFEST.in | 3 ++- README.md | 38 +++++++++++++++++++++++++++++++++++--- openwakeword/model.py | 12 ------------ 4 files changed, 54 insertions(+), 16 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f018482 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Change Log + +## v0.4.0 - 2023/06/14 + +### Added + +* A new wakeword model, "hey rhasspy" +* Added support for tflite versions of the melspectrogram model, embedding model, and pre-trained wakeword models +* Added an inference framework argument to allow users to select either ONNX or tflite as the inference framework +* The `detect_from_microphone.py` example now supports additional arguments and has improved console formatting + +### Changed + +* Made tflite the default inference framework for linux platforms due to improved efficiency, with windows still using ONNX as the default given the lack of pre-built Windows WHLs for the tflite runtime (https://pypi.org/project/tflite/) +* Adjusted the default provider arguments for onnx models to avoid warnings (https://github.com/dscripka/openWakeWord/issues/27) + +### Removed \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index 4f00d13..eef6e33 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ -recursive-include openwakeword *.onnx \ No newline at end of file +recursive-include openwakeword *.onnx +recursive-include openwakeword *.tflite \ No newline at end of file diff --git a/README.md b/README.md index a4c68bc..b3d1010 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,19 @@ openWakeWord is an open-source wakeword library that can be used to create voice-enabled applications and interfaces. It includes pre-trained models for common words & phrases that work well in real-world environments. +# Updates + +**2023/06/14** + +- v0.4.0 of openWakeWord released. See the [changelog](CHANGELOG.md) for a full descriptions of new features and changes. + # Demo You can try an online demo of the included pre-trained models via HuggingFace Spaces [right here!](https://huggingface.co/spaces/davidscripka/openWakeWord). Note that real-time detection of a microphone stream can occasionally behave strangely in Spaces. For the most reliable testing, perform a local installation as described below. -# Installation & Usage +# Installation Installing openWakeWord is simple and has minimal dependencies: @@ -18,6 +24,8 @@ Installing openWakeWord is simple and has minimal dependencies: pip install openwakeword ``` +On Linux systems, both the [onnxruntime](https://pypi.org/project/onnxruntime/) package and [tflite-runtime](https://pypi.org/project/tflite-runtime/) packages will be installed as dependencies since both inference frameworks are supported. On Windows, only onnxruntime is installed due to a lack of support for modern versions of tflite. + To (optionally) use [Speex](https://www.speex.org/) noise suppression on Linux systems to improve performance in noisy environments, install the Speex dependencies and then the pre-built Python package (see the assets [here](https://github.com/dscripka/openWakeWord/releases/tag/v0.1.1) for all .whl versions), adjusting for your python version and system architecture as needed. ``` @@ -27,6 +35,8 @@ pip install https://github.com/dscripka/openWakeWord/releases/download/v0.1.1/sp Many thanks to [TeaPoly](https://github.com/TeaPoly/speexdsp-ns-python) for their Python wrapper of the Speex noise suppression libraries. +# Usage + For quick local testing, clone this repository and use the included [example script](examples/detect_from_microphone.py) to try streaming detection from a local microphone. **Important note!** The model files are stored in this repo using [git-lfs](https://git-lfs.com/); make sure it is installed on your system and if needed use `git-lfs fetch --all` to make sure the the models download correctly. Adding openWakeWord to your own Python code requires just a few lines: @@ -36,7 +46,7 @@ from openwakeword.model import Model # Instantiate the model model = Model( - wakeword_model_paths=["path/to/model.onnx"], # can also leave this argument empty to load all of the included pre-trained models + wakeword_models=["path/to/model.onnx"], # can also leave this argument empty to load all of the included pre-trained models ) # Get audio data containing 16-bit 16khz PCM audio data from a file, microphone, network stream, etc. @@ -48,6 +58,27 @@ frame = my_function_to_get_audio_frame() prediction = model.predict(frame) ``` +Additionally, openWakeWord provides other useful utility functions. For example: + +```python +# Get predictions for individual WAV files (16-bit 16khz PCM) +from openwakeword.model import Model + +model = Model() +model.predict_clip("path/to/wav/file") + +# Get predictions for a large number of files using multiprocessing +from openwakeword.utils import bulk_predict + +bulk_predict( + file_paths = ["path/to/wav/file/1", "path/to/wav/file/2"], + wakeword_models = ["hey jarvis"], + ncpu=2 +) +``` + +See `openwakeword/utils.py` and `openwakeword/model.py` for the full specification of class methods and utility functions. + # Recommendations for Usage ## Noise Suppression and Voice Activity Detection (VAD) @@ -91,6 +122,7 @@ The table below lists each model, examples of the word/phrases it is trained to | alexa | "alexa"| [docs](docs/models/alexa.md) | | hey mycroft | "hey mycroft" | [docs](docs/models/hey_mycroft.md) | | hey jarvis | "hey jarvis" | [docs](docs/models/hey_jarvis.md) | +| hey rhasspy | "hey rhasspy" | TBD | current weather | "what's the weather" | [docs](docs/models/weather.md) | | timers | "set a 10 minute timer" | [docs](docs/models/timers.md) | @@ -186,4 +218,4 @@ Future release road maps may have non-english support. In particular, [Mycroft.A # License -All of the code in openWakeWord is licensed under the **Apache 2.0** license. All of the included pre-trained models are licensed under the [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/) license due to the inclusion of datasets with unknown or restrictive licensing as part of the training data. If you are interested in pre-trained models with more permissive licensing, please raise an issue and we will try to add them to a future release. +All of the code in this repository is licensed under the **Apache 2.0** license. All of the included pre-trained models are licensed under the [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/) license due to the inclusion of datasets with unknown or restrictive licensing as part of the training data. If you are interested in pre-trained models with more permissive licensing, please raise an issue and we will try to add them to a future release. diff --git a/openwakeword/model.py b/openwakeword/model.py index b49c733..1f2eea4 100755 --- a/openwakeword/model.py +++ b/openwakeword/model.py @@ -277,14 +277,6 @@ class Model(): if len(x) > 1280: group_predictions = [] for i in np.arange(len(x)//1280-1, -1, -1): - # group_predictions.extend( - # self.models[mdl].run( - # None, - # {input_name: self.preprocessor.get_features( - # self.model_inputs[mdl], - # start_ndx=-self.model_inputs[mdl] - i - # )} - # ) group_predictions.extend( self.model_prediction_function[mdl]( self.preprocessor.get_features( @@ -295,10 +287,6 @@ class Model(): ) prediction = np.array(group_predictions).max(axis=0)[None, ] else: - # prediction = self.models[mdl].run( - # None, - # {input_name: self.preprocessor.get_features(self.model_inputs[mdl])} - # ) prediction = self.model_prediction_function[mdl]( self.preprocessor.get_features(self.model_inputs[mdl]) ) From 9ac838d9f0d189d578d51c8e2dd0743c4db09395 Mon Sep 17 00:00:00 2001 From: dscripka Date: Thu, 15 Jun 2023 07:50:15 -0400 Subject: [PATCH 015/103] Small content updates [skip ci] --- CHANGELOG.md | 2 +- README.md | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f018482..e406dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Change Log -## v0.4.0 - 2023/06/14 +## v0.4.0 - 2023/06/15 ### Added diff --git a/README.md b/README.md index b3d1010..4d0685c 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,14 @@ openWakeWord is an open-source wakeword library that can be used to create voice-enabled applications and interfaces. It includes pre-trained models for common words & phrases that work well in real-world environments. +**Quick Links** +- [Installation](#installation) +- [Training New Models](#training-new-models) +- [FAQ](#faq) + # Updates -**2023/06/14** - +**2023/06/15** - v0.4.0 of openWakeWord released. See the [changelog](CHANGELOG.md) for a full descriptions of new features and changes. # Demo @@ -216,6 +220,20 @@ Currently, openWakeWord only supports English, primarily because the pre-trained Future release road maps may have non-english support. In particular, [Mycroft.AIs Mimic 3](https://github.com/MycroftAI/mimic3-voices) TTS engine may work well to help extend some support to other languages. +# FAQ + +**Is there a Docker container for openWakeWord?** +- While there isn't an official Docker container, [a contributor has created one](https://github.com/dalehumby/openWakeWord-rhasspy) that works very well! + +**Can openWakeWord be run in a browser with javascript?** +- While the ONNX runtime [does support javascript](https://onnxruntime.ai/docs/get-started/with-javascript.html), much of the other functionality required for openWakeWord models would need to be ported. This is not currently on the roadmap, but please open an issue/start a discussion if this feature is of particular interest. + +**Why are there three separate models instead of just one?** +- Separating the models was an intentional choice to provide flexibility and optimize the efficiency of the end-to-end prediction process. For example, with separate melspectrogram, embedding, and prediction models, each one can operate on different size inputs of audio to optimize overall latency and share computations between models. It certainly is possible to make a combined model with all of the steps integrated, though, if that was a requirement of a particular use case. + +**I still get a large number of false activations when I use the pre-trained models, how can I reduce these?** +- First, review the [recommendations for usage](#recommendations-for-usage) and ensure that these options do not improve overall system accuracy. Second, experiment with [custom verifier models](#user-specific-models), if possible. If neither of these approaches are helping, please open an issue with details of the deployment environment and the types of false activations that you are experiencing. We certainly appreciate feedback & requests on how to improve the base pre-trained models! + # License -All of the code in this repository is licensed under the **Apache 2.0** license. All of the included pre-trained models are licensed under the [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/) license due to the inclusion of datasets with unknown or restrictive licensing as part of the training data. If you are interested in pre-trained models with more permissive licensing, please raise an issue and we will try to add them to a future release. +All of the code in this repository is licensed under the **Apache 2.0** license. All of the included pre-trained models are licensed under the [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/) license due to the inclusion of datasets with unknown or restrictive licensing as part of the training data. If you are interested in pre-trained models with more permissive licensing, please raise an issue and we will try to add them to a future release. \ No newline at end of file From dbe39e374f088be8db743c2f6284048ce329f3bf Mon Sep 17 00:00:00 2001 From: dscripka Date: Thu, 15 Jun 2023 07:53:27 -0400 Subject: [PATCH 016/103] version increment [skip ci] --- pyproject.toml | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8d61186..3a05705 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ testpaths = [ [project] name = "openwakeword" -version = "0.4.0" +version = "0.5.0" authors = [ { name="David Scripka", email="david.scripka@gmail.com" }, ] diff --git a/setup.py b/setup.py index 6323f9a..af0d50b 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def build_additional_requires(): setuptools.setup( name="openwakeword", - version="0.4.0", + version="0.5.0", install_requires=[ 'onnxruntime>=1.10.0,<2', 'tflite-runtime>=2.8.0,<3; platform_system == "Linux"', From 5337cb1fff0c6f26d8a55414020e3b7b46e969c8 Mon Sep 17 00:00:00 2001 From: dscripka Date: Thu, 15 Jun 2023 07:54:16 -0400 Subject: [PATCH 017/103] version number typo [skip ci] --- CHANGELOG.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e406dd4..0484d60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Change Log -## v0.4.0 - 2023/06/15 +## v0.5.0 - 2023/06/15 ### Added diff --git a/README.md b/README.md index 4d0685c..386a7ea 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ openWakeWord is an open-source wakeword library that can be used to create voice # Updates **2023/06/15** -- v0.4.0 of openWakeWord released. See the [changelog](CHANGELOG.md) for a full descriptions of new features and changes. +- v0.5.0 of openWakeWord released. See the [changelog](CHANGELOG.md) for a full descriptions of new features and changes. # Demo From ab329a5f34e6ce35c0cecc54ad104b668493690b Mon Sep 17 00:00:00 2001 From: dscripka Date: Sat, 17 Jun 2023 20:27:30 -0400 Subject: [PATCH 018/103] Updated FAQ [skip ci] --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 386a7ea..7e5f4f5 100644 --- a/README.md +++ b/README.md @@ -222,12 +222,15 @@ Future release road maps may have non-english support. In particular, [Mycroft.A # FAQ -**Is there a Docker container for openWakeWord?** -- While there isn't an official Docker container, [a contributor has created one](https://github.com/dalehumby/openWakeWord-rhasspy) that works very well! +**Is there a Docker implementation for openWakeWord?** +- While there isn't an official Docker implementation, [@dalehumby](https://github.com/dalehumby) [has created one](https://github.com/dalehumby/openWakeWord-rhasspy) that works very well! **Can openWakeWord be run in a browser with javascript?** - While the ONNX runtime [does support javascript](https://onnxruntime.ai/docs/get-started/with-javascript.html), much of the other functionality required for openWakeWord models would need to be ported. This is not currently on the roadmap, but please open an issue/start a discussion if this feature is of particular interest. +**Is there a C++ version of openWakeWord?** +- While the ONNX runtime [also has a C++ API](https://onnxruntime.ai/docs/get-started/with-cpp.html), there isn't an official C++ implementation of the full openWakeWord library. However, [@synesthesiam](https://github.com/synesthesiam) has created a [C++ version](https://github.com/rhasspy/openWakeWord-cpp) of openWakeWord with the essential functionality implemented. + **Why are there three separate models instead of just one?** - Separating the models was an intentional choice to provide flexibility and optimize the efficiency of the end-to-end prediction process. For example, with separate melspectrogram, embedding, and prediction models, each one can operate on different size inputs of audio to optimize overall latency and share computations between models. It certainly is possible to make a combined model with all of the steps integrated, though, if that was a requirement of a particular use case. From 7156e28abccdf639bc7045f3b3b238f18e0fa6be Mon Sep 17 00:00:00 2001 From: Simon J Knibbs Date: Mon, 7 Aug 2023 15:34:28 +0100 Subject: [PATCH 019/103] Update 'hey_rhasspy' to model paths --- openwakeword/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openwakeword/__init__.py b/openwakeword/__init__.py index 514d1e9..d49e9e2 100755 --- a/openwakeword/__init__.py +++ b/openwakeword/__init__.py @@ -15,6 +15,9 @@ models = { "hey_jarvis": { "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_jarvis_v0.1.tflite") }, + "hey_rhasspy": { + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_rhasspy_v0.1.tflite") + }, "timer": { "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/timer_v0.1.tflite") }, From 6aef18da2c693e12fb14ff140c0271bcfade0e79 Mon Sep 17 00:00:00 2001 From: dscripka Date: Tue, 15 Aug 2023 22:00:13 -0400 Subject: [PATCH 020/103] Updated/added functions to support auto training of new wakeword models, tests not yet passing [skip ci] --- examples/detect_from_microphone.py | 4 +- openwakeword/data.py | 283 ++++++++++++++++++++++++++++- openwakeword/utils.py | 61 +++++++ openwakeword/vad.py | 8 +- 4 files changed, 350 insertions(+), 6 deletions(-) diff --git a/examples/detect_from_microphone.py b/examples/detect_from_microphone.py index 7c21e10..6e69c92 100644 --- a/examples/detect_from_microphone.py +++ b/examples/detect_from_microphone.py @@ -22,10 +22,10 @@ import argparse parser=argparse.ArgumentParser() parser.add_argument( "--chunk_size", - help="How much audio (in samples) to predict on at once", + help="How much audio (in number of samples) to predict on at once", type=int, default=1280, - required=True + required=False ) parser.add_argument( "--model_path", diff --git a/openwakeword/data.py b/openwakeword/data.py index 7c34549..d88ad0f 100755 --- a/openwakeword/data.py +++ b/openwakeword/data.py @@ -15,13 +15,18 @@ # imports from multiprocessing.pool import ThreadPool import os +import re +import logging from functools import partial from pathlib import Path import random from tqdm import tqdm from typing import List, Tuple import numpy as np +import pronouncing import torch +import audiomentations +import torch_audiomentations from numpy.lib.format import open_memmap from speechbrain.dataio.dataio import read_audio from speechbrain.processing.signal_processing import reverberate @@ -547,6 +552,180 @@ def apply_reverb(x, rir_files): return reverbed.numpy() +# Alternate data augmentation method using audiomentations library (https://pypi.org/project/audiomentations/) + +def augment_clips(clip_paths: List[str], + total_length: List[str], + sr: int=16000, + batch_size: int=128, + augmentation_probabilities: dict={ + "SevenBandParametricEQ": 0.25, + "TanhDistortion": 0.25, + "PitchShift": 0.25, + "BandStopFilter": 0.25, + "AddColoredNoise": 0.25, + "AddBackgroundNoise": 0.75, + "Gain": 1.0, + "RIR": 0.5 + }, + background_clip_paths: List[str] = [], + RIR_paths: List[str] = [] + ): + """ + Applies audio augmentations to the specified audio clips, returning a generator that applies + the augmentations in batches to support very large quantities of input audio files. + + The augmentations (and probabilities) are chosen from experience based on training openWakeWord models, as well + as for the efficiency of the augmentation. The individual probabilities of each augmentation may be adjusted + with the "augmentation_probabilities" argument. + + Args: + clip_paths (List[str]) = The input audio files (as paths) to augment. Note that these should be shorter + than the "total_length" argument, else they will be truncated. + total_length (int): The total length of audio files (in samples) after augmentation. All input clips + will be left-padded with silence to reach this size, with between 0 and 200 ms + of other audio after the end of the original input clip. + sr (int): The sample size of the input audio files + batch_size (int): The number of audio files to augment at once. + augmentation_probabilities (dict): The individual probabilities of each augmentation. If all probabilities + are zero, the input audio files will simply be padded with silence. THe + default values are: + + { + "SevenBandParametricEQ": 0.25, + "TanhDistortion": 0.25, + "PitchShift": 0.25, + "BandStopFilter": 0.25, + "AddColoredNoise": 0.25, + "AddBackgroundNoise": 0.75, + "Gain": 1.0, + "RIR": 0.5 + } + + background_clip_paths (List[str]) = The paths to background audio files to mix with the input files + RIR_paths (List[str]) = The paths to room impulse response functions (RIRs) to convolve with the input files, + producing a version of the input clip with different acoustic characteristics. + + Returns: + ndarray: A batch of augmented audio clips of size (batch_size, total_length) + """ + + + ## Define augmentations + + # First pass augmentations that can't be done as a batch + augment1 = audiomentations.Compose([ + audiomentations.SevenBandParametricEQ(min_gain_db=-6, max_gain_db=6, p=augmentation_probabilities["SevenBandParametricEQ"]), + audiomentations.TanhDistortion( + min_distortion=0.0001, + max_distortion=0.10, + p=augmentation_probabilities["TanhDistortion"] + ), + ]) + + # Augmentations that can be done as a batch + if background_clip_paths != []: + augment2 = torch_audiomentations.Compose([ + torch_audiomentations.PitchShift( + min_transpose_semitones=-3, + max_transpose_semitones=3, + p=augmentation_probabilities["PitchShift"], + sample_rate=16000, + mode="per_batch" + ), + torch_audiomentations.BandStopFilter(p=augmentation_probabilities["BandStopFilter"], mode="per_batch"), + torch_audiomentations.AddColoredNoise( + min_snr_in_db=10, max_snr_in_db=30, + min_f_decay=-1, max_f_decay=2, p=augmentation_probabilities["AddColoredNoise"], + mode="per_batch" + ), + torch_audiomentations.AddBackgroundNoise( + p=augmentation_probabilities["AddBackgroundNoise"], + background_paths=background_clip_paths, + min_snr_in_db=-10, + max_snr_in_db=15, + mode="per_batch" + ), + torch_audiomentations.Gain(max_gain_in_db=0, p=augmentation_probabilities["Gain"]), + ]) + else: + augment2 = torch_audiomentations.Compose([ + torch_audiomentations.PitchShift( + min_transpose_semitones=-3, + max_transpose_semitones=3, + p=augmentation_probabilities["PitchShift"], + sample_rate=16000, + mode="per_batch" + ), + torch_audiomentations.BandStopFilter(p=augmentation_probabilities["BandStopFilter"], mode="per_batch"), + torch_audiomentations.AddColoredNoise( + min_snr_in_db=10, max_snr_in_db=30, + min_f_decay=-1, max_f_decay=2, p=augmentation_probabilities["AddColoredNoise"], + mode="per_batch" + ), + torch_audiomentations.Gain(max_gain_in_db=0, p=augmentation_probabilities["Gain"]), + ]) + + # Iterate through all clips and augment them + for i in range(0, len(clip_paths), batch_size): + batch = clip_paths[i:i+batch_size] + augmented_clips = [] + for clip in batch: + clip_data, clip_sr = torchaudio.load(clip) + clip_data = clip_data[0] + if clip_data.shape[0] > total_length: + clip_data = clip_data[0:total_length] + + if clip_sr != sr: + raise ValueError("Error! Clip does not have the correct sample rate!") + + clip_data = create_fixed_size_clip(clip_data, total_length, clip_sr) + + # Do first pass augmentations + augmented_clips.append(torch.from_numpy(augment1(samples=clip_data, sample_rate=sr))) + + # Do second pass augmentations + device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu') + augmented_batch = augment2(samples=torch.vstack(augmented_clips).unsqueeze(axis=1).to(device), sample_rate=sr).squeeze(axis=1) + + # Do reverberation + if augmentation_probabilities["RIR"] >= np.random.random() and RIR_paths != []: + rir_waveform, sr = torchaudio.load(random.choice(RIR_paths)) + augmented_batch = reverberate(augmented_batch.cpu(), rir_waveform, rescale_amp="avg") + + # yield batch of 16-bit PCM audio data + yield (augmented_batch.cpu().numpy()*32767).astype(np.int16) + +def create_fixed_size_clip(x, n_samples, sr=16000, start=None, end_jitter=.200): + """ + Create a fixed-length clip of the specified size by padding an input clip with zeros + Optionally specify the start/end position of the input clip, or let it be chosen randomly. + + Args: + x (ndarray): The input audio to pad to a fixed size + n_samples (int): The total number of samples for the fixed length clip + sr (int): The sample rate of the audio + start (int): The start position of the clip in the fixed length output, in samples (default: None) + end_jitter (float): The time (in seconds) from the end of the fixed length output + that the input clip should end, if `start` is None. + + Returns: + ndarray: A new array of audio data of the specified length + """ + dat = np.zeros(n_samples) + end_jitter = int(np.random.uniform(0, end_jitter)*sr) + if start is None: + start = max(0, n_samples - (int(len(x))+end_jitter)) + + if len(x) > n_samples: + if np.random.random() >= 0.5: + dat = x[0:n_samples].numpy() + else: + dat = x[-n_samples:].numpy() + else: + dat[start:start+len(x)] = x + + return dat # Load batches of data from mmaped numpy arrays class mmap_batch_generator: @@ -697,7 +876,7 @@ def trim_mmap(mmap_path): mmap_file2 = open_memmap(output_file2, mode='w+', dtype=np.float32, shape=(N_new, mmap_file1.shape[1], mmap_file1.shape[2])) - for i in tqdm(range(0, mmap_file1.shape[0], 1024), total=mmap_file1.shape[0]//1024): + for i in tqdm(range(0, mmap_file1.shape[0], 1024), total=mmap_file1.shape[0]//1024, desc="Trimming empty rows"): if i + 1024 > N_new: mmap_file2[i:N_new] = mmap_file1[i:N_new].copy() mmap_file2.flush() @@ -710,3 +889,105 @@ def trim_mmap(mmap_path): # Rename new mmap file to match original os.rename(output_file2, mmap_path) + +# Generate words that sound similar ("adversarial") to the input phrase using phoneme overlap +def generate_adversarial_texts(input_text: str, N: int, include_partial_phrase: float = 0, include_input_words: float = 0): + """ + Generate adversarial words and phrases based on phoneme overlap. + Currently only works for english texts. + Note that homophones are excluded, as this wouldn't actually be an adversarial example for the input text. + + Args: + input_text (str): The text to generate adversarial texts for + N (int): The total number of adversarial texts to return. Uses sampling, + so not all possible combinations will be included and some duplicates + may be present. + include_partial_phrase (float): The probability of returning a number of words less than the input + text (but always between 1 and the number of input words) + include_input_words (float): The probability of including individual input words in the adversarial + texts when the input text consists of multiple words. For example, + if the `input_text` was "ok google", then setting this value > 0.0 + will allow for adversarial texts like "ok noodle", versus the word "ok" + never being present in the adversarial texts. + + Returns: + list: A list of strings corresponding to words and phrases that are phonetically similar (but not identical) + to the input text. + """ + # Get phonemes for english vowels (CMUDICT labels) + vowel_phones =["AA", "AE", "AH", "AO", "AW", "AX", "AXR", "AY", "EH", "ER", "EY", "IH", "IX", "IY", "OW", "OY", "UH", "UW", "UX"] + + word_phones = [] + input_text_phones = [pronouncing.phones_for_word(i) for i in input_text.split()] + + # Download phonemizer model for OOV words, if needed + if [] in input_text_phones: + phonemizer_mdl_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "en_us_cmudict_forward.pt") + if not os.path.exists(phonemizer_mdl_path): + logging.warning("Downloading phonemizer model from DeepPhonemizer library...") + import requests + file_url = "https://public-asai-dl-models.s3.eu-central-1.amazonaws.com/DeepPhonemizer/en_us_cmudict_forward.pt" + r = requests.get(file_url, stream = True) + with open(phonemizer_mdl_path, "wb") as f: + for chunk in r.iter_content(chunk_size=2048): + if chunk: + f.write(chunk) + + # Create phonemizer object + from dp.phonemizer import Phonemizer + phonemizer = Phonemizer.from_checkpoint(phonemizer_mdl_path) + + for phones, word in zip(input_text_phones, input_text.split()): + if phones != []: + word_phones.extend(phones) + elif phones == []: + logging.warning(f"The word '{word}' was not found in the pronunciation dictionary! Using the DeepPhonemizer library to predict the phonemes.") + phones = phonemizer(word, lang='en_us') + word_phones.append(re.sub("[\]|\[]", "", re.sub("\]\[", " ", phones))) + elif isinstance(phones[0], list): + logging.warning(f"There are multiple pronunciations for the word '{i}'.") + word_phones.append(phones[0]) + + # add all possible lexical stresses to vowels + word_phones = [re.sub('|'.join(vowel_phones), lambda x: x.group() + '[1|2|3]', re.sub('\d+', '', i)) for i in word_phones] + + adversarial_phrases = [] + for phones, word in zip(word_phones, input_text.split()): + query_exps = [] + phones = phones.split() + adversarial_words = [] + if len(phones) >= 3: + for ndx, phone in enumerate(phones): + query_exps.append(" ".join([j if i != ndx else "(.){1,3}" for i, j in enumerate(phones)])) + elif len(phones) == 2: + query_exps.append(" ".join(phones)) + + for query in query_exps: + matches = pronouncing.search(query) + matches_phones = [pronouncing.phones_for_word(i)[0] for i in matches] + allowed_matches = [i for i, j in zip(matches, matches_phones) if j != phones] + adversarial_words.extend([i for i in allowed_matches if word.lower() != i]) + + if adversarial_words != []: + adversarial_phrases.append(adversarial_words) + + # Build combinations for final output + adversarial_texts = [] + for i in range(N): + txts = [] + for j, k in zip(adversarial_phrases, input_text.split()): + if np.random.random() > (1 - include_input_words): + txts.append(k) + else: + txts.append(np.random.choice(j)) + + if include_partial_phrase is not None and len(input_text.split()) > 1 and np.random.random() <= include_partial_phrase: + n_words = np.random.randint(1, len(input_text.split())+1) + adversarial_texts.append(" ".join(np.random.choice(txts, size=n_words, replace=False))) + else: + adversarial_texts.append(" ".join(txts)) + + # Remove any exact matches to input phrase + adversarial_texts = [i for i in adversarial_texts if i != input_text] + + return adversarial_texts \ No newline at end of file diff --git a/openwakeword/utils.py b/openwakeword/utils.py index ab9c6a8..b969b91 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -21,7 +21,10 @@ from multiprocessing.pool import ThreadPool from multiprocessing import Process, Queue import time import logging +from tqdm import tqdm import openwakeword +from numpy.lib.format import open_memmap +from openwakeword.data import trim_mmap from typing import Union, List, Callable, Deque @@ -507,6 +510,64 @@ def bulk_predict( # Consolidate results and return return {list(i.keys())[0]: list(i.values())[0] for i in results} +def compute_features_from_generator(generator, n_total, clip_duration, output_file, device="cpu", ncpu=1): + """ + Computes audio features from a generator that produces Numpy arrays of shape (batch_size, samples) + containing 16-bit PCM audio data. + + Args: + generator (Generator): The generator that process the arrays of audio data + n_total (int): The total number of rows (audio clips) that the generator will produce. + Ideally this is precise, but it can be approximate as well as the output + .npy file will be automatically trimmed to remove empty values. + clip_duration (float): The duration (in samples) of the audio produced by the generator + output_file (str): The output file (.npy) containing the audio features. Note that this file + will be written to using memmap arrays, so it can be substantially larger + than the available system memory. + device (str): The device ("cpu" or "gpu") to use for computing features. + ncpu (int): The number of cores to use when process the audio features (if computing on CPU) + + Returns: + None + """ + + # Create audio features object + F = AudioFeatures(device=device) + + # Determine the output shape and create output file + n_feature_cols = F.get_embedding_shape(clip_duration/16000) + output_shape = (n_total, n_feature_cols[0], n_feature_cols[1]) + fp = open_memmap(output_file, mode='w+', dtype=np.float32, shape=output_shape) + + # Get batch size by pulling one value from the generator and store features + row_counter = 0 + audio_data = next(generator) + batch_size = audio_data.shape[0] + + if batch_size > n_total: + raise ValueError(f"The value of 'n_total' ({n_total}) is less than the batch size ({batch_size})." + " Please increase 'n_total' to be >= batch size.") + + features = F.embed_clips(audio_data, batch_size=batch_size) + fp[row_counter:row_counter+features.shape[0], :, :] = features + row_counter += features.shape[0] + fp.flush() + + # Compute features and add data to output file + for audio_data in tqdm(generator, total=n_total//batch_size, desc="Computing features"): + if row_counter >= n_total: + break + + features = F.embed_clips(audio_data, batch_size=batch_size, ncpu=ncpu) + if row_counter + features.shape[0] > n_total: + features = features[0:n_total-row_counter] + + fp[row_counter:row_counter+features.shape[0], :, :] = features + row_counter += features.shape[0] + fp.flush() + + # Trip empty rows from the mmapped array + trim_mmap(output_file) # Handle deprecated arguments and naming (thanks to https://stackoverflow.com/a/74564394) def re_arg(kwarg_map): diff --git a/openwakeword/vad.py b/openwakeword/vad.py index 18bf5e3..ff193b9 100755 --- a/openwakeword/vad.py +++ b/openwakeword/vad.py @@ -63,18 +63,20 @@ class VAD(): "resources", "models", "silero_vad.onnx" - ) + ), + n_threads = 1 ): """Initialize the VAD model object. Args: model_path (str): The path to the Silero VAD ONNX model. + n_threads (int): The number of threads to use for the VAD model. """ # Initialize the ONNX model sessionOptions = ort.SessionOptions() - sessionOptions.inter_op_num_threads = 1 - sessionOptions.intra_op_num_threads = 1 + sessionOptions.inter_op_num_threads = n_threads + sessionOptions.intra_op_num_threads = n_threads self.model = ort.InferenceSession(model_path, sess_options=sessionOptions, providers=["CPUExecutionProvider"]) From a831e381a87d5548f4a4818fea7c701b02b46e88 Mon Sep 17 00:00:00 2001 From: dscripka Date: Thu, 17 Aug 2023 08:25:35 -0400 Subject: [PATCH 021/103] covered more permutations in adversarial phrase generation --- openwakeword/data.py | 28 ++++++++++++++++++++++------ openwakeword/utils.py | 5 +++-- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/openwakeword/data.py b/openwakeword/data.py index d88ad0f..37b2587 100755 --- a/openwakeword/data.py +++ b/openwakeword/data.py @@ -23,6 +23,7 @@ import random from tqdm import tqdm from typing import List, Tuple import numpy as np +import itertools import pronouncing import torch import audiomentations @@ -949,18 +950,17 @@ def generate_adversarial_texts(input_text: str, N: int, include_partial_phrase: word_phones.append(phones[0]) # add all possible lexical stresses to vowels - word_phones = [re.sub('|'.join(vowel_phones), lambda x: x.group() + '[1|2|3]', re.sub('\d+', '', i)) for i in word_phones] + word_phones = [re.sub('|'.join(vowel_phones), lambda x: x.group() + '[0|1|2]', re.sub('\d+', '', i)) for i in word_phones] adversarial_phrases = [] for phones, word in zip(word_phones, input_text.split()): query_exps = [] phones = phones.split() adversarial_words = [] - if len(phones) >= 3: - for ndx, phone in enumerate(phones): - query_exps.append(" ".join([j if i != ndx else "(.){1,3}" for i, j in enumerate(phones)])) - elif len(phones) == 2: + if len(phones) == 2: query_exps.append(" ".join(phones)) + else: + query_exps.extend(phoneme_replacement(phones, max_replace=max(0, len(phones)-2), replace_char="(.){1,3}")) for query in query_exps: matches = pronouncing.search(query) @@ -990,4 +990,20 @@ def generate_adversarial_texts(input_text: str, N: int, include_partial_phrase: # Remove any exact matches to input phrase adversarial_texts = [i for i in adversarial_texts if i != input_text] - return adversarial_texts \ No newline at end of file + return adversarial_texts + +def phoneme_replacement(input_chars, max_replace, replace_char='"(.){1,3}"'): + results = [] + chars = list(input_chars) + + # iterate over the number of characters to replace (1 to max_replace) + for r in range(1, max_replace+1): + # get all combinations for a fixed r + comb = itertools.combinations(range(len(chars)), r) + for indices in comb: + chars_copy = chars.copy() + for i in indices: + chars_copy[i] = replace_char + results.append(' '.join(chars_copy)) + + return results \ No newline at end of file diff --git a/openwakeword/utils.py b/openwakeword/utils.py index b969b91..2684622 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -24,7 +24,6 @@ import logging from tqdm import tqdm import openwakeword from numpy.lib.format import open_memmap -from openwakeword.data import trim_mmap from typing import Union, List, Callable, Deque @@ -530,7 +529,9 @@ def compute_features_from_generator(generator, n_total, clip_duration, output_fi Returns: None """ - + # Function specific imports + from openwakeword.data import trim_mmap + # Create audio features object F = AudioFeatures(device=device) From dec9f17898415fd574662d3951d76491d07aaa44 Mon Sep 17 00:00:00 2001 From: dscripka Date: Tue, 22 Aug 2023 00:41:45 -0400 Subject: [PATCH 022/103] Fixed issue where audio would be dropped if input data length was not an integer multiple of 1280 --- openwakeword/model.py | 18 ++++++++++++------ openwakeword/utils.py | 33 +++++++++++++++++++++++---------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/openwakeword/model.py b/openwakeword/model.py index 1f2eea4..3a46072 100755 --- a/openwakeword/model.py +++ b/openwakeword/model.py @@ -231,7 +231,7 @@ class Model(): """Predict with all of the wakeword models on the input audio frames Args: - x (Union[ndarray]): The input audio data to predict on with the models. Should be multiples of 80 ms + x (ndarray): The input audio data to predict on with the models. Should be multiples of 80 ms (1280 samples), with longer lengths reducing overall CPU usage but decreasing detection latency. patience (dict): How many consecutive frames (of 1280 samples or 80 ms) above the threshold that must @@ -260,9 +260,9 @@ class Model(): # Get audio features (optionally with Speex noise suppression) if self.speex_ns: - self.preprocessor(self._suppress_noise_with_speex(x)) + n_prepared_samples = self.preprocessor(self._suppress_noise_with_speex(x)) else: - self.preprocessor(x) + n_prepared_samples = self.preprocessor(x) if timing: timing_dict["models"]["preprocessor"] = time.time() - feature_start @@ -274,9 +274,9 @@ class Model(): model_start = time.time() # Run model to get predictions - if len(x) > 1280: + if n_prepared_samples > 1280: group_predictions = [] - for i in np.arange(len(x)//1280-1, -1, -1): + for i in np.arange(n_prepared_samples//1280-1, -1, -1): group_predictions.extend( self.model_prediction_function[mdl]( self.preprocessor.get_features( @@ -286,10 +286,16 @@ class Model(): ) ) prediction = np.array(group_predictions).max(axis=0)[None, ] - else: + elif n_prepared_samples == 1280: prediction = self.model_prediction_function[mdl]( self.preprocessor.get_features(self.model_inputs[mdl]) ) + else: + if len(self.prediction_buffer[mdl]) > 0: + prediction = [[[self.prediction_buffer[mdl][-1]]]] + else: + for int_label, cls in self.class_mapping[mdl].items(): + prediction = [[[0]*(int(int_label)+1)]] if self.model_outputs[mdl] == 1: predictions[mdl] = prediction[0][0][0] diff --git a/openwakeword/utils.py b/openwakeword/utils.py index ab9c6a8..1ad391e 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -162,7 +162,7 @@ class AudioFeatures(): self.melspectrogram_buffer = np.ones((76, 32)) # n_frames x num_features self.melspectrogram_max_len = 10*97 # 97 is the number of frames in 1 second of 16hz audio self.accumulated_samples = 0 # the samples added to the buffer since the audio preprocessor was last called - # self.feature_buffer = np.vstack([self._get_embeddings(np.random.randint(-1000, 1000, 1280).astype(np.int16)) for _ in range(10)]) + self.raw_data_remainder = np.empty(0) self.feature_buffer = self._get_embeddings(np.random.randint(-1000, 1000, 16000*4).astype(np.int16)) self.feature_buffer_max_len = 120 # ~10 seconds of feature buffer history @@ -377,6 +377,9 @@ class AudioFeatures(): clip is calculated. It's unclear if this difference is significant and will impact model performance. In particular padding with 0 or very small values seems to demonstrate the differences well. """ + if len(self.raw_data_buffer) < 400: + raise ValueError("The number of input frames must be at least 400 samples @ 16khz (25 ms)!") + self.melspectrogram_buffer = np.vstack( (self.melspectrogram_buffer, self._get_melspectrogram(list(self.raw_data_buffer)[-n_samples-160*3:])) ) @@ -388,18 +391,25 @@ class AudioFeatures(): """ Adds raw audio data to the input buffer """ - if len(x) < 400: - raise ValueError("The number of input frames must be at least 400 samples @ 16khz (25 ms)!") self.raw_data_buffer.extend(x.tolist() if isinstance(x, np.ndarray) else x) def _streaming_features(self, x): - # if len(x) != 1280: - # raise ValueError("You must provide input samples in frames of 1280 samples @ 1600khz." - # f"Received a frame of {len(x)} samples.") + # Add raw audio data to buffer, temporarily storing extra frames if not an even number of 80 ms chunks + processed_samples = 0 + if self.raw_data_remainder.shape[0] != 0: + x = np.concatenate((self.raw_data_remainder, x)) - # Add raw audio data to buffer - self._buffer_raw_data(x) - self.accumulated_samples += len(x) + if x.shape[0] < 1280 and self.accumulated_samples == 0: + self._buffer_raw_data(x) + self.accumulated_samples += len(x) + + elif (x.shape[0] >= 1280 and self.accumulated_samples == 0) or \ + (self.accumulated_samples != 0 and self.accumulated_samples + x.shape[0] >= 1280): + remainder = (self.accumulated_samples + x.shape[0]) % 1280 + x_even_chunks = x[0:x.shape[0] - remainder] + self._buffer_raw_data(x_even_chunks) + self.accumulated_samples += len(x_even_chunks) + self.raw_data_remainder = x[x.shape[0] - remainder:] # Only calculate melspectrogram once minimum samples area accumulated if self.accumulated_samples >= 1280: @@ -415,11 +425,14 @@ class AudioFeatures(): self.embedding_model_predict(x))) # Reset raw data buffer counter + processed_samples = self.accumulated_samples self.accumulated_samples = 0 if self.feature_buffer.shape[0] > self.feature_buffer_max_len: self.feature_buffer = self.feature_buffer[-self.feature_buffer_max_len:, :] + return processed_samples if processed_samples != 0 else self.accumulated_samples + def get_features(self, n_feature_frames: int = 16, start_ndx: int = -1): if start_ndx != -1: end_ndx = start_ndx + int(n_feature_frames) \ @@ -429,7 +442,7 @@ class AudioFeatures(): return self.feature_buffer[int(-1*n_feature_frames):, :][None, ].astype(np.float32) def __call__(self, x): - self._streaming_features(x) + return self._streaming_features(x) # Bulk prediction function From 2bc602d4b43b55b1463b522b0ac48e5c599568b3 Mon Sep 17 00:00:00 2001 From: dscripka Date: Thu, 24 Aug 2023 11:02:29 -0400 Subject: [PATCH 023/103] Increased test coverage --- openwakeword/model.py | 11 ++++++++--- setup.py | 4 +++- tests/test_models.py | 42 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/openwakeword/model.py b/openwakeword/model.py index 3a46072..a392cac 100755 --- a/openwakeword/model.py +++ b/openwakeword/model.py @@ -231,9 +231,11 @@ class Model(): """Predict with all of the wakeword models on the input audio frames Args: - x (ndarray): The input audio data to predict on with the models. Should be multiples of 80 ms + x (ndarray): The input audio data to predict on with the models. Ideally should be multiples of 80 ms (1280 samples), with longer lengths reducing overall CPU usage - but decreasing detection latency. + but decreasing detection latency. Input audio with durations greater than or less + than 80 ms is also supported, though this will add a detection delay of up to 80 ms + as the appropriate number of samples are accumulated. patience (dict): How many consecutive frames (of 1280 samples or 80 ms) above the threshold that must be observed before the current frame will be returned as non-zero. Must be provided as an a dictionary where the keys are the @@ -251,6 +253,9 @@ class Model(): wake-word/wake-phrase detected. If the `timing` argument is true, returns a tuple of dicts containing model predictions and timing information, respectively. """ + # Check input data type + if not isinstance(x, np.ndarray): + raise ValueError(f"The input audio data (x) must by a Numpy array, instead received an object of type {type(x)}.") # Setup timing dict if timing: @@ -290,7 +295,7 @@ class Model(): prediction = self.model_prediction_function[mdl]( self.preprocessor.get_features(self.model_inputs[mdl]) ) - else: + elif n_prepared_samples < 1280: if len(self.prediction_buffer[mdl]) > 0: prediction = [[[self.prediction_buffer[mdl][-1]]]] else: diff --git a/setup.py b/setup.py index af0d50b..4a183d9 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,9 @@ setuptools.setup( 'pytest-cov>=2.10.1,<3', 'pytest-flake8>=1.1.1,<2', 'flake8>=4.0,<4.1', - 'pytest-mypy>=0.10.0,<1' + 'pytest-mypy>=0.10.0,<1', + 'mock>=5.1,<6', + 'types-mock>=5.1,<6' ], 'full': [ 'mutagen>=1.46.0,<2', diff --git a/tests/test_models.py b/tests/test_models.py index c5b73e1..cf49db4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -29,11 +29,15 @@ # Imports import openwakeword import os +import sys import numpy as np from pathlib import Path import collections import pytest import platform +import pickle +import tempfile +import mock # Tests @@ -51,9 +55,45 @@ class TestModels: # Prediction on random data owwModel.predict(np.random.randint(-1000, 1000, 1280).astype(np.int16)) - # Prediction on random data with different chunk size + def test_predict_with_different_frame_sizes(self): + owwModel = openwakeword.Model(wakeword_models=[ + os.path.join("openwakeword", "resources", "models", "alexa_v0.1.onnx") + ], inference_framework="onnx") + + # Prediction on random data with integer multiples of standard chunk size (1280 samples) + owwModel.predict(np.random.randint(-1000, 1000, 1280).astype(np.int16)) owwModel.predict(np.random.randint(-1000, 1000, 1280*2).astype(np.int16)) + # Prediction on data with a chunk size not an integer multiple of 1280 + owwModel.predict(np.random.randint(-1000, 1000, 1024).astype(np.int16)) + owwModel.predict(np.random.randint(-1000, 1000, 1024*2).astype(np.int16)) + + def test_exception_handling_for_inference_framework(self): + with mock.patch.dict(sys.modules, {'onnxruntime': None}): + with pytest.raises(ValueError): + openwakeword.Model(wakeword_models=[ + os.path.join("openwakeword", "resources", "models", "alexa_v0.1.onnx") + ], inference_framework="onnx") + + with mock.patch.dict(sys.modules, {'tflite_runtime': None}): + openwakeword.Model(wakeword_models=[ + os.path.join("openwakeword", "resources", "models", "alexa_v0.1.tflite") + ], inference_framework="tflite") + + def test_predict_with_custom_verifier_model(self): + with tempfile.TemporaryDirectory() as tmp_dir: + # Train custom verifier model with random data + verifier_model = openwakeword.custom_verifier_model.train_verifier_model(np.random.random((2, 10)), np.array([0, 1])) + pickle.dump(verifier_model, open(os.path.join(tmp_dir, "test_verifier.pkl"), "wb")) + + # Load model with verifier + owwModel = openwakeword.Model(wakeword_models=[ + os.path.join("openwakeword", "resources", "models", "alexa_v0.1.onnx") + ], inference_framework="onnx", + custom_verifier_models={"alexa_v0.1": os.path.join(tmp_dir, "test_verifier.pkl")}) + + owwModel.predict(np.random.randint(-1000, 1000, 1280).astype(np.int16)) + def test_load_pretrained_model_by_name(self): # Load model with defaults owwModel = openwakeword.Model(wakeword_models=["alexa", "hey mycroft"], inference_framework="onnx") From 3dbc16e11e529dbea6eccd1555072b74d89eba29 Mon Sep 17 00:00:00 2001 From: dscripka Date: Fri, 25 Aug 2023 16:56:17 -0400 Subject: [PATCH 024/103] Increased test coverage, fixed issue with multi-class models --- openwakeword/model.py | 15 +++++++++------ tests/test_models.py | 26 +++++++++++++++++++++----- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/openwakeword/model.py b/openwakeword/model.py index a392cac..a48450e 100755 --- a/openwakeword/model.py +++ b/openwakeword/model.py @@ -295,12 +295,15 @@ class Model(): prediction = self.model_prediction_function[mdl]( self.preprocessor.get_features(self.model_inputs[mdl]) ) - elif n_prepared_samples < 1280: - if len(self.prediction_buffer[mdl]) > 0: - prediction = [[[self.prediction_buffer[mdl][-1]]]] - else: - for int_label, cls in self.class_mapping[mdl].items(): - prediction = [[[0]*(int(int_label)+1)]] + elif n_prepared_samples < 1280: # get previous prediction if there aren't enough samples + if self.model_outputs[mdl] == 1: + if len(self.prediction_buffer[mdl]) > 0: + prediction = [[[self.prediction_buffer[mdl][-1]]]] + else: + prediction = [[[0]]] + elif self.model_outputs[mdl] != 1: + n_classes = max([int(i) for i in self.class_mapping[mdl].keys()]) + prediction = [[[0]*(n_classes+1)]] if self.model_outputs[mdl] == 1: predictions[mdl] = prediction[0][0][0] diff --git a/tests/test_models.py b/tests/test_models.py index cf49db4..f3cd09d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -56,6 +56,7 @@ class TestModels: owwModel.predict(np.random.randint(-1000, 1000, 1280).astype(np.int16)) def test_predict_with_different_frame_sizes(self): + # Test with binary model owwModel = openwakeword.Model(wakeword_models=[ os.path.join("openwakeword", "resources", "models", "alexa_v0.1.onnx") ], inference_framework="onnx") @@ -68,6 +69,19 @@ class TestModels: owwModel.predict(np.random.randint(-1000, 1000, 1024).astype(np.int16)) owwModel.predict(np.random.randint(-1000, 1000, 1024*2).astype(np.int16)) + # Test with multiclass model + owwModel = openwakeword.Model(wakeword_models=[ + os.path.join("openwakeword", "resources", "models", "timer_v0.1.onnx") + ], inference_framework="onnx") + + # Prediction on random data with integer multiples of standard chunk size (1280 samples) + owwModel.predict(np.random.randint(-1000, 1000, 1280).astype(np.int16)) + owwModel.predict(np.random.randint(-1000, 1000, 1280*2).astype(np.int16)) + + # Prediction on data with a chunk size not an integer multiple of 1280 + owwModel.predict(np.random.randint(-1000, 1000, 1024).astype(np.int16)) + owwModel.predict(np.random.randint(-1000, 1000, 1024*2).astype(np.int16)) + def test_exception_handling_for_inference_framework(self): with mock.patch.dict(sys.modules, {'onnxruntime': None}): with pytest.raises(ValueError): @@ -83,14 +97,16 @@ class TestModels: def test_predict_with_custom_verifier_model(self): with tempfile.TemporaryDirectory() as tmp_dir: # Train custom verifier model with random data - verifier_model = openwakeword.custom_verifier_model.train_verifier_model(np.random.random((2, 10)), np.array([0, 1])) + verifier_model = openwakeword.custom_verifier_model.train_verifier_model(np.random.random((2, 1536)), np.array([0, 1])) pickle.dump(verifier_model, open(os.path.join(tmp_dir, "test_verifier.pkl"), "wb")) # Load model with verifier - owwModel = openwakeword.Model(wakeword_models=[ - os.path.join("openwakeword", "resources", "models", "alexa_v0.1.onnx") - ], inference_framework="onnx", - custom_verifier_models={"alexa_v0.1": os.path.join(tmp_dir, "test_verifier.pkl")}) + owwModel = openwakeword.Model( + wakeword_models=[os.path.join("openwakeword", "resources", "models", "alexa_v0.1.onnx")], + inference_framework="onnx", + custom_verifier_models={"alexa_v0.1": os.path.join(tmp_dir, "test_verifier.pkl")}, + custom_verifier_threshold=0.0 + ) owwModel.predict(np.random.randint(-1000, 1000, 1280).astype(np.int16)) From 7056d28a3e441238a6561b3dc6bf1de6d74f081e Mon Sep 17 00:00:00 2001 From: dscripka Date: Fri, 25 Aug 2023 22:07:12 -0400 Subject: [PATCH 025/103] Fixed bugs in handling of variable input data sizes and adjusted tests --- openwakeword/model.py | 2 +- openwakeword/utils.py | 29 +++++++++++++++++------------ tests/test_models.py | 31 +++++++++++++++++++------------ 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/openwakeword/model.py b/openwakeword/model.py index a48450e..46f603a 100755 --- a/openwakeword/model.py +++ b/openwakeword/model.py @@ -97,7 +97,7 @@ class Model(): raise ValueError("Could not find pretrained model for model name '{}'".format(i)) else: wakeword_models[ndx] = matching_model[0] - wakeword_model_names.append(matching_model[0].split(os.path.sep)[-1]) + wakeword_model_names.append(i) # Create attributes to store models and metadata self.models = {} diff --git a/openwakeword/utils.py b/openwakeword/utils.py index 1ad391e..c4f9b15 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -396,23 +396,28 @@ class AudioFeatures(): def _streaming_features(self, x): # Add raw audio data to buffer, temporarily storing extra frames if not an even number of 80 ms chunks processed_samples = 0 + if self.raw_data_remainder.shape[0] != 0: x = np.concatenate((self.raw_data_remainder, x)) + self.raw_data_remainder = np.empty(0) - if x.shape[0] < 1280 and self.accumulated_samples == 0: - self._buffer_raw_data(x) - self.accumulated_samples += len(x) - - elif (x.shape[0] >= 1280 and self.accumulated_samples == 0) or \ - (self.accumulated_samples != 0 and self.accumulated_samples + x.shape[0] >= 1280): + if self.accumulated_samples + x.shape[0] >= 1280: remainder = (self.accumulated_samples + x.shape[0]) % 1280 - x_even_chunks = x[0:x.shape[0] - remainder] - self._buffer_raw_data(x_even_chunks) - self.accumulated_samples += len(x_even_chunks) - self.raw_data_remainder = x[x.shape[0] - remainder:] + if remainder != 0: + x_even_chunks = x[0:-remainder] + self._buffer_raw_data(x_even_chunks) + self.accumulated_samples += len(x_even_chunks) + self.raw_data_remainder = x[-remainder:] + elif remainder == 0: + self._buffer_raw_data(x) + self.accumulated_samples += x.shape[0] + self.raw_data_remainder = np.empty(0) + else: + self.accumulated_samples += x.shape[0] + self._buffer_raw_data(x) - # Only calculate melspectrogram once minimum samples area accumulated - if self.accumulated_samples >= 1280: + # Only calculate melspectrogram once minimum samples are accumulated + if self.accumulated_samples >= 1280 and self.accumulated_samples % 1280 == 0: self._streaming_melspectrogram(self.accumulated_samples) # Calculate new audio embeddings/features based on update melspectrograms diff --git a/tests/test_models.py b/tests/test_models.py index f3cd09d..04ef066 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -57,30 +57,37 @@ class TestModels: def test_predict_with_different_frame_sizes(self): # Test with binary model - owwModel = openwakeword.Model(wakeword_models=[ + owwModel1 = openwakeword.Model(wakeword_models=[ + os.path.join("openwakeword", "resources", "models", "alexa_v0.1.onnx") + ], inference_framework="onnx") + + owwModel2 = openwakeword.Model(wakeword_models=[ os.path.join("openwakeword", "resources", "models", "alexa_v0.1.onnx") ], inference_framework="onnx") # Prediction on random data with integer multiples of standard chunk size (1280 samples) - owwModel.predict(np.random.randint(-1000, 1000, 1280).astype(np.int16)) - owwModel.predict(np.random.randint(-1000, 1000, 1280*2).astype(np.int16)) + predictions1 = owwModel1.predict_clip(os.path.join("tests", "data", "alexa_test.wav"), chunk_size=1280) + predictions2 = owwModel2.predict_clip(os.path.join("tests", "data", "alexa_test.wav"), chunk_size=1280*2) + np.testing.assert_approx_equal(max([i['alexa_v0.1'] for i in predictions1]), max([i['alexa_v0.1'] for i in predictions2]), 5) # Prediction on data with a chunk size not an integer multiple of 1280 - owwModel.predict(np.random.randint(-1000, 1000, 1024).astype(np.int16)) - owwModel.predict(np.random.randint(-1000, 1000, 1024*2).astype(np.int16)) + predictions1 = owwModel1.predict_clip(os.path.join("tests", "data", "alexa_test.wav"), chunk_size=1024) + predictions2 = owwModel2.predict_clip(os.path.join("tests", "data", "alexa_test.wav"), chunk_size=1024*2) + np.testing.assert_approx_equal(max([i['alexa_v0.1'] for i in predictions1]), max([i['alexa_v0.1'] for i in predictions2]), 5) # Test with multiclass model - owwModel = openwakeword.Model(wakeword_models=[ - os.path.join("openwakeword", "resources", "models", "timer_v0.1.onnx") - ], inference_framework="onnx") + owwModel1 = openwakeword.Model(wakeword_models=["timer"], inference_framework="onnx") + owwModel2 = openwakeword.Model(wakeword_models=["timer"], inference_framework="onnx") # Prediction on random data with integer multiples of standard chunk size (1280 samples) - owwModel.predict(np.random.randint(-1000, 1000, 1280).astype(np.int16)) - owwModel.predict(np.random.randint(-1000, 1000, 1280*2).astype(np.int16)) + predictions1 = owwModel1.predict_clip(os.path.join("tests", "data", "alexa_test.wav"), chunk_size=1280) + predictions2 = owwModel2.predict_clip(os.path.join("tests", "data", "alexa_test.wav"), chunk_size=1280*2) + assert abs(max([i['1_minute_timer'] for i in predictions1]) - max([i['1_minute_timer'] for i in predictions2])) < 0.00001 # Prediction on data with a chunk size not an integer multiple of 1280 - owwModel.predict(np.random.randint(-1000, 1000, 1024).astype(np.int16)) - owwModel.predict(np.random.randint(-1000, 1000, 1024*2).astype(np.int16)) + predictions1 = owwModel1.predict_clip(os.path.join("tests", "data", "alexa_test.wav"), chunk_size=1024) + predictions2 = owwModel2.predict_clip(os.path.join("tests", "data", "alexa_test.wav"), chunk_size=1024*2) + assert abs(max([i['1_minute_timer'] for i in predictions1]) - max([i['1_minute_timer'] for i in predictions2])) < 0.00001 def test_exception_handling_for_inference_framework(self): with mock.patch.dict(sys.modules, {'onnxruntime': None}): From ee0a31891e7c7e9c712e546f457654e01ee1e897 Mon Sep 17 00:00:00 2001 From: dscripka Date: Fri, 25 Aug 2023 22:25:40 -0400 Subject: [PATCH 026/103] Adjusted tests to remove dependencies on optional libraries (fixes #32) --- tests/test_models.py | 53 +++++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 04ef066..c38ecb8 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -30,6 +30,7 @@ import openwakeword import os import sys +import logging import numpy as np from pathlib import Path import collections @@ -172,31 +173,37 @@ class TestModels: assert 1 == 1 else: # Load model with defaults - owwModel = openwakeword.Model(enable_speex_noise_suppression=True) + try: + owwModel = openwakeword.Model(enable_speex_noise_suppression=True) - # Get clips for each model (assumes that test clips will have the model name in the filename) - test_dict = {} - for mdl_name in owwModel.models.keys(): - all_clips = [str(i) for i in Path(os.path.join("tests", "data")).glob("*.wav")] - test_dict[mdl_name] = [i for i in all_clips if mdl_name in i] + # Get clips for each model (assumes that test clips will have the model name in the filename) + test_dict = {} + for mdl_name in owwModel.models.keys(): + all_clips = [str(i) for i in Path(os.path.join("tests", "data")).glob("*.wav")] + test_dict[mdl_name] = [i for i in all_clips if mdl_name in i] - # Predict - for model, clips in test_dict.items(): - for clip in clips: - # Get predictions for reach frame in the clip - predictions = owwModel.predict_clip(clip) - owwModel.reset() # reset after each clip to ensure independent results + # Predict + for model, clips in test_dict.items(): + for clip in clips: + # Get predictions for reach frame in the clip + predictions = owwModel.predict_clip(clip) + owwModel.reset() # reset after each clip to ensure independent results - # Make predictions dictionary flatter - predictions_flat = collections.defaultdict(list) - [predictions_flat[key].append(i[key]) for i in predictions for key in i.keys()] + # Make predictions dictionary flatter + predictions_flat = collections.defaultdict(list) + [predictions_flat[key].append(i[key]) for i in predictions for key in i.keys()] - # Check scores against default threshold (0.5) - for key in predictions_flat.keys(): - if key in clip: - assert max(predictions_flat[key]) >= 0.5 - else: - assert max(predictions_flat[key]) < 0.5 + # Check scores against default threshold (0.5) + for key in predictions_flat.keys(): + if key in clip: + assert max(predictions_flat[key]) >= 0.5 + else: + assert max(predictions_flat[key]) < 0.5 + except ImportError: + logging.warning("Attemped to test Speex noise cancelling functionality, but the 'speexdsp_ns' library was not installed!" + " If you want these tests to be run, install this library as shown in the openwakeword documentation." + ) + assert 1 == 1 def test_models_with_vad(self): # Load model with defaults @@ -264,8 +271,8 @@ class TestModels: def test_get_positive_prediction_frames(self): owwModel = openwakeword.Model(wakeword_models=[ - os.path.join("openwakeword", "resources", "models", "alexa_v0.1.tflite") - ], inference_framework="tflite") + os.path.join("openwakeword", "resources", "models", "alexa_v0.1.onnx") + ], inference_framework="onnx") clip = os.path.join("tests", "data", "alexa_test.wav") features = owwModel._get_positive_prediction_frames(clip) From 1eec2158c5c54150ac5f4c15065adacb1003b1e7 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sat, 2 Sep 2023 11:37:00 -0400 Subject: [PATCH 027/103] increment version [skip ci] --- pyproject.toml | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3a05705..23c373c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ testpaths = [ [project] name = "openwakeword" -version = "0.5.0" +version = "0.5.1" authors = [ { name="David Scripka", email="david.scripka@gmail.com" }, ] diff --git a/setup.py b/setup.py index 4a183d9..1c7aecb 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def build_additional_requires(): setuptools.setup( name="openwakeword", - version="0.5.0", + version="0.5.1", install_requires=[ 'onnxruntime>=1.10.0,<2', 'tflite-runtime>=2.8.0,<3; platform_system == "Linux"', From dbd3f7ac992e81aa2862e933db9562a29287cfb1 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sat, 2 Sep 2023 11:45:27 -0400 Subject: [PATCH 028/103] Updated github actions to autopush to PyPi on new tag [skip ci] --- .github/workflows/build_and_publish_to_pypi.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build_and_publish_to_pypi.yml b/.github/workflows/build_and_publish_to_pypi.yml index ce8e4be..e6f642a 100755 --- a/.github/workflows/build_and_publish_to_pypi.yml +++ b/.github/workflows/build_and_publish_to_pypi.yml @@ -3,6 +3,9 @@ name: Publish Python distributions to PyPI on: push: workflow_dispatch: + create: + tags: + - "*" jobs: build-n-publish: From 82b2418ae9c48df471414d761e9bb79b202c22d6 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 3 Sep 2023 17:33:43 -0400 Subject: [PATCH 029/103] Fixed mypy and flake8 issues, added train.py [ckip ci] --- openwakeword/data.py | 84 +++--- openwakeword/train.py | 659 ++++++++++++++++++++++++++++++++++++++++++ openwakeword/utils.py | 18 +- openwakeword/vad.py | 2 +- setup.py | 4 +- 5 files changed, 717 insertions(+), 50 deletions(-) create mode 100755 openwakeword/train.py diff --git a/openwakeword/data.py b/openwakeword/data.py index 37b2587..228b3d3 100755 --- a/openwakeword/data.py +++ b/openwakeword/data.py @@ -553,25 +553,26 @@ def apply_reverb(x, rir_files): return reverbed.numpy() -# Alternate data augmentation method using audiomentations library (https://pypi.org/project/audiomentations/) -def augment_clips(clip_paths: List[str], - total_length: List[str], - sr: int=16000, - batch_size: int=128, - augmentation_probabilities: dict={ - "SevenBandParametricEQ": 0.25, - "TanhDistortion": 0.25, - "PitchShift": 0.25, - "BandStopFilter": 0.25, - "AddColoredNoise": 0.25, - "AddBackgroundNoise": 0.75, - "Gain": 1.0, - "RIR": 0.5 - }, - background_clip_paths: List[str] = [], - RIR_paths: List[str] = [] - ): +# Alternate data augmentation method using audiomentations library (https://pypi.org/project/audiomentations/) +def augment_clips( + clip_paths: List[str], + total_length: int, + sr: int = 16000, + batch_size: int = 128, + augmentation_probabilities: dict = { + "SevenBandParametricEQ": 0.25, + "TanhDistortion": 0.25, + "PitchShift": 0.25, + "BandStopFilter": 0.25, + "AddColoredNoise": 0.25, + "AddBackgroundNoise": 0.75, + "Gain": 1.0, + "RIR": 0.5 + }, + background_clip_paths: List[str] = [], + RIR_paths: List[str] = [] + ): """ Applies audio augmentations to the specified audio clips, returning a generator that applies the augmentations in batches to support very large quantities of input audio files. @@ -602,7 +603,7 @@ def augment_clips(clip_paths: List[str], "Gain": 1.0, "RIR": 0.5 } - + background_clip_paths (List[str]) = The paths to background audio files to mix with the input files RIR_paths (List[str]) = The paths to room impulse response functions (RIRs) to convolve with the input files, producing a version of the input clip with different acoustic characteristics. @@ -610,10 +611,8 @@ def augment_clips(clip_paths: List[str], Returns: ndarray: A batch of augmented audio clips of size (batch_size, total_length) """ + # Define augmentations - - ## Define augmentations - # First pass augmentations that can't be done as a batch augment1 = audiomentations.Compose([ audiomentations.SevenBandParametricEQ(min_gain_db=-6, max_gain_db=6, p=augmentation_probabilities["SevenBandParametricEQ"]), @@ -666,7 +665,7 @@ def augment_clips(clip_paths: List[str], ), torch_audiomentations.Gain(max_gain_in_db=0, p=augmentation_probabilities["Gain"]), ]) - + # Iterate through all clips and augment them for i in range(0, len(clip_paths), batch_size): batch = clip_paths[i:i+batch_size] @@ -676,15 +675,15 @@ def augment_clips(clip_paths: List[str], clip_data = clip_data[0] if clip_data.shape[0] > total_length: clip_data = clip_data[0:total_length] - + if clip_sr != sr: raise ValueError("Error! Clip does not have the correct sample rate!") clip_data = create_fixed_size_clip(clip_data, total_length, clip_sr) - + # Do first pass augmentations augmented_clips.append(torch.from_numpy(augment1(samples=clip_data, sample_rate=sr))) - + # Do second pass augmentations device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu') augmented_batch = augment2(samples=torch.vstack(augmented_clips).unsqueeze(axis=1).to(device), sample_rate=sr).squeeze(axis=1) @@ -693,10 +692,11 @@ def augment_clips(clip_paths: List[str], if augmentation_probabilities["RIR"] >= np.random.random() and RIR_paths != []: rir_waveform, sr = torchaudio.load(random.choice(RIR_paths)) augmented_batch = reverberate(augmented_batch.cpu(), rir_waveform, rescale_amp="avg") - + # yield batch of 16-bit PCM audio data yield (augmented_batch.cpu().numpy()*32767).astype(np.int16) + def create_fixed_size_clip(x, n_samples, sr=16000, start=None, end_jitter=.200): """ Create a fixed-length clip of the specified size by padding an input clip with zeros @@ -725,9 +725,10 @@ def create_fixed_size_clip(x, n_samples, sr=16000, start=None, end_jitter=.200): dat = x[-n_samples:].numpy() else: dat[start:start+len(x)] = x - + return dat + # Load batches of data from mmaped numpy arrays class mmap_batch_generator: """ @@ -891,13 +892,14 @@ def trim_mmap(mmap_path): # Rename new mmap file to match original os.rename(output_file2, mmap_path) + # Generate words that sound similar ("adversarial") to the input phrase using phoneme overlap def generate_adversarial_texts(input_text: str, N: int, include_partial_phrase: float = 0, include_input_words: float = 0): """ Generate adversarial words and phrases based on phoneme overlap. Currently only works for english texts. Note that homophones are excluded, as this wouldn't actually be an adversarial example for the input text. - + Args: input_text (str): The text to generate adversarial texts for N (int): The total number of adversarial texts to return. Uses sampling, @@ -916,11 +918,11 @@ def generate_adversarial_texts(input_text: str, N: int, include_partial_phrase: to the input text. """ # Get phonemes for english vowels (CMUDICT labels) - vowel_phones =["AA", "AE", "AH", "AO", "AW", "AX", "AXR", "AY", "EH", "ER", "EY", "IH", "IX", "IY", "OW", "OY", "UH", "UW", "UX"] + vowel_phones = ["AA", "AE", "AH", "AO", "AW", "AX", "AXR", "AY", "EH", "ER", "EY", "IH", "IX", "IY", "OW", "OY", "UH", "UW", "UX"] word_phones = [] input_text_phones = [pronouncing.phones_for_word(i) for i in input_text.split()] - + # Download phonemizer model for OOV words, if needed if [] in input_text_phones: phonemizer_mdl_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "en_us_cmudict_forward.pt") @@ -928,7 +930,7 @@ def generate_adversarial_texts(input_text: str, N: int, include_partial_phrase: logging.warning("Downloading phonemizer model from DeepPhonemizer library...") import requests file_url = "https://public-asai-dl-models.s3.eu-central-1.amazonaws.com/DeepPhonemizer/en_us_cmudict_forward.pt" - r = requests.get(file_url, stream = True) + r = requests.get(file_url, stream=True) with open(phonemizer_mdl_path, "wb") as f: for chunk in r.iter_content(chunk_size=2048): if chunk: @@ -942,15 +944,16 @@ def generate_adversarial_texts(input_text: str, N: int, include_partial_phrase: if phones != []: word_phones.extend(phones) elif phones == []: - logging.warning(f"The word '{word}' was not found in the pronunciation dictionary! Using the DeepPhonemizer library to predict the phonemes.") + logging.warning(f"The word '{word}' was not found in the pronunciation dictionary! " + "Using the DeepPhonemizer library to predict the phonemes.") phones = phonemizer(word, lang='en_us') - word_phones.append(re.sub("[\]|\[]", "", re.sub("\]\[", " ", phones))) + word_phones.append(re.sub(r"[\]|\[]", "", re.sub(r"\]\[", " ", phones))) elif isinstance(phones[0], list): - logging.warning(f"There are multiple pronunciations for the word '{i}'.") + logging.warning(f"There are multiple pronunciations for the word '{word}'.") word_phones.append(phones[0]) - + # add all possible lexical stresses to vowels - word_phones = [re.sub('|'.join(vowel_phones), lambda x: x.group() + '[0|1|2]', re.sub('\d+', '', i)) for i in word_phones] + word_phones = [re.sub('|'.join(vowel_phones), lambda x: str(x.group(0)) + '[0|1|2]', re.sub(r'\d+', '', i)) for i in word_phones] adversarial_phrases = [] for phones, word in zip(word_phones, input_text.split()): @@ -967,7 +970,7 @@ def generate_adversarial_texts(input_text: str, N: int, include_partial_phrase: matches_phones = [pronouncing.phones_for_word(i)[0] for i in matches] allowed_matches = [i for i, j in zip(matches, matches_phones) if j != phones] adversarial_words.extend([i for i in allowed_matches if word.lower() != i]) - + if adversarial_words != []: adversarial_phrases.append(adversarial_words) @@ -980,7 +983,7 @@ def generate_adversarial_texts(input_text: str, N: int, include_partial_phrase: txts.append(k) else: txts.append(np.random.choice(j)) - + if include_partial_phrase is not None and len(input_text.split()) > 1 and np.random.random() <= include_partial_phrase: n_words = np.random.randint(1, len(input_text.split())+1) adversarial_texts.append(" ".join(np.random.choice(txts, size=n_words, replace=False))) @@ -992,6 +995,7 @@ def generate_adversarial_texts(input_text: str, N: int, include_partial_phrase: return adversarial_texts + def phoneme_replacement(input_chars, max_replace, replace_char='"(.){1,3}"'): results = [] chars = list(input_chars) @@ -1006,4 +1010,4 @@ def phoneme_replacement(input_chars, max_replace, replace_char='"(.){1,3}"'): chars_copy[i] = replace_char results.append(' '.join(chars_copy)) - return results \ No newline at end of file + return results diff --git a/openwakeword/train.py b/openwakeword/train.py new file mode 100755 index 0000000..efef0bb --- /dev/null +++ b/openwakeword/train.py @@ -0,0 +1,659 @@ +import torch +from torch import optim, nn +import torchinfo +import torchmetrics +import copy +import os +import sys +import tempfile +import uuid +import numpy as np +import collections +import argparse +import logging +from tqdm import tqdm +import yaml +from pathlib import Path +import openwakeword +from openwakeword.data import generate_adversarial_texts, augment_clips, mmap_batch_generator +from openwakeword.utils import compute_features_from_generator + + +# Base model class for an openwakeword model +class Model(nn.Module): + def __init__(self, n_classes=1, input_shape=(16, 96), model_type="dnn", + layer_dim=128, seconds_per_example=None): + super().__init__() + + # Store inputs as attributes + self.n_classes = n_classes + self.input_shape = input_shape + self.seconds_per_example = seconds_per_example + self.device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu') + self.best_models = [] + self.best_val_fp = 1000 + self.best_val_accuracy = 0 + self.best_val_recall = 0 + self.best_train_recall = 0 + + # Define model (currently on fully-connected network supported) + if model_type == "dnn": + self.model = nn.Sequential( + nn.Flatten(), + nn.Linear(input_shape[0]*input_shape[1], layer_dim), + nn.LayerNorm(layer_dim), + nn.ReLU(), + nn.Linear(layer_dim, layer_dim), + nn.LayerNorm(layer_dim), + nn.ReLU(), + nn.Linear(layer_dim, n_classes), + nn.Sigmoid() if n_classes == 1 else nn.ReLU(), + ) + elif model_type == "rnn": + class Net(nn.Module): + def __init__(self, input_shape, n_classes=1): + super().__init__() + self.layer1 = nn.LSTM(input_shape[-1], 64, num_layers=2, bidirectional=True, + batch_first=True, dropout=0.0) + self.layer2 = nn.Linear(64*2, n_classes) + self.layer3 = nn.Sigmoid() if n_classes == 1 else nn.ReLU() + + def forward(self, x): + out, h = self.layer1(x) + return self.layer3(self.layer2(out[:, -1])) + self.model = Net(input_shape, n_classes) + + # Define metrics + if n_classes == 1: + self.fp = lambda pred, y: (y-pred <= -0.5).sum() + self.recall = torchmetrics.Recall(task='binary') + self.accuracy = torchmetrics.Accuracy(task='binary') + else: + def multiclass_fp(p, y, threshold=0.5): + probs = torch.nn.functional.softmax(p, dim=1) + neg_ndcs = y == 0 + fp = (probs[neg_ndcs].argmax(axis=1) != 0 & (probs[neg_ndcs].max(axis=1)[0] > threshold)).sum() + return fp + + def positive_class_recall(p, y, negative_class_label=0, threshold=0.5): + probs = torch.nn.functional.softmax(p, dim=1) + pos_ndcs = y != 0 + rcll = (probs[pos_ndcs].argmax(axis=1) > 0 + & (probs[pos_ndcs].max(axis=1)[0] >= threshold)).sum()/pos_ndcs.sum() + return rcll + + def positive_class_accuracy(p, y, negative_class_label=0): + probs = torch.nn.functional.softmax(p, dim=1) + pos_preds = probs.argmax(axis=1) != negative_class_label + acc = (probs[pos_preds].argmax(axis=1) == y[pos_preds]).sum()/pos_preds.sum() + return acc + + self.fp = multiclass_fp + self.acc = positive_class_accuracy + self.recall = positive_class_recall + + self.n_fp = 0 + self.val_fp = 0 + + # Define logging dict (in-memory) + self.history = collections.defaultdict(list) + + # Define optimizer and loss + self.loss = torch.nn.functional.binary_cross_entropy if n_classes == 1 else nn.functional.cross_entropy + self.optimizer = optim.Adam(self.model.parameters(), lr=0.0001) + + def save_model(self, output_path): + """ + Saves the weights of a trained Pytorch model + """ + if self.n_classes == 1: + torch.save(self.model, output_path) + + def export_to_onnx(self, output_path, class_mapping=""): + obj = self + # Make simple model for export based on model structure + if self.n_classes == 1: + # Save ONNX model + torch.onnx.export(self.model.to("cpu"), torch.rand(self.input_shape)[None, ], output_path, + output_names=[class_mapping]) + + elif self.n_classes >= 1: + class M(nn.Module): + def __init__(self): + super().__init__() + + # Define model + self.model = obj.model.to("cpu") + + def forward(self, x): + return torch.nn.functional.softmax(self.model(x), dim=1) + + # Save ONNX model + torch.onnx.export(M(), torch.rand(self.input_shape)[None, ], output_path, + output_names=[class_mapping]) + + def lr_warmup_cosine_decay(self, + global_step, + warmup_steps=0, + hold=0, + total_steps=0, + start_lr=0.0, + target_lr=1e-3 + ): + # Cosine decay + learning_rate = 0.5 * target_lr * (1 + np.cos(np.pi * (global_step - warmup_steps - hold) + / float(total_steps - warmup_steps - hold))) + + # Target LR * progress of warmup (=1 at the final warmup step) + warmup_lr = target_lr * (global_step / warmup_steps) + + # Choose between `warmup_lr`, `target_lr` and `learning_rate` based on whether + # `global_step < warmup_steps` and we're still holding. + # i.e. warm up if we're still warming up and use cosine decayed lr otherwise + if hold > 0: + learning_rate = np.where(global_step > warmup_steps + hold, + learning_rate, target_lr) + + learning_rate = np.where(global_step < warmup_steps, warmup_lr, learning_rate) + return learning_rate + + def forward(self, x): + return self.model(x) + + def summary(self): + return torchinfo.summary(self.model, input_size=(1,) + self.input_shape) + + def average_models(self, models=None): + """Averages the weights of the provided models together to make a new model""" + + if models is None: + models = self.best_models + + # Clone a model from the list as the base for the averaged model + averaged_model = copy.deepcopy(models[0]) + averaged_model_dict = averaged_model.state_dict() + + # Initialize a running total of the weights + for key in averaged_model_dict: + averaged_model_dict[key] *= 0 # set to 0 + + for model in models: + model_dict = model.state_dict() + for key, value in model_dict.items(): + averaged_model_dict[key] += value + + for key in averaged_model_dict: + averaged_model_dict[key] /= len(models) + + # Load the averaged weights into the model + averaged_model.load_state_dict(averaged_model_dict) + + return averaged_model + + def auto_train(self, X_train, X_val, false_positive_val_data, steps=50000, max_negative_weight=1000, + target_val_accuracy=0.7, target_val_recall=0.5, target_val_fp_per_hour=0.2): + """A sequence of training steps that produce relatively strong models + automatically, based on validation data and performance targets provided. + After training merges the best checkpoints and returns a single model. + """ + + # Get false positive validation data duration + val_set_hrs = 11.3 + + # Sequence 1 + print("Starting training sequence 1...") + lr = 0.0001 + weights = np.linspace(1, max_negative_weight, int(steps)).tolist() + val_steps = np.linspace(steps-int(steps*0.25), steps, 20).astype(np.int64) + self.train_model( + X=X_train, + X_val=X_val, + false_positive_val_data=false_positive_val_data, + max_steps=steps, + negative_weight_schedule=weights, + val_steps=val_steps, warmup_steps=steps//5, + hold_steps=steps//3, lr=lr, max_val_fp_per_hr=target_val_fp_per_hour, val_set_hrs=val_set_hrs, + target_val_accuracy=target_val_accuracy, target_val_recall=target_val_recall) + + # Sequence 2 + print("Starting training sequence 2...") + lr = lr/10 + steps = steps/10 + + # Adjust weights as needed based on false positive per hour performance from first sequence + if self.best_val_fp > target_val_fp_per_hour: + max_negative_weight = max_negative_weight*2 + print("Increasing weight on negative examples to reduce false positives...") + + weights = np.linspace(1, max_negative_weight, int(steps)).tolist() + val_steps = np.linspace(1, steps, 20).astype(np.int16) + self.train_model( + X=X_train, + X_val=X_val, + false_positive_val_data=false_positive_val_data, + max_steps=steps, + negative_weight_schedule=weights, + val_steps=val_steps, warmup_steps=steps//5, + hold_steps=steps//3, lr=lr, max_val_fp_per_hr=target_val_fp_per_hour, val_set_hrs=val_set_hrs, + target_val_accuracy=target_val_accuracy, target_val_recall=target_val_recall) + + # Sequence 3 + print("Starting training sequence 3...") + lr = lr/10 + + # Adjust weights as needed based on false positive per hour performance from second sequence + if self.best_val_fp > target_val_fp_per_hour: + max_negative_weight = max_negative_weight*2 + print("Increasing weight on negative examples to reduce false positives...") + + weights = np.linspace(1, max_negative_weight, int(steps)).tolist() + val_steps = np.linspace(1, steps, 20).astype(np.int16) + self.train_model( + X=X_train, + X_val=X_val, + false_positive_val_data=false_positive_val_data, + max_steps=steps, + negative_weight_schedule=weights, + val_steps=val_steps, warmup_steps=steps//5, + hold_steps=steps//3, lr=lr, max_val_fp_per_hr=target_val_fp_per_hour, val_set_hrs=val_set_hrs, + target_val_accuracy=target_val_accuracy, target_val_recall=target_val_recall) + + # Merge best models + print("Merging best checkpoints into single model...") + combined_model = self.average_models(models=self.best_models) + + # Report validationmetrics for combined model + with torch.no_grad(): + for batch in X_val: + x, y = batch[0].to(self.device), batch[1].to(self.device) + val_ps = combined_model(x) + + combined_model_recall = self.recall(val_ps, y[..., None]).detach().cpu().numpy() + combined_model_accuracy = self.accuracy(val_ps, y[..., None].to(torch.int64)).detach().cpu().numpy() + + combined_model_fp = 0 + for batch in false_positive_val_data: + x_val, y_val = batch[0].to(self.device), batch[1].to(self.device) + val_ps = combined_model(x_val) + combined_model_fp += self.fp(val_ps, y_val[..., None]) + + combined_model_fp_per_hr = (combined_model_fp/val_set_hrs).detach().cpu().numpy() + + print("\n################\n") + print("Final Model Accuracy:", combined_model_accuracy) + print("Final Model Recall:", combined_model_recall) + print("Final Model False Positives per Hour:", combined_model_fp_per_hr) + print("\n################\n") + + return combined_model + + def export_model(self, model, model_name, output_dir): + """Saves the trained openwakeword model to both onnx and tflite formats""" + + if self.n_classes != 1: + raise ValueError("Exporting models to both onnx and tflite with more than one class is currently not supported! " + "Use the `export_to_onnx` function instead.") + + # Save ONNX model + model_to_save = copy.deepcopy(model) + torch.onnx.export(model_to_save.to("cpu"), torch.rand(self.input_shape)[None, ], os.path.join(output_dir, model_name + ".onnx")) + + # Convert to tflite from onnx model + import onnx + from onnx_tf.backend import prepare + import tensorflow as tf + + # package versions + # tensorflow==2.8.1 + # tensorflow_probability==0.16.0 + # protobuf==3.20 + # onnx_tf==1.10.0 + # onnx==1.14.0 + + onnx_model = onnx.load(os.path.join(output_dir, model_name + ".onnx")) + tf_rep = prepare(onnx_model, device="CPU") + with tempfile.TemporaryDirectory() as tmp_dir: + tf_rep.export_graph(os.path.join(tmp_dir, "tf_model")) + + converter = tf.lite.TFLiteConverter.from_saved_model(os.path.join(tmp_dir, "tf_model")) + tflite_model = converter.convert() + + with open(os.path.join(output_dir, model_name + ".tflite"), 'wb') as f: + f.write(tflite_model) + + return None + + def train_model(self, X, max_steps, warmup_steps, hold_steps, X_val=None, + false_positive_val_data=None, + negative_weight_schedule=[1], + val_steps=[250], lr=0.0001, max_val_fp_per_hr=0.1, target_val_accuracy=0.7, target_val_recall=0.5, val_set_hrs=1): + # Move models and main class to target device + self.to(self.device) + self.model.to(self.device) + + # Train model + accumulation_steps = 1 + accumulated_samples = 0 + for step_ndx, data in tqdm(enumerate(X, 0), total=max_steps, desc="Training"): + # get the inputs; data is a list of [inputs, labels] + x, y = data[0].to(self.device), data[1].to(self.device) + y_ = y[..., None].to(torch.float32) + + # Update learning rates + for g in self.optimizer.param_groups: + g['lr'] = self.lr_warmup_cosine_decay(step_ndx, warmup_steps=warmup_steps, hold=hold_steps, + total_steps=max_steps, target_lr=lr) + + # zero the parameter gradients + self.optimizer.zero_grad() + + # Get predictions for batch + predictions = self.model(x) + + # Construct batch with only samples that have high loss + neg_high_loss = predictions[(y == 0) & (predictions.squeeze() >= 0.001)] # thresholds were chosen arbitrarily but work well + pos_high_loss = predictions[(y == 1) & (predictions.squeeze() < 0.999)] + y = torch.cat((y[(y == 0) & (predictions.squeeze() >= 0.001)], y[(y == 1) & (predictions.squeeze() < 0.999)])) + y_ = y[..., None].to(torch.float32) + predictions = torch.cat((neg_high_loss, pos_high_loss)) + + # Set weights for batch + if len(negative_weight_schedule) == 1: + w = torch.ones(y.shape[0])*negative_weight_schedule[0] + pos_ndcs = y == 1 + w[pos_ndcs] = 1 + w = w[..., None] + else: + if self.n_classes == 1: + w = torch.ones(y.shape[0])*negative_weight_schedule[step_ndx] + pos_ndcs = y == 1 + w[pos_ndcs] = 1 + w = w[..., None] + + # Do backpropagation, with gradient accumulation if the batch-size after selecting high loss examples is too small + loss = self.loss(predictions, y_ if self.n_classes == 1 else y, w.to(self.device)) + loss = loss/accumulation_steps + accumulated_samples += predictions.shape[0] + if accumulated_samples < 128: + accumulation_steps += 1 + else: + loss.backward() + self.optimizer.step() + accumulation_steps = 1 + accumulated_samples = 0 + + # Compute training metrics and log them + fp = self.fp(predictions, y_ if self.n_classes == 1 else y) + self.n_fp += fp + + self.history["loss"].append(loss.detach().cpu().numpy()) + self.history["recall"].append(self.recall(predictions, y_).detach().cpu().numpy()) + if self.n_classes != 1: + self.history["accuracy"].append(self.acc(predictions, y).detach().cpu().numpy()) + + # Run validation and log validation metrics + if step_ndx in val_steps and step_ndx > 1 and false_positive_val_data is not None: + # Get false positives per hour with false positive data + val_fp = 0 + for val_step_ndx, data in enumerate(false_positive_val_data): + with torch.no_grad(): + x_val, y_val = data[0].to(self.device), data[1].to(self.device) + val_predictions = self.model(x_val) + val_fp += self.fp(val_predictions, y_val[..., None]) + val_fp_per_hr = (val_fp/val_set_hrs).detach().cpu().numpy() + self.history["val_fp_per_hr"].append(val_fp_per_hr) + + if step_ndx in val_steps and step_ndx > 1 and X_val is not None: + # Get accuracy for balanced test examples of positive and negative clips + for val_step_ndx, data in enumerate(X_val): + with torch.no_grad(): + x_val, y_val = data[0].to(self.device), data[1].to(self.device) + val_predictions = self.model(x_val) + val_recall = self.recall(val_predictions, y_val[..., None]).detach().cpu().numpy() + val_acc = self.accuracy(val_predictions, y_val[..., None].to(torch.int64)) + self.history["val_accuracy"].append(val_acc.detach().cpu().numpy()) + self.history["val_recall"].append(val_recall) + + # Save models with a validation score below a given threshold + print(val_fp_per_hr, self.history["val_accuracy"][-1], self.history["val_recall"][-1]) + if val_fp_per_hr <= max(self.best_val_fp, max_val_fp_per_hr) and \ + self.history["val_accuracy"][-1] >= target_val_accuracy and \ + self.history["val_recall"][-1] >= target_val_recall: + print("Saving checkpoint with metrics >= to targets!") + self.best_models.append(copy.deepcopy(self.model)) + self.best_val_fp = val_fp_per_hr + self.best_val_recall = self.history["val_recall"][-1] + self.best_val_accuracy = self.history["val_accuracy"][-1] + + if step_ndx == max_steps-1: + break + + +if __name__ == '__main__': + # Get training config file + parser = argparse.ArgumentParser() + parser.add_argument( + "--training_config", + help="The path to the training config file", + type=str, + required=True + ) + args = parser.parse_args() + config = yaml.load(open(args.training_config, 'r').read(), yaml.Loader) + + # imports Piper for synthetic sample generation + sys.path.insert(0, os.path.abspath(config["piper_sample_generator_path"])) + from generate_samples import generate_samples + + # Define output locations + if not os.path.exists(config["output_dir"]): + os.mkdir(config["output_dir"]) + + positive_train_output_dir = os.path.join(config["output_dir"], config["target_phrase"], "positive_train") + positive_test_output_dir = os.path.join(config["output_dir"], config["target_phrase"], "positive_test") + negative_train_output_dir = os.path.join(config["output_dir"], config["target_phrase"], "negative_train") + negative_test_output_dir = os.path.join(config["output_dir"], config["target_phrase"], "negative_test") + feature_save_dir = os.path.join(config["output_dir"], config["target_phrase"]) + + # Get paths for impulse response and background audio files + rir_paths = [i.path for j in config["rir_paths"] for i in os.scandir(j)] + background_paths = [i.path for j in config["background_paths"] for i in os.scandir(j)] + + # Generate positive clips for training + n_current_samples = len(os.listdir(positive_train_output_dir)) + if n_current_samples <= 0.95*config["n_samples"]: + generate_samples( + text=[config["target_phrase"]], max_samples=config["n_samples"]-n_current_samples, + batch_size=config["tts_batch_size"], + noise_scales=[0.98], noise_scale_ws=[0.98], length_scales=[0.75, 1.0, 1.25], + output_dir=positive_train_output_dir, auto_reduce_batch_size=True, + file_names=[uuid.uuid4().hex + ".wav" for i in range(config["n_samples"])] + ) + torch.cuda.empty_cache() + else: + logging.warning(f"Skipping generation of positive clips for training, as ~{config['n_samples']} already exist") + + # Generate positive clips for testing + n_current_samples = len(os.listdir(positive_test_output_dir)) + if n_current_samples <= 0.95*config["n_samples_test"]: + generate_samples(text=[config["target_phrase"]], max_samples=config["n_samples_test"]-n_current_samples, + batch_size=config["tts_batch_size"], + noise_scales=[1.0], noise_scale_ws=[1.0], length_scales=[0.75, 1.0, 1.25], + output_dir=positive_test_output_dir, auto_reduce_batch_size=True) + torch.cuda.empty_cache() + else: + logging.warning(f"Skipping generation of positive clips testing, as ~{config['n_samples_test']} already exist") + + # Generate adversarial negative clips for training + n_current_samples = len(os.listdir(negative_train_output_dir)) + if n_current_samples <= 0.95*config["n_samples"]: + adversarial_texts = generate_adversarial_texts( + input_text=config["target_phrase"], + N=config["n_samples"], + include_partial_phrase=1.0, + include_input_words=0.2) + config["custom_negative_phrases"] + generate_samples(text=adversarial_texts, max_samples=config["n_samples"]-n_current_samples, + batch_size=config["tts_batch_size"]//7, + noise_scales=[0.98], noise_scale_ws=[0.98], length_scales=[0.75, 1.0, 1.25], + output_dir=negative_train_output_dir, auto_reduce_batch_size=True, + file_names=[uuid.uuid4().hex + ".wav" for i in range(config["n_samples"])] + ) + torch.cuda.empty_cache() + else: + logging.warning(f"Skipping generation of negative clips for training, as ~{config['n_samples']} already exist") + + # Generate adversarial negative clips for testing + n_current_samples = len(os.listdir(negative_test_output_dir)) + if n_current_samples <= 0.95*config["n_samples_test"]: + adversarial_texts = generate_adversarial_texts( + input_text=config["target_phrase"], + N=config["n_samples"], + include_partial_phrase=1.0, + include_input_words=0.2) + config["custom_negative_phrases"] + generate_samples(text=adversarial_texts, max_samples=config["n_samples_test"]-n_current_samples, + batch_size=config["tts_batch_size"]//7, + noise_scales=[1.0], noise_scale_ws=[1.0], length_scales=[0.75, 1.0, 1.25], + output_dir=negative_test_output_dir, auto_reduce_batch_size=True) + torch.cuda.empty_cache() + else: + logging.warning(f"Skipping generation of negative clips for testing, as ~{config['n_samples_test']} already exist") + + # Do Data Augmentation + if not os.path.exists(os.path.join(feature_save_dir, "positive_features_train.npy")): + logging.info("Augmenting generated clips...") + + positive_clips_train = [str(i) for i in Path(positive_train_output_dir).glob("*.wav")]*config["augmentation_rounds"] + positive_clips_train_generator = augment_clips(positive_clips_train, total_length=config["total_length"], + batch_size=config["augmentation_batch_size"], + background_clip_paths=background_paths, + RIR_paths=rir_paths) + + positive_clips_test = [str(i) for i in Path(positive_test_output_dir).glob("*.wav")]*config["augmentation_rounds"] + positive_clips_test_generator = augment_clips(positive_clips_test, total_length=config["total_length"], + batch_size=config["augmentation_batch_size"], + background_clip_paths=background_paths, + RIR_paths=rir_paths) + + negative_clips_train = [str(i) for i in Path(negative_train_output_dir).glob("*.wav")]*config["augmentation_rounds"] + negative_clips_train_generator = augment_clips(negative_clips_train, total_length=config["total_length"], + batch_size=config["augmentation_batch_size"], + background_clip_paths=background_paths, + RIR_paths=rir_paths) + + negative_clips_test = [str(i) for i in Path(negative_test_output_dir).glob("*.wav")]*config["augmentation_rounds"] + negative_clips_test_generator = augment_clips(negative_clips_test, total_length=config["total_length"], + batch_size=config["augmentation_batch_size"], + background_clip_paths=background_paths, + RIR_paths=rir_paths) + + # Compute features and save to disk via memmapped arrays + logging.info("Computer openwakeword features for generated samples...") + n_cpus = os.cpu_count() + if n_cpus is None: + n_cpus = 1 + else: + n_cpus = n_cpus//2 + compute_features_from_generator(positive_clips_train_generator, n_total=len(os.listdir(positive_train_output_dir)), + clip_duration=config["total_length"], + output_file=os.path.join(feature_save_dir, "positive_features_train.npy"), + device="gpu" if torch.cuda.is_available() else "cpu", + ncpu=n_cpus if not torch.cuda_is_available() else 1) + + compute_features_from_generator(negative_clips_train_generator, n_total=len(os.listdir(negative_train_output_dir)), + clip_duration=config["total_length"], + output_file=os.path.join(feature_save_dir, "negative_features_train.npy"), + device="gpu" if torch.cuda.is_available() else "cpu", + ncpu=n_cpus if not torch.cuda_is_available() else 1) + + compute_features_from_generator(positive_clips_test_generator, n_total=len(os.listdir(positive_test_output_dir)), + clip_duration=config["total_length"], + output_file=os.path.join(feature_save_dir, "positive_features_test.npy"), + device="gpu" if torch.cuda.is_available() else "cpu", + ncpu=n_cpus if not torch.cuda_is_available() else 1) + + compute_features_from_generator(negative_clips_test_generator, n_total=len(os.listdir(negative_test_output_dir)), + clip_duration=config["total_length"], + output_file=os.path.join(feature_save_dir, "negative_features_test.npy"), + device="gpu" if torch.cuda.is_available() else "cpu", + ncpu=n_cpus if not torch.cuda_is_available() else 1) + else: + logging.warning("Openwakeword features already exist, skipping data augmentation and feature generation") + + # Create openwakeword model + F = openwakeword.utils.AudioFeatures(device='cpu') + input_shape = F.get_embedding_shape(config["total_length"]//16000) # training data is always 16 khz + + oww = Model(n_classes=1, input_shape=input_shape, model_type=config["model_type"], + layer_dim=config["layer_size"], seconds_per_example=1280*input_shape[0]/16000) + + # Create data and label transform functions for batch generation + def f(x, n=16): + """Simple transformation function to ensure negative data is the appropriate shape for the model size""" + n_chunks = x.shape[1]//n + stacked = np.vstack(( + [x[:, i:i+n, :] for i in range(n_chunks)] + )) + return stacked + + data_transforms = {key: f for key in config["negative_data_files"].keys()} + label_transforms = {key: lambda x: [1 for i in x] if key == "positive" else lambda x: [0 for i in x] + for key in ["positive"] + config["negative_data_files"] + ["adversarial_negative"]} + + # Make PyTorch data loaders for training and validation data + batch_generator = mmap_batch_generator( + config["feature_data_files"], + n_per_class=config["batch_n_per_class"], + data_transform_funcs=data_transforms, + label_transform_funcs=label_transforms + ) + + class IterDataset(torch.utils.data.IterableDataset): + def __init__(self, generator): + self.generator = generator + + def __iter__(self): + return self.generator + + X_train = torch.utils.data.DataLoader(IterDataset(batch_generator), + batch_size=None, num_workers=8, prefetch_factor=16) + + X_val_fp = np.load(config["false_positive_validation_data_path"]) + X_val_fp = np.array([X_val_fp[i:i+input_shape[0]] for i in range(0, X_val_fp.shape[0]-input_shape[0], 1)]) # reshape to match model + X_val_fp_labels = np.zeros(X_val_fp.shape[0]).astype(np.float32) + X_val_fp = torch.utils.data.DataLoader( + torch.utils.data.TensorDataset(torch.from_numpy(X_val_fp), torch.from_numpy(X_val_fp_labels)), + batch_size=len(X_val_fp_labels) + ) + + X_val = np.vstack(( + np.load(os.path.join(feature_save_dir, "positive_features_test.npy")), + np.load(os.path.join(feature_save_dir, "negative_features_test.npy")) + )) + labels = np.hstack((np.ones(X_val.shape[0]//2), np.zeros(X_val.shape[0]//2))).astype(np.float32) + + X_val = torch.utils.data.DataLoader( + torch.utils.data.TensorDataset(torch.from_numpy(X_val), torch.from_numpy(labels)), + batch_size=len(labels) + ) + + # Run auto training and save model + steps = 100000 + max_neg_weight = 1500 + target_accuracy = 0.7 + target_recall = 0.5 + target_fp_per_hour = 0.2 + + # Run auto training + best_model = oww.auto_train( + X_train=X_train, + X_val=X_val, + false_positive_val_data=X_val_fp, + steps=config["steps"], + max_negative_weight=config["max_negative_weight"], + target_val_accuracy=config["target_accuracy"], + target_val_recall=config["target_recall"], + target_val_fp_per_hour=config["target_fp_per_hour"] + ) + + # Export the trained model to onnx and tflite formats + oww.export_model(model=best_model, model_name=config["target_phrase"], output_dir=config["output_dir"]) diff --git a/openwakeword/utils.py b/openwakeword/utils.py index 2684622..a7f65c2 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -509,11 +509,12 @@ def bulk_predict( # Consolidate results and return return {list(i.keys())[0]: list(i.values())[0] for i in results} + def compute_features_from_generator(generator, n_total, clip_duration, output_file, device="cpu", ncpu=1): """ Computes audio features from a generator that produces Numpy arrays of shape (batch_size, samples) containing 16-bit PCM audio data. - + Args: generator (Generator): The generator that process the arrays of audio data n_total (int): The total number of rows (audio clips) that the generator will produce. @@ -525,7 +526,7 @@ def compute_features_from_generator(generator, n_total, clip_duration, output_fi than the available system memory. device (str): The device ("cpu" or "gpu") to use for computing features. ncpu (int): The number of cores to use when process the audio features (if computing on CPU) - + Returns: None """ @@ -534,31 +535,31 @@ def compute_features_from_generator(generator, n_total, clip_duration, output_fi # Create audio features object F = AudioFeatures(device=device) - + # Determine the output shape and create output file n_feature_cols = F.get_embedding_shape(clip_duration/16000) output_shape = (n_total, n_feature_cols[0], n_feature_cols[1]) fp = open_memmap(output_file, mode='w+', dtype=np.float32, shape=output_shape) - + # Get batch size by pulling one value from the generator and store features row_counter = 0 audio_data = next(generator) batch_size = audio_data.shape[0] - + if batch_size > n_total: raise ValueError(f"The value of 'n_total' ({n_total}) is less than the batch size ({batch_size})." " Please increase 'n_total' to be >= batch size.") - + features = F.embed_clips(audio_data, batch_size=batch_size) fp[row_counter:row_counter+features.shape[0], :, :] = features row_counter += features.shape[0] fp.flush() - + # Compute features and add data to output file for audio_data in tqdm(generator, total=n_total//batch_size, desc="Computing features"): if row_counter >= n_total: break - + features = F.embed_clips(audio_data, batch_size=batch_size, ncpu=ncpu) if row_counter + features.shape[0] > n_total: features = features[0:n_total-row_counter] @@ -570,6 +571,7 @@ def compute_features_from_generator(generator, n_total, clip_duration, output_fi # Trip empty rows from the mmapped array trim_mmap(output_file) + # Handle deprecated arguments and naming (thanks to https://stackoverflow.com/a/74564394) def re_arg(kwarg_map): def decorator(func): diff --git a/openwakeword/vad.py b/openwakeword/vad.py index ff193b9..b332ee6 100755 --- a/openwakeword/vad.py +++ b/openwakeword/vad.py @@ -64,7 +64,7 @@ class VAD(): "models", "silero_vad.onnx" ), - n_threads = 1 + n_threads: int = 1 ): """Initialize the VAD model object. diff --git a/setup.py b/setup.py index af0d50b..8fcf708 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,9 @@ setuptools.setup( 'pytest-cov>=2.10.1,<3', 'pytest-flake8>=1.1.1,<2', 'flake8>=4.0,<4.1', - 'pytest-mypy>=0.10.0,<1' + 'pytest-mypy>=0.10.0,<1', + 'types-requests', + 'types-PyYAML' ], 'full': [ 'mutagen>=1.46.0,<2', From ef46734e66467f635760c2b1270fb4be64ae309e Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 3 Sep 2023 20:22:28 -0400 Subject: [PATCH 030/103] Updated requirements for full installation [skip ci] --- setup.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 8fcf708..38cab56 100644 --- a/setup.py +++ b/setup.py @@ -46,13 +46,19 @@ setuptools.setup( ], 'full': [ 'mutagen>=1.46.0,<2', - 'speechbrain>=0.5.13,<1', + 'torch>=1.13.1,<2', + 'torchaudio>=0.13.1,<1', + 'torchinfo>=1.8.0,<2', + 'speechbrain>=0.5.14,<1', + 'audiomentations>=0.30.0,<1', + 'torch-audiomentations>=0.11.0,<1', + 'tqdm>=4.64.0,<5', 'pytest>=7.2.0,<8', 'pytest-cov>=2.10.1,<3', 'pytest-flake8>=1.1.1,<2', 'pytest-mypy>=0.10.0,<1', - 'plotext>=5.2.7,<6', - 'sounddevice>=0.4.1,<1' + 'acoustics>=0.2.6,<1', + 'pyyaml>=6.0,<7' ] }, author="David Scripka", From 1815ca54782b188625fbacf663dc71e87ff45192 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 3 Sep 2023 20:33:44 -0400 Subject: [PATCH 031/103] Added example YML file for training new models --- examples/custom_model.yml | 92 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 examples/custom_model.yml diff --git a/examples/custom_model.yml b/examples/custom_model.yml new file mode 100644 index 0000000..f8c3e5a --- /dev/null +++ b/examples/custom_model.yml @@ -0,0 +1,92 @@ +## Configuration file to be used with `train.py` to create custom wake word/phrase models + +# The target word/phrase to be detected by the model +target_phrase: "hey jarvis" + +# The total length (in samples @ 16khz) of the clips created for training, should be larger for longer target phrases +total_length: 32000 + +# Specific phrases that you do *not* want the model to activate on, outside of those generated automatically via phoneme overlap +# This can be a good way to reduce false positives if you notice that, in practice, certain words or phrases are problematic +custom_negative_phrases: [] + +# The total number of positive samples to generate for training (minimum of 20,000 recommended, often 100,000+ is best) +n_samples: 10000 + +# The total number of positive samples to generate for validation and early stopping of model training +n_samples_val: 2000 + +# The batch size to use with Piper TTS when generating synthetic training data +tts_batch_size: 50 + +# The batch size to use when performing data augmentation on generated clips prior to training +# It's recommended that this not be too large to ensure that there is enough variety in the augmentation +augmentation_batch_size: 16 + +# The path to a fork of the piper-sample-generator repository for TTS (https://github.com/dscripka/piper-sample-generator) +piper_sample_generator_path: "./piper-sample-generator" + +# The output directory for the generated synthetic clips and openwakeword features +# Sub-directories will be automatically created for train and test clips for both positive a negative examples +output_dir: "./generated_data" + +# The directories containing Room Impulse Response recordings +rir_paths: + - "./mit_rirs" + +# The directories containing background audio to mix with training data +background_paths: + - "./background_clips" + +# The location of pre-computed openwakeword features for false-positive validation data +# If you do not have deployment environment validation data, a good general purpose dataset with +# a reasonable mix of ~11 hours of speech, noise, and music is available here: +# LINK TO FEATURES +false_positive_validation_data_path: "./fp_val_data.npy" + +# The number of times to apply augmentations to the generated training data +# Values greater than 1 reuse each generation that many times, producing overall unique +# clips for training due to the randomness intrinsic to the augmentation despite using +# the same original synthetic generation. Can be a useful way to increase model robustness +# without having to generate extremely large numbers of synthetic examples. +augmentation_rounds: 1 + +# Paths to pre-computed openwakeword features for positive and negative data. Each file must be a saved +# .npy array (see the example notebook on manually training new models for details on how to create these). +# There is no limit on the number of files +# but training speed will decrease as more data will need to be read from disk for each additional file. +# Also, there is a custom dataloader that uses memory-mapping with loading data, so the total size +# of the files is not limited by the amount of available system memory (though this will result) +# in decreased training throughput depending on the speed of the underlying storage device. A fast +# NVME SSD is recommended for optimal performance. + +feature_data_files: + "ACAV100M_sample": "./ACAV100M_sample.npy" + +# Define the number of examples from each data file per batch. Note that the key names here +# must correspond to those define in the `negative_data_files` dictionary above (except for +# the `positive` and `adversarial_negative` keys, which are automatically defined). The sum +# of the values for each key define the total batch size for training. Initial testing indicates +# that batch sizes of 1024-4096 work will in practice. + +batch_n_per_class: + "ACAV100M_sample": 1024 + "adversarial_negative": 50 + "positive": 50 + +# Define the type of size of the openwakeword model to train. Increasing the layer size +# may result in a more capable model, at the cost of decreased inference speed. The default +# value (32) seems to work will in practice for most wake words/phrases. + +model_type: "dnn" +layer_size: 32 + +# Define training parameters. The values below are recommended defaults for most applications, +# but unique deployment environments will likely require testing to determine which values +# are the most appropriate. + +steps: 100000 # the maximum number of steps when training the model +max_negative_weight: 1500 # the maximum weight to give negative samples during training to reduce false positives +target_accuracy: 0.7 # the target validation set accuracy for wake word/phrase detection +target_recall: 0.5 # the target validation recall for wake word/phrase detection +target_false_positives_per_hour: 0.2 # the maximum validation false positive rate per hour \ No newline at end of file From d7e26269803fbe25adb85877138815d951059796 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 3 Sep 2023 22:54:26 -0400 Subject: [PATCH 032/103] Partial draft of automatic model training example notebook, updates to train.py [skip ci] --- examples/custom_model.yml | 19 +- notebooks/automatic_model_training.ipynb | 355 ++++++++++++++++++++ openwakeword/data.py | 2 +- openwakeword/train.py | 398 ++++++++++++----------- 4 files changed, 583 insertions(+), 191 deletions(-) create mode 100644 notebooks/automatic_model_training.ipynb diff --git a/examples/custom_model.yml b/examples/custom_model.yml index f8c3e5a..4b5c999 100644 --- a/examples/custom_model.yml +++ b/examples/custom_model.yml @@ -1,9 +1,15 @@ ## Configuration file to be used with `train.py` to create custom wake word/phrase models -# The target word/phrase to be detected by the model -target_phrase: "hey jarvis" +# The name of the model (will be used when creating directoires and when saving the final .onnx and .tflite files) +model_name: "my_model" +# The target word/phrase to be detected by the model. Adding multiple unique words/phrases will +# still only train a binary model detection model, but it will activate on any one of the provided words/phrases. +target_phrase: + - "hey jarvis" -# The total length (in samples @ 16khz) of the clips created for training, should be larger for longer target phrases +# The total length (in samples @ 16khz) of the positive clips used for training, after augmentations. +# Should be large enough to contain the entire target word/phrase, with at least 0.75 seconds +# before the start of the word/phrase, and at least 0.2 seconds after the end of the word/phrase. total_length: 32000 # Specific phrases that you do *not* want the model to activate on, outside of those generated automatically via phoneme overlap @@ -40,9 +46,8 @@ background_paths: # The location of pre-computed openwakeword features for false-positive validation data # If you do not have deployment environment validation data, a good general purpose dataset with -# a reasonable mix of ~11 hours of speech, noise, and music is available here: -# LINK TO FEATURES -false_positive_validation_data_path: "./fp_val_data.npy" +# a reasonable mix of ~11 hours of speech, noise, and music is available here: https://huggingface.co/datasets/davidscripka/openwakeword_features +false_positive_validation_data_path: "./validation_set_features.npy" # The number of times to apply augmentations to the generated training data # Values greater than 1 reuse each generation that many times, producing overall unique @@ -61,7 +66,7 @@ augmentation_rounds: 1 # NVME SSD is recommended for optimal performance. feature_data_files: - "ACAV100M_sample": "./ACAV100M_sample.npy" + "ACAV100M_sample": "./openwakeword_features_ACAV100M_2000_hrs_16bit.npy" # Define the number of examples from each data file per batch. Note that the key names here # must correspond to those define in the `negative_data_files` dictionary above (except for diff --git a/notebooks/automatic_model_training.ipynb b/notebooks/automatic_model_training.ipynb new file mode 100644 index 0000000..b7e4e02 --- /dev/null +++ b/notebooks/automatic_model_training.ipynb @@ -0,0 +1,355 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "4a8bbcb8", + "metadata": {}, + "source": [ + "# Introduction" + ] + }, + { + "cell_type": "markdown", + "id": "ddd29870", + "metadata": {}, + "source": [ + "This notebook demonstrates how to train custom openWakeWord models using pre-defined datasets and an automated process for dataset generation and training. While not guaranteed to always produce the best performing model, the methods shown in this notebook often produce baseline models with releatively strong performance.\n", + "\n", + "Manual data preparation and model training (e.g., see the [training models](training_models.ipynb) notebook) remains an option for when full control over the model development process is needed." + ] + }, + { + "cell_type": "markdown", + "id": "75cebb7c", + "metadata": {}, + "source": [ + "# Environment Setup" + ] + }, + { + "cell_type": "markdown", + "id": "c9bd1f49", + "metadata": {}, + "source": [ + "To begin, we'll need to install the requirements for training custom models. In particular, a relatively recent version of Pytorch and custom fork of the [piper-sample-generator](https://github.com/dscripka/piper-sample-generator) library for generating synthetic examples for the custom model." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "645f5330", + "metadata": {}, + "outputs": [], + "source": [ + "## Environment setup\n", + "\n", + "# install piper-sample-generator\n", + "!git clone https://github.com/dscripka/piper-sample-generator\n", + "!wget -O models/en-us-libritts-high.pt 'https://github.com/rhasspy/piper-sample-generator/releases/download/v1.0.0/en-us-libritts-high.pt'\n", + "\n", + "# install openwakeword (full installation to support training)\n", + "!pip install openwakeword[full]\n" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "259e6491", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T02:00:48.344884Z", + "start_time": "2023-09-04T02:00:48.340514Z" + } + }, + "outputs": [], + "source": [ + "# Imports\n", + "\n", + "import os\n", + "import torch\n", + "from openwakeword.data import mmap_batch_generator, generate_adversarial_texts\n", + "from openwakeword.utils import compute_features_from_generator\n", + "import sys\n", + "from pathlib import Path\n", + "import uuid\n", + "import yaml\n", + "\n", + "# Set paths for locally installed piper-sample-generator\n", + "sys.path.insert(0, \"../../piper-sample-generator/\")\n", + "from generate_samples import generate_samples\n" + ] + }, + { + "cell_type": "markdown", + "id": "cb69d8e4", + "metadata": {}, + "source": [ + "# Download Data" + ] + }, + { + "cell_type": "markdown", + "id": "ec4434c8", + "metadata": {}, + "source": [ + "When training new openWakeWord models using the automated procedure, four specific types of data are required:\n", + "\n", + "1) Synthetic examples of the target word/phrase generated with text-to-speech models\n", + "\n", + "2) Synthetic examples of adversarial words/phrases generated with text-to-speech models\n", + "\n", + "3) Room impulse reponses and noise/background audio data to augment the synthetic examples and make them more realistic\n", + "\n", + "4) Generic \"negative\" audio data that is very unlikely to contain examples of the target word/phrase in the context where the model should detect it. This data can be the original audio data, or precomputed openWakeWord features ready for model training.\n", + "\n", + "5) Validation data to use for early-stopping when training the model.\n", + "\n", + "For the purposes of this notebook, all five of these sources can be obtained from HuggingFace thanks to their excellent `datasets` library and extremely generous hosting policy. Also note that only a portion of some datasets are downloaded. But for the best possible performance, you are encouraged to download the entire dataset and keep a local copy for future training runs." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "4ed7bacd", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T01:07:17.746749Z", + "start_time": "2023-09-04T01:07:17.740846Z" + } + }, + "outputs": [], + "source": [ + "# Download room impulse responses\n", + "\n", + "output_dir = \"./mit_rirs\"\n", + "os.mkdir(output_dir) if not os.path.exists(output_dir)\n", + "rir_dataset = datasets.load_dataset(\"davidscripka/MIT_environmental_impulse_responses\", split=\"train\", streaming=True)\n", + "\n", + "for row in tqdm(rir_dataset):\n", + " name = row['audio']['path'].split('/')[-1]\n", + " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, row['audio']['array'])\n", + " i += 1\n", + " if i == n_total:\n", + " break\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4532caf0", + "metadata": {}, + "outputs": [], + "source": [ + "## Download noise and background audio\n", + "\n", + "# FSD50k Noise Dataset (warning, this can take 5 minutes to prepare when streaming)\n", + "# https://zenodo.org/record/4060432\n", + "output_dir = \"./fsd50k\"\n", + "os.mkdir(output_dir) if not os.path.exists(output_dir)\n", + "fsd50k_dataset = datasets.load_dataset(\"Fhrozen/FSD50k\", split=\"validation\", streaming=True) # ~40,000 files in this split\n", + "fsd50k_dataset = iter(fsd50k_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000)))\n", + "\n", + "n_total = 500 # use only 500 clips for this example notebook, reccomend increasing for full-scale training\n", + "for i in tqdm(range(n_total)):\n", + " row = next(fsd50k_dataset)\n", + " name = row['audio']['path'].split('/')[-1]\n", + " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, row['audio']['array'])\n", + " i += 1\n", + " if i == n_total:\n", + " break\n", + "\n", + "# Free Music Archive dataset\n", + "# https://github.com/mdeff/fma\n", + "\n", + "output_dir = \"./fma\"\n", + "os.mkdir(output_dir) if not os.path.exists(output_dir)\n", + "fma_dataset = datasets.load_dataset(\"rudraml/fma\", name=\"small\", split=\"train\", streaming=True)\n", + "fma_dataset = iter(fma_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000)))\n", + "\n", + "n_hours = 1\n", + "for i in tqdm(range(n_hours*3600//30)): # this works because the FMA dataset is all 30 second clips\n", + " row = next(fma_dataset)\n", + " name = row['audio']['path'].split('/')[-1]\n", + " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, row['audio']['array'])\n", + " i += 1\n", + " if i == n_hours*3600//30:\n", + " break\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3a475459", + "metadata": {}, + "outputs": [], + "source": [ + "# Download pre-computed openWakeWord features for training and validation\n", + "\n", + "# training set (~2,000 hours)\n", + "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/blob/main/openwakeword_features_ACAV100M_2000_hrs_16bit.npy\n", + "\n", + "# validation set (~11 hours)\n", + "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/blob/main/validation_set_features.npy" + ] + }, + { + "cell_type": "markdown", + "id": "bda8e47e", + "metadata": {}, + "source": [ + "# Define Training Configuration" + ] + }, + { + "cell_type": "markdown", + "id": "8c2013a4", + "metadata": {}, + "source": [ + "For automated model training openWakeWord uses a specially designed training script and a [YAML](https://yaml.org/) configuration file that defines all of the information required for training a new wake word/phrase detection model.\n", + "\n", + "It is strongly recommended that you review [the example config file](../examples/custom_model.yml), as each value is fully documented there. For the purposes of this notebook, we'll read in the YAML file to modify certain configuration parameters before saving a new YAML file for training our example model. Specifically:\n", + "\n", + "- We'll train a detection model for the phrase \"hey sebastian\"\n", + "- We'll only generate 5,000 positive and negative examples (to save on time for this example)\n", + "- We'll only generate 1,000 validation positive and negative examples for early stopping (again to save time)\n", + "- The model will be trained for 30,000 steps (larger datasets will benefit from longer training)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "81bc1bea", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T02:03:46.688266Z", + "start_time": "2023-09-04T02:03:46.672580Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "{'model_name': 'my_model',\n", + " 'target_phrase': ['hey jarvis'],\n", + " 'total_length': 32000,\n", + " 'custom_negative_phrases': [],\n", + " 'n_samples': 10000,\n", + " 'n_samples_val': 2000,\n", + " 'tts_batch_size': 50,\n", + " 'augmentation_batch_size': 16,\n", + " 'piper_sample_generator_path': './piper-sample-generator',\n", + " 'output_dir': './generated_data',\n", + " 'rir_paths': ['./mit_rirs'],\n", + " 'background_paths': ['./background_clips'],\n", + " 'false_positive_validation_data_path': './validation_set_features.npy',\n", + " 'augmentation_rounds': 1,\n", + " 'feature_data_files': {'ACAV100M_sample': './openwakeword_features_ACAV100M_2000_hrs_16bit.npy'},\n", + " 'batch_n_per_class': {'ACAV100M_sample': 1024,\n", + " 'adversarial_negative': 50,\n", + " 'positive': 50},\n", + " 'model_type': 'dnn',\n", + " 'layer_size': 32,\n", + " 'steps': 100000,\n", + " 'max_negative_weight': 1500,\n", + " 'target_accuracy': 0.7,\n", + " 'target_recall': 0.5,\n", + " 'target_false_positives_per_hour': 0.2}" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Load YAML file\n", + "config = yaml.load(open(\"../examples/custom_model.yml\", 'r').read(), yaml.Loader)\n", + "config" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "d0af4242", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T02:30:24.194893Z", + "start_time": "2023-09-04T02:30:24.176938Z" + } + }, + "outputs": [], + "source": [ + "# Modify values in the config and save a new version\n", + "\n", + "config[\"target_phrase\"] = [\"hey sebastian\"]\n", + "config[\"n_samples\"] = 5000\n", + "config[\"n_samples_val\"] = 1000\n", + "config[\"steps\"] = 30000\n", + "\n", + "with open('my_model.yaml', 'w') as file:\n", + " documents = yaml.dump(config, file)" + ] + }, + { + "cell_type": "markdown", + "id": "db52159f", + "metadata": {}, + "source": [ + "# Start Model Training" + ] + }, + { + "cell_type": "markdown", + "id": "3e8d66c5", + "metadata": {}, + "source": [ + "With the data downloaded and training configuration set, we can now start training the model. We'll do this in parts to better illustrate the sequence, but you can also execute every step sequentially for a fully automated process." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9dff83ec", + "metadata": {}, + "outputs": [], + "source": [ + "# Step 1: Generate synthetic clips\n", + "\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "openwakeword_dev", + "language": "python", + "name": "openwakeword_dev" + }, + "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.16" + }, + "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": {}, + "toc_section_display": true, + "toc_window_display": false + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/openwakeword/data.py b/openwakeword/data.py index 228b3d3..184dfb5 100755 --- a/openwakeword/data.py +++ b/openwakeword/data.py @@ -901,7 +901,7 @@ def generate_adversarial_texts(input_text: str, N: int, include_partial_phrase: Note that homophones are excluded, as this wouldn't actually be an adversarial example for the input text. Args: - input_text (str): The text to generate adversarial texts for + input_text (str): The target text for adversarial phrases N (int): The total number of adversarial texts to return. Uses sampling, so not all possible combinations will be included and some duplicates may be present. diff --git a/openwakeword/train.py b/openwakeword/train.py index efef0bb..a8c1c75 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -438,6 +438,31 @@ if __name__ == '__main__': type=str, required=True ) + parser.add_argument( + "--generate_clips", + help="Execute the synthetic data generation process", + type=bool, + action="store_true", + default="False", + required=False + ) + parser.add_argument( + "--augment_clips", + help="Execute the synthetic data augmentation process", + type=bool, + action="store_true", + default="False", + required=False + ) + parser.add_argument( + "--train_model", + help="Execute the model training process", + type=bool, + action="store_true", + default="False", + required=False + ) + args = parser.parse_args() config = yaml.load(open(args.training_config, 'r').read(), yaml.Loader) @@ -449,211 +474,218 @@ if __name__ == '__main__': if not os.path.exists(config["output_dir"]): os.mkdir(config["output_dir"]) - positive_train_output_dir = os.path.join(config["output_dir"], config["target_phrase"], "positive_train") - positive_test_output_dir = os.path.join(config["output_dir"], config["target_phrase"], "positive_test") - negative_train_output_dir = os.path.join(config["output_dir"], config["target_phrase"], "negative_train") - negative_test_output_dir = os.path.join(config["output_dir"], config["target_phrase"], "negative_test") - feature_save_dir = os.path.join(config["output_dir"], config["target_phrase"]) + positive_train_output_dir = os.path.join(config["output_dir"], config["model_name"], "positive_train") + positive_test_output_dir = os.path.join(config["output_dir"], config["model_name"], "positive_test") + negative_train_output_dir = os.path.join(config["output_dir"], config["model_name"], "negative_train") + negative_test_output_dir = os.path.join(config["output_dir"], config["model_name"], "negative_test") + feature_save_dir = os.path.join(config["output_dir"], config["model_name"]) # Get paths for impulse response and background audio files rir_paths = [i.path for j in config["rir_paths"] for i in os.scandir(j)] background_paths = [i.path for j in config["background_paths"] for i in os.scandir(j)] - # Generate positive clips for training - n_current_samples = len(os.listdir(positive_train_output_dir)) - if n_current_samples <= 0.95*config["n_samples"]: - generate_samples( - text=[config["target_phrase"]], max_samples=config["n_samples"]-n_current_samples, - batch_size=config["tts_batch_size"], - noise_scales=[0.98], noise_scale_ws=[0.98], length_scales=[0.75, 1.0, 1.25], - output_dir=positive_train_output_dir, auto_reduce_batch_size=True, - file_names=[uuid.uuid4().hex + ".wav" for i in range(config["n_samples"])] - ) - torch.cuda.empty_cache() - else: - logging.warning(f"Skipping generation of positive clips for training, as ~{config['n_samples']} already exist") + if args.generate_clips is True: + # Generate positive clips for training + n_current_samples = len(os.listdir(positive_train_output_dir)) + if n_current_samples <= 0.95*config["n_samples"]: + generate_samples( + text=[config["target_phrase"]], max_samples=config["n_samples"]-n_current_samples, + batch_size=config["tts_batch_size"], + noise_scales=[0.98], noise_scale_ws=[0.98], length_scales=[0.75, 1.0, 1.25], + output_dir=positive_train_output_dir, auto_reduce_batch_size=True, + file_names=[uuid.uuid4().hex + ".wav" for i in range(config["n_samples"])] + ) + torch.cuda.empty_cache() + else: + logging.warning(f"Skipping generation of positive clips for training, as ~{config['n_samples']} already exist") - # Generate positive clips for testing - n_current_samples = len(os.listdir(positive_test_output_dir)) - if n_current_samples <= 0.95*config["n_samples_test"]: - generate_samples(text=[config["target_phrase"]], max_samples=config["n_samples_test"]-n_current_samples, - batch_size=config["tts_batch_size"], - noise_scales=[1.0], noise_scale_ws=[1.0], length_scales=[0.75, 1.0, 1.25], - output_dir=positive_test_output_dir, auto_reduce_batch_size=True) - torch.cuda.empty_cache() - else: - logging.warning(f"Skipping generation of positive clips testing, as ~{config['n_samples_test']} already exist") + # Generate positive clips for testing + n_current_samples = len(os.listdir(positive_test_output_dir)) + if n_current_samples <= 0.95*config["n_samples_val"]: + generate_samples(text=[config["target_phrase"]], max_samples=config["n_samples_val"]-n_current_samples, + batch_size=config["tts_batch_size"], + noise_scales=[1.0], noise_scale_ws=[1.0], length_scales=[0.75, 1.0, 1.25], + output_dir=positive_test_output_dir, auto_reduce_batch_size=True) + torch.cuda.empty_cache() + else: + logging.warning(f"Skipping generation of positive clips testing, as ~{config['n_samples_val']} already exist") - # Generate adversarial negative clips for training - n_current_samples = len(os.listdir(negative_train_output_dir)) - if n_current_samples <= 0.95*config["n_samples"]: - adversarial_texts = generate_adversarial_texts( - input_text=config["target_phrase"], - N=config["n_samples"], - include_partial_phrase=1.0, - include_input_words=0.2) + config["custom_negative_phrases"] - generate_samples(text=adversarial_texts, max_samples=config["n_samples"]-n_current_samples, - batch_size=config["tts_batch_size"]//7, - noise_scales=[0.98], noise_scale_ws=[0.98], length_scales=[0.75, 1.0, 1.25], - output_dir=negative_train_output_dir, auto_reduce_batch_size=True, - file_names=[uuid.uuid4().hex + ".wav" for i in range(config["n_samples"])] - ) - torch.cuda.empty_cache() - else: - logging.warning(f"Skipping generation of negative clips for training, as ~{config['n_samples']} already exist") + # Generate adversarial negative clips for training + n_current_samples = len(os.listdir(negative_train_output_dir)) + if n_current_samples <= 0.95*config["n_samples"]: + adversarial_texts = config["custom_negative_phrases"] + for target_phrase in config["target_phrase"]: + adversarial_texts.append(generate_adversarial_texts( + input_text=target_phrase, + N=config["n_samples"]//len(config["target_phrase"]), + include_partial_phrase=1.0, + include_input_words=0.2)) + generate_samples(text=adversarial_texts, max_samples=config["n_samples"]-n_current_samples, + batch_size=config["tts_batch_size"]//7, + noise_scales=[0.98], noise_scale_ws=[0.98], length_scales=[0.75, 1.0, 1.25], + output_dir=negative_train_output_dir, auto_reduce_batch_size=True, + file_names=[uuid.uuid4().hex + ".wav" for i in range(config["n_samples"])] + ) + torch.cuda.empty_cache() + else: + logging.warning(f"Skipping generation of negative clips for training, as ~{config['n_samples']} already exist") - # Generate adversarial negative clips for testing - n_current_samples = len(os.listdir(negative_test_output_dir)) - if n_current_samples <= 0.95*config["n_samples_test"]: - adversarial_texts = generate_adversarial_texts( - input_text=config["target_phrase"], - N=config["n_samples"], - include_partial_phrase=1.0, - include_input_words=0.2) + config["custom_negative_phrases"] - generate_samples(text=adversarial_texts, max_samples=config["n_samples_test"]-n_current_samples, - batch_size=config["tts_batch_size"]//7, - noise_scales=[1.0], noise_scale_ws=[1.0], length_scales=[0.75, 1.0, 1.25], - output_dir=negative_test_output_dir, auto_reduce_batch_size=True) - torch.cuda.empty_cache() - else: - logging.warning(f"Skipping generation of negative clips for testing, as ~{config['n_samples_test']} already exist") + # Generate adversarial negative clips for testing + n_current_samples = len(os.listdir(negative_test_output_dir)) + if n_current_samples <= 0.95*config["n_samples_val"]: + adversarial_texts = config["custom_negative_phrases"] + for target_phrase in config["target_phrase"]: + adversarial_texts.append(generate_adversarial_texts( + input_text=target_phrase, + N=config["n_samples_val"]//len(config["target_phrase"]), + include_partial_phrase=1.0, + include_input_words=0.2)) + generate_samples(text=adversarial_texts, max_samples=config["n_samples_val"]-n_current_samples, + batch_size=config["tts_batch_size"]//7, + noise_scales=[1.0], noise_scale_ws=[1.0], length_scales=[0.75, 1.0, 1.25], + output_dir=negative_test_output_dir, auto_reduce_batch_size=True) + torch.cuda.empty_cache() + else: + logging.warning(f"Skipping generation of negative clips for testing, as ~{config['n_samples_val']} already exist") # Do Data Augmentation - if not os.path.exists(os.path.join(feature_save_dir, "positive_features_train.npy")): - logging.info("Augmenting generated clips...") + if args.augment_clips is True: + if not os.path.exists(os.path.join(feature_save_dir, "positive_features_train.npy")): + logging.info("Augmenting generated clips...") - positive_clips_train = [str(i) for i in Path(positive_train_output_dir).glob("*.wav")]*config["augmentation_rounds"] - positive_clips_train_generator = augment_clips(positive_clips_train, total_length=config["total_length"], - batch_size=config["augmentation_batch_size"], - background_clip_paths=background_paths, - RIR_paths=rir_paths) + positive_clips_train = [str(i) for i in Path(positive_train_output_dir).glob("*.wav")]*config["augmentation_rounds"] + positive_clips_train_generator = augment_clips(positive_clips_train, total_length=config["total_length"], + batch_size=config["augmentation_batch_size"], + background_clip_paths=background_paths, + RIR_paths=rir_paths) - positive_clips_test = [str(i) for i in Path(positive_test_output_dir).glob("*.wav")]*config["augmentation_rounds"] - positive_clips_test_generator = augment_clips(positive_clips_test, total_length=config["total_length"], - batch_size=config["augmentation_batch_size"], - background_clip_paths=background_paths, - RIR_paths=rir_paths) + positive_clips_test = [str(i) for i in Path(positive_test_output_dir).glob("*.wav")]*config["augmentation_rounds"] + positive_clips_test_generator = augment_clips(positive_clips_test, total_length=config["total_length"], + batch_size=config["augmentation_batch_size"], + background_clip_paths=background_paths, + RIR_paths=rir_paths) - negative_clips_train = [str(i) for i in Path(negative_train_output_dir).glob("*.wav")]*config["augmentation_rounds"] - negative_clips_train_generator = augment_clips(negative_clips_train, total_length=config["total_length"], - batch_size=config["augmentation_batch_size"], - background_clip_paths=background_paths, - RIR_paths=rir_paths) + negative_clips_train = [str(i) for i in Path(negative_train_output_dir).glob("*.wav")]*config["augmentation_rounds"] + negative_clips_train_generator = augment_clips(negative_clips_train, total_length=config["total_length"], + batch_size=config["augmentation_batch_size"], + background_clip_paths=background_paths, + RIR_paths=rir_paths) - negative_clips_test = [str(i) for i in Path(negative_test_output_dir).glob("*.wav")]*config["augmentation_rounds"] - negative_clips_test_generator = augment_clips(negative_clips_test, total_length=config["total_length"], - batch_size=config["augmentation_batch_size"], - background_clip_paths=background_paths, - RIR_paths=rir_paths) + negative_clips_test = [str(i) for i in Path(negative_test_output_dir).glob("*.wav")]*config["augmentation_rounds"] + negative_clips_test_generator = augment_clips(negative_clips_test, total_length=config["total_length"], + batch_size=config["augmentation_batch_size"], + background_clip_paths=background_paths, + RIR_paths=rir_paths) - # Compute features and save to disk via memmapped arrays - logging.info("Computer openwakeword features for generated samples...") - n_cpus = os.cpu_count() - if n_cpus is None: - n_cpus = 1 + # Compute features and save to disk via memmapped arrays + logging.info("Computer openwakeword features for generated samples...") + n_cpus = os.cpu_count() + if n_cpus is None: + n_cpus = 1 + else: + n_cpus = n_cpus//2 + compute_features_from_generator(positive_clips_train_generator, n_total=len(os.listdir(positive_train_output_dir)), + clip_duration=config["total_length"], + output_file=os.path.join(feature_save_dir, "positive_features_train.npy"), + device="gpu" if torch.cuda.is_available() else "cpu", + ncpu=n_cpus if not torch.cuda_is_available() else 1) + + compute_features_from_generator(negative_clips_train_generator, n_total=len(os.listdir(negative_train_output_dir)), + clip_duration=config["total_length"], + output_file=os.path.join(feature_save_dir, "negative_features_train.npy"), + device="gpu" if torch.cuda.is_available() else "cpu", + ncpu=n_cpus if not torch.cuda_is_available() else 1) + + compute_features_from_generator(positive_clips_test_generator, n_total=len(os.listdir(positive_test_output_dir)), + clip_duration=config["total_length"], + output_file=os.path.join(feature_save_dir, "positive_features_test.npy"), + device="gpu" if torch.cuda.is_available() else "cpu", + ncpu=n_cpus if not torch.cuda_is_available() else 1) + + compute_features_from_generator(negative_clips_test_generator, n_total=len(os.listdir(negative_test_output_dir)), + clip_duration=config["total_length"], + output_file=os.path.join(feature_save_dir, "negative_features_test.npy"), + device="gpu" if torch.cuda.is_available() else "cpu", + ncpu=n_cpus if not torch.cuda_is_available() else 1) else: - n_cpus = n_cpus//2 - compute_features_from_generator(positive_clips_train_generator, n_total=len(os.listdir(positive_train_output_dir)), - clip_duration=config["total_length"], - output_file=os.path.join(feature_save_dir, "positive_features_train.npy"), - device="gpu" if torch.cuda.is_available() else "cpu", - ncpu=n_cpus if not torch.cuda_is_available() else 1) - - compute_features_from_generator(negative_clips_train_generator, n_total=len(os.listdir(negative_train_output_dir)), - clip_duration=config["total_length"], - output_file=os.path.join(feature_save_dir, "negative_features_train.npy"), - device="gpu" if torch.cuda.is_available() else "cpu", - ncpu=n_cpus if not torch.cuda_is_available() else 1) - - compute_features_from_generator(positive_clips_test_generator, n_total=len(os.listdir(positive_test_output_dir)), - clip_duration=config["total_length"], - output_file=os.path.join(feature_save_dir, "positive_features_test.npy"), - device="gpu" if torch.cuda.is_available() else "cpu", - ncpu=n_cpus if not torch.cuda_is_available() else 1) - - compute_features_from_generator(negative_clips_test_generator, n_total=len(os.listdir(negative_test_output_dir)), - clip_duration=config["total_length"], - output_file=os.path.join(feature_save_dir, "negative_features_test.npy"), - device="gpu" if torch.cuda.is_available() else "cpu", - ncpu=n_cpus if not torch.cuda_is_available() else 1) - else: - logging.warning("Openwakeword features already exist, skipping data augmentation and feature generation") + logging.warning("Openwakeword features already exist, skipping data augmentation and feature generation") # Create openwakeword model - F = openwakeword.utils.AudioFeatures(device='cpu') - input_shape = F.get_embedding_shape(config["total_length"]//16000) # training data is always 16 khz + if args.train_model is True: + F = openwakeword.utils.AudioFeatures(device='cpu') + input_shape = F.get_embedding_shape(config["total_length"]//16000) # training data is always 16 khz - oww = Model(n_classes=1, input_shape=input_shape, model_type=config["model_type"], - layer_dim=config["layer_size"], seconds_per_example=1280*input_shape[0]/16000) + oww = Model(n_classes=1, input_shape=input_shape, model_type=config["model_type"], + layer_dim=config["layer_size"], seconds_per_example=1280*input_shape[0]/16000) - # Create data and label transform functions for batch generation - def f(x, n=16): - """Simple transformation function to ensure negative data is the appropriate shape for the model size""" - n_chunks = x.shape[1]//n - stacked = np.vstack(( - [x[:, i:i+n, :] for i in range(n_chunks)] + # Create data and label transform functions for batch generation + def f(x, n=16): + """Simple transformation function to ensure negative data is the appropriate shape for the model size""" + n_chunks = x.shape[1]//n + stacked = np.vstack(( + [x[:, i:i+n, :] for i in range(n_chunks)] + )) + return stacked + + data_transforms = {key: f for key in config["negative_data_files"].keys()} + label_transforms = {key: lambda x: [1 for i in x] if key == "positive" else lambda x: [0 for i in x] + for key in ["positive"] + config["negative_data_files"] + ["adversarial_negative"]} + + # Make PyTorch data loaders for training and validation data + batch_generator = mmap_batch_generator( + config["feature_data_files"], + n_per_class=config["batch_n_per_class"], + data_transform_funcs=data_transforms, + label_transform_funcs=label_transforms + ) + + class IterDataset(torch.utils.data.IterableDataset): + def __init__(self, generator): + self.generator = generator + + def __iter__(self): + return self.generator + + X_train = torch.utils.data.DataLoader(IterDataset(batch_generator), + batch_size=None, num_workers=8, prefetch_factor=16) + + X_val_fp = np.load(config["false_positive_validation_data_path"]) + X_val_fp = np.array([X_val_fp[i:i+input_shape[0]] for i in range(0, X_val_fp.shape[0]-input_shape[0], 1)]) # reshape to match model + X_val_fp_labels = np.zeros(X_val_fp.shape[0]).astype(np.float32) + X_val_fp = torch.utils.data.DataLoader( + torch.utils.data.TensorDataset(torch.from_numpy(X_val_fp), torch.from_numpy(X_val_fp_labels)), + batch_size=len(X_val_fp_labels) + ) + + X_val = np.vstack(( + np.load(os.path.join(feature_save_dir, "positive_features_test.npy")), + np.load(os.path.join(feature_save_dir, "negative_features_test.npy")) )) - return stacked + labels = np.hstack((np.ones(X_val.shape[0]//2), np.zeros(X_val.shape[0]//2))).astype(np.float32) - data_transforms = {key: f for key in config["negative_data_files"].keys()} - label_transforms = {key: lambda x: [1 for i in x] if key == "positive" else lambda x: [0 for i in x] - for key in ["positive"] + config["negative_data_files"] + ["adversarial_negative"]} + X_val = torch.utils.data.DataLoader( + torch.utils.data.TensorDataset(torch.from_numpy(X_val), torch.from_numpy(labels)), + batch_size=len(labels) + ) - # Make PyTorch data loaders for training and validation data - batch_generator = mmap_batch_generator( - config["feature_data_files"], - n_per_class=config["batch_n_per_class"], - data_transform_funcs=data_transforms, - label_transform_funcs=label_transforms - ) + # Run auto training and save model + steps = 100000 + max_neg_weight = 1500 + target_accuracy = 0.7 + target_recall = 0.5 + target_fp_per_hour = 0.2 - class IterDataset(torch.utils.data.IterableDataset): - def __init__(self, generator): - self.generator = generator + # Run auto training + best_model = oww.auto_train( + X_train=X_train, + X_val=X_val, + false_positive_val_data=X_val_fp, + steps=config["steps"], + max_negative_weight=config["max_negative_weight"], + target_val_accuracy=config["target_accuracy"], + target_val_recall=config["target_recall"], + target_val_fp_per_hour=config["target_fp_per_hour"] + ) - def __iter__(self): - return self.generator - - X_train = torch.utils.data.DataLoader(IterDataset(batch_generator), - batch_size=None, num_workers=8, prefetch_factor=16) - - X_val_fp = np.load(config["false_positive_validation_data_path"]) - X_val_fp = np.array([X_val_fp[i:i+input_shape[0]] for i in range(0, X_val_fp.shape[0]-input_shape[0], 1)]) # reshape to match model - X_val_fp_labels = np.zeros(X_val_fp.shape[0]).astype(np.float32) - X_val_fp = torch.utils.data.DataLoader( - torch.utils.data.TensorDataset(torch.from_numpy(X_val_fp), torch.from_numpy(X_val_fp_labels)), - batch_size=len(X_val_fp_labels) - ) - - X_val = np.vstack(( - np.load(os.path.join(feature_save_dir, "positive_features_test.npy")), - np.load(os.path.join(feature_save_dir, "negative_features_test.npy")) - )) - labels = np.hstack((np.ones(X_val.shape[0]//2), np.zeros(X_val.shape[0]//2))).astype(np.float32) - - X_val = torch.utils.data.DataLoader( - torch.utils.data.TensorDataset(torch.from_numpy(X_val), torch.from_numpy(labels)), - batch_size=len(labels) - ) - - # Run auto training and save model - steps = 100000 - max_neg_weight = 1500 - target_accuracy = 0.7 - target_recall = 0.5 - target_fp_per_hour = 0.2 - - # Run auto training - best_model = oww.auto_train( - X_train=X_train, - X_val=X_val, - false_positive_val_data=X_val_fp, - steps=config["steps"], - max_negative_weight=config["max_negative_weight"], - target_val_accuracy=config["target_accuracy"], - target_val_recall=config["target_recall"], - target_val_fp_per_hour=config["target_fp_per_hour"] - ) - - # Export the trained model to onnx and tflite formats - oww.export_model(model=best_model, model_name=config["target_phrase"], output_dir=config["output_dir"]) + # Export the trained model to onnx and tflite formats + oww.export_model(model=best_model, model_name=config["model_name"], output_dir=config["output_dir"]) From f3e74cd8c4cb9b144ba6fa60f0b6fefb6bf9a352 Mon Sep 17 00:00:00 2001 From: dscripka Date: Mon, 4 Sep 2023 11:15:50 -0400 Subject: [PATCH 033/103] Working example of automatic model training complete [skip ci] --- examples/custom_model.yml | 6 +- notebooks/automatic_model_training.ipynb | 342 ++++++++++++++++++++--- openwakeword/data.py | 1 - openwakeword/train.py | 164 ++++++----- 4 files changed, 397 insertions(+), 116 deletions(-) diff --git a/examples/custom_model.yml b/examples/custom_model.yml index 4b5c999..50e0747 100644 --- a/examples/custom_model.yml +++ b/examples/custom_model.yml @@ -69,7 +69,7 @@ feature_data_files: "ACAV100M_sample": "./openwakeword_features_ACAV100M_2000_hrs_16bit.npy" # Define the number of examples from each data file per batch. Note that the key names here -# must correspond to those define in the `negative_data_files` dictionary above (except for +# must correspond to those define in the `feature_data_files` dictionary above (except for # the `positive` and `adversarial_negative` keys, which are automatically defined). The sum # of the values for each key define the total batch size for training. Initial testing indicates # that batch sizes of 1024-4096 work will in practice. @@ -88,7 +88,9 @@ layer_size: 32 # Define training parameters. The values below are recommended defaults for most applications, # but unique deployment environments will likely require testing to determine which values -# are the most appropriate. +# are the most appropriate. Note that all "target_" values are determined from the validation data, +# and since early-stopping is utilized, the final performance of the trained model +# may be slighly overfit to the validation data. steps: 100000 # the maximum number of steps when training the model max_negative_weight: 1500 # the maximum weight to give negative samples during training to reduce false positives diff --git a/notebooks/automatic_model_training.ipynb b/notebooks/automatic_model_training.ipynb index b7e4e02..2827e36 100644 --- a/notebooks/automatic_model_training.ipynb +++ b/notebooks/automatic_model_training.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "4a8bbcb8", + "id": "7790ccc3", "metadata": {}, "source": [ "# Introduction" @@ -10,17 +10,26 @@ }, { "cell_type": "markdown", - "id": "ddd29870", + "id": "3af2f31f", "metadata": {}, "source": [ "This notebook demonstrates how to train custom openWakeWord models using pre-defined datasets and an automated process for dataset generation and training. While not guaranteed to always produce the best performing model, the methods shown in this notebook often produce baseline models with releatively strong performance.\n", "\n", - "Manual data preparation and model training (e.g., see the [training models](training_models.ipynb) notebook) remains an option for when full control over the model development process is needed." + "Manual data preparation and model training (e.g., see the [training models](training_models.ipynb) notebook) remains an option for when full control over the model development process is needed.\n", + "\n", + "At a high level, the automatic training process takes advantages of several techniques to try and produce a good model, including:\n", + "\n", + "- Early-stopping and checkpoint averaging (similar to [stochastic weight averaging](https://arxiv.org/abs/1803.05407) to search for the best models found during training, according to the validation data\n", + "- Variable learning rates with cosine decay and multiple cycles\n", + "- Adaptive batch construction to focus on only high-loss examples when the model begins to converge, combined with gradient accumulation to ensure that batch sizes are still large enough for stable training\n", + "- Cycical weight schedules for negative examples to help the model reduce false-positive rates\n", + "\n", + "See the contents of the `train.py` file for more details." ] }, { "cell_type": "markdown", - "id": "75cebb7c", + "id": "6161cbc6", "metadata": {}, "source": [ "# Environment Setup" @@ -28,7 +37,7 @@ }, { "cell_type": "markdown", - "id": "c9bd1f49", + "id": "fffee4d4", "metadata": {}, "source": [ "To begin, we'll need to install the requirements for training custom models. In particular, a relatively recent version of Pytorch and custom fork of the [piper-sample-generator](https://github.com/dscripka/piper-sample-generator) library for generating synthetic examples for the custom model." @@ -37,7 +46,7 @@ { "cell_type": "code", "execution_count": null, - "id": "645f5330", + "id": "2eed3f89", "metadata": {}, "outputs": [], "source": [ @@ -48,20 +57,31 @@ "!wget -O models/en-us-libritts-high.pt 'https://github.com/rhasspy/piper-sample-generator/releases/download/v1.0.0/en-us-libritts-high.pt'\n", "\n", "# install openwakeword (full installation to support training)\n", - "!pip install openwakeword[full]\n" + "!pip install openwakeword[full]\n", + "!git clone https://github.com/dscripka/openwakeword\n", + "!cd openwakeword\n" ] }, { "cell_type": "code", - "execution_count": 17, - "id": "259e6491", + "execution_count": 1, + "id": "7f556e37", "metadata": { "ExecuteTime": { - "end_time": "2023-09-04T02:00:48.344884Z", - "start_time": "2023-09-04T02:00:48.340514Z" + "end_time": "2023-09-04T13:42:01.183840Z", + "start_time": "2023-09-04T13:41:59.752153Z" } }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/dscripka/anaconda3/envs/openwakeword_dev/lib/python3.9/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n" + ] + } + ], "source": [ "# Imports\n", "\n", @@ -72,16 +92,12 @@ "import sys\n", "from pathlib import Path\n", "import uuid\n", - "import yaml\n", - "\n", - "# Set paths for locally installed piper-sample-generator\n", - "sys.path.insert(0, \"../../piper-sample-generator/\")\n", - "from generate_samples import generate_samples\n" + "import yaml\n" ] }, { "cell_type": "markdown", - "id": "cb69d8e4", + "id": "0e38803b", "metadata": {}, "source": [ "# Download Data" @@ -89,7 +105,7 @@ }, { "cell_type": "markdown", - "id": "ec4434c8", + "id": "beef6917", "metadata": {}, "source": [ "When training new openWakeWord models using the automated procedure, four specific types of data are required:\n", @@ -110,7 +126,7 @@ { "cell_type": "code", "execution_count": 15, - "id": "4ed7bacd", + "id": "9929f1b7", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T01:07:17.746749Z", @@ -136,7 +152,7 @@ { "cell_type": "code", "execution_count": null, - "id": "4532caf0", + "id": "16becf16", "metadata": {}, "outputs": [], "source": [ @@ -179,7 +195,7 @@ { "cell_type": "code", "execution_count": null, - "id": "3a475459", + "id": "66d26414", "metadata": {}, "outputs": [], "source": [ @@ -194,7 +210,7 @@ }, { "cell_type": "markdown", - "id": "bda8e47e", + "id": "ed004cb1", "metadata": {}, "source": [ "# Define Training Configuration" @@ -202,7 +218,7 @@ }, { "cell_type": "markdown", - "id": "8c2013a4", + "id": "e9385f81", "metadata": {}, "source": [ "For automated model training openWakeWord uses a specially designed training script and a [YAML](https://yaml.org/) configuration file that defines all of the information required for training a new wake word/phrase detection model.\n", @@ -212,17 +228,17 @@ "- We'll train a detection model for the phrase \"hey sebastian\"\n", "- We'll only generate 5,000 positive and negative examples (to save on time for this example)\n", "- We'll only generate 1,000 validation positive and negative examples for early stopping (again to save time)\n", - "- The model will be trained for 30,000 steps (larger datasets will benefit from longer training)\n" + "- The model will only be trained for 10,000 steps (larger datasets will benefit from longer training)\n" ] }, { "cell_type": "code", - "execution_count": 20, - "id": "81bc1bea", + "execution_count": 3, + "id": "87d35ce8", "metadata": { "ExecuteTime": { - "end_time": "2023-09-04T02:03:46.688266Z", - "start_time": "2023-09-04T02:03:46.672580Z" + "end_time": "2023-09-04T13:42:07.998260Z", + "start_time": "2023-09-04T13:42:07.982635Z" } }, "outputs": [ @@ -256,7 +272,7 @@ " 'target_false_positives_per_hour': 0.2}" ] }, - "execution_count": 20, + "execution_count": 3, "metadata": {}, "output_type": "execute_result" } @@ -269,12 +285,12 @@ }, { "cell_type": "code", - "execution_count": 21, - "id": "d0af4242", + "execution_count": 86, + "id": "fc0fe116", "metadata": { "ExecuteTime": { - "end_time": "2023-09-04T02:30:24.194893Z", - "start_time": "2023-09-04T02:30:24.176938Z" + "end_time": "2023-09-04T15:07:00.859210Z", + "start_time": "2023-09-04T15:07:00.841472Z" } }, "outputs": [], @@ -282,9 +298,21 @@ "# Modify values in the config and save a new version\n", "\n", "config[\"target_phrase\"] = [\"hey sebastian\"]\n", - "config[\"n_samples\"] = 5000\n", + "config[\"n_samples\"] = 1000\n", "config[\"n_samples_val\"] = 1000\n", - "config[\"steps\"] = 30000\n", + "config[\"steps\"] = 10000\n", + "\n", + "## temporary\n", + "config[\"target_accuracy\"] = 0.3\n", + "config[\"target_recall\"] = 0.2\n", + "config[\"piper_sample_generator_path\"] = os.path.abspath(\"../../piper-sample-generator/\")\n", + "config[\"rir_paths\"] = [\"/home/dscripka/dscripkaDrive/Home/computersAndTechnology/machine_learning/speech/openWakeWord_example_data/mit_rirs/16khz\"]\n", + "config[\"background_paths\"] = [\n", + " \"/home/dscripka/dscripkaDrive/Home/computersAndTechnology/machine_learning/speech/openWakeWord_example_data/fsd50k_sample/\",\n", + " \"/home/dscripka/dscripkaDrive/Home/computersAndTechnology/machine_learning/speech/openWakeWord_example_data/fma_sample/\",\n", + "]\n", + "config[\"false_positive_validation_data_path\"] = \"/home/dscripka/dscripkaDrive_nvme_fast/experiments/speech/openWakeWord/wakeword_testing_data/val_set_features.npy\"\n", + "config[\"feature_data_files\"] = {\"ACAV100M_sample\": \"/home/dscripka/dscripkaDrive_nvme_fast/experiments/speech/openWakeWord/wakeword_training_data/negative_examples/openwakeword_features/openwakeword_features_ACAV100M_2000_hrs_16bit.npy\"}\n", "\n", "with open('my_model.yaml', 'w') as file:\n", " documents = yaml.dump(config, file)" @@ -292,15 +320,15 @@ }, { "cell_type": "markdown", - "id": "db52159f", + "id": "676f1caa", "metadata": {}, "source": [ - "# Start Model Training" + "# Train the Model" ] }, { "cell_type": "markdown", - "id": "3e8d66c5", + "id": "77e5b1e0", "metadata": {}, "source": [ "With the data downloaded and training configuration set, we can now start training the model. We'll do this in parts to better illustrate the sequence, but you can also execute every step sequentially for a fully automated process." @@ -308,13 +336,239 @@ }, { "cell_type": "code", - "execution_count": null, - "id": "9dff83ec", - "metadata": {}, - "outputs": [], + "execution_count": 15, + "id": "58eb1bfb", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T13:50:08.803326Z", + "start_time": "2023-09-04T13:50:06.790241Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO:root:Generating positive clips for training\r\n", + "WARNING:root:Skipping generation of positive clips for training, as ~1000 already exist\r\n", + "INFO:root:Generating positive clips for testing\r\n", + "WARNING:root:Skipping generation of positive clips testing, as ~1000 already exist\r\n", + "INFO:root:Generating negative clips for training\r\n", + "WARNING:root:Skipping generation of negative clips for training, as ~1000 already exist\r\n", + "INFO:root:Generating negative clips for testing\r\n", + "WARNING:root:Skipping generation of negative clips for testing, as ~1000 already exist\r\n", + "\u001b[0m" + ] + } + ], "source": [ "# Step 1: Generate synthetic clips\n", - "\n" + "# For the number of clips we are using, this should take ~10 minutes on a free Google Colab instance\n", + "# If generation fails, you can simply run this command again as it will continue generating until the\n", + "# number of files meets the targets specified in the config file\n", + "\n", + "!{sys.executable} ../openwakeword/train.py --training_config my_model.yaml --generate_clips" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "7e2fda2e", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T13:56:08.781018Z", + "start_time": "2023-09-04T13:55:40.203515Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO:root:Creating augmentation generators\n", + "INFO:root:Computing openwakeword features for generated samples\n", + "Computing features: 100%|███████████████████████| 62/62 [00:05<00:00, 10.58it/s]\n", + "Trimming empty rows: 1it [00:00, 7.99it/s]\n", + "Computing features: 100%|███████████████████████| 62/62 [00:05<00:00, 10.45it/s]\n", + "Trimming empty rows: 1it [00:00, 8.07it/s]\n", + "Computing features: 100%|███████████████████████| 62/62 [00:05<00:00, 10.77it/s]\n", + "Trimming empty rows: 1it [00:00, 8.09it/s]\n", + "Computing features: 100%|███████████████████████| 62/62 [00:05<00:00, 10.53it/s]\n", + "Trimming empty rows: 1it [00:00, 8.07it/s]\n", + "\u001b[0m" + ] + } + ], + "source": [ + "# Step 2: Augment the generated clips\n", + "\n", + "!{sys.executable} ../openwakeword/train.py --training_config my_model.yaml --augment_clips" + ] + }, + { + "cell_type": "code", + "execution_count": 87, + "id": "1a9fafe4", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T15:11:14.742260Z", + "start_time": "2023-09-04T15:07:03.755159Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO:root:Starting training sequence 1...\n", + "Training: 75%|████████████████████▏ | 7496/10000 [00:45<00:14, 168.37it/s]1.3274336 0.66 0.331\n", + "INFO:root:Saving checkpoint with metrics >= to targets!\n", + "Training: 76%|█████████████████████▎ | 7621/10000 [00:49<00:28, 82.33it/s]1.3274336 0.66 0.331\n", + "INFO:root:Saving checkpoint with metrics >= to targets!\n", + "Training: 78%|█████████████████████▋ | 7762/10000 [00:53<00:22, 97.84it/s]2.300885 0.6735 0.362\n", + "Training: 79%|██████████████████████ | 7887/10000 [00:57<00:25, 83.36it/s]0.088495575 0.607 0.216\n", + "INFO:root:Saving checkpoint with metrics >= to targets!\n", + "Training: 80%|██████████████████████▍ | 8011/10000 [01:00<00:23, 83.46it/s]2.2123895 0.676 0.367\n", + "Training: 82%|██████████████████████▊ | 8153/10000 [01:04<00:19, 96.94it/s]0.26548672 0.623 0.252\n", + "Training: 83%|███████████████████████▏ | 8277/10000 [01:08<00:20, 82.58it/s]0.088495575 0.605 0.212\n", + "INFO:root:Saving checkpoint with metrics >= to targets!\n", + "Training: 84%|███████████████████████▌ | 8416/10000 [01:12<00:16, 95.46it/s]0.44247788 0.6405 0.29\n", + "Training: 85%|███████████████████████▉ | 8541/10000 [01:16<00:17, 82.74it/s]1.5044248 0.6605 0.335\n", + "Training: 87%|████████████████████████▎ | 8683/10000 [01:19<00:13, 96.74it/s]1.2389381 0.6595 0.333\n", + "Training: 88%|████████████████████████▋ | 8807/10000 [01:23<00:14, 82.40it/s]0.44247788 0.638 0.284\n", + "Training: 89%|█████████████████████████ | 8931/10000 [01:27<00:12, 82.28it/s]0.44247788 0.643 0.295\n", + "Training: 91%|█████████████████████████▍ | 9073/10000 [01:31<00:09, 95.54it/s]0.9734513 0.658 0.327\n", + "Training: 92%|█████████████████████████▊ | 9197/10000 [01:34<00:09, 83.55it/s]1.5929203 0.6635 0.341\n", + "Training: 93%|██████████████████████████▏ | 9338/10000 [01:38<00:06, 96.77it/s]2.0353982 0.6685 0.352\n", + "Training: 95%|██████████████████████████▍ | 9463/10000 [01:42<00:06, 82.58it/s]1.1504425 0.658 0.33\n", + "Training: 96%|██████████████████████████▉ | 9605/10000 [01:46<00:04, 97.06it/s]1.2389381 0.6585 0.331\n", + "Training: 97%|███████████████████████████▏| 9727/10000 [01:49<00:03, 81.79it/s]1.2389381 0.6585 0.331\n", + "Training: 99%|███████████████████████████▌| 9866/10000 [01:53<00:01, 95.62it/s]1.1504425 0.6585 0.33\n", + "Training: 100%|███████████████████████████▉| 9999/10000 [01:57<00:00, 85.19it/s]\n", + "INFO:root:Starting training sequence 2...\n", + "Training: 4%|█▏ | 41/1000.0 [00:00<00:11, 86.52it/s]1.1504425 0.658 0.33\n", + "Training: 9%|██▌ | 89/1000.0 [00:04<00:37, 24.32it/s]1.4159292 0.6595 0.333\n", + "Training: 14%|████ | 143/1000.0 [00:07<00:36, 23.68it/s]1.3274336 0.6585 0.331\n", + "Training: 20%|█████▍ | 195/1000.0 [00:10<00:34, 23.10it/s]1.5044248 0.6605 0.335\n", + "Training: 25%|██████▉ | 247/1000.0 [00:14<00:32, 22.84it/s]1.6814159 0.665 0.344\n", + "Training: 30%|████████▍ | 300/1000.0 [00:17<00:30, 22.77it/s]1.858407 0.668 0.352\n", + "Training: 35%|█████████▉ | 353/1000.0 [00:20<00:27, 23.39it/s]2.1238937 0.671 0.359\n", + "Training: 40%|███████████▎ | 405/1000.0 [00:24<00:27, 22.00it/s]1.858407 0.6685 0.352\n", + "Training: 46%|████████████▊ | 458/1000.0 [00:27<00:23, 23.08it/s]1.4159292 0.6605 0.335\n", + "Training: 51%|██████████████▎ | 512/1000.0 [00:30<00:20, 23.52it/s]1.5929203 0.664 0.342\n", + "Training: 56%|███████████████▊ | 563/1000.0 [00:33<00:19, 22.59it/s]1.1504425 0.659 0.33\n", + "Training: 62%|█████████████████▏ | 616/1000.0 [00:37<00:17, 22.47it/s]0.44247788 0.641 0.291\n", + "Training: 67%|██████████████████▋ | 667/1000.0 [00:40<00:14, 22.93it/s]0.44247788 0.6415 0.292\n", + "Training: 74%|████████████████████▋ | 737/1000.0 [00:43<00:08, 30.03it/s]0.44247788 0.6435 0.296\n", + "Training: 79%|██████████████████████ | 787/1000.0 [00:47<00:08, 23.88it/s]0.53097343 0.646 0.301\n", + "Training: 84%|███████████████████████▍ | 837/1000.0 [00:50<00:07, 22.92it/s]0.53097343 0.646 0.301\n", + "Training: 89%|████████████████████████▊ | 886/1000.0 [00:53<00:05, 22.13it/s]0.53097343 0.646 0.301\n", + "Training: 94%|██████████████████████████▏ | 935/1000.0 [00:57<00:02, 22.19it/s]0.53097343 0.6465 0.303\n", + "Training: 100%|███████████████████████████▉| 999/1000.0 [01:00<00:00, 16.52it/s]\n", + "INFO:root:Starting training sequence 3...\n", + "Training: 5%|█▎ | 47/1000.0 [00:00<00:09, 97.00it/s]0.53097343 0.6465 0.303\n", + "Training: 10%|██▊ | 95/1000.0 [00:04<00:35, 25.72it/s]0.53097343 0.6465 0.303\n", + "Training: 14%|████ | 145/1000.0 [00:07<00:36, 23.53it/s]0.53097343 0.6465 0.303\n", + "Training: 20%|█████▍ | 195/1000.0 [00:10<00:35, 22.67it/s]0.53097343 0.6465 0.303\n", + "Training: 25%|██████▉ | 247/1000.0 [00:14<00:34, 22.09it/s]0.53097343 0.647 0.304\n", + "Training: 30%|████████▎ | 299/1000.0 [00:17<00:30, 22.78it/s]0.7079646 0.6485 0.308\n", + "Training: 35%|█████████▉ | 353/1000.0 [00:20<00:28, 22.88it/s]0.7079646 0.6485 0.308\n", + "Training: 41%|███████████▎ | 406/1000.0 [00:23<00:25, 23.08it/s]0.7079646 0.649 0.309\n", + "Training: 46%|████████████▊ | 457/1000.0 [00:27<00:23, 22.67it/s]0.7079646 0.6495 0.31\n", + "Training: 51%|██████████████▎ | 511/1000.0 [00:30<00:21, 23.20it/s]0.7079646 0.65 0.311\n", + "Training: 56%|███████████████▊ | 563/1000.0 [00:33<00:19, 22.79it/s]0.7079646 0.651 0.313\n", + "Training: 62%|█████████████████▏ | 616/1000.0 [00:37<00:16, 23.22it/s]0.7079646 0.652 0.315\n", + "Training: 67%|██████████████████▋ | 668/1000.0 [00:40<00:14, 22.49it/s]0.7079646 0.653 0.317\n", + "Training: 72%|████████████████████▏ | 720/1000.0 [00:43<00:12, 22.51it/s]0.7079646 0.654 0.319\n", + "Training: 77%|█████████████████████▋ | 774/1000.0 [00:47<00:09, 22.95it/s]0.7079646 0.654 0.319\n", + "Training: 82%|███████████████████████ | 825/1000.0 [00:50<00:07, 22.49it/s]0.7079646 0.6535 0.318\n", + "Training: 88%|████████████████████████▌ | 879/1000.0 [00:53<00:05, 23.56it/s]0.7079646 0.654 0.319\n", + "Training: 95%|██████████████████████████▌ | 947/1000.0 [00:56<00:01, 30.09it/s]0.7079646 0.654 0.319\n", + "Training: 100%|███████████████████████████▉| 999/1000.0 [01:00<00:00, 16.55it/s]\n", + "INFO:root:Merging best checkpoints into single model...\n", + "\n", + "\n", + "INFO:root:\n", + "################\n", + "Final Model Accuracy: 0.637499988079071\n", + "Final Model Recall: 0.2809999883174896\n", + "Final Model False Positives per Hour: 0.44247788190841675\n", + "################\n", + "\n", + "DEBUG:tensorflow:Falling back to TensorFlow client; we recommended you install the Cloud TPU client directly with pip install cloud-tpu-client.\n", + "DEBUG:h5py._conv:Creating converter from 7 to 5\n", + "DEBUG:h5py._conv:Creating converter from 5 to 7\n", + "DEBUG:h5py._conv:Creating converter from 7 to 5\n", + "DEBUG:h5py._conv:Creating converter from 5 to 7\n", + "2023-09-04 11:11:12.446476: I tensorflow/stream_executor/cuda/cuda_gpu_executor.cc:936] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero\n", + "2023-09-04 11:11:12.446795: W tensorflow/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libcusolver.so.11'; dlerror: libcusolver.so.11: cannot open shared object file: No such file or directory\n", + "2023-09-04 11:11:12.446847: W tensorflow/core/common_runtime/gpu/gpu_device.cc:1850] Cannot dlopen some GPU libraries. Please make sure the missing libraries mentioned above are installed properly if you would like to use GPU. Follow the guide at https://www.tensorflow.org/install/gpu for how to download and setup the required libraries for your platform.\n", + "Skipping registering GPU devices...\n", + "2023-09-04 11:11:12.446979: I tensorflow/core/platform/cpu_feature_guard.cc:151] This TensorFlow binary is optimized with oneAPI Deep Neural Network Library (oneDNN) to use the following CPU instructions in performance-critical operations: AVX2 AVX512F FMA\n", + "To enable them in other operations, rebuild TensorFlow with the appropriate compiler flags.\n", + "WARNING:absl:Function `__call__` contains input name(s) onnx_tf__tf_Flatten_0_45bfc89f with unsupported characters which will be renamed to onnx_tf__tf_flatten_0_45bfc89f in the SavedModel.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2023-09-04 11:11:13.859582: W tensorflow/python/util/util.cc:368] Sets are not currently considered sequences, but this may change in the future, so consider avoiding using them.\n", + "WARNING:absl:Found untraced functions such as gen_tensor_dict while saving (showing 1 of 1). These functions will not be directly callable after loading.\n", + "INFO:tensorflow:Assets written to: /tmp/tmpqvff79vt/tf_model/assets\n", + "2023-09-04 11:11:14.012590: W tensorflow/compiler/mlir/lite/python/tf_tfl_flatbuffer_helpers.cc:357] Ignored output_format.\n", + "2023-09-04 11:11:14.012606: W tensorflow/compiler/mlir/lite/python/tf_tfl_flatbuffer_helpers.cc:360] Ignored drop_control_dependency.\n", + "2023-09-04 11:11:14.013126: I tensorflow/cc/saved_model/reader.cc:43] Reading SavedModel from: /tmp/tmpqvff79vt/tf_model\n", + "2023-09-04 11:11:14.013536: I tensorflow/cc/saved_model/reader.cc:78] Reading meta graph with tags { serve }\n", + "2023-09-04 11:11:14.013546: I tensorflow/cc/saved_model/reader.cc:119] Reading SavedModel debug info (if present) from: /tmp/tmpqvff79vt/tf_model\n", + "2023-09-04 11:11:14.014464: I tensorflow/cc/saved_model/loader.cc:228] Restoring SavedModel bundle.\n", + "2023-09-04 11:11:14.025569: I tensorflow/cc/saved_model/loader.cc:212] Running initialization op on SavedModel bundle at path: /tmp/tmpqvff79vt/tf_model\n", + "2023-09-04 11:11:14.030851: I tensorflow/cc/saved_model/loader.cc:301] SavedModel load for tags { serve }; Status: success: OK. Took 17727 microseconds.\n", + "2023-09-04 11:11:14.036528: I tensorflow/compiler/mlir/tensorflow/utils/dump_mlir_util.cc:237] disabling MLIR crash reproducer, set env var `MLIR_CRASH_REPRODUCER_DIRECTORY` to enable.\n", + "2023-09-04 11:11:14.046951: I tensorflow/compiler/mlir/lite/flatbuffer_export.cc:1963] Estimated count of arithmetic ops: 0.101 M ops, equivalently 0.050 M MACs\n", + "\n", + "Estimated count of arithmetic ops: 0.101 M ops, equivalently 0.050 M MACs\n", + "\u001b[0m" + ] + } + ], + "source": [ + "# Step 3: Train model\n", + "\n", + "!{sys.executable} ../openwakeword/train.py --training_config my_model.yaml --train_model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5b0eed06", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "b069686e", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T13:56:23.163906Z", + "start_time": "2023-09-04T13:56:23.027821Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "negative_features_test.npy negative_train\t\t positive_test\r\n", + "negative_features_train.npy positive_features_test.npy positive_train\r\n", + "negative_test\t\t positive_features_train.npy\r\n" + ] + } + ], + "source": [ + "!ls generated_data/my_model/" ] } ], diff --git a/openwakeword/data.py b/openwakeword/data.py index 184dfb5..0ea5344 100755 --- a/openwakeword/data.py +++ b/openwakeword/data.py @@ -826,7 +826,6 @@ class mmap_batch_generator: # Restart at zeroth index if an array reaches the end if self.data_counter[label] >= self.shapes[label][0]: self.data_counter[label] = 0 - # self.data[label] = np.load(self.data_files[label], mmap_mode='r') # Get data from mmaped file x = self.data[label][self.data_counter[label]:self.data_counter[label]+n] diff --git a/openwakeword/train.py b/openwakeword/train.py index a8c1c75..9019df7 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -201,7 +201,7 @@ class Model(nn.Module): val_set_hrs = 11.3 # Sequence 1 - print("Starting training sequence 1...") + logging.info("Starting training sequence 1...") lr = 0.0001 weights = np.linspace(1, max_negative_weight, int(steps)).tolist() val_steps = np.linspace(steps-int(steps*0.25), steps, 20).astype(np.int64) @@ -216,14 +216,14 @@ class Model(nn.Module): target_val_accuracy=target_val_accuracy, target_val_recall=target_val_recall) # Sequence 2 - print("Starting training sequence 2...") + logging.info("Starting training sequence 2...") lr = lr/10 steps = steps/10 # Adjust weights as needed based on false positive per hour performance from first sequence if self.best_val_fp > target_val_fp_per_hour: max_negative_weight = max_negative_weight*2 - print("Increasing weight on negative examples to reduce false positives...") + logging.info("Increasing weight on negative examples to reduce false positives...") weights = np.linspace(1, max_negative_weight, int(steps)).tolist() val_steps = np.linspace(1, steps, 20).astype(np.int16) @@ -238,13 +238,13 @@ class Model(nn.Module): target_val_accuracy=target_val_accuracy, target_val_recall=target_val_recall) # Sequence 3 - print("Starting training sequence 3...") + logging.info("Starting training sequence 3...") lr = lr/10 # Adjust weights as needed based on false positive per hour performance from second sequence if self.best_val_fp > target_val_fp_per_hour: max_negative_weight = max_negative_weight*2 - print("Increasing weight on negative examples to reduce false positives...") + logging.info("Increasing weight on negative examples to reduce false positives...") weights = np.linspace(1, max_negative_weight, int(steps)).tolist() val_steps = np.linspace(1, steps, 20).astype(np.int16) @@ -259,10 +259,16 @@ class Model(nn.Module): target_val_accuracy=target_val_accuracy, target_val_recall=target_val_recall) # Merge best models - print("Merging best checkpoints into single model...") - combined_model = self.average_models(models=self.best_models) + if len(self.best_models) == 0: + logging.warning("No checkpoint with metrics >= than target values was found!\n" + "Consider generating more examples, or reducing target metrics." + "Returning the model corresponding to the last training step.\n\n") + return self.model + else: + logging.info("Merging best checkpoints into single model...") + combined_model = self.average_models(models=self.best_models) - # Report validationmetrics for combined model + # Report validation metrics for combined model with torch.no_grad(): for batch in X_val: x, y = batch[0].to(self.device), batch[1].to(self.device) @@ -279,11 +285,9 @@ class Model(nn.Module): combined_model_fp_per_hr = (combined_model_fp/val_set_hrs).detach().cpu().numpy() - print("\n################\n") - print("Final Model Accuracy:", combined_model_accuracy) - print("Final Model Recall:", combined_model_recall) - print("Final Model False Positives per Hour:", combined_model_fp_per_hr) - print("\n################\n") + logging.info(f"\n################\nFinal Model Accuracy: {combined_model_accuracy}" + f"\nFinal Model Recall: {combined_model_recall}\nFinal Model False Positives per Hour: {combined_model_fp_per_hr}" + "\n################\n") return combined_model @@ -295,6 +299,7 @@ class Model(nn.Module): "Use the `export_to_onnx` function instead.") # Save ONNX model + logging.info(f"Saving ONNX mode as '{os.path.join(output_dir, model_name + '.onnx')}'") model_to_save = copy.deepcopy(model) torch.onnx.export(model_to_save.to("cpu"), torch.rand(self.input_shape)[None, ], os.path.join(output_dir, model_name + ".onnx")) @@ -318,6 +323,7 @@ class Model(nn.Module): converter = tf.lite.TFLiteConverter.from_saved_model(os.path.join(tmp_dir, "tf_model")) tflite_model = converter.convert() + logging.info(f"Saving tflite mode as '{os.path.join(output_dir, model_name + '.tflite')}'") with open(os.path.join(output_dir, model_name + ".tflite"), 'wb') as f: f.write(tflite_model) @@ -415,11 +421,11 @@ class Model(nn.Module): self.history["val_recall"].append(val_recall) # Save models with a validation score below a given threshold - print(val_fp_per_hr, self.history["val_accuracy"][-1], self.history["val_recall"][-1]) + # print(val_fp_per_hr, self.history["val_accuracy"][-1], self.history["val_recall"][-1]) if val_fp_per_hr <= max(self.best_val_fp, max_val_fp_per_hr) and \ self.history["val_accuracy"][-1] >= target_val_accuracy and \ self.history["val_recall"][-1] >= target_val_recall: - print("Saving checkpoint with metrics >= to targets!") + # logging.info("Saving checkpoint with metrics >= to targets!") self.best_models.append(copy.deepcopy(self.model)) self.best_val_fp = val_fp_per_hr self.best_val_recall = self.history["val_recall"][-1] @@ -441,7 +447,6 @@ if __name__ == '__main__': parser.add_argument( "--generate_clips", help="Execute the synthetic data generation process", - type=bool, action="store_true", default="False", required=False @@ -449,7 +454,6 @@ if __name__ == '__main__': parser.add_argument( "--augment_clips", help="Execute the synthetic data augmentation process", - type=bool, action="store_true", default="False", required=False @@ -457,7 +461,6 @@ if __name__ == '__main__': parser.add_argument( "--train_model", help="Execute the model training process", - type=bool, action="store_true", default="False", required=False @@ -471,8 +474,11 @@ if __name__ == '__main__': from generate_samples import generate_samples # Define output locations + config["output_dir"] = os.path.abspath(config["output_dir"]) if not os.path.exists(config["output_dir"]): os.mkdir(config["output_dir"]) + if not os.path.exists(os.path.join(config["output_dir"], config["model_name"])): + os.mkdir(os.path.join(config["output_dir"], config["model_name"])) positive_train_output_dir = os.path.join(config["output_dir"], config["model_name"], "positive_train") positive_test_output_dir = os.path.join(config["output_dir"], config["model_name"], "positive_test") @@ -486,10 +492,13 @@ if __name__ == '__main__': if args.generate_clips is True: # Generate positive clips for training + logging.info("Generating positive clips for training") + if not os.path.exists(positive_train_output_dir): + os.mkdir(positive_train_output_dir) n_current_samples = len(os.listdir(positive_train_output_dir)) if n_current_samples <= 0.95*config["n_samples"]: generate_samples( - text=[config["target_phrase"]], max_samples=config["n_samples"]-n_current_samples, + text=config["target_phrase"], max_samples=config["n_samples"]-n_current_samples, batch_size=config["tts_batch_size"], noise_scales=[0.98], noise_scale_ws=[0.98], length_scales=[0.75, 1.0, 1.25], output_dir=positive_train_output_dir, auto_reduce_batch_size=True, @@ -500,50 +509,59 @@ if __name__ == '__main__': logging.warning(f"Skipping generation of positive clips for training, as ~{config['n_samples']} already exist") # Generate positive clips for testing + logging.info("Generating positive clips for testing") + if not os.path.exists(positive_test_output_dir): + os.mkdir(positive_test_output_dir) n_current_samples = len(os.listdir(positive_test_output_dir)) if n_current_samples <= 0.95*config["n_samples_val"]: - generate_samples(text=[config["target_phrase"]], max_samples=config["n_samples_val"]-n_current_samples, - batch_size=config["tts_batch_size"], - noise_scales=[1.0], noise_scale_ws=[1.0], length_scales=[0.75, 1.0, 1.25], - output_dir=positive_test_output_dir, auto_reduce_batch_size=True) + generate_samples(text=config["target_phrase"], max_samples=config["n_samples_val"]-n_current_samples, + batch_size=config["tts_batch_size"], + noise_scales=[1.0], noise_scale_ws=[1.0], length_scales=[0.75, 1.0, 1.25], + output_dir=positive_test_output_dir, auto_reduce_batch_size=True) torch.cuda.empty_cache() else: logging.warning(f"Skipping generation of positive clips testing, as ~{config['n_samples_val']} already exist") # Generate adversarial negative clips for training + logging.info("Generating negative clips for training") + if not os.path.exists(negative_train_output_dir): + os.mkdir(negative_train_output_dir) n_current_samples = len(os.listdir(negative_train_output_dir)) if n_current_samples <= 0.95*config["n_samples"]: adversarial_texts = config["custom_negative_phrases"] for target_phrase in config["target_phrase"]: - adversarial_texts.append(generate_adversarial_texts( + adversarial_texts.extend(generate_adversarial_texts( input_text=target_phrase, N=config["n_samples"]//len(config["target_phrase"]), include_partial_phrase=1.0, include_input_words=0.2)) generate_samples(text=adversarial_texts, max_samples=config["n_samples"]-n_current_samples, - batch_size=config["tts_batch_size"]//7, - noise_scales=[0.98], noise_scale_ws=[0.98], length_scales=[0.75, 1.0, 1.25], - output_dir=negative_train_output_dir, auto_reduce_batch_size=True, - file_names=[uuid.uuid4().hex + ".wav" for i in range(config["n_samples"])] - ) + batch_size=config["tts_batch_size"]//7, + noise_scales=[0.98], noise_scale_ws=[0.98], length_scales=[0.75, 1.0, 1.25], + output_dir=negative_train_output_dir, auto_reduce_batch_size=True, + file_names=[uuid.uuid4().hex + ".wav" for i in range(config["n_samples"])] + ) torch.cuda.empty_cache() else: logging.warning(f"Skipping generation of negative clips for training, as ~{config['n_samples']} already exist") # Generate adversarial negative clips for testing + logging.info("Generating negative clips for testing") + if not os.path.exists(negative_test_output_dir): + os.mkdir(negative_test_output_dir) n_current_samples = len(os.listdir(negative_test_output_dir)) if n_current_samples <= 0.95*config["n_samples_val"]: adversarial_texts = config["custom_negative_phrases"] for target_phrase in config["target_phrase"]: - adversarial_texts.append(generate_adversarial_texts( + adversarial_texts.extend(generate_adversarial_texts( input_text=target_phrase, N=config["n_samples_val"]//len(config["target_phrase"]), include_partial_phrase=1.0, include_input_words=0.2)) generate_samples(text=adversarial_texts, max_samples=config["n_samples_val"]-n_current_samples, - batch_size=config["tts_batch_size"]//7, - noise_scales=[1.0], noise_scale_ws=[1.0], length_scales=[0.75, 1.0, 1.25], - output_dir=negative_test_output_dir, auto_reduce_batch_size=True) + batch_size=config["tts_batch_size"]//7, + noise_scales=[1.0], noise_scale_ws=[1.0], length_scales=[0.75, 1.0, 1.25], + output_dir=negative_test_output_dir, auto_reduce_batch_size=True) torch.cuda.empty_cache() else: logging.warning(f"Skipping generation of negative clips for testing, as ~{config['n_samples_val']} already exist") @@ -551,34 +569,34 @@ if __name__ == '__main__': # Do Data Augmentation if args.augment_clips is True: if not os.path.exists(os.path.join(feature_save_dir, "positive_features_train.npy")): - logging.info("Augmenting generated clips...") + logging.info("Creating augmentation generators") positive_clips_train = [str(i) for i in Path(positive_train_output_dir).glob("*.wav")]*config["augmentation_rounds"] positive_clips_train_generator = augment_clips(positive_clips_train, total_length=config["total_length"], - batch_size=config["augmentation_batch_size"], - background_clip_paths=background_paths, - RIR_paths=rir_paths) + batch_size=config["augmentation_batch_size"], + background_clip_paths=background_paths, + RIR_paths=rir_paths) positive_clips_test = [str(i) for i in Path(positive_test_output_dir).glob("*.wav")]*config["augmentation_rounds"] positive_clips_test_generator = augment_clips(positive_clips_test, total_length=config["total_length"], - batch_size=config["augmentation_batch_size"], - background_clip_paths=background_paths, - RIR_paths=rir_paths) + batch_size=config["augmentation_batch_size"], + background_clip_paths=background_paths, + RIR_paths=rir_paths) negative_clips_train = [str(i) for i in Path(negative_train_output_dir).glob("*.wav")]*config["augmentation_rounds"] negative_clips_train_generator = augment_clips(negative_clips_train, total_length=config["total_length"], - batch_size=config["augmentation_batch_size"], - background_clip_paths=background_paths, - RIR_paths=rir_paths) + batch_size=config["augmentation_batch_size"], + background_clip_paths=background_paths, + RIR_paths=rir_paths) negative_clips_test = [str(i) for i in Path(negative_test_output_dir).glob("*.wav")]*config["augmentation_rounds"] negative_clips_test_generator = augment_clips(negative_clips_test, total_length=config["total_length"], - batch_size=config["augmentation_batch_size"], - background_clip_paths=background_paths, - RIR_paths=rir_paths) + batch_size=config["augmentation_batch_size"], + background_clip_paths=background_paths, + RIR_paths=rir_paths) # Compute features and save to disk via memmapped arrays - logging.info("Computer openwakeword features for generated samples...") + logging.info("Computing openwakeword features for generated samples") n_cpus = os.cpu_count() if n_cpus is None: n_cpus = 1 @@ -588,25 +606,25 @@ if __name__ == '__main__': clip_duration=config["total_length"], output_file=os.path.join(feature_save_dir, "positive_features_train.npy"), device="gpu" if torch.cuda.is_available() else "cpu", - ncpu=n_cpus if not torch.cuda_is_available() else 1) + ncpu=n_cpus if not torch.cuda.is_available() else 1) compute_features_from_generator(negative_clips_train_generator, n_total=len(os.listdir(negative_train_output_dir)), clip_duration=config["total_length"], output_file=os.path.join(feature_save_dir, "negative_features_train.npy"), device="gpu" if torch.cuda.is_available() else "cpu", - ncpu=n_cpus if not torch.cuda_is_available() else 1) + ncpu=n_cpus if not torch.cuda.is_available() else 1) compute_features_from_generator(positive_clips_test_generator, n_total=len(os.listdir(positive_test_output_dir)), clip_duration=config["total_length"], output_file=os.path.join(feature_save_dir, "positive_features_test.npy"), device="gpu" if torch.cuda.is_available() else "cpu", - ncpu=n_cpus if not torch.cuda_is_available() else 1) + ncpu=n_cpus if not torch.cuda.is_available() else 1) compute_features_from_generator(negative_clips_test_generator, n_total=len(os.listdir(negative_test_output_dir)), clip_duration=config["total_length"], output_file=os.path.join(feature_save_dir, "negative_features_test.npy"), device="gpu" if torch.cuda.is_available() else "cpu", - ncpu=n_cpus if not torch.cuda_is_available() else 1) + ncpu=n_cpus if not torch.cuda.is_available() else 1) else: logging.warning("Openwakeword features already exist, skipping data augmentation and feature generation") @@ -618,18 +636,28 @@ if __name__ == '__main__': oww = Model(n_classes=1, input_shape=input_shape, model_type=config["model_type"], layer_dim=config["layer_size"], seconds_per_example=1280*input_shape[0]/16000) - # Create data and label transform functions for batch generation + # Create data transform function for batch generation to handle differ clip lengths (todo: write tests for this) def f(x, n=16): """Simple transformation function to ensure negative data is the appropriate shape for the model size""" - n_chunks = x.shape[1]//n - stacked = np.vstack(( - [x[:, i:i+n, :] for i in range(n_chunks)] - )) - return stacked + if n > x.shape[1] or n < x.shape[1]: + x = np.vstack(x) + new_batch = np.array([x[i:i+n, :] for i in range(0, x.shape[0]-n, n)]) + else: + return x + return new_batch - data_transforms = {key: f for key in config["negative_data_files"].keys()} - label_transforms = {key: lambda x: [1 for i in x] if key == "positive" else lambda x: [0 for i in x] - for key in ["positive"] + config["negative_data_files"] + ["adversarial_negative"]} + # Create label transforms as needed for model (currently only supports binary classification models) + data_transforms = {key: f for key in config["feature_data_files"].keys()} + label_transforms = {} + for key in ["positive"] + list(config["feature_data_files"].keys()) + ["adversarial_negative"]: + if key == "positive": + label_transforms[key] = lambda x: [1 for i in x] + else: + label_transforms[key] = lambda x: [0 for i in x] + + # Add generated positive and adversarial negative clips to the feature data files dictionary + config["feature_data_files"]['positive'] = os.path.join(feature_save_dir, "positive_features_train.npy") + config["feature_data_files"]['adversarial_negative'] = os.path.join(feature_save_dir, "negative_features_train.npy") # Make PyTorch data loaders for training and validation data batch_generator = mmap_batch_generator( @@ -646,8 +674,13 @@ if __name__ == '__main__': def __iter__(self): return self.generator + n_cpus = os.cpu_count() + if n_cpus is None: + n_cpus = 1 + else: + n_cpus = n_cpus//2 X_train = torch.utils.data.DataLoader(IterDataset(batch_generator), - batch_size=None, num_workers=8, prefetch_factor=16) + batch_size=None, num_workers=n_cpus, prefetch_factor=16) X_val_fp = np.load(config["false_positive_validation_data_path"]) X_val_fp = np.array([X_val_fp[i:i+input_shape[0]] for i in range(0, X_val_fp.shape[0]-input_shape[0], 1)]) # reshape to match model @@ -668,13 +701,6 @@ if __name__ == '__main__': batch_size=len(labels) ) - # Run auto training and save model - steps = 100000 - max_neg_weight = 1500 - target_accuracy = 0.7 - target_recall = 0.5 - target_fp_per_hour = 0.2 - # Run auto training best_model = oww.auto_train( X_train=X_train, @@ -684,7 +710,7 @@ if __name__ == '__main__': max_negative_weight=config["max_negative_weight"], target_val_accuracy=config["target_accuracy"], target_val_recall=config["target_recall"], - target_val_fp_per_hour=config["target_fp_per_hour"] + target_val_fp_per_hour=config["target_false_positives_per_hour"] ) # Export the trained model to onnx and tflite formats From 6a64b19cc963da8cf75f4e0fcc6876d3f27254a4 Mon Sep 17 00:00:00 2001 From: dscripka Date: Mon, 4 Sep 2023 13:57:49 -0400 Subject: [PATCH 034/103] Updated requirements for full install [skip ci] --- setup.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 38cab56..e824f6c 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,12 @@ setuptools.setup( 'pytest-flake8>=1.1.1,<2', 'pytest-mypy>=0.10.0,<1', 'acoustics>=0.2.6,<1', - 'pyyaml>=6.0,<7' + 'pyyaml>=6.0,<7', + 'tensorflow==2.8.1', + 'tensorflow_probability==0.16.0', + 'protobuf==3.20', + 'onnx_tf==1.10.0', + 'onnx==1.14.0' ] }, author="David Scripka", From e0f0c0e6ddf3a0c82db1b3918161752329324e4c Mon Sep 17 00:00:00 2001 From: dscripka Date: Mon, 4 Sep 2023 19:11:17 -0400 Subject: [PATCH 035/103] Updated requirements to fix gaps, edits to example notebook to work with Google colab [skip ci] --- notebooks/automatic_model_training.ipynb | 154 ++++++++++------------- setup.py | 7 +- 2 files changed, 72 insertions(+), 89 deletions(-) diff --git a/notebooks/automatic_model_training.ipynb b/notebooks/automatic_model_training.ipynb index 2827e36..e234901 100644 --- a/notebooks/automatic_model_training.ipynb +++ b/notebooks/automatic_model_training.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "7790ccc3", + "id": "1970bd02", "metadata": {}, "source": [ "# Introduction" @@ -10,16 +10,16 @@ }, { "cell_type": "markdown", - "id": "3af2f31f", + "id": "62fa8fb7", "metadata": {}, "source": [ - "This notebook demonstrates how to train custom openWakeWord models using pre-defined datasets and an automated process for dataset generation and training. While not guaranteed to always produce the best performing model, the methods shown in this notebook often produce baseline models with releatively strong performance.\n", + "This notebook demonstrates how to train custom openWakeWord models using pre-defined datasets, an automated process for synthetic data generation/augmentation, and a custom training process. While not guaranteed to always produce the best performing model, the methods shown in this notebook often produce baseline models with relatively strong performance.\n", "\n", "Manual data preparation and model training (e.g., see the [training models](training_models.ipynb) notebook) remains an option for when full control over the model development process is needed.\n", "\n", "At a high level, the automatic training process takes advantages of several techniques to try and produce a good model, including:\n", "\n", - "- Early-stopping and checkpoint averaging (similar to [stochastic weight averaging](https://arxiv.org/abs/1803.05407) to search for the best models found during training, according to the validation data\n", + "- Early-stopping and checkpoint averaging (similar to [stochastic weight averaging](https://arxiv.org/abs/1803.05407)) to search for the best models found during training, according to the validation data\n", "- Variable learning rates with cosine decay and multiple cycles\n", "- Adaptive batch construction to focus on only high-loss examples when the model begins to converge, combined with gradient accumulation to ensure that batch sizes are still large enough for stable training\n", "- Cycical weight schedules for negative examples to help the model reduce false-positive rates\n", @@ -29,7 +29,7 @@ }, { "cell_type": "markdown", - "id": "6161cbc6", + "id": "0af4f86d", "metadata": {}, "source": [ "# Environment Setup" @@ -37,35 +37,39 @@ }, { "cell_type": "markdown", - "id": "fffee4d4", + "id": "ddbbd9f0", "metadata": {}, "source": [ - "To begin, we'll need to install the requirements for training custom models. In particular, a relatively recent version of Pytorch and custom fork of the [piper-sample-generator](https://github.com/dscripka/piper-sample-generator) library for generating synthetic examples for the custom model." + "To begin, we'll need to install the requirements for training custom models. In particular, a relatively recent version of Pytorch and custom fork of the [piper-sample-generator](https://github.com/dscripka/piper-sample-generator) library for generating synthetic examples for the custom model.\n", + "\n", + "**Important Note!** Currently, automated model training is only supported on linux systems due to the requirements of the text to speech library used for synthetic sample generation (Piper). It may be possible to use Piper on Windows/Mac systems, but that has not (yet) been tested." ] }, { "cell_type": "code", "execution_count": null, - "id": "2eed3f89", + "id": "9fbc61cc", "metadata": {}, "outputs": [], "source": [ "## Environment setup\n", "\n", - "# install piper-sample-generator\n", + "# install piper-sample-generator (currently only supports linux systems)\n", "!git clone https://github.com/dscripka/piper-sample-generator\n", - "!wget -O models/en-us-libritts-high.pt 'https://github.com/rhasspy/piper-sample-generator/releases/download/v1.0.0/en-us-libritts-high.pt'\n", + "!wget -O piper-sample-generator/models/en-us-libritts-high.pt 'https://github.com/rhasspy/piper-sample-generator/releases/download/v1.0.0/en-us-libritts-high.pt'\n", + "!apt-get install libespeak-ng1 # may not be required on all systems\n", + "!pip install espeak_phonemizer\n", "\n", "# install openwakeword (full installation to support training)\n", - "!pip install openwakeword[full]\n", "!git clone https://github.com/dscripka/openwakeword\n", + "!pip install -e ./openwakeword[full]\n", "!cd openwakeword\n" ] }, { "cell_type": "code", "execution_count": 1, - "id": "7f556e37", + "id": "8db5c9e4", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T13:42:01.183840Z", @@ -92,12 +96,15 @@ "import sys\n", "from pathlib import Path\n", "import uuid\n", - "import yaml\n" + "import yaml\n", + "import datasets\n", + "import scipy\n", + "from tqdm import tqdm\n" ] }, { "cell_type": "markdown", - "id": "0e38803b", + "id": "9d4bbe15", "metadata": {}, "source": [ "# Download Data" @@ -105,7 +112,7 @@ }, { "cell_type": "markdown", - "id": "beef6917", + "id": "24580c3e", "metadata": {}, "source": [ "When training new openWakeWord models using the automated procedure, four specific types of data are required:\n", @@ -120,13 +127,13 @@ "\n", "5) Validation data to use for early-stopping when training the model.\n", "\n", - "For the purposes of this notebook, all five of these sources can be obtained from HuggingFace thanks to their excellent `datasets` library and extremely generous hosting policy. Also note that only a portion of some datasets are downloaded. But for the best possible performance, you are encouraged to download the entire dataset and keep a local copy for future training runs." + "For the purposes of this notebook, all five of these sources will either be generated manually or can be obtained from HuggingFace thanks to their excellent `datasets` library and extremely generous hosting policy. Also note that while only a portion of some datasets are downloaded, for the best possible performance it is recommended to download the entire dataset and keep a local copy for future training runs." ] }, { "cell_type": "code", "execution_count": 15, - "id": "9929f1b7", + "id": "37feb7f7", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T01:07:17.746749Z", @@ -135,24 +142,23 @@ }, "outputs": [], "source": [ - "# Download room impulse responses\n", + "# Download room impulse responses collected by MIT\n", + "# https://mcdermottlab.mit.edu/Reverb/IR_Survey.html\n", "\n", "output_dir = \"./mit_rirs\"\n", - "os.mkdir(output_dir) if not os.path.exists(output_dir)\n", + "if not os.path.exists(output_dir):\n", + " os.mkdir(output_dir)\n", "rir_dataset = datasets.load_dataset(\"davidscripka/MIT_environmental_impulse_responses\", split=\"train\", streaming=True)\n", "\n", "for row in tqdm(rir_dataset):\n", " name = row['audio']['path'].split('/')[-1]\n", - " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, row['audio']['array'])\n", - " i += 1\n", - " if i == n_total:\n", - " break\n" + " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, row['audio']['array'])\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "16becf16", + "id": "6e16363e", "metadata": {}, "outputs": [], "source": [ @@ -161,11 +167,12 @@ "# FSD50k Noise Dataset (warning, this can take 5 minutes to prepare when streaming)\n", "# https://zenodo.org/record/4060432\n", "output_dir = \"./fsd50k\"\n", - "os.mkdir(output_dir) if not os.path.exists(output_dir)\n", + "if not os.path.exists(output_dir):\n", + " os.mkdir(output_dir)\n", "fsd50k_dataset = datasets.load_dataset(\"Fhrozen/FSD50k\", split=\"validation\", streaming=True) # ~40,000 files in this split\n", "fsd50k_dataset = iter(fsd50k_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000)))\n", "\n", - "n_total = 500 # use only 500 clips for this example notebook, reccomend increasing for full-scale training\n", + "n_total = 500 # use only 500 clips for this example notebook, recommend increasing for full-scale training\n", "for i in tqdm(range(n_total)):\n", " row = next(fsd50k_dataset)\n", " name = row['audio']['path'].split('/')[-1]\n", @@ -178,11 +185,12 @@ "# https://github.com/mdeff/fma\n", "\n", "output_dir = \"./fma\"\n", - "os.mkdir(output_dir) if not os.path.exists(output_dir)\n", + "if not os.path.exists(output_dir):\n", + " os.mkdir(output_dir)\n", "fma_dataset = datasets.load_dataset(\"rudraml/fma\", name=\"small\", split=\"train\", streaming=True)\n", "fma_dataset = iter(fma_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000)))\n", "\n", - "n_hours = 1\n", + "n_hours = 1 # use only 1 hour of clips for this example notebook, recommend increasing for full-scale training\n", "for i in tqdm(range(n_hours*3600//30)): # this works because the FMA dataset is all 30 second clips\n", " row = next(fma_dataset)\n", " name = row['audio']['path'].split('/')[-1]\n", @@ -195,7 +203,7 @@ { "cell_type": "code", "execution_count": null, - "id": "66d26414", + "id": "a500d6be", "metadata": {}, "outputs": [], "source": [ @@ -204,13 +212,13 @@ "# training set (~2,000 hours)\n", "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/blob/main/openwakeword_features_ACAV100M_2000_hrs_16bit.npy\n", "\n", - "# validation set (~11 hours)\n", + "# validation set for false positive rate estimation (~11 hours)\n", "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/blob/main/validation_set_features.npy" ] }, { "cell_type": "markdown", - "id": "ed004cb1", + "id": "af9ba30f", "metadata": {}, "source": [ "# Define Training Configuration" @@ -218,7 +226,7 @@ }, { "cell_type": "markdown", - "id": "e9385f81", + "id": "1258ea70", "metadata": {}, "source": [ "For automated model training openWakeWord uses a specially designed training script and a [YAML](https://yaml.org/) configuration file that defines all of the information required for training a new wake word/phrase detection model.\n", @@ -228,17 +236,20 @@ "- We'll train a detection model for the phrase \"hey sebastian\"\n", "- We'll only generate 5,000 positive and negative examples (to save on time for this example)\n", "- We'll only generate 1,000 validation positive and negative examples for early stopping (again to save time)\n", - "- The model will only be trained for 10,000 steps (larger datasets will benefit from longer training)\n" + "- The model will only be trained for 10,000 steps (larger datasets will benefit from longer training)\n", + "- We'll reduce the target metrics to account for the small dataset size and limited training.\n", + "\n", + "On the topic of target metrics, there are *not* specific guidelines about what these metrics should be in practice, and you will need to conduct testing in your target deployment environment to establish good thresholds. However, from very limited testing the default values in the config file (accuracy >= 0.7, recall >= 0.5, false-positive rate <= 0.2 per hour) seem to produce model with reasonable performance.\n" ] }, { "cell_type": "code", - "execution_count": 3, - "id": "87d35ce8", + "execution_count": 89, + "id": "e4ea6bcd", "metadata": { "ExecuteTime": { - "end_time": "2023-09-04T13:42:07.998260Z", - "start_time": "2023-09-04T13:42:07.982635Z" + "end_time": "2023-09-04T18:11:33.893397Z", + "start_time": "2023-09-04T18:11:33.878938Z" } }, "outputs": [ @@ -272,21 +283,21 @@ " 'target_false_positives_per_hour': 0.2}" ] }, - "execution_count": 3, + "execution_count": 89, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "# Load YAML file\n", - "config = yaml.load(open(\"../examples/custom_model.yml\", 'r').read(), yaml.Loader)\n", + "# Load default YAML config file for training\n", + "config = yaml.load(open(\"openwakeword/examples/custom_model.yml\", 'r').read(), yaml.Loader)\n", "config" ] }, { "cell_type": "code", "execution_count": 86, - "id": "fc0fe116", + "id": "026fa5cf", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T15:07:00.859210Z", @@ -301,18 +312,12 @@ "config[\"n_samples\"] = 1000\n", "config[\"n_samples_val\"] = 1000\n", "config[\"steps\"] = 10000\n", + "config[\"target_accuracy\"] = 0.6\n", + "config[\"target_recall\"] = 0.25\n", "\n", - "## temporary\n", - "config[\"target_accuracy\"] = 0.3\n", - "config[\"target_recall\"] = 0.2\n", - "config[\"piper_sample_generator_path\"] = os.path.abspath(\"../../piper-sample-generator/\")\n", - "config[\"rir_paths\"] = [\"/home/dscripka/dscripkaDrive/Home/computersAndTechnology/machine_learning/speech/openWakeWord_example_data/mit_rirs/16khz\"]\n", - "config[\"background_paths\"] = [\n", - " \"/home/dscripka/dscripkaDrive/Home/computersAndTechnology/machine_learning/speech/openWakeWord_example_data/fsd50k_sample/\",\n", - " \"/home/dscripka/dscripkaDrive/Home/computersAndTechnology/machine_learning/speech/openWakeWord_example_data/fma_sample/\",\n", - "]\n", - "config[\"false_positive_validation_data_path\"] = \"/home/dscripka/dscripkaDrive_nvme_fast/experiments/speech/openWakeWord/wakeword_testing_data/val_set_features.npy\"\n", - "config[\"feature_data_files\"] = {\"ACAV100M_sample\": \"/home/dscripka/dscripkaDrive_nvme_fast/experiments/speech/openWakeWord/wakeword_training_data/negative_examples/openwakeword_features/openwakeword_features_ACAV100M_2000_hrs_16bit.npy\"}\n", + "config[\"background_paths\"] = []#['./fsd50k', './fma']\n", + "config[\"false_positive_validation_data_path\"] = \"val_set_features.npy\"\n", + "config[\"feature_data_files\"] = {\"ACAV100M_sample\": \"openwakeword_features_ACAV100M_2000_hrs_16bit.npy\"}\n", "\n", "with open('my_model.yaml', 'w') as file:\n", " documents = yaml.dump(config, file)" @@ -320,7 +325,7 @@ }, { "cell_type": "markdown", - "id": "676f1caa", + "id": "f2fa8b05", "metadata": {}, "source": [ "# Train the Model" @@ -328,16 +333,16 @@ }, { "cell_type": "markdown", - "id": "77e5b1e0", + "id": "02f8bb92", "metadata": {}, "source": [ - "With the data downloaded and training configuration set, we can now start training the model. We'll do this in parts to better illustrate the sequence, but you can also execute every step sequentially for a fully automated process." + "With the data downloaded and training configuration set, we can now start training the model. We'll do this in parts to better illustrate the sequence, but you can also execute every step at once for a fully automated process." ] }, { "cell_type": "code", "execution_count": 15, - "id": "58eb1bfb", + "id": "dab81483", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T13:50:08.803326Z", @@ -367,13 +372,13 @@ "# If generation fails, you can simply run this command again as it will continue generating until the\n", "# number of files meets the targets specified in the config file\n", "\n", - "!{sys.executable} ../openwakeword/train.py --training_config my_model.yaml --generate_clips" + "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --generate_clips" ] }, { "cell_type": "code", "execution_count": 24, - "id": "7e2fda2e", + "id": "7ef78386", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T13:56:08.781018Z", @@ -402,13 +407,13 @@ "source": [ "# Step 2: Augment the generated clips\n", "\n", - "!{sys.executable} ../openwakeword/train.py --training_config my_model.yaml --augment_clips" + "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --augment_clips" ] }, { "cell_type": "code", "execution_count": 87, - "id": "1a9fafe4", + "id": "e2acb50a", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T15:11:14.742260Z", @@ -535,41 +540,16 @@ "source": [ "# Step 3: Train model\n", "\n", - "!{sys.executable} ../openwakeword/train.py --training_config my_model.yaml --train_model" + "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --train_model" ] }, { "cell_type": "code", "execution_count": null, - "id": "5b0eed06", + "id": "79ba9c43", "metadata": {}, "outputs": [], "source": [] - }, - { - "cell_type": "code", - "execution_count": 25, - "id": "b069686e", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-04T13:56:23.163906Z", - "start_time": "2023-09-04T13:56:23.027821Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "negative_features_test.npy negative_train\t\t positive_test\r\n", - "negative_features_train.npy positive_features_test.npy positive_train\r\n", - "negative_test\t\t positive_features_train.npy\r\n" - ] - } - ], - "source": [ - "!ls generated_data/my_model/" - ] } ], "metadata": { diff --git a/setup.py b/setup.py index e824f6c..8e75202 100644 --- a/setup.py +++ b/setup.py @@ -49,6 +49,7 @@ setuptools.setup( 'torch>=1.13.1,<2', 'torchaudio>=0.13.1,<1', 'torchinfo>=1.8.0,<2', + 'torchmetrics>=0.11.4,<1', 'speechbrain>=0.5.14,<1', 'audiomentations>=0.30.0,<1', 'torch-audiomentations>=0.11.0,<1', @@ -61,9 +62,11 @@ setuptools.setup( 'pyyaml>=6.0,<7', 'tensorflow==2.8.1', 'tensorflow_probability==0.16.0', - 'protobuf==3.20', + 'protobuf>=3.20,<4', 'onnx_tf==1.10.0', - 'onnx==1.14.0' + 'onnx==1.14.0', + 'pronouncing>=0.2.0,<1', + 'datasets>=2.14.4,<3' ] }, author="David Scripka", From 7cafd26551d6821172628fe093be1830acc284a7 Mon Sep 17 00:00:00 2001 From: dscripka Date: Mon, 4 Sep 2023 19:36:06 -0400 Subject: [PATCH 036/103] Edits to example notebook for auto training [skip ci] --- notebooks/automatic_model_training.ipynb | 271 ++++------------------- 1 file changed, 40 insertions(+), 231 deletions(-) diff --git a/notebooks/automatic_model_training.ipynb b/notebooks/automatic_model_training.ipynb index e234901..1f8912f 100644 --- a/notebooks/automatic_model_training.ipynb +++ b/notebooks/automatic_model_training.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "1970bd02", + "id": "f49227ca", "metadata": {}, "source": [ "# Introduction" @@ -10,7 +10,7 @@ }, { "cell_type": "markdown", - "id": "62fa8fb7", + "id": "6483fd4d", "metadata": {}, "source": [ "This notebook demonstrates how to train custom openWakeWord models using pre-defined datasets, an automated process for synthetic data generation/augmentation, and a custom training process. While not guaranteed to always produce the best performing model, the methods shown in this notebook often produce baseline models with relatively strong performance.\n", @@ -29,7 +29,7 @@ }, { "cell_type": "markdown", - "id": "0af4f86d", + "id": "67ea460d", "metadata": {}, "source": [ "# Environment Setup" @@ -37,7 +37,7 @@ }, { "cell_type": "markdown", - "id": "ddbbd9f0", + "id": "004ef4db", "metadata": {}, "source": [ "To begin, we'll need to install the requirements for training custom models. In particular, a relatively recent version of Pytorch and custom fork of the [piper-sample-generator](https://github.com/dscripka/piper-sample-generator) library for generating synthetic examples for the custom model.\n", @@ -48,7 +48,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9fbc61cc", + "id": "ad77a769", "metadata": {}, "outputs": [], "source": [ @@ -68,24 +68,15 @@ }, { "cell_type": "code", - "execution_count": 1, - "id": "8db5c9e4", + "execution_count": null, + "id": "da85818d", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T13:42:01.183840Z", "start_time": "2023-09-04T13:41:59.752153Z" } }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/dscripka/anaconda3/envs/openwakeword_dev/lib/python3.9/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", - " from .autonotebook import tqdm as notebook_tqdm\n" - ] - } - ], + "outputs": [], "source": [ "# Imports\n", "\n", @@ -104,7 +95,7 @@ }, { "cell_type": "markdown", - "id": "9d4bbe15", + "id": "658ccb2a", "metadata": {}, "source": [ "# Download Data" @@ -112,7 +103,7 @@ }, { "cell_type": "markdown", - "id": "24580c3e", + "id": "1e4450ab", "metadata": {}, "source": [ "When training new openWakeWord models using the automated procedure, four specific types of data are required:\n", @@ -132,8 +123,8 @@ }, { "cell_type": "code", - "execution_count": 15, - "id": "37feb7f7", + "execution_count": null, + "id": "ab3c1fce", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T01:07:17.746749Z", @@ -158,7 +149,7 @@ { "cell_type": "code", "execution_count": null, - "id": "6e16363e", + "id": "b28717ef", "metadata": {}, "outputs": [], "source": [ @@ -166,6 +157,7 @@ "\n", "# FSD50k Noise Dataset (warning, this can take 5 minutes to prepare when streaming)\n", "# https://zenodo.org/record/4060432\n", + "\n", "output_dir = \"./fsd50k\"\n", "if not os.path.exists(output_dir):\n", " os.mkdir(output_dir)\n", @@ -203,7 +195,7 @@ { "cell_type": "code", "execution_count": null, - "id": "a500d6be", + "id": "b5bbe225", "metadata": {}, "outputs": [], "source": [ @@ -218,7 +210,7 @@ }, { "cell_type": "markdown", - "id": "af9ba30f", + "id": "9265dbcf", "metadata": {}, "source": [ "# Define Training Configuration" @@ -226,7 +218,7 @@ }, { "cell_type": "markdown", - "id": "1258ea70", + "id": "1e204f3e", "metadata": {}, "source": [ "For automated model training openWakeWord uses a specially designed training script and a [YAML](https://yaml.org/) configuration file that defines all of the information required for training a new wake word/phrase detection model.\n", @@ -244,50 +236,15 @@ }, { "cell_type": "code", - "execution_count": 89, - "id": "e4ea6bcd", + "execution_count": null, + "id": "c0fea7ab", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T18:11:33.893397Z", "start_time": "2023-09-04T18:11:33.878938Z" } }, - "outputs": [ - { - "data": { - "text/plain": [ - "{'model_name': 'my_model',\n", - " 'target_phrase': ['hey jarvis'],\n", - " 'total_length': 32000,\n", - " 'custom_negative_phrases': [],\n", - " 'n_samples': 10000,\n", - " 'n_samples_val': 2000,\n", - " 'tts_batch_size': 50,\n", - " 'augmentation_batch_size': 16,\n", - " 'piper_sample_generator_path': './piper-sample-generator',\n", - " 'output_dir': './generated_data',\n", - " 'rir_paths': ['./mit_rirs'],\n", - " 'background_paths': ['./background_clips'],\n", - " 'false_positive_validation_data_path': './validation_set_features.npy',\n", - " 'augmentation_rounds': 1,\n", - " 'feature_data_files': {'ACAV100M_sample': './openwakeword_features_ACAV100M_2000_hrs_16bit.npy'},\n", - " 'batch_n_per_class': {'ACAV100M_sample': 1024,\n", - " 'adversarial_negative': 50,\n", - " 'positive': 50},\n", - " 'model_type': 'dnn',\n", - " 'layer_size': 32,\n", - " 'steps': 100000,\n", - " 'max_negative_weight': 1500,\n", - " 'target_accuracy': 0.7,\n", - " 'target_recall': 0.5,\n", - " 'target_false_positives_per_hour': 0.2}" - ] - }, - "execution_count": 89, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# Load default YAML config file for training\n", "config = yaml.load(open(\"openwakeword/examples/custom_model.yml\", 'r').read(), yaml.Loader)\n", @@ -296,8 +253,8 @@ }, { "cell_type": "code", - "execution_count": 86, - "id": "026fa5cf", + "execution_count": null, + "id": "fbe862c5", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T15:07:00.859210Z", @@ -325,7 +282,7 @@ }, { "cell_type": "markdown", - "id": "f2fa8b05", + "id": "c7460b02", "metadata": {}, "source": [ "# Train the Model" @@ -333,7 +290,7 @@ }, { "cell_type": "markdown", - "id": "02f8bb92", + "id": "c1de9710", "metadata": {}, "source": [ "With the data downloaded and training configuration set, we can now start training the model. We'll do this in parts to better illustrate the sequence, but you can also execute every step at once for a fully automated process." @@ -341,31 +298,15 @@ }, { "cell_type": "code", - "execution_count": 15, - "id": "dab81483", + "execution_count": null, + "id": "85115fc9", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T13:50:08.803326Z", "start_time": "2023-09-04T13:50:06.790241Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "INFO:root:Generating positive clips for training\r\n", - "WARNING:root:Skipping generation of positive clips for training, as ~1000 already exist\r\n", - "INFO:root:Generating positive clips for testing\r\n", - "WARNING:root:Skipping generation of positive clips testing, as ~1000 already exist\r\n", - "INFO:root:Generating negative clips for training\r\n", - "WARNING:root:Skipping generation of negative clips for training, as ~1000 already exist\r\n", - "INFO:root:Generating negative clips for testing\r\n", - "WARNING:root:Skipping generation of negative clips for testing, as ~1000 already exist\r\n", - "\u001b[0m" - ] - } - ], + "outputs": [], "source": [ "# Step 1: Generate synthetic clips\n", "# For the number of clips we are using, this should take ~10 minutes on a free Google Colab instance\n", @@ -377,33 +318,15 @@ }, { "cell_type": "code", - "execution_count": 24, - "id": "7ef78386", + "execution_count": null, + "id": "9efa15af", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T13:56:08.781018Z", "start_time": "2023-09-04T13:55:40.203515Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "INFO:root:Creating augmentation generators\n", - "INFO:root:Computing openwakeword features for generated samples\n", - "Computing features: 100%|███████████████████████| 62/62 [00:05<00:00, 10.58it/s]\n", - "Trimming empty rows: 1it [00:00, 7.99it/s]\n", - "Computing features: 100%|███████████████████████| 62/62 [00:05<00:00, 10.45it/s]\n", - "Trimming empty rows: 1it [00:00, 8.07it/s]\n", - "Computing features: 100%|███████████████████████| 62/62 [00:05<00:00, 10.77it/s]\n", - "Trimming empty rows: 1it [00:00, 8.09it/s]\n", - "Computing features: 100%|███████████████████████| 62/62 [00:05<00:00, 10.53it/s]\n", - "Trimming empty rows: 1it [00:00, 8.07it/s]\n", - "\u001b[0m" - ] - } - ], + "outputs": [], "source": [ "# Step 2: Augment the generated clips\n", "\n", @@ -412,131 +335,15 @@ }, { "cell_type": "code", - "execution_count": 87, - "id": "e2acb50a", + "execution_count": null, + "id": "916b511a", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T15:11:14.742260Z", "start_time": "2023-09-04T15:07:03.755159Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "INFO:root:Starting training sequence 1...\n", - "Training: 75%|████████████████████▏ | 7496/10000 [00:45<00:14, 168.37it/s]1.3274336 0.66 0.331\n", - "INFO:root:Saving checkpoint with metrics >= to targets!\n", - "Training: 76%|█████████████████████▎ | 7621/10000 [00:49<00:28, 82.33it/s]1.3274336 0.66 0.331\n", - "INFO:root:Saving checkpoint with metrics >= to targets!\n", - "Training: 78%|█████████████████████▋ | 7762/10000 [00:53<00:22, 97.84it/s]2.300885 0.6735 0.362\n", - "Training: 79%|██████████████████████ | 7887/10000 [00:57<00:25, 83.36it/s]0.088495575 0.607 0.216\n", - "INFO:root:Saving checkpoint with metrics >= to targets!\n", - "Training: 80%|██████████████████████▍ | 8011/10000 [01:00<00:23, 83.46it/s]2.2123895 0.676 0.367\n", - "Training: 82%|██████████████████████▊ | 8153/10000 [01:04<00:19, 96.94it/s]0.26548672 0.623 0.252\n", - "Training: 83%|███████████████████████▏ | 8277/10000 [01:08<00:20, 82.58it/s]0.088495575 0.605 0.212\n", - "INFO:root:Saving checkpoint with metrics >= to targets!\n", - "Training: 84%|███████████████████████▌ | 8416/10000 [01:12<00:16, 95.46it/s]0.44247788 0.6405 0.29\n", - "Training: 85%|███████████████████████▉ | 8541/10000 [01:16<00:17, 82.74it/s]1.5044248 0.6605 0.335\n", - "Training: 87%|████████████████████████▎ | 8683/10000 [01:19<00:13, 96.74it/s]1.2389381 0.6595 0.333\n", - "Training: 88%|████████████████████████▋ | 8807/10000 [01:23<00:14, 82.40it/s]0.44247788 0.638 0.284\n", - "Training: 89%|█████████████████████████ | 8931/10000 [01:27<00:12, 82.28it/s]0.44247788 0.643 0.295\n", - "Training: 91%|█████████████████████████▍ | 9073/10000 [01:31<00:09, 95.54it/s]0.9734513 0.658 0.327\n", - "Training: 92%|█████████████████████████▊ | 9197/10000 [01:34<00:09, 83.55it/s]1.5929203 0.6635 0.341\n", - "Training: 93%|██████████████████████████▏ | 9338/10000 [01:38<00:06, 96.77it/s]2.0353982 0.6685 0.352\n", - "Training: 95%|██████████████████████████▍ | 9463/10000 [01:42<00:06, 82.58it/s]1.1504425 0.658 0.33\n", - "Training: 96%|██████████████████████████▉ | 9605/10000 [01:46<00:04, 97.06it/s]1.2389381 0.6585 0.331\n", - "Training: 97%|███████████████████████████▏| 9727/10000 [01:49<00:03, 81.79it/s]1.2389381 0.6585 0.331\n", - "Training: 99%|███████████████████████████▌| 9866/10000 [01:53<00:01, 95.62it/s]1.1504425 0.6585 0.33\n", - "Training: 100%|███████████████████████████▉| 9999/10000 [01:57<00:00, 85.19it/s]\n", - "INFO:root:Starting training sequence 2...\n", - "Training: 4%|█▏ | 41/1000.0 [00:00<00:11, 86.52it/s]1.1504425 0.658 0.33\n", - "Training: 9%|██▌ | 89/1000.0 [00:04<00:37, 24.32it/s]1.4159292 0.6595 0.333\n", - "Training: 14%|████ | 143/1000.0 [00:07<00:36, 23.68it/s]1.3274336 0.6585 0.331\n", - "Training: 20%|█████▍ | 195/1000.0 [00:10<00:34, 23.10it/s]1.5044248 0.6605 0.335\n", - "Training: 25%|██████▉ | 247/1000.0 [00:14<00:32, 22.84it/s]1.6814159 0.665 0.344\n", - "Training: 30%|████████▍ | 300/1000.0 [00:17<00:30, 22.77it/s]1.858407 0.668 0.352\n", - "Training: 35%|█████████▉ | 353/1000.0 [00:20<00:27, 23.39it/s]2.1238937 0.671 0.359\n", - "Training: 40%|███████████▎ | 405/1000.0 [00:24<00:27, 22.00it/s]1.858407 0.6685 0.352\n", - "Training: 46%|████████████▊ | 458/1000.0 [00:27<00:23, 23.08it/s]1.4159292 0.6605 0.335\n", - "Training: 51%|██████████████▎ | 512/1000.0 [00:30<00:20, 23.52it/s]1.5929203 0.664 0.342\n", - "Training: 56%|███████████████▊ | 563/1000.0 [00:33<00:19, 22.59it/s]1.1504425 0.659 0.33\n", - "Training: 62%|█████████████████▏ | 616/1000.0 [00:37<00:17, 22.47it/s]0.44247788 0.641 0.291\n", - "Training: 67%|██████████████████▋ | 667/1000.0 [00:40<00:14, 22.93it/s]0.44247788 0.6415 0.292\n", - "Training: 74%|████████████████████▋ | 737/1000.0 [00:43<00:08, 30.03it/s]0.44247788 0.6435 0.296\n", - "Training: 79%|██████████████████████ | 787/1000.0 [00:47<00:08, 23.88it/s]0.53097343 0.646 0.301\n", - "Training: 84%|███████████████████████▍ | 837/1000.0 [00:50<00:07, 22.92it/s]0.53097343 0.646 0.301\n", - "Training: 89%|████████████████████████▊ | 886/1000.0 [00:53<00:05, 22.13it/s]0.53097343 0.646 0.301\n", - "Training: 94%|██████████████████████████▏ | 935/1000.0 [00:57<00:02, 22.19it/s]0.53097343 0.6465 0.303\n", - "Training: 100%|███████████████████████████▉| 999/1000.0 [01:00<00:00, 16.52it/s]\n", - "INFO:root:Starting training sequence 3...\n", - "Training: 5%|█▎ | 47/1000.0 [00:00<00:09, 97.00it/s]0.53097343 0.6465 0.303\n", - "Training: 10%|██▊ | 95/1000.0 [00:04<00:35, 25.72it/s]0.53097343 0.6465 0.303\n", - "Training: 14%|████ | 145/1000.0 [00:07<00:36, 23.53it/s]0.53097343 0.6465 0.303\n", - "Training: 20%|█████▍ | 195/1000.0 [00:10<00:35, 22.67it/s]0.53097343 0.6465 0.303\n", - "Training: 25%|██████▉ | 247/1000.0 [00:14<00:34, 22.09it/s]0.53097343 0.647 0.304\n", - "Training: 30%|████████▎ | 299/1000.0 [00:17<00:30, 22.78it/s]0.7079646 0.6485 0.308\n", - "Training: 35%|█████████▉ | 353/1000.0 [00:20<00:28, 22.88it/s]0.7079646 0.6485 0.308\n", - "Training: 41%|███████████▎ | 406/1000.0 [00:23<00:25, 23.08it/s]0.7079646 0.649 0.309\n", - "Training: 46%|████████████▊ | 457/1000.0 [00:27<00:23, 22.67it/s]0.7079646 0.6495 0.31\n", - "Training: 51%|██████████████▎ | 511/1000.0 [00:30<00:21, 23.20it/s]0.7079646 0.65 0.311\n", - "Training: 56%|███████████████▊ | 563/1000.0 [00:33<00:19, 22.79it/s]0.7079646 0.651 0.313\n", - "Training: 62%|█████████████████▏ | 616/1000.0 [00:37<00:16, 23.22it/s]0.7079646 0.652 0.315\n", - "Training: 67%|██████████████████▋ | 668/1000.0 [00:40<00:14, 22.49it/s]0.7079646 0.653 0.317\n", - "Training: 72%|████████████████████▏ | 720/1000.0 [00:43<00:12, 22.51it/s]0.7079646 0.654 0.319\n", - "Training: 77%|█████████████████████▋ | 774/1000.0 [00:47<00:09, 22.95it/s]0.7079646 0.654 0.319\n", - "Training: 82%|███████████████████████ | 825/1000.0 [00:50<00:07, 22.49it/s]0.7079646 0.6535 0.318\n", - "Training: 88%|████████████████████████▌ | 879/1000.0 [00:53<00:05, 23.56it/s]0.7079646 0.654 0.319\n", - "Training: 95%|██████████████████████████▌ | 947/1000.0 [00:56<00:01, 30.09it/s]0.7079646 0.654 0.319\n", - "Training: 100%|███████████████████████████▉| 999/1000.0 [01:00<00:00, 16.55it/s]\n", - "INFO:root:Merging best checkpoints into single model...\n", - "\n", - "\n", - "INFO:root:\n", - "################\n", - "Final Model Accuracy: 0.637499988079071\n", - "Final Model Recall: 0.2809999883174896\n", - "Final Model False Positives per Hour: 0.44247788190841675\n", - "################\n", - "\n", - "DEBUG:tensorflow:Falling back to TensorFlow client; we recommended you install the Cloud TPU client directly with pip install cloud-tpu-client.\n", - "DEBUG:h5py._conv:Creating converter from 7 to 5\n", - "DEBUG:h5py._conv:Creating converter from 5 to 7\n", - "DEBUG:h5py._conv:Creating converter from 7 to 5\n", - "DEBUG:h5py._conv:Creating converter from 5 to 7\n", - "2023-09-04 11:11:12.446476: I tensorflow/stream_executor/cuda/cuda_gpu_executor.cc:936] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero\n", - "2023-09-04 11:11:12.446795: W tensorflow/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libcusolver.so.11'; dlerror: libcusolver.so.11: cannot open shared object file: No such file or directory\n", - "2023-09-04 11:11:12.446847: W tensorflow/core/common_runtime/gpu/gpu_device.cc:1850] Cannot dlopen some GPU libraries. Please make sure the missing libraries mentioned above are installed properly if you would like to use GPU. Follow the guide at https://www.tensorflow.org/install/gpu for how to download and setup the required libraries for your platform.\n", - "Skipping registering GPU devices...\n", - "2023-09-04 11:11:12.446979: I tensorflow/core/platform/cpu_feature_guard.cc:151] This TensorFlow binary is optimized with oneAPI Deep Neural Network Library (oneDNN) to use the following CPU instructions in performance-critical operations: AVX2 AVX512F FMA\n", - "To enable them in other operations, rebuild TensorFlow with the appropriate compiler flags.\n", - "WARNING:absl:Function `__call__` contains input name(s) onnx_tf__tf_Flatten_0_45bfc89f with unsupported characters which will be renamed to onnx_tf__tf_flatten_0_45bfc89f in the SavedModel.\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2023-09-04 11:11:13.859582: W tensorflow/python/util/util.cc:368] Sets are not currently considered sequences, but this may change in the future, so consider avoiding using them.\n", - "WARNING:absl:Found untraced functions such as gen_tensor_dict while saving (showing 1 of 1). These functions will not be directly callable after loading.\n", - "INFO:tensorflow:Assets written to: /tmp/tmpqvff79vt/tf_model/assets\n", - "2023-09-04 11:11:14.012590: W tensorflow/compiler/mlir/lite/python/tf_tfl_flatbuffer_helpers.cc:357] Ignored output_format.\n", - "2023-09-04 11:11:14.012606: W tensorflow/compiler/mlir/lite/python/tf_tfl_flatbuffer_helpers.cc:360] Ignored drop_control_dependency.\n", - "2023-09-04 11:11:14.013126: I tensorflow/cc/saved_model/reader.cc:43] Reading SavedModel from: /tmp/tmpqvff79vt/tf_model\n", - "2023-09-04 11:11:14.013536: I tensorflow/cc/saved_model/reader.cc:78] Reading meta graph with tags { serve }\n", - "2023-09-04 11:11:14.013546: I tensorflow/cc/saved_model/reader.cc:119] Reading SavedModel debug info (if present) from: /tmp/tmpqvff79vt/tf_model\n", - "2023-09-04 11:11:14.014464: I tensorflow/cc/saved_model/loader.cc:228] Restoring SavedModel bundle.\n", - "2023-09-04 11:11:14.025569: I tensorflow/cc/saved_model/loader.cc:212] Running initialization op on SavedModel bundle at path: /tmp/tmpqvff79vt/tf_model\n", - "2023-09-04 11:11:14.030851: I tensorflow/cc/saved_model/loader.cc:301] SavedModel load for tags { serve }; Status: success: OK. Took 17727 microseconds.\n", - "2023-09-04 11:11:14.036528: I tensorflow/compiler/mlir/tensorflow/utils/dump_mlir_util.cc:237] disabling MLIR crash reproducer, set env var `MLIR_CRASH_REPRODUCER_DIRECTORY` to enable.\n", - "2023-09-04 11:11:14.046951: I tensorflow/compiler/mlir/lite/flatbuffer_export.cc:1963] Estimated count of arithmetic ops: 0.101 M ops, equivalently 0.050 M MACs\n", - "\n", - "Estimated count of arithmetic ops: 0.101 M ops, equivalently 0.050 M MACs\n", - "\u001b[0m" - ] - } - ], + "outputs": [], "source": [ "# Step 3: Train model\n", "\n", @@ -544,12 +351,14 @@ ] }, { - "cell_type": "code", - "execution_count": null, - "id": "79ba9c43", + "cell_type": "markdown", + "id": "4925229c", "metadata": {}, - "outputs": [], - "source": [] + "source": [ + "After the model finishes training, the auto training script will automatically convert it to ONNX and tflite versions, saving them as `.onnx/tflite` in the present working directory, where `` is defined in the YAML training config file.\n", + "\n", + "At this point, you can take the trained models and use them as normal with openWakeWord." + ] } ], "metadata": { From 8ad5248179bb67a4cd080881209d2b682e78026a Mon Sep 17 00:00:00 2001 From: dscripka Date: Mon, 4 Sep 2023 20:26:14 -0400 Subject: [PATCH 037/103] More edits to example auto training notebook [ckip ci] --- notebooks/automatic_model_training.ipynb | 80 +++++++++++++----------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/notebooks/automatic_model_training.ipynb b/notebooks/automatic_model_training.ipynb index 1f8912f..eb576ca 100644 --- a/notebooks/automatic_model_training.ipynb +++ b/notebooks/automatic_model_training.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "f49227ca", + "id": "43d8967d", "metadata": {}, "source": [ "# Introduction" @@ -10,7 +10,7 @@ }, { "cell_type": "markdown", - "id": "6483fd4d", + "id": "ffbb278d", "metadata": {}, "source": [ "This notebook demonstrates how to train custom openWakeWord models using pre-defined datasets, an automated process for synthetic data generation/augmentation, and a custom training process. While not guaranteed to always produce the best performing model, the methods shown in this notebook often produce baseline models with relatively strong performance.\n", @@ -29,7 +29,7 @@ }, { "cell_type": "markdown", - "id": "67ea460d", + "id": "b1d34904", "metadata": {}, "source": [ "# Environment Setup" @@ -37,7 +37,7 @@ }, { "cell_type": "markdown", - "id": "004ef4db", + "id": "10224159", "metadata": {}, "source": [ "To begin, we'll need to install the requirements for training custom models. In particular, a relatively recent version of Pytorch and custom fork of the [piper-sample-generator](https://github.com/dscripka/piper-sample-generator) library for generating synthetic examples for the custom model.\n", @@ -48,7 +48,7 @@ { "cell_type": "code", "execution_count": null, - "id": "ad77a769", + "id": "2d59a6b5", "metadata": {}, "outputs": [], "source": [ @@ -69,7 +69,7 @@ { "cell_type": "code", "execution_count": null, - "id": "da85818d", + "id": "8f30acfd", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T13:42:01.183840Z", @@ -95,7 +95,7 @@ }, { "cell_type": "markdown", - "id": "658ccb2a", + "id": "a4bdf3fd", "metadata": {}, "source": [ "# Download Data" @@ -103,7 +103,7 @@ }, { "cell_type": "markdown", - "id": "1e4450ab", + "id": "e002fd0d", "metadata": {}, "source": [ "When training new openWakeWord models using the automated procedure, four specific types of data are required:\n", @@ -124,7 +124,7 @@ { "cell_type": "code", "execution_count": null, - "id": "ab3c1fce", + "id": "58e4811e", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T01:07:17.746749Z", @@ -149,29 +149,34 @@ { "cell_type": "code", "execution_count": null, - "id": "b28717ef", + "id": "bd867817", "metadata": {}, "outputs": [], "source": [ "## Download noise and background audio\n", "\n", - "# FSD50k Noise Dataset (warning, this can take 5 minutes to prepare when streaming)\n", - "# https://zenodo.org/record/4060432\n", + "# Audioset Dataset (https://research.google.com/audioset/dataset/index.html)\n", + "# Download one part of the audioset .tar files, extract, and convert to 16khz\n", + "# For full-scale training, it's reccomended to download the entire dataset, and\n", + "# even combine with other background noise datasets (e.g., FSD50k, Freesound, etc.)\n", + "fname = \"bal_train09.tar\"\n", + "out_dir = f\"audioset/{fname}\"\n", + "link = \"https://huggingface.co/datasets/agkphysics/AudioSet/resolve/main/\" + fname\n", + "!wget -O {out_dir} {link}\n", + "!cd audioset && tar -xvf bal_train09.tar\n", "\n", - "output_dir = \"./fsd50k\"\n", + "if not os.path.exists(\"audioset\"):\n", + " os.mkdir(\"audioset\")\n", + "\n", + "output_dir = \"./audioset_16k\"\n", "if not os.path.exists(output_dir):\n", " os.mkdir(output_dir)\n", - "fsd50k_dataset = datasets.load_dataset(\"Fhrozen/FSD50k\", split=\"validation\", streaming=True) # ~40,000 files in this split\n", - "fsd50k_dataset = iter(fsd50k_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000)))\n", "\n", - "n_total = 500 # use only 500 clips for this example notebook, recommend increasing for full-scale training\n", - "for i in tqdm(range(n_total)):\n", - " row = next(fsd50k_dataset)\n", - " name = row['audio']['path'].split('/')[-1]\n", + "audioset_dataset = datasets.Dataset.from_dict({\"audio\": [str(i) for i in Path(\"audioset/audio\").glob(\"**/*.flac\")]})\n", + "audioset_dataset = audioset_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000))\n", + "for row in tqdm(audioset_dataset):\n", + " name = row['audio']['path'].split('/')[-1].replace(\".flac\", \".wav\")\n", " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, row['audio']['array'])\n", - " i += 1\n", - " if i == n_total:\n", - " break\n", "\n", "# Free Music Archive dataset\n", "# https://github.com/mdeff/fma\n", @@ -185,7 +190,7 @@ "n_hours = 1 # use only 1 hour of clips for this example notebook, recommend increasing for full-scale training\n", "for i in tqdm(range(n_hours*3600//30)): # this works because the FMA dataset is all 30 second clips\n", " row = next(fma_dataset)\n", - " name = row['audio']['path'].split('/')[-1]\n", + " name = row['audio']['path'].split('/')[-1].replace(\".mp3\", \".wav\")\n", " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, row['audio']['array'])\n", " i += 1\n", " if i == n_hours*3600//30:\n", @@ -195,22 +200,23 @@ { "cell_type": "code", "execution_count": null, - "id": "b5bbe225", + "id": "203df175", "metadata": {}, "outputs": [], "source": [ "# Download pre-computed openWakeWord features for training and validation\n", "\n", - "# training set (~2,000 hours)\n", - "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/blob/main/openwakeword_features_ACAV100M_2000_hrs_16bit.npy\n", + "# training set (~2,000 hours from the ACAV100M Dataset)\n", + "# See https://huggingface.co/datasets/davidscripka/openwakeword_features for more information\n", + "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/openwakeword_features_ACAV100M_2000_hrs_16bit.npy\n", "\n", "# validation set for false positive rate estimation (~11 hours)\n", - "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/blob/main/validation_set_features.npy" + "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/validation_set_features.npy" ] }, { "cell_type": "markdown", - "id": "9265dbcf", + "id": "290865f2", "metadata": {}, "source": [ "# Define Training Configuration" @@ -218,7 +224,7 @@ }, { "cell_type": "markdown", - "id": "1e204f3e", + "id": "041ac7e6", "metadata": {}, "source": [ "For automated model training openWakeWord uses a specially designed training script and a [YAML](https://yaml.org/) configuration file that defines all of the information required for training a new wake word/phrase detection model.\n", @@ -237,7 +243,7 @@ { "cell_type": "code", "execution_count": null, - "id": "c0fea7ab", + "id": "fc70e5ab", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T18:11:33.893397Z", @@ -254,7 +260,7 @@ { "cell_type": "code", "execution_count": null, - "id": "fbe862c5", + "id": "bc278709", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T15:07:00.859210Z", @@ -282,7 +288,7 @@ }, { "cell_type": "markdown", - "id": "c7460b02", + "id": "b46c080b", "metadata": {}, "source": [ "# Train the Model" @@ -290,7 +296,7 @@ }, { "cell_type": "markdown", - "id": "c1de9710", + "id": "55bc110f", "metadata": {}, "source": [ "With the data downloaded and training configuration set, we can now start training the model. We'll do this in parts to better illustrate the sequence, but you can also execute every step at once for a fully automated process." @@ -299,7 +305,7 @@ { "cell_type": "code", "execution_count": null, - "id": "85115fc9", + "id": "5c0ffb3a", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T13:50:08.803326Z", @@ -319,7 +325,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9efa15af", + "id": "190dea93", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T13:56:08.781018Z", @@ -336,7 +342,7 @@ { "cell_type": "code", "execution_count": null, - "id": "916b511a", + "id": "6ec6e791", "metadata": { "ExecuteTime": { "end_time": "2023-09-04T15:11:14.742260Z", @@ -352,7 +358,7 @@ }, { "cell_type": "markdown", - "id": "4925229c", + "id": "6deb1fda", "metadata": {}, "source": [ "After the model finishes training, the auto training script will automatically convert it to ONNX and tflite versions, saving them as `.onnx/tflite` in the present working directory, where `` is defined in the YAML training config file.\n", From 83d8bae37c0dfe73e51ef38128a055f9aa8fa9c8 Mon Sep 17 00:00:00 2001 From: dscripka Date: Tue, 5 Sep 2023 07:58:37 -0400 Subject: [PATCH 038/103] Convert clips to 16-bit PCM before saving [skip ci] --- notebooks/automatic_model_training.ipynb | 25 ++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/notebooks/automatic_model_training.ipynb b/notebooks/automatic_model_training.ipynb index eb576ca..bf26a24 100644 --- a/notebooks/automatic_model_training.ipynb +++ b/notebooks/automatic_model_training.ipynb @@ -68,12 +68,12 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 91, "id": "8f30acfd", "metadata": { "ExecuteTime": { - "end_time": "2023-09-04T13:42:01.183840Z", - "start_time": "2023-09-04T13:41:59.752153Z" + "end_time": "2023-09-05T11:52:51.331099Z", + "start_time": "2023-09-05T11:52:50.994098Z" } }, "outputs": [], @@ -127,8 +127,8 @@ "id": "58e4811e", "metadata": { "ExecuteTime": { - "end_time": "2023-09-04T01:07:17.746749Z", - "start_time": "2023-09-04T01:07:17.740846Z" + "end_time": "2023-09-05T11:53:57.310392Z", + "start_time": "2023-09-05T11:53:54.490518Z" } }, "outputs": [], @@ -141,9 +141,10 @@ " os.mkdir(output_dir)\n", "rir_dataset = datasets.load_dataset(\"davidscripka/MIT_environmental_impulse_responses\", split=\"train\", streaming=True)\n", "\n", + "# Save clips to 16-bit PCM wav files\n", "for row in tqdm(rir_dataset):\n", " name = row['audio']['path'].split('/')[-1]\n", - " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, row['audio']['array'])\n" + " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n" ] }, { @@ -157,8 +158,10 @@ "\n", "# Audioset Dataset (https://research.google.com/audioset/dataset/index.html)\n", "# Download one part of the audioset .tar files, extract, and convert to 16khz\n", - "# For full-scale training, it's reccomended to download the entire dataset, and\n", - "# even combine with other background noise datasets (e.g., FSD50k, Freesound, etc.)\n", + "# For full-scale training, it's recommended to download the entire dataset from \n", + "# https://huggingface.co/datasets/agkphysics/AudioSet, and\n", + "# even potentially combine it with other background noise datasets (e.g., FSD50k, Freesound, etc.)\n", + "\n", "fname = \"bal_train09.tar\"\n", "out_dir = f\"audioset/{fname}\"\n", "link = \"https://huggingface.co/datasets/agkphysics/AudioSet/resolve/main/\" + fname\n", @@ -172,11 +175,12 @@ "if not os.path.exists(output_dir):\n", " os.mkdir(output_dir)\n", "\n", + "# Save clips to 16-bit PCM wav files\n", "audioset_dataset = datasets.Dataset.from_dict({\"audio\": [str(i) for i in Path(\"audioset/audio\").glob(\"**/*.flac\")]})\n", "audioset_dataset = audioset_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000))\n", "for row in tqdm(audioset_dataset):\n", " name = row['audio']['path'].split('/')[-1].replace(\".flac\", \".wav\")\n", - " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, row['audio']['array'])\n", + " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n", "\n", "# Free Music Archive dataset\n", "# https://github.com/mdeff/fma\n", @@ -187,11 +191,12 @@ "fma_dataset = datasets.load_dataset(\"rudraml/fma\", name=\"small\", split=\"train\", streaming=True)\n", "fma_dataset = iter(fma_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000)))\n", "\n", + "# Save clips to 16-bit PCM wav files\n", "n_hours = 1 # use only 1 hour of clips for this example notebook, recommend increasing for full-scale training\n", "for i in tqdm(range(n_hours*3600//30)): # this works because the FMA dataset is all 30 second clips\n", " row = next(fma_dataset)\n", " name = row['audio']['path'].split('/')[-1].replace(\".mp3\", \".wav\")\n", - " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, row['audio']['array'])\n", + " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n", " i += 1\n", " if i == n_hours*3600//30:\n", " break\n" From 8ecb4930efd7f0fa6c4c2a570e22f81218631f32 Mon Sep 17 00:00:00 2001 From: dscripka Date: Tue, 5 Sep 2023 21:12:39 -0400 Subject: [PATCH 039/103] Moved tflite conversion to its own function [skip ci] --- openwakeword/train.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index 9019df7..4b9c950 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -303,28 +303,28 @@ class Model(nn.Module): model_to_save = copy.deepcopy(model) torch.onnx.export(model_to_save.to("cpu"), torch.rand(self.input_shape)[None, ], os.path.join(output_dir, model_name + ".onnx")) - # Convert to tflite from onnx model + # Save tflite model + self.convert_onnx_to_tflite(os.path.join(output_dir, model_name + ".onnx"), os.path.join(output_dir, model_name + ".tflite")) + + return None + + def convert_onnx_to_tflite(self, onnx_model_path, output_path): + """Converts an ONNX version of an openwakeword model to the Tensorflow tflite format.""" + # imports import onnx from onnx_tf.backend import prepare import tensorflow as tf - # package versions - # tensorflow==2.8.1 - # tensorflow_probability==0.16.0 - # protobuf==3.20 - # onnx_tf==1.10.0 - # onnx==1.14.0 - - onnx_model = onnx.load(os.path.join(output_dir, model_name + ".onnx")) + # Convert to tflite from onnx model + onnx_model = onnx.load(onnx_model_path) tf_rep = prepare(onnx_model, device="CPU") with tempfile.TemporaryDirectory() as tmp_dir: tf_rep.export_graph(os.path.join(tmp_dir, "tf_model")) - converter = tf.lite.TFLiteConverter.from_saved_model(os.path.join(tmp_dir, "tf_model")) tflite_model = converter.convert() - logging.info(f"Saving tflite mode as '{os.path.join(output_dir, model_name + '.tflite')}'") - with open(os.path.join(output_dir, model_name + ".tflite"), 'wb') as f: + logging.info(f"Saving tflite mode to '{output_path}'") + with open(output_path, 'wb') as f: f.write(tflite_model) return None From fdab81fe28088150246770fc7f0f7ef39fb11493 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 10 Sep 2023 20:36:36 -0400 Subject: [PATCH 040/103] Moved tflite conversion outside of training class for more portability [skip ci] --- openwakeword/train.py | 52 ++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index 4b9c950..51894eb 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -303,30 +303,6 @@ class Model(nn.Module): model_to_save = copy.deepcopy(model) torch.onnx.export(model_to_save.to("cpu"), torch.rand(self.input_shape)[None, ], os.path.join(output_dir, model_name + ".onnx")) - # Save tflite model - self.convert_onnx_to_tflite(os.path.join(output_dir, model_name + ".onnx"), os.path.join(output_dir, model_name + ".tflite")) - - return None - - def convert_onnx_to_tflite(self, onnx_model_path, output_path): - """Converts an ONNX version of an openwakeword model to the Tensorflow tflite format.""" - # imports - import onnx - from onnx_tf.backend import prepare - import tensorflow as tf - - # Convert to tflite from onnx model - onnx_model = onnx.load(onnx_model_path) - tf_rep = prepare(onnx_model, device="CPU") - with tempfile.TemporaryDirectory() as tmp_dir: - tf_rep.export_graph(os.path.join(tmp_dir, "tf_model")) - converter = tf.lite.TFLiteConverter.from_saved_model(os.path.join(tmp_dir, "tf_model")) - tflite_model = converter.convert() - - logging.info(f"Saving tflite mode to '{output_path}'") - with open(output_path, 'wb') as f: - f.write(tflite_model) - return None def train_model(self, X, max_steps, warmup_steps, hold_steps, X_val=None, @@ -434,6 +410,28 @@ class Model(nn.Module): if step_ndx == max_steps-1: break +# Separate function to convert onnx models to tflite format +def convert_onnx_to_tflite(self, onnx_model_path, output_path): + """Converts an ONNX version of an openwakeword model to the Tensorflow tflite format.""" + # imports + import onnx + from onnx_tf.backend import prepare + import tensorflow as tf + + # Convert to tflite from onnx model + onnx_model = onnx.load(onnx_model_path) + tf_rep = prepare(onnx_model, device="CPU") + with tempfile.TemporaryDirectory() as tmp_dir: + tf_rep.export_graph(os.path.join(tmp_dir, "tf_model")) + converter = tf.lite.TFLiteConverter.from_saved_model(os.path.join(tmp_dir, "tf_model")) + tflite_model = converter.convert() + + logging.info(f"Saving tflite mode to '{output_path}'") + with open(output_path, 'wb') as f: + f.write(tflite_model) + + return None + if __name__ == '__main__': # Get training config file @@ -713,5 +711,9 @@ if __name__ == '__main__': target_val_fp_per_hour=config["target_false_positives_per_hour"] ) - # Export the trained model to onnx and tflite formats + # Export the trained model to onnx oww.export_model(model=best_model, model_name=config["model_name"], output_dir=config["output_dir"]) + + # Convert the model from onnx to tflite format + convert_onnx_to_tflite(os.path.join(config["output_dir"], config["model_name"]), + os.path.join(config["output_dir"], config["model_name"] + "tflite")) From 6700161d94605c4c448452640cc0525be290c738 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 10 Sep 2023 21:01:38 -0400 Subject: [PATCH 041/103] fixed bad arg [skip ci] --- openwakeword/train.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index 51894eb..6d3fbd7 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -411,7 +411,7 @@ class Model(nn.Module): break # Separate function to convert onnx models to tflite format -def convert_onnx_to_tflite(self, onnx_model_path, output_path): +def convert_onnx_to_tflite(onnx_model_path, output_path): """Converts an ONNX version of an openwakeword model to the Tensorflow tflite format.""" # imports import onnx From 7d27b9bd932465e22dfd78ad63c570dbff5c9055 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 10 Sep 2023 21:35:45 -0400 Subject: [PATCH 042/103] Edits to README and example training notebooks [skip ci] --- README.md | 6 ++++-- notebooks/automatic_model_training.ipynb | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7e5f4f5..9d54f96 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,9 @@ While the models are trained with background noise to increase robustness, in so # Training New Models -Training new models is conceptually simple, and the entire process is demonstrated in a [tutorial notebook](notebooks/training_models.ipynb). +Recent versions of openWakeWord support an [automatic model training](openwakeword/train.py) utility that greatly simplifies the process of training custom models. See the example [notebook](notebooks/automatic_model_training.ipynb), or run it in [Google Colab](https://colab.research.google.com/drive/1yyFH-fpguX2BTAW8wSQxTrJnJTM-0QAd?usp=sharing) for an end-to-end example of how the process works. + +For users interested in understanding the fundamental concepts behind model training there is a more detailed, educational [tutorial notebook](notebooks/training_models.ipynb) also available. However, this specific notebook is not intended for training production models, and the automated process above is recommended for that purpose. Fundamentally, a new model requires two data generation and collection steps: @@ -229,7 +231,7 @@ Future release road maps may have non-english support. In particular, [Mycroft.A - While the ONNX runtime [does support javascript](https://onnxruntime.ai/docs/get-started/with-javascript.html), much of the other functionality required for openWakeWord models would need to be ported. This is not currently on the roadmap, but please open an issue/start a discussion if this feature is of particular interest. **Is there a C++ version of openWakeWord?** -- While the ONNX runtime [also has a C++ API](https://onnxruntime.ai/docs/get-started/with-cpp.html), there isn't an official C++ implementation of the full openWakeWord library. However, [@synesthesiam](https://github.com/synesthesiam) has created a [C++ version](https://github.com/rhasspy/openWakeWord-cpp) of openWakeWord with the essential functionality implemented. +- While the ONNX runtime [also has a C++ API](https://onnxruntime.ai/docs/get-started/with-cpp.html), there isn't an official C++ implementation of the full openWakeWord library. However, [@synesthesiam](https://github.com/synesthesiam) has created a [C++ version](https://github.com/rhasspy/openWakeWord-cpp) of openWakeWord with basic functionality implemented. **Why are there three separate models instead of just one?** - Separating the models was an intentional choice to provide flexibility and optimize the efficiency of the end-to-end prediction process. For example, with separate melspectrogram, embedding, and prediction models, each one can operate on different size inputs of audio to optimize overall latency and share computations between models. It certainly is possible to make a combined model with all of the steps integrated, though, if that was a requirement of a particular use case. diff --git a/notebooks/automatic_model_training.ipynb b/notebooks/automatic_model_training.ipynb index bf26a24..f54105e 100644 --- a/notebooks/automatic_model_training.ipynb +++ b/notebooks/automatic_model_training.ipynb @@ -81,6 +81,7 @@ "# Imports\n", "\n", "import os\n", + "import numpy as np\n", "import torch\n", "from openwakeword.data import mmap_batch_generator, generate_adversarial_texts\n", "from openwakeword.utils import compute_features_from_generator\n", @@ -283,8 +284,8 @@ "config[\"target_accuracy\"] = 0.6\n", "config[\"target_recall\"] = 0.25\n", "\n", - "config[\"background_paths\"] = []#['./fsd50k', './fma']\n", - "config[\"false_positive_validation_data_path\"] = \"val_set_features.npy\"\n", + "config[\"background_paths\"] = ['./audioset_16k', './fma'] # multiple background datasets are supported\n", + "config[\"false_positive_validation_data_path\"] = \"validation_set_features.npy\"\n", "config[\"feature_data_files\"] = {\"ACAV100M_sample\": \"openwakeword_features_ACAV100M_2000_hrs_16bit.npy\"}\n", "\n", "with open('my_model.yaml', 'w') as file:\n", From dfdeaa2f8d88d3793caf92fcc1d8188adc2f7c61 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Sun, 1 Oct 2023 20:18:48 -0400 Subject: [PATCH 043/103] Small adjustments to training config file and train.py --- examples/custom_model.yml | 26 +++++++++++++--------- openwakeword/data.py | 1 + openwakeword/train.py | 47 +++++++++++++++++++++++---------------- 3 files changed, 45 insertions(+), 29 deletions(-) diff --git a/examples/custom_model.yml b/examples/custom_model.yml index 50e0747..dac30b3 100644 --- a/examples/custom_model.yml +++ b/examples/custom_model.yml @@ -33,20 +33,26 @@ augmentation_batch_size: 16 piper_sample_generator_path: "./piper-sample-generator" # The output directory for the generated synthetic clips and openwakeword features -# Sub-directories will be automatically created for train and test clips for both positive a negative examples +# Sub-directories will be automatically created for train and test clips for both positive and negative examples output_dir: "./generated_data" # The directories containing Room Impulse Response recordings rir_paths: - "./mit_rirs" -# The directories containing background audio to mix with training data +# The directories containing background audio files to mix with training data background_paths: - "./background_clips" +# The duplication rate for the background audio clips listed above (1 or higher). Can be useful as a way to oversample +# a particular type of background noise more relevant to a given deployment environment. Values apply in the same +# order as the background_paths list above. Only useful when multiple directories are provided above. +background_paths_duplication_rate: + - 1 + # The location of pre-computed openwakeword features for false-positive validation data # If you do not have deployment environment validation data, a good general purpose dataset with -# a reasonable mix of ~11 hours of speech, noise, and music is available here: https://huggingface.co/datasets/davidscripka/openwakeword_features +# a reasonable mix with ~11 hours of speech, noise, and music is available here: https://huggingface.co/datasets/davidscripka/openwakeword_features false_positive_validation_data_path: "./validation_set_features.npy" # The number of times to apply augmentations to the generated training data @@ -58,11 +64,11 @@ augmentation_rounds: 1 # Paths to pre-computed openwakeword features for positive and negative data. Each file must be a saved # .npy array (see the example notebook on manually training new models for details on how to create these). -# There is no limit on the number of files -# but training speed will decrease as more data will need to be read from disk for each additional file. +# There is no limit on the number of files but training speed will decrease as more +# data will need to be read from disk for each additional file. # Also, there is a custom dataloader that uses memory-mapping with loading data, so the total size -# of the files is not limited by the amount of available system memory (though this will result) -# in decreased training throughput depending on the speed of the underlying storage device. A fast +# of the files is not limited by the amount of available system memory (though this will result +# in decreased training throughput depending on the speed of the underlying storage device). A fast # NVME SSD is recommended for optimal performance. feature_data_files: @@ -72,7 +78,7 @@ feature_data_files: # must correspond to those define in the `feature_data_files` dictionary above (except for # the `positive` and `adversarial_negative` keys, which are automatically defined). The sum # of the values for each key define the total batch size for training. Initial testing indicates -# that batch sizes of 1024-4096 work will in practice. +# that batch sizes of 1024-4096 work well in practice. batch_n_per_class: "ACAV100M_sample": 1024 @@ -81,7 +87,7 @@ batch_n_per_class: # Define the type of size of the openwakeword model to train. Increasing the layer size # may result in a more capable model, at the cost of decreased inference speed. The default -# value (32) seems to work will in practice for most wake words/phrases. +# value (32) seems to work well in practice for most wake words/phrases. model_type: "dnn" layer_size: 32 @@ -92,7 +98,7 @@ layer_size: 32 # and since early-stopping is utilized, the final performance of the trained model # may be slighly overfit to the validation data. -steps: 100000 # the maximum number of steps when training the model +steps: 50000 # the maximum number of steps when training the model max_negative_weight: 1500 # the maximum weight to give negative samples during training to reduce false positives target_accuracy: 0.7 # the target validation set accuracy for wake word/phrase detection target_recall: 0.5 # the target validation recall for wake word/phrase detection diff --git a/openwakeword/data.py b/openwakeword/data.py index 0ea5344..c91d7f3 100755 --- a/openwakeword/data.py +++ b/openwakeword/data.py @@ -946,6 +946,7 @@ def generate_adversarial_texts(input_text: str, N: int, include_partial_phrase: logging.warning(f"The word '{word}' was not found in the pronunciation dictionary! " "Using the DeepPhonemizer library to predict the phonemes.") phones = phonemizer(word, lang='en_us') + logging.warning(f"Phones for '{word}': {phones}") word_phones.append(re.sub(r"[\]|\[]", "", re.sub(r"\]\[", " ", phones))) elif isinstance(phones[0], list): logging.warning(f"There are multiple pronunciations for the word '{word}'.") diff --git a/openwakeword/train.py b/openwakeword/train.py index 6d3fbd7..dd284b4 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -201,7 +201,7 @@ class Model(nn.Module): val_set_hrs = 11.3 # Sequence 1 - logging.info("Starting training sequence 1...") + logging.info("#"*50 + "\nStarting training sequence 1...\n" + "#"*50) lr = 0.0001 weights = np.linspace(1, max_negative_weight, int(steps)).tolist() val_steps = np.linspace(steps-int(steps*0.25), steps, 20).astype(np.int64) @@ -216,7 +216,7 @@ class Model(nn.Module): target_val_accuracy=target_val_accuracy, target_val_recall=target_val_recall) # Sequence 2 - logging.info("Starting training sequence 2...") + logging.info("#"*50 + "\nStarting training sequence 2...\n" + "#"*50) lr = lr/10 steps = steps/10 @@ -238,7 +238,7 @@ class Model(nn.Module): target_val_accuracy=target_val_accuracy, target_val_recall=target_val_recall) # Sequence 3 - logging.info("Starting training sequence 3...") + logging.info("#"*50 + "\nStarting training sequence 3...\n" + "#"*50) lr = lr/10 # Adjust weights as needed based on false positive per hour performance from second sequence @@ -260,10 +260,10 @@ class Model(nn.Module): # Merge best models if len(self.best_models) == 0: - logging.warning("No checkpoint with metrics >= than target values was found!\n" - "Consider generating more examples, or reducing target metrics." + logging.warning("WARNING!\nNo checkpoint with metrics >= than target values was found!\n" + "Consider generating more examples, or reducing target metrics. " "Returning the model corresponding to the last training step.\n\n") - return self.model + combined_model = self.model else: logging.info("Merging best checkpoints into single model...") combined_model = self.average_models(models=self.best_models) @@ -299,7 +299,7 @@ class Model(nn.Module): "Use the `export_to_onnx` function instead.") # Save ONNX model - logging.info(f"Saving ONNX mode as '{os.path.join(output_dir, model_name + '.onnx')}'") + logging.info(f"####\nSaving ONNX mode as '{os.path.join(output_dir, model_name + '.onnx')}'") model_to_save = copy.deepcopy(model) torch.onnx.export(model_to_save.to("cpu"), torch.rand(self.input_shape)[None, ], os.path.join(output_dir, model_name + ".onnx")) @@ -426,7 +426,7 @@ def convert_onnx_to_tflite(onnx_model_path, output_path): converter = tf.lite.TFLiteConverter.from_saved_model(os.path.join(tmp_dir, "tf_model")) tflite_model = converter.convert() - logging.info(f"Saving tflite mode to '{output_path}'") + logging.info(f"####\nSaving tflite mode to '{output_path}'") with open(output_path, 'wb') as f: f.write(tflite_model) @@ -463,6 +463,13 @@ if __name__ == '__main__': default="False", required=False ) + parser.add_argument( + "--overwrite", + help="Overwrite existing openwakeword features when the --augment_clips flag is used", + action="store_true", + default="False", + required=False + ) args = parser.parse_args() config = yaml.load(open(args.training_config, 'r').read(), yaml.Loader) @@ -486,11 +493,15 @@ if __name__ == '__main__': # Get paths for impulse response and background audio files rir_paths = [i.path for j in config["rir_paths"] for i in os.scandir(j)] - background_paths = [i.path for j in config["background_paths"] for i in os.scandir(j)] + background_paths = [] + if len(config["background_paths_duplication_rate"]) != len(config["background_paths"]): + config["background_paths_duplication_rate"] = [1]*len(config["background_paths"]) + for background_path, duplication_rate in zip(config["background_paths"], config["background_paths_duplication_rate"]): + background_paths.extend([i.path for i in os.scandir(background_path)]*duplication_rate) if args.generate_clips is True: # Generate positive clips for training - logging.info("Generating positive clips for training") + logging.info("#"*50 + "\nGenerating positive clips for training\n" + "#"*50) if not os.path.exists(positive_train_output_dir): os.mkdir(positive_train_output_dir) n_current_samples = len(os.listdir(positive_train_output_dir)) @@ -507,7 +518,7 @@ if __name__ == '__main__': logging.warning(f"Skipping generation of positive clips for training, as ~{config['n_samples']} already exist") # Generate positive clips for testing - logging.info("Generating positive clips for testing") + logging.info("#"*50 + "\nGenerating positive clips for testing\n" + "#"*50) if not os.path.exists(positive_test_output_dir): os.mkdir(positive_test_output_dir) n_current_samples = len(os.listdir(positive_test_output_dir)) @@ -521,7 +532,7 @@ if __name__ == '__main__': logging.warning(f"Skipping generation of positive clips testing, as ~{config['n_samples_val']} already exist") # Generate adversarial negative clips for training - logging.info("Generating negative clips for training") + logging.info("#"*50 + "\nGenerating negative clips for training\n" + "#"*50) if not os.path.exists(negative_train_output_dir): os.mkdir(negative_train_output_dir) n_current_samples = len(os.listdir(negative_train_output_dir)) @@ -544,7 +555,7 @@ if __name__ == '__main__': logging.warning(f"Skipping generation of negative clips for training, as ~{config['n_samples']} already exist") # Generate adversarial negative clips for testing - logging.info("Generating negative clips for testing") + logging.info("#"*50 + "\nGenerating negative clips for testing\n" + "#"*50) if not os.path.exists(negative_test_output_dir): os.mkdir(negative_test_output_dir) n_current_samples = len(os.listdir(negative_test_output_dir)) @@ -566,9 +577,7 @@ if __name__ == '__main__': # Do Data Augmentation if args.augment_clips is True: - if not os.path.exists(os.path.join(feature_save_dir, "positive_features_train.npy")): - logging.info("Creating augmentation generators") - + if not os.path.exists(os.path.join(feature_save_dir, "positive_features_train.npy")) or args.overwrite is True: positive_clips_train = [str(i) for i in Path(positive_train_output_dir).glob("*.wav")]*config["augmentation_rounds"] positive_clips_train_generator = augment_clips(positive_clips_train, total_length=config["total_length"], batch_size=config["augmentation_batch_size"], @@ -594,7 +603,7 @@ if __name__ == '__main__': RIR_paths=rir_paths) # Compute features and save to disk via memmapped arrays - logging.info("Computing openwakeword features for generated samples") + logging.info("#"*50 + "\nComputing openwakeword features for generated samples\n" + "#"*50) n_cpus = os.cpu_count() if n_cpus is None: n_cpus = 1 @@ -715,5 +724,5 @@ if __name__ == '__main__': oww.export_model(model=best_model, model_name=config["model_name"], output_dir=config["output_dir"]) # Convert the model from onnx to tflite format - convert_onnx_to_tflite(os.path.join(config["output_dir"], config["model_name"]), - os.path.join(config["output_dir"], config["model_name"] + "tflite")) + convert_onnx_to_tflite(os.path.join(config["output_dir"], config["model_name"] + ".onnx"), + os.path.join(config["output_dir"], config["model_name"] + ".tflite")) From fd36a564cd4ef44582177898cb7f069805a922a4 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Sun, 1 Oct 2023 20:43:41 -0400 Subject: [PATCH 044/103] Adjusted warning message --- openwakeword/train.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index dd284b4..9bb280f 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -261,7 +261,7 @@ class Model(nn.Module): # Merge best models if len(self.best_models) == 0: logging.warning("WARNING!\nNo checkpoint with metrics >= than target values was found!\n" - "Consider generating more examples, or reducing target metrics. " + "Consider generating more positive and negative examples for training or reducing target metrics. " "Returning the model corresponding to the last training step.\n\n") combined_model = self.model else: From a07006136f1df0672f7e9b07ae7377aee8f2d8f2 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 1 Oct 2023 21:40:09 -0400 Subject: [PATCH 045/103] Passing flake8 and mypy tests locally [skip ci] --- openwakeword/data.py | 6 +++--- openwakeword/train.py | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/openwakeword/data.py b/openwakeword/data.py index c91d7f3..964ce77 100755 --- a/openwakeword/data.py +++ b/openwakeword/data.py @@ -451,7 +451,7 @@ def mix_clips_batch( # Apply volume augmentation if volume_augmentation: volume_levels = np.random.uniform(0.02, 1.0, mixed_clips_batch.shape[0]) - mixed_clips_batch = (volume_levels/mixed_clips_batch.max(axis=1)[0])[..., None]*mixed_clips_batch + mixed_clips_batch = (volume_levels/mixed_clips_batch.max(dim=1)[0])[..., None]*mixed_clips_batch else: # Normalize clips only if max value is outside of [-1, 1] abs_max, _ = torch.max( @@ -463,7 +463,7 @@ def mix_clips_batch( mixed_clips_batch = (mixed_clips_batch.numpy()*32767).astype(np.int16) # Remove any clips that are silent (happens rarely when mixing/reverberating) - error_index = np.where(mixed_clips_batch.max(axis=1) != 0)[0] + error_index = torch.from_numpy(np.where(mixed_clips_batch.max(dim=1) != 0)[0]) mixed_clips_batch = mixed_clips_batch[error_index] labels_batch = labels_batch[error_index] sequence_labels_batch = sequence_labels_batch[error_index] @@ -686,7 +686,7 @@ def augment_clips( # Do second pass augmentations device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu') - augmented_batch = augment2(samples=torch.vstack(augmented_clips).unsqueeze(axis=1).to(device), sample_rate=sr).squeeze(axis=1) + augmented_batch = augment2(samples=torch.vstack(augmented_clips).unsqueeze(dim=1).to(device), sample_rate=sr).squeeze(axis=1) # Do reverberation if augmentation_probabilities["RIR"] >= np.random.random() and RIR_paths != []: diff --git a/openwakeword/train.py b/openwakeword/train.py index 9bb280f..b741627 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -410,6 +410,7 @@ class Model(nn.Module): if step_ndx == max_steps-1: break + # Separate function to convert onnx models to tflite format def convert_onnx_to_tflite(onnx_model_path, output_path): """Converts an ONNX version of an openwakeword model to the Tensorflow tflite format.""" @@ -697,14 +698,15 @@ if __name__ == '__main__': batch_size=len(X_val_fp_labels) ) - X_val = np.vstack(( - np.load(os.path.join(feature_save_dir, "positive_features_test.npy")), - np.load(os.path.join(feature_save_dir, "negative_features_test.npy")) - )) - labels = np.hstack((np.ones(X_val.shape[0]//2), np.zeros(X_val.shape[0]//2))).astype(np.float32) + X_val_pos = np.load(os.path.join(feature_save_dir, "positive_features_test.npy")) + X_val_neg = np.load(os.path.join(feature_save_dir, "negative_features_test.npy")) + labels = np.hstack((np.ones(X_val_pos.shape[0]), np.zeros(X_val_neg.shape[0]))).astype(np.float32) X_val = torch.utils.data.DataLoader( - torch.utils.data.TensorDataset(torch.from_numpy(X_val), torch.from_numpy(labels)), + torch.utils.data.TensorDataset( + torch.from_numpy(np.vstack((X_val_pos, X_val_neg))), + torch.from_numpy(labels) + ), batch_size=len(labels) ) From 4833873913d2d766cb01a7a5a5b249aed0d19c67 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Mon, 2 Oct 2023 21:57:22 -0400 Subject: [PATCH 046/103] Updated autotraining function to save checkpoints based on percentiles instead of fixed thresholds --- openwakeword/train.py | 55 +++++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index b741627..7ba216d 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -31,6 +31,7 @@ class Model(nn.Module): self.seconds_per_example = seconds_per_example self.device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu') self.best_models = [] + self.best_model_scores = [] self.best_val_fp = 1000 self.best_val_accuracy = 0 self.best_val_recall = 0 @@ -259,14 +260,29 @@ class Model(nn.Module): target_val_accuracy=target_val_accuracy, target_val_recall=target_val_recall) # Merge best models - if len(self.best_models) == 0: - logging.warning("WARNING!\nNo checkpoint with metrics >= than target values was found!\n" - "Consider generating more positive and negative examples for training or reducing target metrics. " - "Returning the model corresponding to the last training step.\n\n") - combined_model = self.model + logging.info("Merging checkpoints above the 90th percentile into single model...") + accuracy_percentile = np.percentile(self.history["val_accuracy"], 90) + recall_percentile = np.percentile(self.history["val_recall"], 90) + fp_percentile = np.percentile(self.history["val_fp_per_hr"], 10) + + # Show warning if 90th percentile is above/below targets + if accuracy_percentile < target_val_accuracy or recall_percentile < target_val_recall or \ + fp_percentile > target_val_fp_per_hour: + logging.warning("\nWARNING!\nNo checkpoint with metrics better than the target values was found!\n" + "Consider generating more positive and negative examples for training or reducing target metrics.") + + # Get models above the 90th percentile + models = [] + for model, score in zip(self.best_models, self.best_model_scores): + if score["val_accuracy"] >= accuracy_percentile and \ + score["val_recall"] >= recall_percentile and \ + score["val_fp_per_hr"] <= fp_percentile: + models.append(model) + + if len(models) > 0: + combined_model = self.average_models(models=models) else: - logging.info("Merging best checkpoints into single model...") - combined_model = self.average_models(models=self.best_models) + combined_model = self.model # Report validation metrics for combined model with torch.no_grad(): @@ -396,13 +412,15 @@ class Model(nn.Module): self.history["val_accuracy"].append(val_acc.detach().cpu().numpy()) self.history["val_recall"].append(val_recall) - # Save models with a validation score below a given threshold - # print(val_fp_per_hr, self.history["val_accuracy"][-1], self.history["val_recall"][-1]) - if val_fp_per_hr <= max(self.best_val_fp, max_val_fp_per_hr) and \ - self.history["val_accuracy"][-1] >= target_val_accuracy and \ - self.history["val_recall"][-1] >= target_val_recall: + # Save models with a validation score above/below the 90th percentile + # of the validation scores up to that point + if val_fp_per_hr <= np.percentile(self.history["val_fp_per_hr"], 10) and \ + self.history["val_accuracy"][-1] >= np.percentile(self.history["val_accuracy"], 90) and \ + self.history["val_recall"][-1] >= np.percentile(self.history["val_recall"], 90): # logging.info("Saving checkpoint with metrics >= to targets!") self.best_models.append(copy.deepcopy(self.model)) + self.best_model_scores.append({"val_fp_per_hr": val_fp_per_hr, "val_accuracy": self.history["val_accuracy"][-1], + "val_recall": self.history["val_recall"][-1]}) self.best_val_fp = val_fp_per_hr self.best_val_recall = self.history["val_recall"][-1] self.best_val_accuracy = self.history["val_accuracy"][-1] @@ -439,9 +457,10 @@ if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument( "--training_config", - help="The path to the training config file", + help="The path to the training config file (required)", type=str, - required=True + required=True, + metavar="" ) parser.add_argument( "--generate_clips", @@ -458,15 +477,15 @@ if __name__ == '__main__': required=False ) parser.add_argument( - "--train_model", - help="Execute the model training process", + "--overwrite", + help="Overwrite existing openwakeword features when the --augment_clips flag is used", action="store_true", default="False", required=False ) parser.add_argument( - "--overwrite", - help="Overwrite existing openwakeword features when the --augment_clips flag is used", + "--train_model", + help="Execute the model training process", action="store_true", default="False", required=False From 62818e52fa67157d1261916d551207a9865e1d46 Mon Sep 17 00:00:00 2001 From: dscripka Date: Thu, 5 Oct 2023 22:19:05 -0400 Subject: [PATCH 047/103] Fixed argparse issue [skip ci] --- openwakeword/train.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index 7ba216d..198a60e 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -459,8 +459,7 @@ if __name__ == '__main__': "--training_config", help="The path to the training config file (required)", type=str, - required=True, - metavar="" + required=True ) parser.add_argument( "--generate_clips", From 3594e594c7b84aa89789ca194b8da7d647f86bd3 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sat, 7 Oct 2023 12:06:06 -0400 Subject: [PATCH 048/103] added missing dependency [skip ci] --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9bac6ac..eeb5e2e 100644 --- a/setup.py +++ b/setup.py @@ -68,7 +68,8 @@ setuptools.setup( 'onnx_tf==1.10.0', 'onnx==1.14.0', 'pronouncing>=0.2.0,<1', - 'datasets>=2.14.4,<3' + 'datasets>=2.14.4,<3', + 'deep-phonemizer==0.0.19' ] }, author="David Scripka", From ef50fcf1c1da855436aa8127c17a44901c1d9e04 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Sat, 7 Oct 2023 21:27:43 -0400 Subject: [PATCH 049/103] Fixed bugs in auto-training process, removed deprecated arguments [skip ci] --- examples/custom_model.yml | 22 ++++++++---------- openwakeword/train.py | 47 ++++++++++++++++++++++----------------- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/examples/custom_model.yml b/examples/custom_model.yml index dac30b3..ed8d340 100644 --- a/examples/custom_model.yml +++ b/examples/custom_model.yml @@ -2,16 +2,12 @@ # The name of the model (will be used when creating directoires and when saving the final .onnx and .tflite files) model_name: "my_model" + # The target word/phrase to be detected by the model. Adding multiple unique words/phrases will # still only train a binary model detection model, but it will activate on any one of the provided words/phrases. target_phrase: - "hey jarvis" -# The total length (in samples @ 16khz) of the positive clips used for training, after augmentations. -# Should be large enough to contain the entire target word/phrase, with at least 0.75 seconds -# before the start of the word/phrase, and at least 0.2 seconds after the end of the word/phrase. -total_length: 32000 - # Specific phrases that you do *not* want the model to activate on, outside of those generated automatically via phoneme overlap # This can be a good way to reduce false positives if you notice that, in practice, certain words or phrases are problematic custom_negative_phrases: [] @@ -94,12 +90,12 @@ layer_size: 32 # Define training parameters. The values below are recommended defaults for most applications, # but unique deployment environments will likely require testing to determine which values -# are the most appropriate. Note that all "target_" values are determined from the validation data, -# and since early-stopping is utilized, the final performance of the trained model -# may be slighly overfit to the validation data. +# are the most appropriate. -steps: 50000 # the maximum number of steps when training the model -max_negative_weight: 1500 # the maximum weight to give negative samples during training to reduce false positives -target_accuracy: 0.7 # the target validation set accuracy for wake word/phrase detection -target_recall: 0.5 # the target validation recall for wake word/phrase detection -target_false_positives_per_hour: 0.2 # the maximum validation false positive rate per hour \ No newline at end of file +# The maximum number of steps to train the model +steps: 50000 + +# The maximum negative weight and target false positives per hour, used to control the auto training process +# The target false positive rate may not be achieved, and adjusting the maximum negative weight may be necessary +max_negative_weight: 1500 +target_false_positives_per_hour: 0.2 \ No newline at end of file diff --git a/openwakeword/train.py b/openwakeword/train.py index 198a60e..411fc43 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -8,6 +8,7 @@ import sys import tempfile import uuid import numpy as np +import scipy import collections import argparse import logging @@ -192,7 +193,7 @@ class Model(nn.Module): return averaged_model def auto_train(self, X_train, X_val, false_positive_val_data, steps=50000, max_negative_weight=1000, - target_val_accuracy=0.7, target_val_recall=0.5, target_val_fp_per_hour=0.2): + target_fp_per_hour=0.2): """A sequence of training steps that produce relatively strong models automatically, based on validation data and performance targets provided. After training merges the best checkpoints and returns a single model. @@ -213,8 +214,7 @@ class Model(nn.Module): max_steps=steps, negative_weight_schedule=weights, val_steps=val_steps, warmup_steps=steps//5, - hold_steps=steps//3, lr=lr, max_val_fp_per_hr=target_val_fp_per_hour, val_set_hrs=val_set_hrs, - target_val_accuracy=target_val_accuracy, target_val_recall=target_val_recall) + hold_steps=steps//3, lr=lr, val_set_hrs=val_set_hrs) # Sequence 2 logging.info("#"*50 + "\nStarting training sequence 2...\n" + "#"*50) @@ -222,7 +222,7 @@ class Model(nn.Module): steps = steps/10 # Adjust weights as needed based on false positive per hour performance from first sequence - if self.best_val_fp > target_val_fp_per_hour: + if self.best_val_fp > target_fp_per_hour: max_negative_weight = max_negative_weight*2 logging.info("Increasing weight on negative examples to reduce false positives...") @@ -235,15 +235,14 @@ class Model(nn.Module): max_steps=steps, negative_weight_schedule=weights, val_steps=val_steps, warmup_steps=steps//5, - hold_steps=steps//3, lr=lr, max_val_fp_per_hr=target_val_fp_per_hour, val_set_hrs=val_set_hrs, - target_val_accuracy=target_val_accuracy, target_val_recall=target_val_recall) + hold_steps=steps//3, lr=lr, val_set_hrs=val_set_hrs) # Sequence 3 logging.info("#"*50 + "\nStarting training sequence 3...\n" + "#"*50) lr = lr/10 # Adjust weights as needed based on false positive per hour performance from second sequence - if self.best_val_fp > target_val_fp_per_hour: + if self.best_val_fp > target_fp_per_hour: max_negative_weight = max_negative_weight*2 logging.info("Increasing weight on negative examples to reduce false positives...") @@ -256,8 +255,7 @@ class Model(nn.Module): max_steps=steps, negative_weight_schedule=weights, val_steps=val_steps, warmup_steps=steps//5, - hold_steps=steps//3, lr=lr, max_val_fp_per_hr=target_val_fp_per_hour, val_set_hrs=val_set_hrs, - target_val_accuracy=target_val_accuracy, target_val_recall=target_val_recall) + hold_steps=steps//3, lr=lr, val_set_hrs=val_set_hrs) # Merge best models logging.info("Merging checkpoints above the 90th percentile into single model...") @@ -265,12 +263,6 @@ class Model(nn.Module): recall_percentile = np.percentile(self.history["val_recall"], 90) fp_percentile = np.percentile(self.history["val_fp_per_hr"], 10) - # Show warning if 90th percentile is above/below targets - if accuracy_percentile < target_val_accuracy or recall_percentile < target_val_recall or \ - fp_percentile > target_val_fp_per_hour: - logging.warning("\nWARNING!\nNo checkpoint with metrics better than the target values was found!\n" - "Consider generating more positive and negative examples for training or reducing target metrics.") - # Get models above the 90th percentile models = [] for model, score in zip(self.best_models, self.best_model_scores): @@ -324,7 +316,7 @@ class Model(nn.Module): def train_model(self, X, max_steps, warmup_steps, hold_steps, X_val=None, false_positive_val_data=None, negative_weight_schedule=[1], - val_steps=[250], lr=0.0001, max_val_fp_per_hr=0.1, target_val_accuracy=0.7, target_val_recall=0.5, val_set_hrs=1): + val_steps=[250], lr=0.0001, val_set_hrs=1): # Move models and main class to target device self.to(self.device) self.model.to(self.device) @@ -560,7 +552,7 @@ if __name__ == '__main__': for target_phrase in config["target_phrase"]: adversarial_texts.extend(generate_adversarial_texts( input_text=target_phrase, - N=config["n_samples"]//len(config["target_phrase"]), + N=config["n_samples"], include_partial_phrase=1.0, include_input_words=0.2)) generate_samples(text=adversarial_texts, max_samples=config["n_samples"]-n_current_samples, @@ -583,7 +575,7 @@ if __name__ == '__main__': for target_phrase in config["target_phrase"]: adversarial_texts.extend(generate_adversarial_texts( input_text=target_phrase, - N=config["n_samples_val"]//len(config["target_phrase"]), + N=config["n_samples_val"], include_partial_phrase=1.0, include_input_words=0.2)) generate_samples(text=adversarial_texts, max_samples=config["n_samples_val"]-n_current_samples, @@ -594,6 +586,21 @@ if __name__ == '__main__': else: logging.warning(f"Skipping generation of negative clips for testing, as ~{config['n_samples_val']} already exist") + # Set the total length of the training clips based on the ~median generated clip duration, rounding to the nearest 1000 samples + # and setting to 32000 when the median + 750 ms is close to that, as it's a good default value + n = 50 # sample size + positive_clips = [str(i) for i in Path(positive_test_output_dir).glob("*.wav")] + duration_in_samples = [] + for i in range(n): + sr, dat = scipy.io.wavfile.read(positive_clips[np.random.randint(0, len(positive_clips))]) + duration_in_samples.append(len(dat)) + + config["total_length"] = int(round(np.median(duration_in_samples)/1000)*1000) + 12000 # add 750 ms to clip duration as buffer + if config["total_length"] < 32000: + config["total_length"] = 32000 # set a minimum of 32000 samples (2 seconds) + elif abs(config["total_length"] - 32000) <= 4000: + config["total_length"] = 32000 + # Do Data Augmentation if args.augment_clips is True: if not os.path.exists(os.path.join(feature_save_dir, "positive_features_train.npy")) or args.overwrite is True: @@ -735,9 +742,7 @@ if __name__ == '__main__': false_positive_val_data=X_val_fp, steps=config["steps"], max_negative_weight=config["max_negative_weight"], - target_val_accuracy=config["target_accuracy"], - target_val_recall=config["target_recall"], - target_val_fp_per_hour=config["target_false_positives_per_hour"] + target_fp_per_hour=config["target_false_positives_per_hour"], ) # Export the trained model to onnx From 1ae41de488563b7b0b14daa2611921ee9136dfa4 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Sat, 7 Oct 2023 21:32:03 -0400 Subject: [PATCH 050/103] Adjusted training config example [skip ci] --- examples/custom_model.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/custom_model.yml b/examples/custom_model.yml index ed8d340..a41d327 100644 --- a/examples/custom_model.yml +++ b/examples/custom_model.yml @@ -28,9 +28,9 @@ augmentation_batch_size: 16 # The path to a fork of the piper-sample-generator repository for TTS (https://github.com/dscripka/piper-sample-generator) piper_sample_generator_path: "./piper-sample-generator" -# The output directory for the generated synthetic clips and openwakeword features +# The output directory for the generated synthetic clips, openwakeword features, and trained models # Sub-directories will be automatically created for train and test clips for both positive and negative examples -output_dir: "./generated_data" +output_dir: "./my_custom_model" # The directories containing Room Impulse Response recordings rir_paths: From 74839d5ca2da5703151d246a68a81563ffe43c34 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 8 Oct 2023 21:03:21 -0400 Subject: [PATCH 051/103] Added model download utility functions and updated model metadata for official models --- openwakeword/__init__.py | 35 +++++++++++++++++------ openwakeword/data.py | 4 +-- openwakeword/model.py | 4 +-- openwakeword/utils.py | 62 ++++++++++++++++++++++++++++++++++++++++ setup.py | 3 +- 5 files changed, 94 insertions(+), 14 deletions(-) diff --git a/openwakeword/__init__.py b/openwakeword/__init__.py index d49e9e2..b74f8bc 100755 --- a/openwakeword/__init__.py +++ b/openwakeword/__init__.py @@ -5,24 +5,41 @@ from openwakeword.custom_verifier_model import train_custom_verifier __all__ = ['Model', 'VAD', 'train_custom_verifier'] -models = { +FEATURE_MODELS = { + "embedding": { + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/embedding_model.tflite"), + "download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/embedding_model.tflite" + }, + "melspectrogram": { + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/melspectrogram_model.tflite"), + "download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/melspectrogram_model.tflite" + }, +} + +MODELS = { "alexa": { - "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/alexa_v0.1.tflite") + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/alexa_v0.1.tflite"), + "download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/alexa_v0.1.tflite" }, "hey_mycroft": { - "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_mycroft_v0.1.tflite") + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_mycroft_v0.1.tflite"), + "download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/hey_mycroft_v0.1.tflite" }, "hey_jarvis": { - "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_jarvis_v0.1.tflite") + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_jarvis_v0.1.tflite"), + "download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/hey_jarvis_v0.1.tflite" }, "hey_rhasspy": { - "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_rhasspy_v0.1.tflite") + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/hey_rhasspy_v0.1.tflite"), + "download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/hey_rhasspy_v0.1.tflite" }, "timer": { - "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/timer_v0.1.tflite") + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/timer_v0.1.tflite"), + "download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/timer_v0.1.tflite" }, "weather": { - "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/weather_v0.1.tflite") + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/weather_v0.1.tflite"), + "download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/weather_v0.1.tflite" } } @@ -40,6 +57,6 @@ model_class_mappings = { def get_pretrained_model_paths(inference_framework="tflite"): if inference_framework == "tflite": - return [models[i]["model_path"] for i in models.keys()] + return [MODELS[i]["model_path"] for i in MODELS.keys()] elif inference_framework == "onnx": - return [models[i]["model_path"].replace(".tflite", ".onnx") for i in models.keys()] + return [MODELS[i]["model_path"].replace(".tflite", ".onnx") for i in MODELS.keys()] diff --git a/openwakeword/data.py b/openwakeword/data.py index 7c34549..b5db3c2 100755 --- a/openwakeword/data.py +++ b/openwakeword/data.py @@ -445,7 +445,7 @@ def mix_clips_batch( # Apply volume augmentation if volume_augmentation: volume_levels = np.random.uniform(0.02, 1.0, mixed_clips_batch.shape[0]) - mixed_clips_batch = (volume_levels/mixed_clips_batch.max(axis=1)[0])[..., None]*mixed_clips_batch + mixed_clips_batch = (volume_levels/mixed_clips_batch.max(dim=1)[0])[..., None]*mixed_clips_batch else: # Normalize clips only if max value is outside of [-1, 1] abs_max, _ = torch.max( @@ -457,7 +457,7 @@ def mix_clips_batch( mixed_clips_batch = (mixed_clips_batch.numpy()*32767).astype(np.int16) # Remove any clips that are silent (happens rarely when mixing/reverberating) - error_index = np.where(mixed_clips_batch.max(axis=1) != 0)[0] + error_index = torch.from_numpy(np.where(mixed_clips_batch.max(dim=1) != 0)[0]) mixed_clips_batch = mixed_clips_batch[error_index] labels_batch = labels_batch[error_index] sequence_labels_batch = sequence_labels_batch[error_index] diff --git a/openwakeword/model.py b/openwakeword/model.py index 46f603a..6ae820c 100755 --- a/openwakeword/model.py +++ b/openwakeword/model.py @@ -67,7 +67,7 @@ class Model(): with VAD scores above the threshold will be returned. The default value (0), disables voice activity detection entirely. custom_verifier_models (dict): A dictionary of paths to custom verifier models, where - the keys are the model names (corresponding to the openwakeword.models + the keys are the model names (corresponding to the openwakeword.MODELS attribute) and the values are the filepaths of the custom verifier models. custom_verifier_threshold (float): The score threshold to use a custom verifier model. If the score @@ -85,7 +85,7 @@ class Model(): wakeword_model_names = [] if wakeword_models == []: wakeword_models = pretrained_model_paths - wakeword_model_names = list(openwakeword.models.keys()) + wakeword_model_names = list(openwakeword.MODELS.keys()) elif len(wakeword_models) >= 1: for ndx, i in enumerate(wakeword_models): if os.path.exists(i): diff --git a/openwakeword/utils.py b/openwakeword/utils.py index c4f9b15..4aa932d 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -23,6 +23,8 @@ import time import logging import openwakeword from typing import Union, List, Callable, Deque +import requests +from tqdm import tqdm # Base class for computing audio features using Google's speech_embedding @@ -526,6 +528,66 @@ def bulk_predict( return {list(i.keys())[0]: list(i.values())[0] for i in results} +# Function to download files from a URL with a progress bar +def download_file(url, target_directory, file_size=None): + """A simpel function to download a file from a URL with a progress bar using only the requests library""" + local_filename = url.split('/')[-1] + + with requests.get(url, stream=True) as r: + if file_size is not None: + progress_bar = tqdm(total=file_size, unit='iB', unit_scale=True, desc=f"{local_filename}") + else: + total_size = int(r.headers.get('content-length', 0)) + progress_bar = tqdm(total=total_size, unit='iB', unit_scale=True, desc=f"{local_filename}") + + with open(local_filename, 'wb') as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + progress_bar.update(len(chunk)) + + progress_bar.close() + + +# Function to download models from GitHub release assets +def download_models( + model_names: List[str] = [], + target_directory: str = os.path.join(pathlib.Path(__file__).parent.resolve(), "resources", "models") + ): + """ + Download the specified models from the release assets in the openWakeWord GitHub repository. + Uses the official urls in the MODELS dictionary in openwakeword/__init__.py. + + Args: + model_names (List[str]): The names of the models to download (e.g., hey_jarvis_v0.1). Both ONNX and + tflite models will be downloaded. If not provided (the default), + the latest versions of all models will be downloaded. + target_directory (str): The directory to save the models to. Defaults to the install location + of openWakeWord (i.e., the `resources/models` directory). + Returns: + None + """ + + # Always download melspectrogram and embedding models, if they don't already exist + for feature_model in openwakeword.FEATURE_MODELS.values(): + if not os.path.exists(os.path.join(target_directory, feature_model["download_url"].split("/")[-1])): + download_file(feature_model["download_url"], target_directory) + download_file(feature_model["download_url"].replace(".tflite", ".onnx"), target_directory) + + # Get all model urls + official_model_urls = [i["download_url"] for i in openwakeword.MODELS.values()] + official_model_names = [i["download_url"].split("/")[-1] for i in openwakeword.MODELS.values()] + + if model_names != []: + for model_name in model_names: + url = [i for i, j in zip(official_model_urls, official_model_names) if model_name in j] + if url != []: + download_file(url[0], target_directory) + else: + for official_model_url in official_model_urls: + download_file(official_model_url, target_directory) + download_file(official_model_url.replace(".tflite", ".onnx"), target_directory) + + # Handle deprecated arguments and naming (thanks to https://stackoverflow.com/a/74564394) def re_arg(kwarg_map): def decorator(func): diff --git a/setup.py b/setup.py index 1c7aecb..1773a99 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,8 @@ setuptools.setup( 'tflite-runtime>=2.8.0,<3; platform_system == "Linux"', 'tqdm>=4.0,<5.0', 'scipy>=1.3,<2', - 'scikit-learn>=1,<2' + 'scikit-learn>=1,<2', + 'requests>=2.0,<3', ], extras_require={ 'test': [ From 9f394d7abc83add32e679b8eb06f06a80f8c6190 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 8 Oct 2023 21:29:40 -0400 Subject: [PATCH 052/103] Added VAD model, adjusted download function behavior to skip files that already exist [skip ci] --- openwakeword/__init__.py | 13 ++++++++++--- openwakeword/utils.py | 14 +++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/openwakeword/__init__.py b/openwakeword/__init__.py index b74f8bc..6ad8f3f 100755 --- a/openwakeword/__init__.py +++ b/openwakeword/__init__.py @@ -11,9 +11,16 @@ FEATURE_MODELS = { "download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/embedding_model.tflite" }, "melspectrogram": { - "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/melspectrogram_model.tflite"), - "download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/melspectrogram_model.tflite" - }, + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/melspectrogram.tflite"), + "download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/melspectrogram.tflite" + } +} + +VAD_MODELS = { + "silero_vad": { + "model_path": os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources/models/silero_vad.onnx"), + "download_url": "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/silero_vad.onnx" + } } MODELS = { diff --git a/openwakeword/utils.py b/openwakeword/utils.py index 4aa932d..ff89c42 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -573,6 +573,11 @@ def download_models( download_file(feature_model["download_url"], target_directory) download_file(feature_model["download_url"].replace(".tflite", ".onnx"), target_directory) + # Always download VAD models, if they don't already exist + for vad_model in openwakeword.VAD_MODELS.values(): + if not os.path.exists(os.path.join(target_directory, vad_model["download_url"].split("/")[-1])): + download_file(vad_model["download_url"], target_directory) + # Get all model urls official_model_urls = [i["download_url"] for i in openwakeword.MODELS.values()] official_model_names = [i["download_url"].split("/")[-1] for i in openwakeword.MODELS.values()] @@ -581,11 +586,14 @@ def download_models( for model_name in model_names: url = [i for i, j in zip(official_model_urls, official_model_names) if model_name in j] if url != []: - download_file(url[0], target_directory) + if not os.path.exists(os.path.join(target_directory, url[0].split("/")[-1])): + download_file(url[0], target_directory) else: + print(official_model_urls) for official_model_url in official_model_urls: - download_file(official_model_url, target_directory) - download_file(official_model_url.replace(".tflite", ".onnx"), target_directory) + if not os.path.exists(os.path.join(target_directory, official_model_url.split("/")[-1])): + download_file(official_model_url, target_directory) + download_file(official_model_url.replace(".tflite", ".onnx"), target_directory) # Handle deprecated arguments and naming (thanks to https://stackoverflow.com/a/74564394) From 185cda534356c21574aad6bf0066ca4ccd5bea16 Mon Sep 17 00:00:00 2001 From: dscripka Date: Mon, 9 Oct 2023 19:54:53 -0400 Subject: [PATCH 053/103] Remove git-lfs files and configuration --- .gitattributes | 2 -- MANIFEST.in | 2 -- README.md | 10 +++++++--- openwakeword/resources/models/alexa_v0.1.onnx | 3 --- openwakeword/resources/models/alexa_v0.1.tflite | 3 --- openwakeword/resources/models/embedding_model.onnx | 3 --- openwakeword/resources/models/embedding_model.tflite | 3 --- openwakeword/resources/models/hey_jarvis_v0.1.onnx | 3 --- openwakeword/resources/models/hey_jarvis_v0.1.tflite | 3 --- openwakeword/resources/models/hey_mycroft_v0.1.onnx | 3 --- openwakeword/resources/models/hey_mycroft_v0.1.tflite | 3 --- openwakeword/resources/models/hey_rhasspy_v0.1.onnx | 3 --- openwakeword/resources/models/hey_rhasspy_v0.1.tflite | 3 --- openwakeword/resources/models/melspectrogram.onnx | 3 --- openwakeword/resources/models/melspectrogram.tflite | 3 --- openwakeword/resources/models/silero_vad.onnx | 3 --- openwakeword/resources/models/timer_v0.1.onnx | 3 --- openwakeword/resources/models/timer_v0.1.tflite | 3 --- openwakeword/resources/models/weather_v0.1.onnx | 3 --- openwakeword/resources/models/weather_v0.1.tflite | 3 --- openwakeword/utils.py | 5 ++++- tests/test_custom_verifier_model.py | 3 +++ tests/test_models.py | 3 +++ 23 files changed, 17 insertions(+), 59 deletions(-) delete mode 100644 .gitattributes delete mode 100644 MANIFEST.in delete mode 100644 openwakeword/resources/models/alexa_v0.1.onnx delete mode 100644 openwakeword/resources/models/alexa_v0.1.tflite delete mode 100644 openwakeword/resources/models/embedding_model.onnx delete mode 100644 openwakeword/resources/models/embedding_model.tflite delete mode 100644 openwakeword/resources/models/hey_jarvis_v0.1.onnx delete mode 100644 openwakeword/resources/models/hey_jarvis_v0.1.tflite delete mode 100644 openwakeword/resources/models/hey_mycroft_v0.1.onnx delete mode 100644 openwakeword/resources/models/hey_mycroft_v0.1.tflite delete mode 100644 openwakeword/resources/models/hey_rhasspy_v0.1.onnx delete mode 100644 openwakeword/resources/models/hey_rhasspy_v0.1.tflite delete mode 100644 openwakeword/resources/models/melspectrogram.onnx delete mode 100644 openwakeword/resources/models/melspectrogram.tflite delete mode 100755 openwakeword/resources/models/silero_vad.onnx delete mode 100644 openwakeword/resources/models/timer_v0.1.onnx delete mode 100644 openwakeword/resources/models/timer_v0.1.tflite delete mode 100644 openwakeword/resources/models/weather_v0.1.onnx delete mode 100644 openwakeword/resources/models/weather_v0.1.tflite diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 8343da4..0000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -*.onnx filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index eef6e33..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -recursive-include openwakeword *.onnx -recursive-include openwakeword *.tflite \ No newline at end of file diff --git a/README.md b/README.md index 7e5f4f5..be2d18f 100644 --- a/README.md +++ b/README.md @@ -41,16 +41,20 @@ Many thanks to [TeaPoly](https://github.com/TeaPoly/speexdsp-ns-python) for thei # Usage -For quick local testing, clone this repository and use the included [example script](examples/detect_from_microphone.py) to try streaming detection from a local microphone. **Important note!** The model files are stored in this repo using [git-lfs](https://git-lfs.com/); make sure it is installed on your system and if needed use `git-lfs fetch --all` to make sure the the models download correctly. +For quick local testing, clone this repository and use the included [example script](examples/detect_from_microphone.py) to try streaming detection from a local microphone. Adding openWakeWord to your own Python code requires just a few lines: ```python +import openwakeword from openwakeword.model import Model -# Instantiate the model +# One-time download of all pre-trained models (or only select models) +openwakeword.utils.download_models() + +# Instantiate the model(s) model = Model( - wakeword_models=["path/to/model.onnx"], # can also leave this argument empty to load all of the included pre-trained models + wakeword_models=["path/to/model.tflite"], # can also leave this argument empty to load all of the included pre-trained models ) # Get audio data containing 16-bit 16khz PCM audio data from a file, microphone, network stream, etc. diff --git a/openwakeword/resources/models/alexa_v0.1.onnx b/openwakeword/resources/models/alexa_v0.1.onnx deleted file mode 100644 index f52240e..0000000 --- a/openwakeword/resources/models/alexa_v0.1.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6ff566a01d12670e8d9e3c59da32651db1575d17272a601b7f8a39283dfbae3e -size 854246 diff --git a/openwakeword/resources/models/alexa_v0.1.tflite b/openwakeword/resources/models/alexa_v0.1.tflite deleted file mode 100644 index 5d516e2..0000000 --- a/openwakeword/resources/models/alexa_v0.1.tflite +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7333a317a790070a7f3432b81d9439c779481cc4ebd67c73da7174ea3cf48397 -size 855312 diff --git a/openwakeword/resources/models/embedding_model.onnx b/openwakeword/resources/models/embedding_model.onnx deleted file mode 100644 index 2c928ee..0000000 --- a/openwakeword/resources/models/embedding_model.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:70d164290c1d095d1d4ee149bc5e00543250a7316b59f31d056cff7bd3075c1f -size 1326578 diff --git a/openwakeword/resources/models/embedding_model.tflite b/openwakeword/resources/models/embedding_model.tflite deleted file mode 100644 index 52a5336..0000000 --- a/openwakeword/resources/models/embedding_model.tflite +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c0aea21eb84a4ce90a08c870da41b7a7173b45269e6a3207c71d67c40f3a59d8 -size 1330312 diff --git a/openwakeword/resources/models/hey_jarvis_v0.1.onnx b/openwakeword/resources/models/hey_jarvis_v0.1.onnx deleted file mode 100644 index a45f1de..0000000 --- a/openwakeword/resources/models/hey_jarvis_v0.1.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:94a13cfe60075b132f6a472e7e462e8123ee70861bc3fb58434a73712ee0d2cb -size 1271370 diff --git a/openwakeword/resources/models/hey_jarvis_v0.1.tflite b/openwakeword/resources/models/hey_jarvis_v0.1.tflite deleted file mode 100644 index d155242..0000000 --- a/openwakeword/resources/models/hey_jarvis_v0.1.tflite +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:14bff778604985e1b5c19f0f7bbe477a69cf281d8db34b232b3b972411f710e2 -size 1278912 diff --git a/openwakeword/resources/models/hey_mycroft_v0.1.onnx b/openwakeword/resources/models/hey_mycroft_v0.1.onnx deleted file mode 100644 index b9952b3..0000000 --- a/openwakeword/resources/models/hey_mycroft_v0.1.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c2a311e8fa1338de89c31b3b46dc4dffd4af2f9a8d6ddead48893c2d301b1f18 -size 857691 diff --git a/openwakeword/resources/models/hey_mycroft_v0.1.tflite b/openwakeword/resources/models/hey_mycroft_v0.1.tflite deleted file mode 100644 index 53b373c..0000000 --- a/openwakeword/resources/models/hey_mycroft_v0.1.tflite +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bf9e43136afd3ca323698820a6e32a47f885ef4c30a3b8b577ec71688a9d64d8 -size 860300 diff --git a/openwakeword/resources/models/hey_rhasspy_v0.1.onnx b/openwakeword/resources/models/hey_rhasspy_v0.1.onnx deleted file mode 100644 index dea9d9d..0000000 --- a/openwakeword/resources/models/hey_rhasspy_v0.1.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5a9b3ed3be2910e35780e097905aa9f35a9c10038df47914cf2b3ec4d670f6ea -size 204081 diff --git a/openwakeword/resources/models/hey_rhasspy_v0.1.tflite b/openwakeword/resources/models/hey_rhasspy_v0.1.tflite deleted file mode 100644 index 4fb9b6c..0000000 --- a/openwakeword/resources/models/hey_rhasspy_v0.1.tflite +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:01d2526b45068f565aa3849d6ec2b7abae099154fc1b496f9ef20de9ef241fe9 -size 416140 diff --git a/openwakeword/resources/models/melspectrogram.onnx b/openwakeword/resources/models/melspectrogram.onnx deleted file mode 100644 index be0643d..0000000 --- a/openwakeword/resources/models/melspectrogram.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ba2b0e0f8b7b875369a2c89cb13360ff53bac436f2895cced9f479fa65eb176f -size 1087958 diff --git a/openwakeword/resources/models/melspectrogram.tflite b/openwakeword/resources/models/melspectrogram.tflite deleted file mode 100644 index c0f0ab8..0000000 --- a/openwakeword/resources/models/melspectrogram.tflite +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:96fa0adccb6e8cf95cb14465409a1a2898ee4a96a85bb9ed3c7eb0e68bf163e8 -size 1092516 diff --git a/openwakeword/resources/models/silero_vad.onnx b/openwakeword/resources/models/silero_vad.onnx deleted file mode 100755 index 664012e..0000000 --- a/openwakeword/resources/models/silero_vad.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a35ebf52fd3ce5f1469b2a36158dba761bc47b973ea3382b3186ca15b1f5af28 -size 1807522 diff --git a/openwakeword/resources/models/timer_v0.1.onnx b/openwakeword/resources/models/timer_v0.1.onnx deleted file mode 100644 index 5603f7d..0000000 --- a/openwakeword/resources/models/timer_v0.1.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:371e44535470a29248b3b8f1bbbbaf2525c86417fd8f75c67fcf02ae0b9626df -size 1742475 diff --git a/openwakeword/resources/models/timer_v0.1.tflite b/openwakeword/resources/models/timer_v0.1.tflite deleted file mode 100644 index 11a7d50..0000000 --- a/openwakeword/resources/models/timer_v0.1.tflite +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:21d5b0267e97df64870b7aca312e2043ebed248d365698926a115a3694ff9626 -size 1743316 diff --git a/openwakeword/resources/models/weather_v0.1.onnx b/openwakeword/resources/models/weather_v0.1.onnx deleted file mode 100644 index 6c5599e..0000000 --- a/openwakeword/resources/models/weather_v0.1.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8441da8e746899e8d969528d5bad5651cdd563079c05962788f77753041f60e7 -size 1149158 diff --git a/openwakeword/resources/models/weather_v0.1.tflite b/openwakeword/resources/models/weather_v0.1.tflite deleted file mode 100644 index 95dab6e..0000000 --- a/openwakeword/resources/models/weather_v0.1.tflite +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4178991c7aeb76670f5a56559eb4129a6f3ae6207886db8bd8094fea7d362c3f -size 1150224 diff --git a/openwakeword/utils.py b/openwakeword/utils.py index ff89c42..aa649bb 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -540,7 +540,7 @@ def download_file(url, target_directory, file_size=None): total_size = int(r.headers.get('content-length', 0)) progress_bar = tqdm(total=total_size, unit='iB', unit_scale=True, desc=f"{local_filename}") - with open(local_filename, 'wb') as f: + with open(os.path.join(target_directory, local_filename), 'wb') as f: for chunk in r.iter_content(chunk_size=8192): f.write(chunk) progress_bar.update(len(chunk)) @@ -566,6 +566,8 @@ def download_models( Returns: None """ + if not isinstance(model_names, list): + raise ValueError("The model_names argument must be a list of strings") # Always download melspectrogram and embedding models, if they don't already exist for feature_model in openwakeword.FEATURE_MODELS.values(): @@ -588,6 +590,7 @@ def download_models( if url != []: if not os.path.exists(os.path.join(target_directory, url[0].split("/")[-1])): download_file(url[0], target_directory) + download_file(url[0].replace(".tflite", ".onnx"), target_directory) else: print(official_model_urls) for official_model_url in official_model_urls: diff --git a/tests/test_custom_verifier_model.py b/tests/test_custom_verifier_model.py index d5665e5..53f02ce 100644 --- a/tests/test_custom_verifier_model.py +++ b/tests/test_custom_verifier_model.py @@ -34,6 +34,9 @@ import scipy.io.wavfile import tempfile import pytest +# Download models needed for tests +openwakeword.utils.download_models(model_names=["alexa_v0.1", "hey_mycroft_v0.1"]) + # Tests class TestModels: diff --git a/tests/test_models.py b/tests/test_models.py index c38ecb8..e728065 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -40,6 +40,9 @@ import pickle import tempfile import mock +# Download models needed for tests +openwakeword.utils.download_models() + # Tests class TestModels: From 7b7a73d501dda311d8d279cbda9a9946a03f91f4 Mon Sep 17 00:00:00 2001 From: dscripka Date: Mon, 9 Oct 2023 20:13:15 -0400 Subject: [PATCH 054/103] covered edge case for missing folder [skip ci] --- openwakeword/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openwakeword/utils.py b/openwakeword/utils.py index aa649bb..1ea4b88 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -570,6 +570,8 @@ def download_models( raise ValueError("The model_names argument must be a list of strings") # Always download melspectrogram and embedding models, if they don't already exist + if not os.path.exists(target_directory): + os.makedirs(target_directory) for feature_model in openwakeword.FEATURE_MODELS.values(): if not os.path.exists(os.path.join(target_directory, feature_model["download_url"].split("/")[-1])): download_file(feature_model["download_url"], target_directory) From b7e3c3dbaf742c65b5a96231a2c3631a8c88af04 Mon Sep 17 00:00:00 2001 From: dscripka Date: Mon, 9 Oct 2023 20:17:04 -0400 Subject: [PATCH 055/103] Update tests.yml --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6a13242..50486a9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,6 +8,7 @@ on: branches: [ "main" ] pull_request: branches: [ "main" ] + workflow_dispatch: jobs: unit_tests_linux: From 9e43889745375f587418abbc4ce8eaa708ad24dc Mon Sep 17 00:00:00 2001 From: dscripka Date: Mon, 9 Oct 2023 20:22:32 -0400 Subject: [PATCH 056/103] missing requirement for tests --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1773a99..0b1783d 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,8 @@ setuptools.setup( 'flake8>=4.0,<4.1', 'pytest-mypy>=0.10.0,<1', 'mock>=5.1,<6', - 'types-mock>=5.1,<6' + 'types-mock>=5.1,<6', + 'types-requests>=2.0,<3' ], 'full': [ 'mutagen>=1.46.0,<2', From 5b76e49621ba211ef92382ffb9c96558f6622c4b Mon Sep 17 00:00:00 2001 From: dscripka Date: Mon, 9 Oct 2023 20:26:29 -0400 Subject: [PATCH 057/103] remove lfs from github actions yamls --- .github/workflows/build_and_publish_to_pypi.yml | 2 -- .github/workflows/tests.yml | 4 ---- 2 files changed, 6 deletions(-) diff --git a/.github/workflows/build_and_publish_to_pypi.yml b/.github/workflows/build_and_publish_to_pypi.yml index e6f642a..bee4a13 100755 --- a/.github/workflows/build_and_publish_to_pypi.yml +++ b/.github/workflows/build_and_publish_to_pypi.yml @@ -13,8 +13,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - with: - lfs: true - name: Set up Python 3.8 uses: actions/setup-python@v3 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 50486a9..ffc7c50 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,8 +19,6 @@ jobs: steps: - uses: actions/checkout@v3 - with: - lfs: true - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: @@ -43,8 +41,6 @@ jobs: steps: - uses: actions/checkout@v3 - with: - lfs: true - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: From ed90629c46eabd0b54c76f8fb9fc944aa774d123 Mon Sep 17 00:00:00 2001 From: dscripka Date: Mon, 9 Oct 2023 20:38:17 -0400 Subject: [PATCH 058/103] adjusted readme [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index be2d18f..e29cfe2 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Many thanks to [TeaPoly](https://github.com/TeaPoly/speexdsp-ns-python) for thei # Usage -For quick local testing, clone this repository and use the included [example script](examples/detect_from_microphone.py) to try streaming detection from a local microphone. +For quick local testing, clone this repository and use the included [example script](examples/detect_from_microphone.py) to try streaming detection from a local microphone. You can individually download pre-trained models from current and past [releases](https://github.com/dscripka/openWakeWord/releases/), or you can download them using Python (see below). Adding openWakeWord to your own Python code requires just a few lines: From ace14736e19d80f9f9c7e649dcb4a7c76c62ca3c Mon Sep 17 00:00:00 2001 From: dscripka Date: Tue, 10 Oct 2023 21:51:07 -0400 Subject: [PATCH 059/103] Added links to updated colab notebooks in Readme [skip ci] --- README.md | 6 +- notebooks/automatic_model_training.ipynb | 833 ++++++++++++----------- 2 files changed, 432 insertions(+), 407 deletions(-) diff --git a/README.md b/README.md index 9d54f96..26b75e5 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,11 @@ While the models are trained with background noise to increase robustness, in so # Training New Models -Recent versions of openWakeWord support an [automatic model training](openwakeword/train.py) utility that greatly simplifies the process of training custom models. See the example [notebook](notebooks/automatic_model_training.ipynb), or run it in [Google Colab](https://colab.research.google.com/drive/1yyFH-fpguX2BTAW8wSQxTrJnJTM-0QAd?usp=sharing) for an end-to-end example of how the process works. +openWakeWord includes an automated utility that greatly simplifies the process of training custom models. This can be used in two ways: + +1) In a simple [Google Colab](https://colab.research.google.com/drive/1q1oe2zOyZp7UsB3jJiQ1IFn8z5YfjwEb?usp=sharing) notebook with an easy to use interface and simple end-to-end process. This allows anyone to produce a custom model very quickly (<1 hour) and doesn't require any development experience, but the performance of the model may be low in some deployment scenarios. + +2) A more detailed [notebook](notebooks/automatic_model_training.ipynb) (also on [Google Colab](https://colab.research.google.com/drive/1yyFH-fpguX2BTAW8wSQxTrJnJTM-0QAd?usp=sharing)) that describes the training process in more details, and enables more customization. This can produce high quality models, but requires more development experience. For users interested in understanding the fundamental concepts behind model training there is a more detailed, educational [tutorial notebook](notebooks/training_models.ipynb) also available. However, this specific notebook is not intended for training production models, and the automated process above is recommended for that purpose. diff --git a/notebooks/automatic_model_training.ipynb b/notebooks/automatic_model_training.ipynb index f54105e..e1169d6 100644 --- a/notebooks/automatic_model_training.ipynb +++ b/notebooks/automatic_model_training.ipynb @@ -1,410 +1,431 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "43d8967d", - "metadata": {}, - "source": [ - "# Introduction" - ] - }, - { - "cell_type": "markdown", - "id": "ffbb278d", - "metadata": {}, - "source": [ - "This notebook demonstrates how to train custom openWakeWord models using pre-defined datasets, an automated process for synthetic data generation/augmentation, and a custom training process. While not guaranteed to always produce the best performing model, the methods shown in this notebook often produce baseline models with relatively strong performance.\n", - "\n", - "Manual data preparation and model training (e.g., see the [training models](training_models.ipynb) notebook) remains an option for when full control over the model development process is needed.\n", - "\n", - "At a high level, the automatic training process takes advantages of several techniques to try and produce a good model, including:\n", - "\n", - "- Early-stopping and checkpoint averaging (similar to [stochastic weight averaging](https://arxiv.org/abs/1803.05407)) to search for the best models found during training, according to the validation data\n", - "- Variable learning rates with cosine decay and multiple cycles\n", - "- Adaptive batch construction to focus on only high-loss examples when the model begins to converge, combined with gradient accumulation to ensure that batch sizes are still large enough for stable training\n", - "- Cycical weight schedules for negative examples to help the model reduce false-positive rates\n", - "\n", - "See the contents of the `train.py` file for more details." - ] - }, - { - "cell_type": "markdown", - "id": "b1d34904", - "metadata": {}, - "source": [ - "# Environment Setup" - ] - }, - { - "cell_type": "markdown", - "id": "10224159", - "metadata": {}, - "source": [ - "To begin, we'll need to install the requirements for training custom models. In particular, a relatively recent version of Pytorch and custom fork of the [piper-sample-generator](https://github.com/dscripka/piper-sample-generator) library for generating synthetic examples for the custom model.\n", - "\n", - "**Important Note!** Currently, automated model training is only supported on linux systems due to the requirements of the text to speech library used for synthetic sample generation (Piper). It may be possible to use Piper on Windows/Mac systems, but that has not (yet) been tested." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2d59a6b5", - "metadata": {}, - "outputs": [], - "source": [ - "## Environment setup\n", - "\n", - "# install piper-sample-generator (currently only supports linux systems)\n", - "!git clone https://github.com/dscripka/piper-sample-generator\n", - "!wget -O piper-sample-generator/models/en-us-libritts-high.pt 'https://github.com/rhasspy/piper-sample-generator/releases/download/v1.0.0/en-us-libritts-high.pt'\n", - "!apt-get install libespeak-ng1 # may not be required on all systems\n", - "!pip install espeak_phonemizer\n", - "\n", - "# install openwakeword (full installation to support training)\n", - "!git clone https://github.com/dscripka/openwakeword\n", - "!pip install -e ./openwakeword[full]\n", - "!cd openwakeword\n" - ] - }, - { - "cell_type": "code", - "execution_count": 91, - "id": "8f30acfd", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-05T11:52:51.331099Z", - "start_time": "2023-09-05T11:52:50.994098Z" + "cells": [ + { + "cell_type": "markdown", + "id": "c1eab0b3", + "metadata": { + "id": "c1eab0b3" + }, + "source": [ + "# Introduction" + ] + }, + { + "cell_type": "markdown", + "id": "882058c5", + "metadata": { + "id": "882058c5" + }, + "source": [ + "This notebook demonstrates how to train custom openWakeWord models using pre-defined datasets and an automated process for dataset generation and training. While not guaranteed to always produce the best performing model, the methods shown in this notebook often produce baseline models with releatively strong performance.\n", + "\n", + "Manual data preparation and model training (e.g., see the [training models](training_models.ipynb) notebook) remains an option for when full control over the model development process is needed.\n", + "\n", + "At a high level, the automatic training process takes advantages of several techniques to try and produce a good model, including:\n", + "\n", + "- Early-stopping and checkpoint averaging (similar to [stochastic weight averaging](https://arxiv.org/abs/1803.05407)) to search for the best models found during training, according to the validation data\n", + "- Variable learning rates with cosine decay and multiple cycles\n", + "- Adaptive batch construction to focus on only high-loss examples when the model begins to converge, combined with gradient accumulation to ensure that batch sizes are still large enough for stable training\n", + "- Cycical weight schedules for negative examples to help the model reduce false-positive rates\n", + "\n", + "See the contents of the `train.py` file for more details." + ] + }, + { + "cell_type": "markdown", + "id": "e08d031b", + "metadata": { + "id": "e08d031b" + }, + "source": [ + "# Environment Setup" + ] + }, + { + "cell_type": "markdown", + "id": "aee78c37", + "metadata": { + "id": "aee78c37" + }, + "source": [ + "To begin, we'll need to install the requirements for training custom models. In particular, a relatively recent version of Pytorch and custom fork of the [piper-sample-generator](https://github.com/dscripka/piper-sample-generator) library for generating synthetic examples for the custom model.\n", + "\n", + "**Important Note!** Currently, automated model training is only supported on linux systems due to the requirements of the text to speech library used for synthetic sample generation (Piper). It may be possible to use Piper on Windows/Mac systems, but that has not (yet) been tested." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4b1227eb", + "metadata": { + "id": "4b1227eb" + }, + "outputs": [], + "source": [ + "## Environment setup\n", + "\n", + "# install piper-sample-generator (currently only supports linux systems)\n", + "!git clone https://github.com/rhasspy/piper-sample-generator\n", + "!wget -O piper-sample-generator/models/en_US-libritts_r-medium.pt 'https://github.com/rhasspy/piper-sample-generator/releases/download/v2.0.0/en_US-libritts_r-medium.pt'\n", + "!pip install piper-phonemize\n", + "\n", + "# install openwakeword (full installation to support training)\n", + "!git clone --branch auto_training https://github.com/dscripka/openwakeword\n", + "!pip install -e ./openwakeword[full]\n", + "!cd openwakeword\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d4c1056e", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T13:42:01.183840Z", + "start_time": "2023-09-04T13:41:59.752153Z" + }, + "id": "d4c1056e" + }, + "outputs": [], + "source": [ + "# Imports\n", + "\n", + "import os\n", + "import numpy as np\n", + "import torch\n", + "import sys\n", + "from pathlib import Path\n", + "import uuid\n", + "import yaml\n", + "import datasets\n", + "import scipy\n", + "from tqdm import tqdm\n" + ] + }, + { + "cell_type": "markdown", + "id": "e9d7a05a", + "metadata": { + "id": "e9d7a05a" + }, + "source": [ + "# Download Data" + ] + }, + { + "cell_type": "markdown", + "id": "c52f75cc", + "metadata": { + "id": "c52f75cc" + }, + "source": [ + "When training new openWakeWord models using the automated procedure, four specific types of data are required:\n", + "\n", + "1) Synthetic examples of the target word/phrase generated with text-to-speech models\n", + "\n", + "2) Synthetic examples of adversarial words/phrases generated with text-to-speech models\n", + "\n", + "3) Room impulse reponses and noise/background audio data to augment the synthetic examples and make them more realistic\n", + "\n", + "4) Generic \"negative\" audio data that is very unlikely to contain examples of the target word/phrase in the context where the model should detect it. This data can be the original audio data, or precomputed openWakeWord features ready for model training.\n", + "\n", + "5) Validation data to use for early-stopping when training the model.\n", + "\n", + "For the purposes of this notebook, all five of these sources will either be generated manually or can be obtained from HuggingFace thanks to their excellent `datasets` library and extremely generous hosting policy. Also note that while only a portion of some datasets are downloaded, for the best possible performance it is recommended to download the entire dataset and keep a local copy for future training runs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d25a93b1", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T01:07:17.746749Z", + "start_time": "2023-09-04T01:07:17.740846Z" + }, + "id": "d25a93b1" + }, + "outputs": [], + "source": [ + "# Download room impulse responses collected by MIT\n", + "# https://mcdermottlab.mit.edu/Reverb/IR_Survey.html\n", + "\n", + "output_dir = \"./mit_rirs\"\n", + "if not os.path.exists(output_dir):\n", + " os.mkdir(output_dir)\n", + "rir_dataset = datasets.load_dataset(\"davidscripka/MIT_environmental_impulse_responses\", split=\"train\", streaming=True)\n", + "\n", + "# Save clips to 16-bit PCM wav files\n", + "for row in tqdm(rir_dataset):\n", + " name = row['audio']['path'].split('/')[-1]\n", + " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2c0e178b", + "metadata": { + "id": "2c0e178b" + }, + "outputs": [], + "source": [ + "## Download noise and background audio\n", + "\n", + "# Audioset Dataset (https://research.google.com/audioset/dataset/index.html)\n", + "# Download one part of the audioset .tar files, extract, and convert to 16khz\n", + "# For full-scale training, it's recommended to download the entire dataset from\n", + "# https://huggingface.co/datasets/agkphysics/AudioSet, and\n", + "# even potentially combine it with other background noise datasets (e.g., FSD50k, Freesound, etc.)\n", + "\n", + "if not os.path.exists(\"audioset\"):\n", + " os.mkdir(\"audioset\")\n", + "\n", + "fname = \"bal_train09.tar\"\n", + "out_dir = f\"audioset/{fname}\"\n", + "link = \"https://huggingface.co/datasets/agkphysics/AudioSet/resolve/main/\" + fname\n", + "!wget -O {out_dir} {link}\n", + "!cd audioset && tar -xvf bal_train09.tar\n", + "\n", + "output_dir = \"./audioset_16k\"\n", + "if not os.path.exists(output_dir):\n", + " os.mkdir(output_dir)\n", + "\n", + "# Convert audioset files to 16khz sample rate\n", + "audioset_dataset = datasets.Dataset.from_dict({\"audio\": [str(i) for i in Path(\"audioset/audio\").glob(\"**/*.flac\")]})\n", + "audioset_dataset = audioset_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000))\n", + "for row in tqdm(audioset_dataset):\n", + " name = row['audio']['path'].split('/')[-1].replace(\".flac\", \".wav\")\n", + " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n", + "\n", + "# Free Music Archive dataset (https://github.com/mdeff/fma)\n", + "output_dir = \"./fma\"\n", + "if not os.path.exists(output_dir):\n", + " os.mkdir(output_dir)\n", + "fma_dataset = datasets.load_dataset(\"rudraml/fma\", name=\"small\", split=\"train\", streaming=True)\n", + "fma_dataset = iter(fma_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000)))\n", + "\n", + "n_hours = 1 # use only 1 hour of clips for this example notebook, recommend increasing for full-scale training\n", + "for i in tqdm(range(n_hours*3600//30)): # this works because the FMA dataset is all 30 second clips\n", + " row = next(fma_dataset)\n", + " name = row['audio']['path'].split('/')[-1].replace(\".mp3\", \".wav\")\n", + " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n", + " i += 1\n", + " if i == n_hours*3600//30:\n", + " break\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d01ec467", + "metadata": { + "id": "d01ec467" + }, + "outputs": [], + "source": [ + "# Download pre-computed openWakeWord features for training and validation\n", + "\n", + "# training set (~2,000 hours from the ACAV100M Dataset)\n", + "# See https://huggingface.co/datasets/davidscripka/openwakeword_features for more information\n", + "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/openwakeword_features_ACAV100M_2000_hrs_16bit.npy\n", + "\n", + "# validation set for false positive rate estimation (~11 hours)\n", + "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/validation_set_features.npy" + ] + }, + { + "cell_type": "markdown", + "id": "cfe82647", + "metadata": { + "id": "cfe82647" + }, + "source": [ + "# Define Training Configuration" + ] + }, + { + "cell_type": "markdown", + "id": "b2e71329", + "metadata": { + "id": "b2e71329" + }, + "source": [ + "For automated model training openWakeWord uses a specially designed training script and a [YAML](https://yaml.org/) configuration file that defines all of the information required for training a new wake word/phrase detection model.\n", + "\n", + "It is strongly recommended that you review [the example config file](../examples/custom_model.yml), as each value is fully documented there. For the purposes of this notebook, we'll read in the YAML file to modify certain configuration parameters before saving a new YAML file for training our example model. Specifically:\n", + "\n", + "- We'll train a detection model for the phrase \"hey sebastian\"\n", + "- We'll only generate 5,000 positive and negative examples (to save on time for this example)\n", + "- We'll only generate 1,000 validation positive and negative examples for early stopping (again to save time)\n", + "- The model will only be trained for 10,000 steps (larger datasets will benefit from longer training)\n", + "- We'll reduce the target metrics to account for the small dataset size and limited training.\n", + "\n", + "On the topic of target metrics, there are *not* specific guidelines about what these metrics should be in practice, and you will need to conduct testing in your target deployment environment to establish good thresholds. However, from very limited testing the default values in the config file (accuracy >= 0.7, recall >= 0.5, false-positive rate <= 0.2 per hour) seem to produce models with reasonable performance.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fb0b6e4f", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T18:11:33.893397Z", + "start_time": "2023-09-04T18:11:33.878938Z" + }, + "id": "fb0b6e4f" + }, + "outputs": [], + "source": [ + "# Load default YAML config file for training\n", + "config = yaml.load(open(\"openwakeword/examples/custom_model.yml\", 'r').read(), yaml.Loader)\n", + "config" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "482cf2d0", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T15:07:00.859210Z", + "start_time": "2023-09-04T15:07:00.841472Z" + }, + "id": "482cf2d0" + }, + "outputs": [], + "source": [ + "# Modify values in the config and save a new version\n", + "\n", + "config[\"target_phrase\"] = [\"hey sebastian\"]\n", + "config[\"model_name\"] = config[\"target_phrase\"][0].replace(\" \", \"_\")\n", + "config[\"n_samples\"] = 1000\n", + "config[\"n_samples_val\"] = 1000\n", + "config[\"steps\"] = 10000\n", + "config[\"target_accuracy\"] = 0.6\n", + "config[\"target_recall\"] = 0.25\n", + "\n", + "config[\"background_paths\"] = ['./audioset_16k', './fma'] # multiple background datasets are supported\n", + "config[\"false_positive_validation_data_path\"] = \"validation_set_features.npy\"\n", + "config[\"feature_data_files\"] = {\"ACAV100M_sample\": \"openwakeword_features_ACAV100M_2000_hrs_16bit.npy\"}\n", + "\n", + "with open('my_model.yaml', 'w') as file:\n", + " documents = yaml.dump(config, file)" + ] + }, + { + "cell_type": "markdown", + "id": "aa6b2ab0", + "metadata": { + "id": "aa6b2ab0" + }, + "source": [ + "# Train the Model" + ] + }, + { + "cell_type": "markdown", + "id": "a51202c0", + "metadata": { + "id": "a51202c0" + }, + "source": [ + "With the data downloaded and training configuration set, we can now start training the model. We'll do this in parts to better illustrate the sequence, but you can also execute every step at once for a fully automated process." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f01531fa", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T13:50:08.803326Z", + "start_time": "2023-09-04T13:50:06.790241Z" + }, + "id": "f01531fa" + }, + "outputs": [], + "source": [ + "# Step 1: Generate synthetic clips\n", + "# For the number of clips we are using, this should take ~10 minutes on a free Google Colab instance with a T4 GPU\n", + "# If generation fails, you can simply run this command again as it will continue generating until the\n", + "# number of files meets the targets specified in the config file\n", + "\n", + "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --generate_clips" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "afeedae4", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T13:56:08.781018Z", + "start_time": "2023-09-04T13:55:40.203515Z" + }, + "id": "afeedae4" + }, + "outputs": [], + "source": [ + "# Step 2: Augment the generated clips\n", + "\n", + "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --augment_clips" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9ad81ea0", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T15:11:14.742260Z", + "start_time": "2023-09-04T15:07:03.755159Z" + }, + "id": "9ad81ea0" + }, + "outputs": [], + "source": [ + "# Step 3: Train model\n", + "\n", + "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --train_model" + ] + }, + { + "cell_type": "markdown", + "source": [ + "After the model finishes training, the auto training script will automatically convert it to ONNX and tflite versions, saving them as `.onnx/tflite` in the present working directory, where `` is defined in the YAML training config file. Either version can be used as normal with `openwakeword`. I recommend testing them with the [`detect_from_microphone.py`](https://github.com/dscripka/openWakeWord/blob/main/examples/detect_from_microphone.py) example script to see how the model performs!" + ], + "metadata": { + "id": "f9OyUW3ltOSs" + }, + "id": "f9OyUW3ltOSs" } - }, - "outputs": [], - "source": [ - "# Imports\n", - "\n", - "import os\n", - "import numpy as np\n", - "import torch\n", - "from openwakeword.data import mmap_batch_generator, generate_adversarial_texts\n", - "from openwakeword.utils import compute_features_from_generator\n", - "import sys\n", - "from pathlib import Path\n", - "import uuid\n", - "import yaml\n", - "import datasets\n", - "import scipy\n", - "from tqdm import tqdm\n" - ] - }, - { - "cell_type": "markdown", - "id": "a4bdf3fd", - "metadata": {}, - "source": [ - "# Download Data" - ] - }, - { - "cell_type": "markdown", - "id": "e002fd0d", - "metadata": {}, - "source": [ - "When training new openWakeWord models using the automated procedure, four specific types of data are required:\n", - "\n", - "1) Synthetic examples of the target word/phrase generated with text-to-speech models\n", - "\n", - "2) Synthetic examples of adversarial words/phrases generated with text-to-speech models\n", - "\n", - "3) Room impulse reponses and noise/background audio data to augment the synthetic examples and make them more realistic\n", - "\n", - "4) Generic \"negative\" audio data that is very unlikely to contain examples of the target word/phrase in the context where the model should detect it. This data can be the original audio data, or precomputed openWakeWord features ready for model training.\n", - "\n", - "5) Validation data to use for early-stopping when training the model.\n", - "\n", - "For the purposes of this notebook, all five of these sources will either be generated manually or can be obtained from HuggingFace thanks to their excellent `datasets` library and extremely generous hosting policy. Also note that while only a portion of some datasets are downloaded, for the best possible performance it is recommended to download the entire dataset and keep a local copy for future training runs." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "58e4811e", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-05T11:53:57.310392Z", - "start_time": "2023-09-05T11:53:54.490518Z" + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + }, + "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": {}, + "toc_section_display": true, + "toc_window_display": false + }, + "colab": { + "provenance": [] } - }, - "outputs": [], - "source": [ - "# Download room impulse responses collected by MIT\n", - "# https://mcdermottlab.mit.edu/Reverb/IR_Survey.html\n", - "\n", - "output_dir = \"./mit_rirs\"\n", - "if not os.path.exists(output_dir):\n", - " os.mkdir(output_dir)\n", - "rir_dataset = datasets.load_dataset(\"davidscripka/MIT_environmental_impulse_responses\", split=\"train\", streaming=True)\n", - "\n", - "# Save clips to 16-bit PCM wav files\n", - "for row in tqdm(rir_dataset):\n", - " name = row['audio']['path'].split('/')[-1]\n", - " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n" - ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "bd867817", - "metadata": {}, - "outputs": [], - "source": [ - "## Download noise and background audio\n", - "\n", - "# Audioset Dataset (https://research.google.com/audioset/dataset/index.html)\n", - "# Download one part of the audioset .tar files, extract, and convert to 16khz\n", - "# For full-scale training, it's recommended to download the entire dataset from \n", - "# https://huggingface.co/datasets/agkphysics/AudioSet, and\n", - "# even potentially combine it with other background noise datasets (e.g., FSD50k, Freesound, etc.)\n", - "\n", - "fname = \"bal_train09.tar\"\n", - "out_dir = f\"audioset/{fname}\"\n", - "link = \"https://huggingface.co/datasets/agkphysics/AudioSet/resolve/main/\" + fname\n", - "!wget -O {out_dir} {link}\n", - "!cd audioset && tar -xvf bal_train09.tar\n", - "\n", - "if not os.path.exists(\"audioset\"):\n", - " os.mkdir(\"audioset\")\n", - "\n", - "output_dir = \"./audioset_16k\"\n", - "if not os.path.exists(output_dir):\n", - " os.mkdir(output_dir)\n", - "\n", - "# Save clips to 16-bit PCM wav files\n", - "audioset_dataset = datasets.Dataset.from_dict({\"audio\": [str(i) for i in Path(\"audioset/audio\").glob(\"**/*.flac\")]})\n", - "audioset_dataset = audioset_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000))\n", - "for row in tqdm(audioset_dataset):\n", - " name = row['audio']['path'].split('/')[-1].replace(\".flac\", \".wav\")\n", - " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n", - "\n", - "# Free Music Archive dataset\n", - "# https://github.com/mdeff/fma\n", - "\n", - "output_dir = \"./fma\"\n", - "if not os.path.exists(output_dir):\n", - " os.mkdir(output_dir)\n", - "fma_dataset = datasets.load_dataset(\"rudraml/fma\", name=\"small\", split=\"train\", streaming=True)\n", - "fma_dataset = iter(fma_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000)))\n", - "\n", - "# Save clips to 16-bit PCM wav files\n", - "n_hours = 1 # use only 1 hour of clips for this example notebook, recommend increasing for full-scale training\n", - "for i in tqdm(range(n_hours*3600//30)): # this works because the FMA dataset is all 30 second clips\n", - " row = next(fma_dataset)\n", - " name = row['audio']['path'].split('/')[-1].replace(\".mp3\", \".wav\")\n", - " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n", - " i += 1\n", - " if i == n_hours*3600//30:\n", - " break\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "203df175", - "metadata": {}, - "outputs": [], - "source": [ - "# Download pre-computed openWakeWord features for training and validation\n", - "\n", - "# training set (~2,000 hours from the ACAV100M Dataset)\n", - "# See https://huggingface.co/datasets/davidscripka/openwakeword_features for more information\n", - "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/openwakeword_features_ACAV100M_2000_hrs_16bit.npy\n", - "\n", - "# validation set for false positive rate estimation (~11 hours)\n", - "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/validation_set_features.npy" - ] - }, - { - "cell_type": "markdown", - "id": "290865f2", - "metadata": {}, - "source": [ - "# Define Training Configuration" - ] - }, - { - "cell_type": "markdown", - "id": "041ac7e6", - "metadata": {}, - "source": [ - "For automated model training openWakeWord uses a specially designed training script and a [YAML](https://yaml.org/) configuration file that defines all of the information required for training a new wake word/phrase detection model.\n", - "\n", - "It is strongly recommended that you review [the example config file](../examples/custom_model.yml), as each value is fully documented there. For the purposes of this notebook, we'll read in the YAML file to modify certain configuration parameters before saving a new YAML file for training our example model. Specifically:\n", - "\n", - "- We'll train a detection model for the phrase \"hey sebastian\"\n", - "- We'll only generate 5,000 positive and negative examples (to save on time for this example)\n", - "- We'll only generate 1,000 validation positive and negative examples for early stopping (again to save time)\n", - "- The model will only be trained for 10,000 steps (larger datasets will benefit from longer training)\n", - "- We'll reduce the target metrics to account for the small dataset size and limited training.\n", - "\n", - "On the topic of target metrics, there are *not* specific guidelines about what these metrics should be in practice, and you will need to conduct testing in your target deployment environment to establish good thresholds. However, from very limited testing the default values in the config file (accuracy >= 0.7, recall >= 0.5, false-positive rate <= 0.2 per hour) seem to produce model with reasonable performance.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fc70e5ab", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-04T18:11:33.893397Z", - "start_time": "2023-09-04T18:11:33.878938Z" - } - }, - "outputs": [], - "source": [ - "# Load default YAML config file for training\n", - "config = yaml.load(open(\"openwakeword/examples/custom_model.yml\", 'r').read(), yaml.Loader)\n", - "config" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bc278709", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-04T15:07:00.859210Z", - "start_time": "2023-09-04T15:07:00.841472Z" - } - }, - "outputs": [], - "source": [ - "# Modify values in the config and save a new version\n", - "\n", - "config[\"target_phrase\"] = [\"hey sebastian\"]\n", - "config[\"n_samples\"] = 1000\n", - "config[\"n_samples_val\"] = 1000\n", - "config[\"steps\"] = 10000\n", - "config[\"target_accuracy\"] = 0.6\n", - "config[\"target_recall\"] = 0.25\n", - "\n", - "config[\"background_paths\"] = ['./audioset_16k', './fma'] # multiple background datasets are supported\n", - "config[\"false_positive_validation_data_path\"] = \"validation_set_features.npy\"\n", - "config[\"feature_data_files\"] = {\"ACAV100M_sample\": \"openwakeword_features_ACAV100M_2000_hrs_16bit.npy\"}\n", - "\n", - "with open('my_model.yaml', 'w') as file:\n", - " documents = yaml.dump(config, file)" - ] - }, - { - "cell_type": "markdown", - "id": "b46c080b", - "metadata": {}, - "source": [ - "# Train the Model" - ] - }, - { - "cell_type": "markdown", - "id": "55bc110f", - "metadata": {}, - "source": [ - "With the data downloaded and training configuration set, we can now start training the model. We'll do this in parts to better illustrate the sequence, but you can also execute every step at once for a fully automated process." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5c0ffb3a", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-04T13:50:08.803326Z", - "start_time": "2023-09-04T13:50:06.790241Z" - } - }, - "outputs": [], - "source": [ - "# Step 1: Generate synthetic clips\n", - "# For the number of clips we are using, this should take ~10 minutes on a free Google Colab instance\n", - "# If generation fails, you can simply run this command again as it will continue generating until the\n", - "# number of files meets the targets specified in the config file\n", - "\n", - "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --generate_clips" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "190dea93", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-04T13:56:08.781018Z", - "start_time": "2023-09-04T13:55:40.203515Z" - } - }, - "outputs": [], - "source": [ - "# Step 2: Augment the generated clips\n", - "\n", - "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --augment_clips" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6ec6e791", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-04T15:11:14.742260Z", - "start_time": "2023-09-04T15:07:03.755159Z" - } - }, - "outputs": [], - "source": [ - "# Step 3: Train model\n", - "\n", - "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --train_model" - ] - }, - { - "cell_type": "markdown", - "id": "6deb1fda", - "metadata": {}, - "source": [ - "After the model finishes training, the auto training script will automatically convert it to ONNX and tflite versions, saving them as `.onnx/tflite` in the present working directory, where `` is defined in the YAML training config file.\n", - "\n", - "At this point, you can take the trained models and use them as normal with openWakeWord." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "openwakeword_dev", - "language": "python", - "name": "openwakeword_dev" - }, - "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.16" - }, - "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": {}, - "toc_section_display": true, - "toc_window_display": false - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file From b318bfed3263d21c628e0729a465f4a3e1ec39c3 Mon Sep 17 00:00:00 2001 From: dscripka Date: Wed, 11 Oct 2023 21:35:48 -0400 Subject: [PATCH 060/103] Added acknowledgements to readme [skip ci] --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 26b75e5..0612791 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,16 @@ Future release road maps may have non-english support. In particular, [Mycroft.A **I still get a large number of false activations when I use the pre-trained models, how can I reduce these?** - First, review the [recommendations for usage](#recommendations-for-usage) and ensure that these options do not improve overall system accuracy. Second, experiment with [custom verifier models](#user-specific-models), if possible. If neither of these approaches are helping, please open an issue with details of the deployment environment and the types of false activations that you are experiencing. We certainly appreciate feedback & requests on how to improve the base pre-trained models! +# Acknowledgements + +I am very grateful for the encouraging and positive response from the open-source community since the release of openWakeWord in January 2023. In particular, I want to acknowledge and thank the following individuals and groups for their feedback, collaboration, and development support: + +- [synesthesiam](https://github.com/synesthesiam) +- [SecretSauceAI](https://github.com/secretsauceai) +- [OpenVoiceOS](https://github.com/OpenVoiceOS) +- [Nabu Casa](https://github.com/NabuCasa) +- [Home Assistant](https://github.com/home-assistant) + # License All of the code in this repository is licensed under the **Apache 2.0** license. All of the included pre-trained models are licensed under the [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/) license due to the inclusion of datasets with unknown or restrictive licensing as part of the training data. If you are interested in pre-trained models with more permissive licensing, please raise an issue and we will try to add them to a future release. \ No newline at end of file From a99d5f732f17135f196f824985c515d1e7903976 Mon Sep 17 00:00:00 2001 From: dscripka Date: Wed, 11 Oct 2023 21:58:00 -0400 Subject: [PATCH 061/103] README updates [skip ci] --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 31c436f..cc0bf07 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ openWakeWord is an open-source wakeword library that can be used to create voice # Updates +**2023/10/11** +- Significant improvements to the process of [training new models](#training-new-models), including an example Google Colab notebook demonstrating how to train a basic wake word model in <1 hour. + **2023/06/15** - v0.5.0 of openWakeWord released. See the [changelog](CHANGELOG.md) for a full descriptions of new features and changes. @@ -212,7 +215,7 @@ While the models are trained with background noise to increase robustness, in so openWakeWord includes an automated utility that greatly simplifies the process of training custom models. This can be used in two ways: -1) In a simple [Google Colab](https://colab.research.google.com/drive/1q1oe2zOyZp7UsB3jJiQ1IFn8z5YfjwEb?usp=sharing) notebook with an easy to use interface and simple end-to-end process. This allows anyone to produce a custom model very quickly (<1 hour) and doesn't require any development experience, but the performance of the model may be low in some deployment scenarios. +1) A simple [Google Colab](https://colab.research.google.com/drive/1q1oe2zOyZp7UsB3jJiQ1IFn8z5YfjwEb?usp=sharing) notebook with an easy to use interface and simple end-to-end process. This allows anyone to produce a custom model very quickly (<1 hour) and doesn't require any development experience, but the performance of the model may be low in some deployment scenarios. 2) A more detailed [notebook](notebooks/automatic_model_training.ipynb) (also on [Google Colab](https://colab.research.google.com/drive/1yyFH-fpguX2BTAW8wSQxTrJnJTM-0QAd?usp=sharing)) that describes the training process in more details, and enables more customization. This can produce high quality models, but requires more development experience. From 9a23c43ab4575cecede292380535f165bfe55e3a Mon Sep 17 00:00:00 2001 From: dscripka Date: Thu, 12 Oct 2023 12:01:30 -0400 Subject: [PATCH 062/103] Fixed bug when downloading phonemizer model [skip ci] --- openwakeword/data.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openwakeword/data.py b/openwakeword/data.py index 964ce77..6e90969 100755 --- a/openwakeword/data.py +++ b/openwakeword/data.py @@ -925,6 +925,8 @@ def generate_adversarial_texts(input_text: str, N: int, include_partial_phrase: # Download phonemizer model for OOV words, if needed if [] in input_text_phones: phonemizer_mdl_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "en_us_cmudict_forward.pt") + if not os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources")): + os.mkdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources")) if not os.path.exists(phonemizer_mdl_path): logging.warning("Downloading phonemizer model from DeepPhonemizer library...") import requests From 2d663e8c680320360738813ecd4a71faad3213e2 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Thu, 12 Oct 2023 21:15:20 -0400 Subject: [PATCH 063/103] Removed stray print statement [skip ci] --- openwakeword/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openwakeword/utils.py b/openwakeword/utils.py index 5f7a926..c032c5a 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -657,7 +657,6 @@ def download_models( download_file(url[0], target_directory) download_file(url[0].replace(".tflite", ".onnx"), target_directory) else: - print(official_model_urls) for official_model_url in official_model_urls: if not os.path.exists(os.path.join(target_directory, official_model_url.split("/")[-1])): download_file(official_model_url, target_directory) From 8ec1286cff955a1590abb0ba744593c6cd9e9d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20K=C3=BChn?= Date: Fri, 13 Oct 2023 09:18:39 +0200 Subject: [PATCH 064/103] Properly create cv11_test_clips-Folder --- notebooks/.gitignore | 1 + notebooks/training_models.ipynb | 37 ++++++++++++--------------------- 2 files changed, 14 insertions(+), 24 deletions(-) create mode 100644 notebooks/.gitignore diff --git a/notebooks/.gitignore b/notebooks/.gitignore new file mode 100644 index 0000000..b0f0998 --- /dev/null +++ b/notebooks/.gitignore @@ -0,0 +1 @@ +cv11_test_clips diff --git a/notebooks/training_models.ipynb b/notebooks/training_models.ipynb index 6f118fd..43fed78 100644 --- a/notebooks/training_models.ipynb +++ b/notebooks/training_models.ipynb @@ -43,16 +43,7 @@ "start_time": "2023-02-18T03:26:24.785801Z" } }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/dscripka/anaconda3/envs/torch_gpu/lib/python3.9/site-packages/tqdm/auto.py:22: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", - " from .autonotebook import tqdm as notebook_tqdm\n" - ] - } - ], + "outputs": [], "source": [ "# Imports\n", "\n", @@ -128,10 +119,8 @@ "name": "stderr", "output_type": "stream", "text": [ - " 0%| | 0/5000 [00:00" ] @@ -776,7 +765,7 @@ }, { "data": { - "image/png": "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AABcE7rOSE1NjR588EGtWrVKu3fv1o033qiGhgZVV1dL6p7vMX/+fP/46upqvfnmm6qpqdHu3bu1atUqrVy5UjfffPPIncUwFRUVaenSpWlDQq6Jwnlyju6IwnlG4RylaJwn5zgyQi/tlbqLnt17771qamrSjBkz9G//9m8699xzJUlXX3213njjDW3cuNE/ftOmTbrxxhu1c+dOlZWV6dZbb/XDCwAAiLZhhREAAICRwt40AADAKsIIAACwijACAACsIowAAACrIh1Gli9frvLychUXF6uiokJbtmyx3aRhu+OOO3p24Ux+TJ482X/eGKM77rhDZWVlGjNmjD73uc9p586dFls8tM2bN+uSSy5RWVmZYrGYfvGLX6Q8H+ScOjo6dP3112vixIkaO3as/vqv/1pvvfXWKJ7F0IY6z6uvvjrt2n7mM59JOSbbz7O2tlZ/9Vd/pXHjxumYY47RpZdeqtdeey3lmFy/nkHOMdev5YoVK3T66af7lTgrKyv161//2n8+16+hZ6jzzPXr2J/a2lrFYjEtXrzYf2xUr+cQe+M569FHHzUFBQXmP/7jP8yuXbvMDTfcYMaOHWvefPNN200blqVLl5pPfepTpqmpyf9oaWnxn7/nnnvMuHHjzNq1a82OHTvMvHnzzJQpU8z+/fsttnpw69evN7fffrtZu3atkWQef/zxlOeDnFN1dbU59thjTV1dnXnppZfM5z//eTNz5kzT2dk5ymczsKHO86qrrjJf+tKXUq7tvn37Uo7J9vO88MILzUMPPWReffVVs337dnPxxRebadOmmQMHDvjH5Pr1DHKOuX4tn3jiCfOrX/3KvPbaa+a1114zt912mykoKDCvvvqqMSb3r6FnqPPM9evY1+9//3tz/PHHm9NPP93ccMMN/uOjeT0jG0bOOussU11dnfLYJz/5SfOd73zHUos+mqVLl5qZM2f2+1wikTCTJ08299xzj//Yhx9+aEpKSsz9998/Si38aPr+kg5yTu+9954pKCgwjz76qH/M22+/beLxuHnyySdHre1hDBRG/uZv/mbA1+Tieba0tBhJZtOmTcYYN69n33M0xs1redRRR5kHH3zQyWvYm3eexrh1Hdvb280nPvEJU1dXZ8477zw/jIz29YzkMM2hQ4dUX1+vqqqqlMerqqq0detWS6366Pbs2aOysjKVl5fr7/7u7/T6669Lkvbu3avm5uaU8y0qKtJ5552Xs+cb5Jzq6+t1+PDhlGPKyso0Y8aMnDvvjRs36phjjtFJJ52kb37zm2ppafGfy8XzbGtrkyQdffTRkty8nn3P0ePKtezq6tKjjz6q999/X5WVlU5eQyn9PD2uXMdrr71WF198sb7whS+kPD7a13NYu/bmutbWVnV1daVt7ldaWpq2qV+uOPvss/Xwww/rpJNO0jvvvKO7775bs2fP1s6dO/1z6u9833zzTRvN/ciCnFNzc7MKCwt11FFHpR2TS9d57ty5+upXv6rp06dr7969+sd//Eedf/75qq+vV1FRUc6dpzFGNTU1+uxnP6sZM2ZIcu969neOkhvXcseOHaqsrNSHH36oI444Qo8//rhOPfVU/5ePK9dwoPOU3LiOkvToo4/qpZde0gsvvJD23Gj/m4xkGPHEYrGUr40xaY/lirlz5/p/Pu2001RZWakTTzxRP/7xj/2JVS6dr2c455Rr5z1v3jz/zzNmzNCsWbM0ffp0/epXv9JXvvKVAV+Xred53XXX6ZVXXtEzzzyT9pwr13Ogc3ThWp588snavn273nvvPa1du1ZXXXWVNm3a5D/vyjUc6DxPPfVUJ65jY2OjbrjhBm3YsEHFxcUDHjda1zOSwzQTJ05UXl5eWnJraWlJS4G5auzYsTrttNO0Z88ef1WNS+cb5JwmT56sQ4cO6S9/+cuAx+SiKVOmaPr06dqzZ4+k3DrP66+/Xk888YSefvppHXfccf7jLl3Pgc6xP7l4LQsLC/Xxj39cs2bNUm1trWbOnKnvfe97Tl1DaeDz7E8uXsf6+nq1tLSooqJC+fn5ys/P16ZNm/T9739f+fn5fjtH63pGMowUFhaqoqJCdXV1KY/X1dVp9uzZllo1sjo6OrR7925NmTJF5eXlmjx5csr5Hjp0SJs2bcrZ8w1yThUVFSooKEg5pqmpSa+++mrOnrck7du3T42NjZoyZYqk3DhPY4yuu+46rVu3Tr/73e9UXl6e8rwL13Ooc+xPLl7Lvowx6ujocOIaDsY7z/7k4nW84IILtGPHDm3fvt3/mDVrlv7+7/9e27dv1wknnDC61zPkxFtneEt7V65caXbt2mUWL15sxo4da9544w3bTRuWm266yWzcuNG8/vrr5vnnnzdf/vKXzbhx4/zzueeee0xJSYlZt26d2bFjh/na176W9Ut729vbzbZt28y2bduMJLNs2TKzbds2f/l1kHOqrq42xx13nPnNb35jXnrpJXP++edn3fK6wc6zvb3d3HTTTWbr1q1m79695umnnzaVlZXm2GOPzanz/Pa3v21KSkrMxo0bU5ZDHjx40D8m16/nUOfowrVcsmSJ2bx5s9m7d6955ZVXzG233Wbi8bjZsGGDMSb3r6FnsPN04ToOpPdqGmNG93pGNowYY8y///u/m+nTp5vCwkLz6U9/OmUJXq7x1n8XFBSYsrIy85WvfMXs3LnTfz6RSJilS5eayZMnm6KiInPuueeaHTt2WGzx0J5++mkjKe3jqquuMsYEO6cPPvjAXHfddeboo482Y8aMMV/+8pdNQ0ODhbMZ2GDnefDgQVNVVWUmTZpkCgoKzLRp08xVV12Vdg7Zfp79nZ8k89BDD/nH5Pr1HOocXbiW3/jGN/z3zEmTJpkLLrjADyLG5P419Ax2ni5cx4H0DSOjeT1jxhgTri8FAABg5ERyzggAAMgehBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABY9f8B7h6mjcwkwbsAAAAASUVORK5CYII=\n", + "image/png": "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AABcE7rOSE1NjR588EGtWrVKu3fv1o033qiGhgZVV1dL6p7vMX/+fP/46upqvfnmm6qpqdHu3bu1atUqrVy5UjfffPPIncUwFRUVaenSpWlDQq6Jwnlyju6IwnlG4RylaJwn5zgyQi/tlbqLnt17771qamrSjBkz9G//9m8699xzJUlXX3213njjDW3cuNE/ftOmTbrxxhu1c+dOlZWV6dZbb/XDCwAAiLZhhREAAICRwt40AADAKsIIAACwijACAACsIowAAACrIh1Gli9frvLychUXF6uiokJbtmyx3aRhu+OOO3p24Ux+TJ482X/eGKM77rhDZWVlGjNmjD73uc9p586dFls8tM2bN+uSSy5RWVmZYrGYfvGLX6Q8H+ScOjo6dP3112vixIkaO3as/vqv/1pvvfXWKJ7F0IY6z6uvvjrt2n7mM59JOSbbz7O2tlZ/9Vd/pXHjxumYY47RpZdeqtdeey3lmFy/nkHOMdev5YoVK3T66af7lTgrKyv161//2n8+16+hZ6jzzPXr2J/a2lrFYjEtXrzYf2xUr+cQe+M569FHHzUFBQXmP/7jP8yuXbvMDTfcYMaOHWvefPNN200blqVLl5pPfepTpqmpyf9oaWnxn7/nnnvMuHHjzNq1a82OHTvMvHnzzJQpU8z+/fsttnpw69evN7fffrtZu3atkWQef/zxlOeDnFN1dbU59thjTV1dnXnppZfM5z//eTNz5kzT2dk5ymczsKHO86qrrjJf+tKXUq7tvn37Uo7J9vO88MILzUMPPWReffVVs337dnPxxRebadOmmQMHDvjH5Pr1DHKOuX4tn3jiCfOrX/3KvPbaa+a1114zt912mykoKDCvvvqqMSb3r6FnqPPM9evY1+9//3tz/PHHm9NPP93ccMMN/uOjeT0jG0bOOussU11dnfLYJz/5SfOd73zHUos+mqVLl5qZM2f2+1wikTCTJ08299xzj//Yhx9+aEpKSsz9998/Si38aPr+kg5yTu+9954pKCgwjz76qH/M22+/beLxuHnyySdHre1hDBRG/uZv/mbA1+Tieba0tBhJZtOmTcYYN69n33M0xs1redRRR5kHH3zQyWvYm3eexrh1Hdvb280nPvEJU1dXZ8477zw/jIz29YzkMM2hQ4dUX1+vqqqqlMerqqq0detWS6366Pbs2aOysjKVl5fr7/7u7/T6669Lkvbu3avm5uaU8y0qKtJ5552Xs+cb5Jzq6+t1+PDhlGPKyso0Y8aMnDvvjRs36phjjtFJJ52kb37zm2ppafGfy8XzbGtrkyQdffTRkty8nn3P0ePKtezq6tKjjz6q999/X5WVlU5eQyn9PD2uXMdrr71WF198sb7whS+kPD7a13NYu/bmutbWVnV1daVt7ldaWpq2qV+uOPvss/Xwww/rpJNO0jvvvKO7775bs2fP1s6dO/1z6u9833zzTRvN/ciCnFNzc7MKCwt11FFHpR2TS9d57ty5+upXv6rp06dr7969+sd//Eedf/75qq+vV1FRUc6dpzFGNTU1+uxnP6sZM2ZIcu969neOkhvXcseOHaqsrNSHH36oI444Qo8//rhOPfVU/5ePK9dwoPOU3LiOkvToo4/qpZde0gsvvJD23Gj/m4xkGPHEYrGUr40xaY/lirlz5/p/Pu2001RZWakTTzxRP/7xj/2JVS6dr2c455Rr5z1v3jz/zzNmzNCsWbM0ffp0/epXv9JXvvKVAV+Xred53XXX6ZVXXtEzzzyT9pwr13Ogc3ThWp588snavn273nvvPa1du1ZXXXWVNm3a5D/vyjUc6DxPPfVUJ65jY2OjbrjhBm3YsEHFxcUDHjda1zOSwzQTJ05UXl5eWnJraWlJS4G5auzYsTrttNO0Z88ef1WNS+cb5JwmT56sQ4cO6S9/+cuAx+SiKVOmaPr06dqzZ4+k3DrP66+/Xk888YSefvppHXfccf7jLl3Pgc6xP7l4LQsLC/Xxj39cs2bNUm1trWbOnKnvfe97Tl1DaeDz7E8uXsf6+nq1tLSooqJC+fn5ys/P16ZNm/T9739f+fn5fjtH63pGMowUFhaqoqJCdXV1KY/X1dVp9uzZllo1sjo6OrR7925NmTJF5eXlmjx5csr5Hjp0SJs2bcrZ8w1yThUVFSooKEg5pqmpSa+++mrOnrck7du3T42NjZoyZYqk3DhPY4yuu+46rVu3Tr/73e9UXl6e8rwL13Ooc+xPLl7Lvowx6ujocOIaDsY7z/7k4nW84IILtGPHDm3fvt3/mDVrlv7+7/9e27dv1wknnDC61zPkxFtneEt7V65caXbt2mUWL15sxo4da9544w3bTRuWm266yWzcuNG8/vrr5vnnnzdf/vKXzbhx4/zzueeee0xJSYlZt26d2bFjh/na176W9Ut729vbzbZt28y2bduMJLNs2TKzbds2f/l1kHOqrq42xx13nPnNb35jXnrpJXP++edn3fK6wc6zvb3d3HTTTWbr1q1m79695umnnzaVlZXm2GOPzanz/Pa3v21KSkrMxo0bU5ZDHjx40D8m16/nUOfowrVcsmSJ2bx5s9m7d6955ZVXzG233Wbi8bjZsGGDMSb3r6FnsPN04ToOpPdqGmNG93pGNowYY8y///u/m+nTp5vCwkLz6U9/OmUJXq7x1n8XFBSYsrIy85WvfMXs3LnTfz6RSJilS5eayZMnm6KiInPuueeaHTt2WGzx0J5++mkjKe3jqquuMsYEO6cPPvjAXHfddeboo482Y8aMMV/+8pdNQ0ODhbMZ2GDnefDgQVNVVWUmTZpkCgoKzLRp08xVV12Vdg7Zfp79nZ8k89BDD/nH5Pr1HOocXbiW3/jGN/z3zEmTJpkLLrjADyLG5P419Ax2ni5cx4H0DSOjeT1jxhgTri8FAABg5ERyzggAAMgehBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABY9f8B7h6mjcwkwbsAAAAASUVORK5CYII=", "text/plain": [ "
" ] @@ -872,7 +861,7 @@ }, { "data": { - "image/png": "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Dx48GHeP1/t57bXXmqeeesq88847pq2tzVx//fVmxowZ5tChQ9F7sv15Hk8fs/1Zbty40bz00kvm3XffNe+++6554IEHTH5+vnnnnXeMMdn/DG3H6me2P8dE//mf/2nOOeccc/HFF5u77747et3J55mzYeSSSy4xDQ0Ncdc+//nPm+985zsutejErFixwsyaNSvle5FIxJSWlppHH300eu3TTz81xcXF5vHHH3eohScm8Yf08fTp448/Nvn5+ea5556L3vPhhx+aQCBgXn75Zcfanol0YeQv//Iv034mG/vZ3d1tJJmtW7caY/z5PBP7aIw/n+Xpp59unnzySV8+w1h2P43x13Ps7+83559/vmlubjZf+cpXomHE6eeZk9M0g4ODamlpUX19fdz1+vp67dixw6VWnbi9e/eqvLxclZWV+uu//mvt27dPkrR//351dXXF9TcUCukrX/lK1vb3ePrU0tKio0ePxt1TXl6uqqqqrOv3li1bdOaZZ2rmzJm67bbb1N3dHX0vG/vZ29srSTrjjDMk+fN5JvbR5pdnGQ6H9dxzz+nw4cOqra315TOUkvtp88tzvPPOO3X99dfrmmuuibvu9PMc16m92a6np0fhcDjpcL+SkpKkQ/2yxaWXXqpnnnlGM2fO1IEDB/TII4+orq5Ou3btivYpVX/ff/99N5p7wo6nT11dXSooKNDpp5+edE82Pec5c+bom9/8pioqKrR//379/d//va666iq1tLQoFAplXT+NMWpsbNTll1+uqqoqSf57nqn6KPnjWe7cuVO1tbX69NNPdcopp+iFF17QhRdeGP3h45dnmK6fkj+eoyQ999xzeuutt/Tb3/426T2n/5vMyTBisywr7mtjTNK1bDFnzpzo7y+66CLV1tbqvPPO07/8y79EC6v81F/bePqUbf2eO3du9PdVVVWqqalRRUWFXnrpJX39619P+zmv9nPRokX63e9+p9deey3pPb88z3R99MOz/NznPqe2tjZ9/PHHev755zV//nxt3bo1+r5fnmG6fl544YW+eI4dHR26++67tXnzZhUWFqa9z6nnmZPTNFOnTlUwGExKbt3d3UkpMFtNnjxZF110kfbu3RtdVeOn/h5Pn0pLSzU4OKj//u//TntPNiorK1NFRYX27t0rKbv6edddd2njxo169dVXdfbZZ0ev++l5putjKtn4LAsKCvTZz35WNTU1ampq0qxZs/T973/fV89QSt/PVLLxOba0tKi7u1vV1dXKy8tTXl6etm7dqh/84AfKy8uLttOp55mTYaSgoEDV1dVqbm6Ou97c3Ky6ujqXWnVyDQwMaM+ePSorK1NlZaVKS0vj+js4OKitW7dmbX+Pp0/V1dXKz8+Pu6ezs1PvvPNO1vZbkg4ePKiOjg6VlZVJyo5+GmO0aNEibdiwQf/xH/+hysrKuPf98DyP1cdUsvFZJjLGaGBgwBfPcCx2P1PJxud49dVXa+fOnWpra4u+ampq9Ld/+7dqa2vTueee6+zzzLDw1jfspb1r1641u3fvNkuWLDGTJ0827733nttNG5d77rnHbNmyxezbt8/85je/MV/72tfMqaeeGu3Po48+aoqLi82GDRvMzp07zd/8zd94fmlvf3+/aW1tNa2trUaSWblypWltbY0uvz6ePjU0NJizzz7b/PrXvzZvvfWWueqqqzy3vG6sfvb395t77rnH7Nixw+zfv9+8+uqrpra21px11llZ1c+/+7u/M8XFxWbLli1xyyGPHDkSvSfbn+ex+uiHZ7ls2TKzbds2s3//fvO73/3OPPDAAyYQCJjNmzcbY7L/GdrG6qcfnmM6satpjHH2eeZsGDHGmB//+MemoqLCFBQUmC9+8YtxS/Cyjb3+Oz8/35SXl5uvf/3rZteuXdH3I5GIWbFihSktLTWhUMhcccUVZufOnS62+NheffVVIynpNX/+fGPM8fXpk08+MYsWLTJnnHGGmTRpkvna175m2tvbXehNemP188iRI6a+vt5MmzbN5OfnmxkzZpj58+cn9cHr/UzVP0nmqaeeit6T7c/zWH30w7P89re/Hf1/5rRp08zVV18dDSLGZP8ztI3VTz88x3QSw4iTz9MyxpjMxlIAAABOnpysGQEAAN5BGAEAAK4ijAAAAFcRRgAAgKsIIwAAwFWEEQAA4CrCCAAAcBVhBAAAuIowAgAAXEUYAQAAriKMAAAAV/1/NEoHiB/UT8gAAAAASUVORK5CYII=\n", + "image/png": "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Dx48GHeP1/t57bXXmqeeesq88847pq2tzVx//fVmxowZ5tChQ9F7sv15Hk8fs/1Zbty40bz00kvm3XffNe+++6554IEHTH5+vnnnnXeMMdn/DG3H6me2P8dE//mf/2nOOeccc/HFF5u77747et3J55mzYeSSSy4xDQ0Ncdc+//nPm+985zsutejErFixwsyaNSvle5FIxJSWlppHH300eu3TTz81xcXF5vHHH3eohScm8Yf08fTp448/Nvn5+ea5556L3vPhhx+aQCBgXn75Zcfanol0YeQv//Iv034mG/vZ3d1tJJmtW7caY/z5PBP7aIw/n+Xpp59unnzySV8+w1h2P43x13Ps7+83559/vmlubjZf+cpXomHE6eeZk9M0g4ODamlpUX19fdz1+vp67dixw6VWnbi9e/eqvLxclZWV+uu//mvt27dPkrR//351dXXF9TcUCukrX/lK1vb3ePrU0tKio0ePxt1TXl6uqqqqrOv3li1bdOaZZ2rmzJm67bbb1N3dHX0vG/vZ29srSTrjjDMk+fN5JvbR5pdnGQ6H9dxzz+nw4cOqra315TOUkvtp88tzvPPOO3X99dfrmmuuibvu9PMc16m92a6np0fhcDjpcL+SkpKkQ/2yxaWXXqpnnnlGM2fO1IEDB/TII4+orq5Ou3btivYpVX/ff/99N5p7wo6nT11dXSooKNDpp5+edE82Pec5c+bom9/8pioqKrR//379/d//va666iq1tLQoFAplXT+NMWpsbNTll1+uqqoqSf57nqn6KPnjWe7cuVO1tbX69NNPdcopp+iFF17QhRdeGP3h45dnmK6fkj+eoyQ999xzeuutt/Tb3/426T2n/5vMyTBisywr7mtjTNK1bDFnzpzo7y+66CLV1tbqvPPO07/8y79EC6v81F/bePqUbf2eO3du9PdVVVWqqalRRUWFXnrpJX39619P+zmv9nPRokX63e9+p9deey3pPb88z3R99MOz/NznPqe2tjZ9/PHHev755zV//nxt3bo1+r5fnmG6fl544YW+eI4dHR26++67tXnzZhUWFqa9z6nnmZPTNFOnTlUwGExKbt3d3UkpMFtNnjxZF110kfbu3RtdVeOn/h5Pn0pLSzU4OKj//u//TntPNiorK1NFRYX27t0rKbv6edddd2njxo169dVXdfbZZ0ev++l5putjKtn4LAsKCvTZz35WNTU1ampq0qxZs/T973/fV89QSt/PVLLxOba0tKi7u1vV1dXKy8tTXl6etm7dqh/84AfKy8uLttOp55mTYaSgoEDV1dVqbm6Ou97c3Ky6ujqXWnVyDQwMaM+ePSorK1NlZaVKS0vj+js4OKitW7dmbX+Pp0/V1dXKz8+Pu6ezs1PvvPNO1vZbkg4ePKiOjg6VlZVJyo5+GmO0aNEibdiwQf/xH/+hysrKuPf98DyP1cdUsvFZJjLGaGBgwBfPcCx2P1PJxud49dVXa+fOnWpra4u+ampq9Ld/+7dqa2vTueee6+zzzLDw1jfspb1r1641u3fvNkuWLDGTJ0827733nttNG5d77rnHbNmyxezbt8/85je/MV/72tfMqaeeGu3Po48+aoqLi82GDRvMzp07zd/8zd94fmlvf3+/aW1tNa2trUaSWblypWltbY0uvz6ePjU0NJizzz7b/PrXvzZvvfWWueqqqzy3vG6sfvb395t77rnH7Nixw+zfv9+8+uqrpra21px11llZ1c+/+7u/M8XFxWbLli1xyyGPHDkSvSfbn+ex+uiHZ7ls2TKzbds2s3//fvO73/3OPPDAAyYQCJjNmzcbY7L/GdrG6qcfnmM6satpjHH2eeZsGDHGmB//+MemoqLCFBQUmC9+8YtxS/Cyjb3+Oz8/35SXl5uvf/3rZteuXdH3I5GIWbFihSktLTWhUMhcccUVZufOnS62+NheffVVIynpNX/+fGPM8fXpk08+MYsWLTJnnHGGmTRpkvna175m2tvbXehNemP188iRI6a+vt5MmzbN5OfnmxkzZpj58+cn9cHr/UzVP0nmqaeeit6T7c/zWH30w7P89re/Hf1/5rRp08zVV18dDSLGZP8ztI3VTz88x3QSw4iTz9MyxpjMxlIAAABOnpysGQEAAN5BGAEAAK4ijAAAAFcRRgAAgKsIIwAAwFWEEQAA4CrCCAAAcBVhBAAAuIowAgAAXEUYAQAAriKMAAAAV/1/NEoHiB/UT8gAAAAASUVORK5CYII=", "text/plain": [ "
" ] @@ -1003,7 +992,7 @@ "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAiMAAAGdCAYAAADAAnMpAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8qNh9FAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAw3UlEQVR4nO3df3BU9b3/8dduNj/4lSAggUDAWH/RcqU1aAsWW380Fq39tuMMfGun2BZmmqIg5Np+RWaqZTqN7bQM2gq0V5BxxquMF+y1t6mS3lZAsVMJSUWhaiuSAAkxCEn4lR+7n+8fm3M2mx+754Sc7IbzfMzsJJz97OazORvyyufzPp9PwBhjBAAAkCLBVHcAAAD4G2EEAACkFGEEAACkFGEEAACkFGEEAACkFGEEAACkFGEEAACkFGEEAACkVCjVHXAiEono2LFjGjNmjAKBQKq7AwAAHDDGqLW1VQUFBQoG+x//GBZh5NixYyosLEx1NwAAwADU1dVp6tSp/d4/LMLImDFjJEVfTG5ubop7AwAAnGhpaVFhYaH9e7w/wyKMWFMzubm5hBEAAIaZZCUWFLACAICUIowAAICUIowAAICUIowAAICUch1Gdu3apbvuuksFBQUKBAL63e9+l/QxO3fuVHFxsXJycnT55Zdr48aNA+krAAC4CLkOI2fOnNGsWbP061//2lH7Q4cO6Y477tC8efNUXV2thx9+WMuXL9e2bdtcdxYAAFx8XF/aO3/+fM2fP99x+40bN2ratGlat26dJGnGjBnau3evfvGLX+juu+92++UBAMBFxvOakTfeeEMlJSVxx26//Xbt3btXHR0dfT6mra1NLS0tcTcAAHBx8jyMNDQ0KD8/P+5Yfn6+Ojs71dTU1OdjysvLlZeXZ99YCh4AgIvXkFxN03PlNWNMn8ctq1atUnNzs32rq6vzvI8AACA1PF8OftKkSWpoaIg71tjYqFAopPHjx/f5mOzsbGVnZ3vdNQAAkAY8HxmZM2eOKisr447t2LFDs2fPVmZmptdfHgAApDnXYeT06dOqqalRTU2NpOiluzU1NaqtrZUUnWJZtGiR3b60tFSHDx9WWVmZDh48qM2bN2vTpk168MEHB+cVAACQxowx+mfjaXWEI73uaz7bod/u+pfqPj6bgp6lD9fTNHv37tXNN99s/7usrEySdO+992rLli2qr6+3g4kkFRUVqaKiQitXrtSTTz6pgoICPfHEE1zWCwC46IUjRg9te0svVB3RrKl5embxZ5U3Ijor0BGO6PM/+7Na2zr14Ymz+unX/y3FvU2dgLGqSdNYS0uL8vLy1NzcrNzc3FR3BwAAR/78j+P67pa99r+tQJIdCmr5c9XaceC4JOm6aWO1femNqeqmZ5z+/va8gBUAAL9qOt0uSZo4Jlsd4Yj+fqRZt63dqayMoI6eOme3m3LJyFR1MS2wUR4AAB77VEGunl3yOU0ZO0Iftbbp6KlzGj8qS1/+1CRJUiT9Jyk8xcgIAABe6coYgUBAnyzI1Y6VN+kP++tljNGtM/JVsb9eL7/TYLfzK8IIAAAeMV0pw1ric1R2SAtmx1YVtxb/9PvICNM0AAB4xNgjI33fbx0mjAAAgJQIdqUUn2cRwggAAF6JZYy+h0aCXYcjhBEAAOCFpNM0Aaudv9MIYQQAAI/0LGDtiQLWKMIIAAAeSZYx7JqRIehLOiOMAADgseRX0wxZV9ISYQQAAI9YGSPQXwFr129hakYAAIA3ukJGfyMjXNobRRgBAMAjyTIGBaxRhBEAADzGCqyJEUYAAPCIvc5Iv4ueMU0jEUYAAPCMiaWRPgXtRc+Gpj/pijACAIBHYlfT9C1gLwfv7zRCGAEAwCPJMkaARc8kEUYAAPBcoJ8K1iBX00gijAAA4Jmk0zRdH1mBFQAAeMIkW/QsaDccmg6lKcIIAAApElv0LMUdSTHCCAAAHks+TePvNEIYAQDAI/YyI0kKWH2eRQgjAAB4xXSVsPY3MsLVNFGEEQAAPGKSXE4TYAVWSYQRAAA8k3zXXqudv9MIYQQAAI8l2yiPq2kAAIAnYgWsfd/P1TRRhBEAADyStIA1yNU0EmEEAADPJAsZQbuA1d9phDACAIDH+pumscZMqBkBAACe6r+ANfqRmhEAAOCJpBvlsQKrJMIIAACeSXo1DTUjkggjAAB4JlnEYJ2RKMIIAACe63tohBVYowgjAAB4JPmiZ4yMSIQRAAA8k3zRs6521IwAAAAvJF/0jKtpJMIIAACeY2+axAgjAAB4xIoY/S16FuBqGkmEEQAAvJN00TOrmb/TCGEEAACPJIsYAWpGJBFGAADwjH1pbz/3szdNFGEEAACPBfqZp7GvphnKzqQhwggAAB5xurIqIyMAAMATyVZgDQa5mkYijAAA4JnkG+V1tWNkBAAAeKm/dUZYgTWKMAIAgEeSb5QXRc0IAADwRLKN8liBNYowAgCAV5JulNetqY9HRwgjAAB4rN9pmm53+DiLEEYAAPCKvVFev4uexT73c90IYQQAAI9YUy/JakYkf6/COqAwsn79ehUVFSknJ0fFxcXavXt3wvbPPvusZs2apZEjR2ry5Mn6zne+oxMnTgyowwAADBf2YEe/0zSxzxkZcWHr1q1asWKFVq9ererqas2bN0/z589XbW1tn+1fe+01LVq0SIsXL9Y777yjF154QW+++aaWLFlywZ0HACCdJV/0jJoRaQBhZO3atVq8eLGWLFmiGTNmaN26dSosLNSGDRv6bP/Xv/5Vl112mZYvX66ioiJ9/vOf1/e+9z3t3bv3gjsPAMBw0P+iZ7HPCSMOtbe3q6qqSiUlJXHHS0pKtGfPnj4fM3fuXB05ckQVFRUyxuj48eP6r//6L9155539fp22tja1tLTE3QAAGG6SL3oWu4NpGoeampoUDoeVn58fdzw/P18NDQ19Pmbu3Ll69tlntXDhQmVlZWnSpEkaO3asfvWrX/X7dcrLy5WXl2ffCgsL3XQTAIC0kHzRs9jnhBGXel6iZIzp97KlAwcOaPny5frRj36kqqoqvfzyyzp06JBKS0v7ff5Vq1apubnZvtXV1Q2kmwAApFSyfBHkahpJUshN4wkTJigjI6PXKEhjY2Ov0RJLeXm5brzxRv3gBz+QJF177bUaNWqU5s2bp5/85CeaPHlyr8dkZ2crOzvbTdcAAEhb/S96FvvcRIamL+nI1chIVlaWiouLVVlZGXe8srJSc+fO7fMxZ8+eVTAY/2UyMjIk+XvpWwCAfyTbtVdimsaVsrIyPfXUU9q8ebMOHjyolStXqra21p52WbVqlRYtWmS3v+uuu7R9+3Zt2LBBH3zwgV5//XUtX75cN9xwgwoKCgbvlQAAkGbsRc/6GRmJu5pmCPqTrlxN00jSwoULdeLECa1Zs0b19fWaOXOmKioqNH36dElSfX193Joj3/72t9Xa2qpf//rX+vd//3eNHTtWt9xyi372s58N3qsAACANJVnzLK7e0s8jI67DiCQtXbpUS5cu7fO+LVu29Dq2bNkyLVu2bCBfCgCAYctJvggEou38HEbYmwYAAK/1N0+jbnUj/s0ihBEAALySbJ2R7vdFCCMAAGCwJVuBVYqNjDBNAwAABp2TeGEFFcIIAADwTH/rjEixMOLjLEIYAQDAK26maQgjAADAA8kLWKkZIYwAAOAZR+uMWG097Ul6I4wAAOARJ9M0FLASRgAA8Fwg0aJnQatmhDACAAAGmXEw+WJP0/g3ixBGAADwirtFz4agQ2mKMAIAgEecLXrG1TSEEQAAPMaiZ4kRRgAA8IizaZroR0ZGAADAoHOyay8rsBJGAADwjqtFz/ybRggjAAB4xIoXiRc942oawggAAB5LVMAa7PpNTM0IAAAYdNaqquzamxhhBAAAjzhaZ8Rq6+M0QhgBAMAjTvIFK7ASRgAA8FyijfJii575N40QRgAA8Ih9NU2CNlxNQxgBAMAzzgpY49v6EWEEAACPOIkX1IwQRgAA8I61N42jpv5NI4QRAAA8lqiAlZERwggAAJ6xN8pLVDPCCqyEEQAAvOIkX9hLxfs3ixBGAADwinFQM2JdTcPICAAA8E7CRc+oGSGMAADgEbtmJEEbVmAljAAA4Bl7msbBrr2MjAAAgEHnbNGzrraMjAAAgMEWK2BNUDPSdZ9/owhhBAAAzyWapglwNQ1hBAAA7yQvYKVmhDACAIBnHC16Rs0IYQQAAK9Y8cLJ1TQ+ziKEEQAAvJawgJWaEcIIAABeMQ7Wgw8wMkIYAQDAK/Y0TYI27E1DGAEAwDNO8gU1I4QRAAA8F0i0UV7XR0ZGAADAoHMyTWPXjHjem/RFGAEAwCNWAWviS3ujHxkZAQAAKRG7tDe1/UglwggAAB6xr+x1sOiZnytYCSMAAHgs0aJn7E1DGAEAwDNGyWtGRM0IYQQAAK+wzogzhBEAADziLIxEPzIyAgAAPJNo0TNGRggjAAB4xq4ZSdDGus/4eNkzwggAAB5xcmlvgKtpCCMAAHjFSb6gZmSAYWT9+vUqKipSTk6OiouLtXv37oTt29ratHr1ak2fPl3Z2dn6xCc+oc2bNw+owwAADBvWyEiCiRrWPJNCbh+wdetWrVixQuvXr9eNN96o3/zmN5o/f74OHDigadOm9fmYBQsW6Pjx49q0aZOuuOIKNTY2qrOz84I7DwDAcOBkBVbj4zTiOoysXbtWixcv1pIlSyRJ69at0yuvvKINGzaovLy8V/uXX35ZO3fu1AcffKBx48ZJki677LIL6zUAAMOAowJWakbcTdO0t7erqqpKJSUlccdLSkq0Z8+ePh/z0ksvafbs2fr5z3+uKVOm6KqrrtKDDz6oc+fODbzXAAAMA04GO5imcTky0tTUpHA4rPz8/Ljj+fn5amho6PMxH3zwgV577TXl5OToxRdfVFNTk5YuXaqPP/6437qRtrY2tbW12f9uaWlx000AANKClS8ST9NEP1LA6lLPxVuMMf0u6BKJRBQIBPTss8/qhhtu0B133KG1a9dqy5Yt/Y6OlJeXKy8vz74VFhYOpJsAAKQJJ4ueEUYcmTBhgjIyMnqNgjQ2NvYaLbFMnjxZU6ZMUV5enn1sxowZMsboyJEjfT5m1apVam5utm91dXVuugkAQFqwAkbCdUastt53J225CiNZWVkqLi5WZWVl3PHKykrNnTu3z8fceOONOnbsmE6fPm0fe++99xQMBjV16tQ+H5Odna3c3Ny4GwAAw409TZOgTayA1b9xxPU0TVlZmZ566ilt3rxZBw8e1MqVK1VbW6vS0lJJ0VGNRYsW2e3vuecejR8/Xt/5znd04MAB7dq1Sz/4wQ/03e9+VyNGjBi8VwIAQJpxs2uvn6+mcX1p78KFC3XixAmtWbNG9fX1mjlzpioqKjR9+nRJUn19vWpra+32o0ePVmVlpZYtW6bZs2dr/PjxWrBggX7yk58M3qsAACANxQpYky965ueREddhRJKWLl2qpUuX9nnfli1beh275pprek3tAADgF4mmaYIUjbA3DQAAnnFQwBqkZoQwAgCAVxzFC3uaxsuepDfCCAAAHrEGO5ztTTMEHUpThBEAADyWaNdeVmAljAAA4Blro7xEFaxWUGEFVgAAMOhM8ixij4z4N4oQRgAA8IyzXXu5moYwAgCAR9wteuZ9f9IVYQQAAI8lnqbhahrCCAAAHnGya69dM+LjNEIYAQAghagZIYwAAOCZ2NU0yWtGfJxFCCMAAHjN2d40Q9SZNEQYAQDAI9aiZ4kKWO1Ne308NEIYAQDAI07yhX01jcd9SWeEEQAAPGIHjETLwbM3DWEEAACv2Jf2Jpyosdp63Zv0RRgBAMBjTgpYfZxFCCMAAHjFXg4+QRumaQgjAAB4x8lGeS7aXqwIIwAAeMTJRnnBoDVN4980QhgBAMBjiWpGrLsikSHpSloijAAA4JHY1TT9CwQYGSGMAADgESfxIlbA6mlX0hphBAAAj9gb5SWcpgnEtfUjwggAAJ5LUMDK5TSEEQAAvGJvlOdoOfgh6FCaIowAAOARe5omQZvYNI1/0whhBAAAjzjJF9bIiH+jCGEEAADPJVr0zLqPaRoAAOCZRNM0VgEr0zQAAGDQ2YueOShg9XEWIYwAAOAVR4ueiRVYCSMAAHgkdjVNopqR+LZ+RBgBAMBjiadprAJW/6YRwggAAB5xMvVi5RQfZxHCCAAAXnGyN03Q3rXXvwgjAAB4xM2uvVzaCwAABp2TAtYgBayEEQAAvJZomsaqGqGAFQAAeMDFomdD0Jt0RRgBAMAjTgY77AJWH6cRwggAAB6x8kXCRc+stj5OI4QRAAA8xjRNYoQRAAA8Ym+Ul6AN0zSEEQAAPOMoX3QlFa6mAQAAg87VCqz+zSKEEQAAvBIrSnVQwOp5b9IXYQQAAI85KmD18dAIYQQAAI8kHxdhmkYijAAA4B0HAcMKKhSwAgCAQWePjCScp4lv60eEEQAAPOZsmsa/cYQwAgCAR+xFz5IPjFAzAgAABp+TfBEMBhy3vVgRRgAA8Ii96Bkb5SVEGAEAwCNGDqZp7OXgh6BDaYowAgBACllX2hgfT9QMKIysX79eRUVFysnJUXFxsXbv3u3oca+//rpCoZA+/elPD+TLAgAwrDjZm8ZeZyTieXfSluswsnXrVq1YsUKrV69WdXW15s2bp/nz56u2tjbh45qbm7Vo0SLdeuutA+4sAADDiZOxjoRrkPiE6zCydu1aLV68WEuWLNGMGTO0bt06FRYWasOGDQkf973vfU/33HOP5syZM+DOAgAwrNgjI/0HjiB707gLI+3t7aqqqlJJSUnc8ZKSEu3Zs6ffxz399NP617/+pUceecTR12lra1NLS0vcDQCA4SrR2Id1pQ0FrA41NTUpHA4rPz8/7nh+fr4aGhr6fMz777+vhx56SM8++6xCoZCjr1NeXq68vDz7VlhY6KabAACkBTdX01DA6lLP4SZjTJ9DUOFwWPfcc49+/OMf66qrrnL8/KtWrVJzc7N9q6urG0g3AQBIKSczL3YY8W8WkbOhii4TJkxQRkZGr1GQxsbGXqMlktTa2qq9e/equrpa999/vyQpEonIGKNQKKQdO3bolltu6fW47OxsZWdnu+kaAABpx94oL+GiZ0zTuBoZycrKUnFxsSorK+OOV1ZWau7cub3a5+bmav/+/aqpqbFvpaWluvrqq1VTU6PPfvazF9Z7AADSmJO9aYL2b2L/phFXIyOSVFZWpm9961uaPXu25syZo9/+9reqra1VaWmppOgUy9GjR/XMM88oGAxq5syZcY+fOHGicnJyeh0HAOBi5aSAlWkaFxYuXKgTJ05ozZo1qq+v18yZM1VRUaHp06dLkurr65OuOQIAgB/Y+cLRcvD+TSMBMwwubG5paVFeXp6am5uVm5ub6u4AAODIZQ/9QZL0t9W3auKYnD7b/LOxVbet3aWxIzNV86OSPtsMV05/f7M3DQAAHktUwGoNm0R8XMFKGAEAwGMJC1jtdUb8izACAIAHuldBJBwXCZBGCCMAAHjAaUWmvWtv+pdweoYwAgCAB7pHi8Qb5QV6tfcbwggAAB5wPk1jtfe2P+mMMAIAgMcSFbBamKYBAACDymm0CAaZpiGMAADgge4DHYk3yrPa+zeOEEYAAPCAUVwa6Rc1I4QRAAA8l3jRM6ZpCCMAAHjAOBsYYZ0REUYAAEgpaw0SH2cRwggAAF6IGxlJME/T/S6/FrESRgAA8JiTaRrJv6MjhBEAADzQ/WoaJwWs0cf4E2EEAAAPON4or1tQ8WsRK2EEAAAPxG2Ul3DRs24jI/7MIoQRAAC8ELdRXqJFz7r9JjY+naghjAAAkEIUsBJGAADwRNw0jdMCVsIIAAAYLAMpYGWaBgAADB7Hu/bG7ov4M4sQRgAA8FrCAlZWYCWMAADghbhFzxK0i5+m8SfCCAAAHnBcM9J9nZGIR51Jc4QRAAA8EH81Tf9jI0EKWAkjAAB4IW7RswTtugcVClgBAIAnEq8zEvucAlYAADBonMaKALv2EkYAAPBC90GORDUj3bFrLwAAGDRuilHtqRp/ZhHCCAAAXnIyKGKNnPg0ixBGAADwRFeycDJBY7VhmgYAAAwaN7HC2rnXp1mEMAIAgBesYOGoeLWrCSMjAABg0FgFrE6maawCVp9mEcIIAABecjYw4uzS34sVYQQAAA+4GeUIME0DAAAGmxUrnIx6UMAKAAAGnb3PjPP6VdYZAQAAg89RNQjTNAAAYLDFLu1N3pZpGgAAkFKxwOLPNEIYAQDAA7GSEecFrBF/ZhHCCAAAXrAXPXNTwEoYAQAAg83RRnkUsAIAgMHmbtEzClgBAMAgsxc9czBPE1tnxJ9phDACAIAHrEXP3EzTMDICAAAGH+uMJEUYAQDAA7G9aZJjmgYA4Fg4YrTgN2/ogeerU90VpLmBFLCyzggAIKm3jpzS3w59rP+uOZbqriDtWeuMOChgtWtG/JlGCCMA4MK5jrD9uV9/ccAZN3vTxNYZ8a4/6YwwAgAutHVG7M/9+osD7jipGQkGYlUjfkQYAQAX2rqNjHRGIglawu/cxAqWgx+A9evXq6ioSDk5OSouLtbu3bv7bbt9+3Z96Utf0qWXXqrc3FzNmTNHr7zyyoA7DACp1H1kJMzQCBKITdM4qRmhgNWVrVu3asWKFVq9erWqq6s1b948zZ8/X7W1tX2237Vrl770pS+poqJCVVVVuvnmm3XXXXepuppKdADDD2EETtkb5TloSwGrS2vXrtXixYu1ZMkSzZgxQ+vWrVNhYaE2bNjQZ/t169bphz/8oa6//npdeeWV+ulPf6orr7xSv//97y+48wAw1AgjcMvVrr2e9iR9uQoj7e3tqqqqUklJSdzxkpIS7dmzx9FzRCIRtba2aty4cf22aWtrU0tLS9wNANJB95oRwggSGdg6I/58T7kKI01NTQqHw8rPz487np+fr4aGBkfP8ctf/lJnzpzRggUL+m1TXl6uvLw8+1ZYWOimmwDgmbPthBE4E8sVyYdGgj4fGhlQAWvPYhxjjKMCneeee06PPvqotm7dqokTJ/bbbtWqVWpubrZvdXV1A+kmAAy6M+2d9udhn/4VC2fsmhFH0zT+LmANuWk8YcIEZWRk9BoFaWxs7DVa0tPWrVu1ePFivfDCC7rtttsSts3OzlZ2drabrgHAkDjXbWSkM+zT3xxwxL6axkFbu4DVp0MjrkZGsrKyVFxcrMrKyrjjlZWVmjt3br+Pe+655/Ttb39b//mf/6k777xzYD0FgDRwpo1pGrjjbAVWf+/a62pkRJLKysr0rW99S7Nnz9acOXP029/+VrW1tSotLZUUnWI5evSonnnmGUnRILJo0SI9/vjj+tznPmePqowYMUJ5eXmD+FIAwHtnmaaBB6y84tcCVtdhZOHChTpx4oTWrFmj+vp6zZw5UxUVFZo+fbokqb6+Pm7Nkd/85jfq7OzUfffdp/vuu88+fu+992rLli0X/goAYAhRwAqnYtM0DgpYu+Yp/PqOch1GJGnp0qVaunRpn/f1DBivvvrqQL4EAKSluJERwggSGEgBq1/TCHvTAIALjIzALTcFrH6dpiGMAIAL3cNIJ2EECQxk0TOfZhHCCAC4caaNaRo4Y707HG2U1+MxfkMYAQAXzjFNA4fcbHoXZJoGAOCEMSZ+BVbCCBxgnZHkCCMA4FBbZyRuuW7CCBKJTdMkb2tP0/g0jRBGAMCh7sWrEoueITE3b4+gNTLiUV/SHWEEABxq6+wRRiKRFPUEw0PXOiNOLu61lhnxaRohjACAQz03xmOjPCRir8DqYpqGAlYAQEId4fiREL/+4oA7ThY9Y5oGAOBIz0XOWPQMibh5dwTsaRp/vqcIIwDgUM+REa6mQSKxaRoHG+VxaS8AwImeNSKEESRijXK42ZvG+HSihjACAA51RhgZwQA4SSNd/HqBFmEEABzqYGQELrh5d1DACgBwpGf4oIAVidg1Iw7aUsAKAHCES3vhhlX/4WrXXp++pQgjAOAQi57BFRcjI7FpGn++pwgjAOBQzwJWRkbghLNde6Mf/fqWIowAgEM9C1ipGUEi7hY9i6YRv76lCCMA4BCX9sKNWAGri5oRpmkAAIlwaS/ciBWwJm/LNA0AwJFeBayEEQyS2HLw/nxPEUYAwKFeBayEESTgJlfEloP3J8IIADhEASvcsN4dztYZ6Spg9el7ijACAA51sugZXBjYRnn+RBgBAId6joSw6BkSiY2MJG8bsGtGvOtPOiOMAIBDPZeDD/t1i1W44iSMBLva+HW0jTACAA71HAkJ+/QXBxxyU8DqXS+GBcIIADjUwaJncMFeZ8TJomdM0wAAnOg1MkIYQQL2CqwuFj1jmgYAkJB1NU1WKPpfJ5f2wglHV9PI2rXXnwgjAOCQFT6yu8KIX9eEgDMDWfSMkREAQELWNE1OZkb034QRJGC/OxzM0wTZmwYA4IRVwGqNjFAzgkRcLXrm8+tpCCMA4FDPkRHCCBJxs+hZsOu3sV+n/ggjAOBQJyMjGABnYx4UsAIAHOjoOTLi1wl+ODKgXXt9+pYijACAQ9alvTmZjIzAia6aERcFrFxNAwBIKHZpLzUjSM5e9MxBW9YZAQA40tFjZIRLe+GEmxVYDSMjAIBErKtpGBmBE27eHUH2pgEAONERsQpYqRlBcrFpGudriBifTtQQRgDAIauAlZEROGEHC1cb5XnXn3RGGAEAh+xpGkZG4ICbAlamaQAAjljLwecwMoJBZgUWpmkAAAn1HBmxVmQF+uJuOXhGRgAADvSsGWFgBInENspLnkbskRGfphHCCAA41GEvesbICJxzMjIiloMHADgRWw6+a2SELOKpi2WUwNE0TVcjv462EUYAwKFORkaGzAPPV+uWX+7UmbbOVHdlwFxtlGc9hgJWAEAinT137SWLeKKtM6z/rjmmQ01ntOu9j1LdnQGzgoWjmhGfT9OEUt0BABgurJGQ2AqspJHB9LOX/6F9h0/qh1++2j528mxHCnt0Yex1RlxM01wsU1NuEUYAwAFjjDp67E3DRnmDJxwx2vDqvyRJj/3xH/bxD0+cSVWXLtjApmn8iWkaAHCg+wJn1shIhDAyaOo+Pmt//uaHJ+3PP/ho+IaRk2fbJUljcpL/3R+wC1j9+Z4ijACAA91HQayRkY6I8e2w+mB773hrn8eH88iI1ffLxo9K2tbvNSOEEQBwoKNbteqkvByFggG1d0Z0rPl8Cnt18Xi/8XSfx2tPnB22y+5/2BQd7Sma4CCMdE3UDM9XeuEGFEbWr1+voqIi5eTkqLi4WLt3707YfufOnSouLlZOTo4uv/xybdy4cUCdBYBUsa6kkaTR2SFdPWmMJOmtulMp6tHFpefIyFX5oyVJ7eGIDg/T0ZFDTdF+OwkjQXtkxJ9xxHUY2bp1q1asWKHVq1erurpa8+bN0/z581VbW9tn+0OHDumOO+7QvHnzVF1drYcffljLly/Xtm3bLrjzAGKoX/DWu12/LMdkh5QRDOjaqXmSpL8faU5lty4KxhgdONYiSfrVNz6j1/7fzXplxU363OXjJEl//kdjKrs3IOc7wjrWfE6SdJmTkRGfT9O4vppm7dq1Wrx4sZYsWSJJWrdunV555RVt2LBB5eXlvdpv3LhR06ZN07p16yRJM2bM0N69e/WLX/xCd99994X1Pg2c7wjr/eOnFTHxS9VY6Tb+mP1Z3DFjfex6juix6B0mrp2xnyMYCCiUEVBmRkAZwaCCgeixQNfH6eNHamRWSE2n23T05Dl1hCNqD0dkTPRNH1CsbV+XnUUiRuGIsefJrXYBRQutrM+DwUDXsa7jPZ6n589V/A+aSXBf/GtP9j3t/t3v+dqCASkjGFRGIKBgMHa/9X1QV/+7P1fsa8aeu3v/+mzbo489z7fpeT77aGu6nfSe5773c8Re9ZGPz+oXO97TFRNHa9Gc6bp0THbc9yL6edfHHue8+zmLfy/E/hEx0fdDpOs9ETaxz40xyggGFQpG35MZwYAyM4LKCAYUCgbU0Hxe/3uwUSfOtOuuWZM19ZKRXe/b6C36vNHLZiPGqDMcff6xI7I0bfxI/b3ulIyJnsdg12OCgYCCgWi/IiZaXBox0eeJmOjjjTGaMnakrp40RvXN5/TWkWZlZQSVHQoqKxRUdihDGcGAfQ67/4xZ5zJ2bow2vXZIkvSVWZMlSddOHavn/lanbfuO6GufKbBfR7jre2XdpGjfA93ej1LAPhYM9H5PBpP8mRgKBnTZ+FE6WN+qpjNtau+MqK0zovbOiDKCUigYVGZGQKFgUKGM6PcrIxj9GiOzQvq3KXnKCMbOb2c4ov1Hm9URNsoIKta+2/tnTE5I0xPUPrR3RnSwvkWdkej/M5Gu97j10Sh2vowx9vc7EpHea2zV+42nlR0Kas4nxmvC6Oj7945/m6y/fvCx/rvmmD53+XgFAwF1RiLqCBt1hCPqDBt1RKIfO8MRXZk/RldMHG336Z+NrXr/+Onoe7Hr/8pQ13vI+hg9u9IVE0crNycz8Te+h8aW8/q4q0i1p7qPz8mY6Pdt/KispM/l9wJWV2Gkvb1dVVVVeuihh+KOl5SUaM+ePX0+5o033lBJSUncsdtvv12bNm1SR0eHMjN7n/y2tja1tbXZ/25paXHTTce2VR3R28cG/leNMVLlgeM6eurcIPZqcFwyMlOfLRqv//3HcftyRFz8qg6fVNXhk8kbpsifDh4f8q859xPjta/2pM53DM6aIP/3+mmSZI+MfNTapi+vSzxVnW5mFY7VddPG2v9+88OP9fbR5P/PfvHqS/udcnj13Y/saYmB+t5Nl9tBRJJu/9QkPfLSO9p/tFlf+dVrSR8fDEh3XzdVo3NCOn2+U9v2HXG8vHreiEz9n08XxIW0RJpOt+t/3jqWdCSjaMIoO2gkYjWprj2lH//+HUd9GGx3XzdVM6fkpeRruwojTU1NCofDys/Pjzuen5+vhoaGPh/T0NDQZ/vOzk41NTVp8uTJvR5TXl6uH//4x266NiA73/tIL/392AU/T25OSGO6Jeq4vzrj/gIN9Hk8aI0odB95UPwIRuxx0fsipusvg66/vky3vw7PdYR18myHXn4nek4m5eYoJzOoUEZ0BCXuL78+fpCMoj/UoWCw27bWJu4vR3skqNvn1vGeP3c9x0v6+6vcen3xj43/JNBHu9ixrv730adwt5Ge7iMW3Uc1Auret75GFOJHUvrsSyB52+6vqedzdO9DX+e+V3+6/pERCGhW4ViFgkHtqz2ps+2dcefZeo3dR9vU7T77fvt4t89lukaVon9NWn81h4LRY8FAwP7+Wu9JewQlYhTKCOiWaybKGOn1fzaprTOiTqudMfbz2beur3Wo6YzCEaNLx2Rr/Kis2OhMt/e61d4aCYv+9W+NmkgH61u0518nJEmXTxilkdkZau8aQWjrjPah589a3Ohfj+Nzr5hgh5BPTs7Vt+depj//o1Fn2jr7/f5Y3+fu5yPSbVRA6nq/xr1vrXdk3063deh8R0RZGUFdmT9a2V0jPZmhoCLdzkNnODqKEPu6RkdOntPf607p7z1qXUZlZSg/N8c+b9Zf6NZ74aPTbXr13Y/06rv9r4g6JiekcaOyoqOm9vcwYI/cquu4NULZfcRo6iUjVfrFT8Q9X35ujr7/hU/of96qV3tnRGFjlBkMKJQRHfHJ7Br5CWUE1dEZ0YH6Fr1QdSTuOT5VkKvsUND++e/+0SpKPtce1okz7XrmjcP9vrb+TBidpf7OVSgY0Dc/O83R81i/Q95vPN1vMa/XPjPtkuERRiw9f2EYYxImv77a93XcsmrVKpWVldn/bmlpUWFh4UC6mtCXPpmvwnEjLug5JozO1sLrCzUyK33Wj+sIR7R93xHVfnxWs6eP0xevvtRRMgfSydtHm7X3w4+14AJ+vl7/Z5P2/KtJ08eP0t3XTXX8V68TgUBAj371U3r0q58atOd06uMz7dq+74huuupSXZU/xtVjj506p+37juhcR9g+NjIrpLuvm6pJeTn9Pu7dhlZV7K/vdz+esSOytPCGQtdTHcn88MvX6IdfviZpO2OMdhw4rreOnLKPXTt1rEo+mZ/0/7+OcETbqo6o7uTZhO26Cyigm666VDcUjXP8mES+/pkpOt8e1qlzfU/7DIUru01xDbWAcVG6297erpEjR+qFF17Q17/+dfv4Aw88oJqaGu3cubPXY2666SZ95jOf0eOPP24fe/HFF7VgwQKdPXu2z2manlpaWpSXl6fm5mbl5uY67S4AAEghp7+/XV1Nk5WVpeLiYlVWVsYdr6ys1Ny5c/t8zJw5c3q137Fjh2bPnu0oiAAAgIub60t7y8rK9NRTT2nz5s06ePCgVq5cqdraWpWWlkqKTrEsWrTIbl9aWqrDhw+rrKxMBw8e1ObNm7Vp0yY9+OCDg/cqAADAsOV6InbhwoU6ceKE1qxZo/r6es2cOVMVFRWaPn26JKm+vj5uzZGioiJVVFRo5cqVevLJJ1VQUKAnnnjiorisFwAAXDhXNSOpQs0IAADDjyc1IwAAAIONMAIAAFKKMAIAAFKKMAIAAFKKMAIAAFKKMAIAAFKKMAIAAFKKMAIAAFKKMAIAAFIqffa9T8BaJLalpSXFPQEAAE5Zv7eTLfY+LMJIa2urJKmwsDDFPQEAAG61trYqLy+v3/uHxd40kUhEx44d05gxYxQIBAbteVtaWlRYWKi6ujr2vEkxzkV64XykD85F+uBcuGeMUWtrqwoKChQM9l8ZMixGRoLBoKZOnerZ8+fm5vLGShOci/TC+UgfnIv0wblwJ9GIiIUCVgAAkFKEEQAAkFK+DiPZ2dl65JFHlJ2dnequ+B7nIr1wPtIH5yJ9cC68MywKWAEAwMXL1yMjAAAg9QgjAAAgpQgjAAAgpQgjAAAgpXwdRtavX6+ioiLl5OSouLhYu3fvTnWXLjq7du3SXXfdpYKCAgUCAf3ud7+Lu98Yo0cffVQFBQUaMWKEvvjFL+qdd96Ja9PW1qZly5ZpwoQJGjVqlL761a/qyJEjQ/gqhr/y8nJdf/31GjNmjCZOnKivfe1revfdd+PacC6GxoYNG3TttdfaC2fNmTNHf/zjH+37OQ+pU15erkAgoBUrVtjHOB9DxPjU888/bzIzM81//Md/mAMHDpgHHnjAjBo1yhw+fDjVXbuoVFRUmNWrV5tt27YZSebFF1+Mu/+xxx4zY8aMMdu2bTP79+83CxcuNJMnTzYtLS12m9LSUjNlyhRTWVlp9u3bZ26++WYza9Ys09nZOcSvZvi6/fbbzdNPP23efvttU1NTY+68804zbdo0c/r0absN52JovPTSS+YPf/iDeffdd827775rHn74YZOZmWnefvttYwznIVX+9re/mcsuu8xce+215oEHHrCPcz6Ghm/DyA033GBKS0vjjl1zzTXmoYceSlGPLn49w0gkEjGTJk0yjz32mH3s/PnzJi8vz2zcuNEYY8ypU6dMZmamef755+02R48eNcFg0Lz88stD1veLTWNjo5Fkdu7caYzhXKTaJZdcYp566inOQ4q0traaK6+80lRWVpovfOELdhjhfAwdX07TtLe3q6qqSiUlJXHHS0pKtGfPnhT1yn8OHTqkhoaGuPOQnZ2tL3zhC/Z5qKqqUkdHR1ybgoICzZw5k3N1AZqbmyVJ48aNk8S5SJVwOKznn39eZ86c0Zw5czgPKXLffffpzjvv1G233RZ3nPMxdIbFRnmDrampSeFwWPn5+XHH8/Pz1dDQkKJe+Y/1ve7rPBw+fNhuk5WVpUsuuaRXG87VwBhjVFZWps9//vOaOXOmJM7FUNu/f7/mzJmj8+fPa/To0XrxxRf1yU9+0v7lxXkYOs8//7z27dunN998s9d9/FwMHV+GEUsgEIj7tzGm1zF4byDngXM1cPfff7/eeustvfbaa73u41wMjauvvlo1NTU6deqUtm3bpnvvvVc7d+607+c8DI26ujo98MAD2rFjh3Jycvptx/nwni+naSZMmKCMjIxeqbWxsbFXAoZ3Jk2aJEkJz8OkSZPU3t6ukydP9tsGzi1btkwvvfSS/vKXv2jq1Kn2cc7F0MrKytIVV1yh2bNnq7y8XLNmzdLjjz/OeRhiVVVVamxsVHFxsUKhkEKhkHbu3KknnnhCoVDI/n5yPrznyzCSlZWl4uJiVVZWxh2vrKzU3LlzU9Qr/ykqKtKkSZPizkN7e7t27txpn4fi4mJlZmbGtamvr9fbb7/NuXLBGKP7779f27dv15///GcVFRXF3c+5SC1jjNra2jgPQ+zWW2/V/v37VVNTY99mz56tb37zm6qpqdHll1/O+RgqqambTT3r0t5NmzaZAwcOmBUrVphRo0aZDz/8MNVdu6i0traa6upqU11dbSSZtWvXmurqavsS6scee8zk5eWZ7du3m/3795tvfOMbfV42N3XqVPOnP/3J7Nu3z9xyyy1cNufS97//fZOXl2deffVVU19fb9/Onj1rt+FcDI1Vq1aZXbt2mUOHDpm33nrLPPzwwyYYDJodO3YYYzgPqdb9ahpjOB9DxbdhxBhjnnzySTN9+nSTlZVlrrvuOvsyRwyev/zlL0ZSr9u9995rjIleOvfII4+YSZMmmezsbHPTTTeZ/fv3xz3HuXPnzP3332/GjRtnRowYYb7yla+Y2traFLya4auvcyDJPP3003YbzsXQ+O53v2v/v3PppZeaW2+91Q4ixnAeUq1nGOF8DI2AMcakZkwGAADApzUjAAAgfRBGAABAShFGAABAShFGAABAShFGAABAShFGAABAShFGAABAShFGAABAShFGAABAShFGAABAShFGAABAShFGAABASv1/mvbc4SbC9sQAAAAASUVORK5CYII=", "text/plain": [ "
" ] @@ -1046,7 +1035,7 @@ "outputs": [ { "data": { - "image/png": "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QxDqHPWlvdKZm5796U9/UmFhodLS0vTnP/9ZN9xwgyTpzjvv1L59+7R8+fLG5VesWKF77rlH27ZtU58+fXT//fc3hhcAABBsMYURAAAAq/DbNAAAwFGEEQAA4CjCCAAAcBRhBAAAOCrQYWTGjBnq37+/unbtqvT0dK1a1fpn291o6tSpSkpKavbXq1evxueNMZo6dar69Omjc845R9/5zne0bdu2Zu9RVVWlu+++Wz169NC5556rH/3oRzpw4ECzZY4dO6YRI0YoJSVFKSkpGjFihI4fP25HFbVy5UoNGzZMffr0UVJSkt58881mz9tZx/z8fA0bNkznnnuuevTooQkTJqi6utr2Ot95552ttvt1113n2TpnZmbqm9/8prp166aePXvq1ltv1c6dO5st47ftHEmd/badZ86cqSuvvLLx7qEZGRl69913G5/32zaOpM5+28aWaO/H8fzqtddeM507dzYvvPCC2b59u/nv//5vc+6555r9+/c7XbR2TZkyxXzta18zhYWFjX/FxcWNzz/22GOmW7duZv78+WbLli1m+PDhpnfv3qa8vLxxmbFjx5qLL77YZGVlmQ0bNpgbb7zRXHXVVaa2trZxmR/84AcmLS3NZGdnm+zsbJOWlmZuueUWW+q4aNEi8+CDD5r58+cbSeaNN95o9rxddaytrTVpaWnmxhtvNBs2bDBZWVmmT58+Zvz48bbXedSoUeYHP/hBs+1eWlrabBkv1fn73/++eemll8zWrVvNxo0bzdChQ82ll15qTpw40biM37ZzJHX223ZeuHCheeedd8zOnTvNzp07zQMPPGA6d+5stm7daozx3zaOpM5+28ZWCGwYueaaa8zYsWObPfaVr3zF/OY3v3GoRJGbMmWKueqqq0I+V19fb3r16mUee+yxxscqKytNSkqKefbZZ40xxhw/ftx07tzZvPbaa43LHDx40HTo0MG89957xhhjtm/fbiSZdevWNS6zdu1aI8ns2LEjAbUKr2XHbGcdFy1aZDp06GAOHjzYuMyrr75qkpOTTVlZWULqa0zrOhtzpgH7j//4j7Cv8Xqdi4uLjSSzYsUKY0wwtnPLOhvj/+1sjDEXXHCBmTVrViC2cYOGOhsTjG0crUCepqmurlZOTo4GDx7c7PHBgwcrOzvboVJFZ9euXerTp4/69++v//qv/9KePXskSXv37lVRUVGzuiUnJ+vb3/52Y91ycnJUU1PTbJk+ffooLS2tcZm1a9cqJSVF1157beMy1113nVJSUhxfR3bWce3atUpLS2v2i5Pf//73VVVVpZycnITWM5Tly5erZ8+euuyyy/SLX/xCxcXFjc95vc5lZWWSpAsvvFBSMLZzyzo38Ot2rqur02uvvaaTJ08qIyMjENu4ZZ0b+HUbxyqmX+31upKSEtXV1bX6cb/U1NRWP+rnRtdee61efvllXXbZZTp8+LAeeeQRDRw4UNu2bWssf6i67d+/X5JUVFSkLl266IILLmi1TMPri4qK1LNnz1af3bNnT8fXkZ11LCoqavU5F1xwgbp06WL7ehgyZIj+8z//U/369dPevXv1u9/9TjfddJNycnKUnJzs6TobYzRp0iR961vfUlpaWmM5GsrflF+2c6g6S/7czlu2bFFGRoYqKyv1uc99Tm+88Ya++tWvNnaaftzG4eos+XMbxyuQYaRBUlJSs38bY1o95kZDhgxp/P8rrrhCGRkZ+uIXv6i//vWvjZOgYqlby2VCLe+mdWRXHd2yHoYPH974/2lpaRowYID69eund955Rz/+8Y/Dvs4LdR4/frw2b96s1atXt3rOr9s5XJ39uJ2//OUva+PGjTp+/Ljmz5+vUaNGacWKFWHL4YdtHK7OX/3qV325jeMVyNM0PXr0UMeOHVslw+Li4lYp0gvOPfdcXXHFFdq1a1fjVTVt1a1Xr16qrq7WsWPH2lzm8OHDrT7ryJEjjq8jO+vYq1evVp9z7Ngx1dTUOL4eevfurX79+mnXrl2SvFvnu+++WwsXLtSyZct0ySWXND7u5+0crs6h+GE7d+nSRV/60pc0YMAAZWZm6qqrrtKTTz7p620crs6h+GEbxyuQYaRLly5KT09XVlZWs8ezsrI0cOBAh0oVu6qqKuXl5al3797q37+/evXq1axu1dXVWrFiRWPd0tPT1blz52bLFBYWauvWrY3LZGRkqKysTB999FHjMh9++KHKysocX0d21jEjI0Nbt25VYWFh4zJLlixRcnKy0tPTE1rP9pSWlqqgoEC9e/eW5L06G2M0fvx4LViwQB988IH69+/f7Hk/buf26hyK17dzKMYYVVVV+XIbh9NQ51D8uI2jZsMkWVdquLT3xRdfNNu3bzcTJ0405557rtm3b5/TRWvXvffea5YvX2727Nlj1q1bZ2655RbTrVu3xrI/9thjJiUlxSxYsMBs2bLF/OQnPwl5qdwll1xili5dajZs2GBuuummkJeNXXnllWbt2rVm7dq15oorrrDt0t6KigqTm5trcnNzjSQzbdo0k5ub23jptV11bLg07uabbzYbNmwwS5cuNZdccklCLo1rq84VFRXm3nvvNdnZ2Wbv3r1m2bJlJiMjw1x88cWerfMvf/lLk5KSYpYvX97sEsdTp041LuO37dxenf24nSdPnmxWrlxp9u7dazZv3mweeOAB06FDB7NkyRJjjP+2cXt19uM2tkJgw4gxxjzzzDOmX79+pkuXLuYb3/hGs8vr3KzhOvzOnTubPn36mB//+Mdm27Ztjc/X19ebKVOmmF69epnk5GRzww03mC1btjR7j9OnT5vx48ebCy+80JxzzjnmlltuMfn5+c2WKS0tNT/96U9Nt27dTLdu3cxPf/pTc+zYMTuqaJYtW2YktfobNWqU7XXcv3+/GTp0qDnnnHPMhRdeaMaPH28qKyttrfOpU6fM4MGDzUUXXWQ6d+5sLr30UjNq1KhW9fFSnUPVVZJ56aWXGpfx23Zur85+3M4///nPG9vZiy66yNx8882NQcQY/23j9ursx21shSRjjLFvHAYAAKC5QM4ZAQAA7kEYAQAAjiKMAAAARxFGAACAowgjAADAUYQRAADgKMIIAABwFGEEAAA4ijACAAAcRRgBAACOIowAAABH/T8N5CwyCqzeawAAAABJRU5ErkJggg==\n", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAiMAAAGiCAYAAAA1LsZRAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8qNh9FAAAACXBIWXMAAA9hAAAPYQGoP6dpAAA9bElEQVR4nO3de3hU1aH//0+4BY/FqCABFCltj61t1NpQNVhs1ZaWIj3+6u/IqX0AW+hXSpGDaL8WbQtaj7H1SNEqeEGktqjYgooVhaDcAyoh3AOCXBIgISRAEi65r+8fmJDLTDKXPfv6fj1PfGRmz8xae+9Z67PXXntPkjHGCAAAwCEdnC4AAAAINsIIAABwFGEEAAA4ijACAAAcRRgBAACOIowAAABHEUYAAICjCCMAAMBRhBEAAOAowggAAHBU1GFk5cqVGjZsmPr06aOkpCS9+eab7b5mxYoVSk9PV9euXfWFL3xBzz77bCxlBQAAPhR1GDl58qSuuuoqPf300xEtv3fvXv3whz/UoEGDlJubqwceeEATJkzQ/Pnzoy4sAADwn6R4figvKSlJb7zxhm699dawy9x///1auHCh8vLyGh8bO3asNm3apLVr18b60QAAwCc6JfoD1q5dq8GDBzd77Pvf/75efPFF1dTUqHPnzq1eU1VVpaqqqsZ/19fX6+jRo+revbuSkpISXWQAAGABY4wqKirUp08fdegQ/mRMwsNIUVGRUlNTmz2Wmpqq2tpalZSUqHfv3q1ek5mZqYceeijRRQMAADYoKCjQJZdcEvb5hIcRSa1GMxrODIUb5Zg8ebImTZrU+O+ysjJdeumlKigo0HnnnZe4ggIAAMuUl5erb9++6tatW5vLJTyM9OrVS0VFRc0eKy4uVqdOndS9e/eQr0lOTlZycnKrx8877zzCCAAAHtPeFIuE32ckIyNDWVlZzR5bsmSJBgwYEHK+CAAACJaow8iJEye0ceNGbdy4UdKZS3c3btyo/Px8SWdOsYwcObJx+bFjx2r//v2aNGmS8vLyNHv2bL344ou67777rKkBgJgt2lKonP3HnC6GrxWWnVZ1bb3TxQBcLeowsn79el199dW6+uqrJUmTJk3S1Vdfrd///veSpMLCwsZgIkn9+/fXokWLtHz5cn3961/XH/7wBz311FO67bbbLKoCgFjsKCrXuLkbdNvMbKeLEpWjJ6sVxx0JbLXtUJkyMj/Q7c/ZexuD4vJK1dd7Yx0BUgxzRr7zne+02RDMmTOn1WPf/va3tWHDhmg/CkAC5ZeecroIUVu0pVDj5m7QnQM/r6k/+prTxWnX/JyDkqSNBcdt+8zlO4t150sf6wdf66VnR6Tb9rlAPPhtGgCe8eiiMzdPnJO9z9mCRKiDA7dFen7lHknSe9uK2lkScA/CCICw9peeVHF5pdPFAOBzhBH4wp4jJ1RT579JggVHT2nG8t0qO11j+2cfO1mtbz++XNc8+r7tn+0X5ZX2bzfAi2y56RmQSP/afEjjX8nV9V/qrrljrnO6OJb60dOrdexUjfIKK/SXn1xt62fvKTlp6+f5zUd7j+r19QecLgbgCYyMwPP++tn8gTW7S50tSAIcO3XmyHrtpyUOlwTR+ssHu5wuAuAZhBEAjjhdXaefvfSR5n643+miAHAYYQSAI/66dp+W7TyiB9/Y6nRRADiMMALAERVM7gTwGcII4IDD5ZXKzec27IAb1dbV6z+eXq2Jr+U6Vob9pSf1zLLdgbkiizACOODaR9/X/zcjW1sOlDldFAAtrN9/TJsOlOnNjYccK8OQJ1fp8cU7NXXhNsfKYCfCCOCgj/cddboIAFqod8FvH52qrpN05hLxICCMIGFq6+r13IpPtfnAcaeLAgBwMcIIEuaVj/KV+e4O/ejpNU4XBQ7bXXxCs1btUWVNnWXvmbkoTzc/sVwnqmote08AzuAOrEiYvMIKp4sAl/jutBWSpBNVtZr43cssec/nPvtBuNc+yteYQV+w5D0BOIOREQC2yc0/bvl7uuD0PoA4EUYAAICjCCMA4COMFMGLCCOAj0xf+onu+tt61dXTIwHwDsKIR3x65ISqa+udLgZcbvrSXVq87bBW7+ZXfgF4B2HEAxZtKdTNT6zQiBc/dLoo8IgqCy+hBYBEI4x4wMtr90mSPgzInfjQmhPzAJKS7P9MAMFEGAEAAI4ijAAAAEcRRgAAgKMIIwAAwFGEEQAA4CjCCCBpb8lJ7Szih/0AwAn8ai88L97LXo0xuvF/l0uSNk0ZrJRzOsdfKABAxBgZQeA1vXP6kYpK5wqCdnHvE8CfCCOAg4L2CzJN65skkgXciX3TfoQRAADgKMIIEoYhdQBAJAgj8DxCD3CWCdzJP/gBYQQAADiKMAIAABxFGAECKonzWwBcgjDiAVxmhkQw7dwtzit7HXMk4Gfx3tTRKwgjgAcEpD0CEFCEEQAA4CjCCADPCMqQNRA0hBEAAOAowggAAHAUYQQAADiKMAIAABxFGAF8iHmeQOy4d439CCOAg7xyYzGrtHejNQDBRBhB4DUNBPSVAGA/wggAAC4Vy09I1dcb/TPngHYXn7C+QAnSyekCAEHGQAysxuge3t58SPf9Y5Mkad9jQx0uTWQYGQEAoAmv/zhpbv5xp4sQNcIIEsaurzNHggDgbYQRD4jlnCHgduzXABoQRgCElERaAGATwgiAkLgnCAC7EEaAAKmsqSNkAHAdwggQELuLK/SV372ne1/f5HRRAKAZwggQo7kf7tdLa/Y6XYyIzVp1pqwLcg86XJLYMY0F8CduegbEoKq2Tg++sVWSNOyqPurxuWQZY5j0CQAxYGQEiEFd/dl5F5U1dXpm2W5983+WquDoKQdLBQDeRBiB57lhMOLxxTtVcqJaj723w+miBA7zcQHvI4wAgI+4IZwD0SKMAAAARxFGAACAo2IKIzNmzFD//v3VtWtXpaena9WqVW0uP3fuXF111VX6t3/7N/Xu3Vs/+9nPVFpaGlOBAfgDcz2A9gXlexJ1GJk3b54mTpyoBx98ULm5uRo0aJCGDBmi/Pz8kMuvXr1aI0eO1OjRo7Vt2zb94x//0Mcff6wxY8bEXXjAagH53gOAq0QdRqZNm6bRo0drzJgxuvzyyzV9+nT17dtXM2fODLn8unXr9PnPf14TJkxQ//799a1vfUt33XWX1q9fH3fhAQDNBeVIGv4SVRiprq5WTk6OBg8e3OzxwYMHKzs7O+RrBg4cqAMHDmjRokUyxujw4cP65z//qaFDh4b9nKqqKpWXlzf7AwAA/hRVGCkpKVFdXZ1SU1ObPZ6amqqioqKQrxk4cKDmzp2r4cOHq0uXLurVq5fOP/98/eUvfwn7OZmZmUpJSWn869u3bzTF9B0u1QMA+FlME1hb3vK6rdtgb9++XRMmTNDvf/975eTk6L333tPevXs1duzYsO8/efJklZWVNf4VFBTEUkw4jBDVPjf/gi63tkdQsevbL6rfpunRo4c6duzYahSkuLi41WhJg8zMTF1//fX69a9/LUm68sorde6552rQoEF65JFH1Lt371avSU5OVnJycjRFAxBQdByA90U1MtKlSxelp6crKyur2eNZWVkaOHBgyNecOnVKHTo0/5iOHTtKcvdRIQAAsEfUp2kmTZqkWbNmafbs2crLy9M999yj/Pz8xtMukydP1siRIxuXHzZsmBYsWKCZM2dqz549WrNmjSZMmKBrrrlGffr0sa4mgEPsyNTRBndyPgAvieo0jSQNHz5cpaWlevjhh1VYWKi0tDQtWrRI/fr1kyQVFhY2u+fInXfeqYqKCj399NO69957df755+umm27SH//4R+tqAdgsSWHODbg4BLQ8ncGcEABuEXUYkaRx48Zp3LhxIZ+bM2dOq8fuvvtu3X333bF8FACfIgsBaMBv08DzOCUBAN5GGAF8KJJRByaQA3ALwogH0GfYhzMHALzOiwcahBFExRij3cUVqqmrd7ooAACfIIygXcYYLdpSqPzSU1qw4aC+O22lRv+VHzoEAFgjpqtpECzvbS3SuLkbJElpF58nSVr5yREniwQACePBsxyex8gI2vXxvmNOFwGQRCcB+BVhBHAQNx6D1chr8CLCiAfQXwHhMVoCeB9hBIFnwvw/AMAehBEAnsEoIeBPhBEAIdHvA84LSgAnjCBhwv6yrQ+EayAMJ3oAIGqEEXiel48cvHjbZgCwGmEE8AAiC2AfLx/geBVhBAgMWlgA7kQYAQDApWI5k+vFkVTCCAAAcBRhBIBtmK8LIBTCCAAAcBRhBAAAOIowAgAAHEUYAeAILjQGQjtdXed0EWxHGEFU3HiLdyZFtsY6Abxp8bYiXf7795wuhu0IIx7gxgCAs+j44Srsj572639scroIjiCMAAAARxFGAAsxSgLAaV4cSyeMAE0QJs7ix8IA2IUwAgAAHEUYAQAAjiKMAAiJU1aAN3nxq0sYQcIw58Bd2B4A3IowAgSEX0c6fFotIFAIIx5j/NqjAIBLMIhoP8KIx9zw+DLl7D+mQX/6QAs2HHC6OPCwJAfO2xjGMQCEQBjxmIKjp3XbzGwVHD2tSa8H87bBbtO0T2deBgBEjzACAAAcRRhBuzjaBwAkEmEEiBNziu3DL1jD75yYy+UGhBEgoLgyC4BbEEY8IKBBOWJuWj/073AaVyzFjzVoP8IIouKmjh8A4A+EEQAA4CjCCICQGAUDYBfCCBKG+RNoE2kHSAgvtr2EEbTLizs2EFRc/gwvIowATXAlgo1iSLlsH8CfCCPwPEZuIsNZEQBuRRgBPIDA5T1sMyByhBEkDEfiTqInBOAdhBFExe9He0z+Syy/7z9NEcaByBFGAHgafT7gfYQRIE5c4QEA8SGMADEINwQfpNMQgF8x2mY/wkiC1dUblZ6ocroYAAC4FmEkwX7y/DqlP7JUWw+WOV0UwJcYjAK8jzCSYB/tOypJ+sf6AodLAjiPK0wSjzlM8CLCCOBL9PoAvIMwgqhwZAsA7ubF0THCSIROVtXKWHCpxKnqWu0uPmFBiQD7WLHvA0A4hJEI7DlyQl+bsli/eDkn7ve6+YkV+u60Ffr4s7kkXlZ6okoPvrFFWw4wOdePuBstALsQRiLw93X5kqSleYfjfq/CskpJ0rtbiuJ+L6c98MYWzf0wX8OeXu10UQAAHkYYQcx2HeZ0EwAgfjGFkRkzZqh///7q2rWr0tPTtWrVqjaXr6qq0oMPPqh+/fopOTlZX/ziFzV79uyYCgz/qq6t1/t5h1VRWeN0UXyJky4A3KpTtC+YN2+eJk6cqBkzZuj666/Xc889pyFDhmj79u269NJLQ77m9ttv1+HDh/Xiiy/qS1/6koqLi1VbWxt34eEv/7tkp55fuUff/PwF+sfYgbZ9btPJmV6chQ4AXhd1GJk2bZpGjx6tMWPGSJKmT5+uxYsXa+bMmcrMzGy1/HvvvacVK1Zoz549uvDCCyVJn//85+MrNXzp9c9uDPfxvmMOl8R9uJoFgJ9FdZqmurpaOTk5Gjx4cLPHBw8erOzs7JCvWbhwoQYMGKA//elPuvjii3XZZZfpvvvu0+nTp8N+TlVVlcrLy5v9AW5FTohcs3UVw01ruMIHfhfUezlFNTJSUlKiuro6paamNns8NTVVRUWhrw7Zs2ePVq9era5du+qNN95QSUmJxo0bp6NHj4adN5KZmamHHnoomqIBrsBpHgCIXkwTWJNaRDdjTKvHGtTX1yspKUlz587VNddcox/+8IeaNm2a5syZE3Z0ZPLkySorK2v8Kyjgd128yK6AH9QjCQDwi6hGRnr06KGOHTu2GgUpLi5uNVrSoHfv3rr44ouVkpLS+Njll18uY4wOHDigf//3f2/1muTkZCUnJ0dTNCQQnT0AIJGiGhnp0qWL0tPTlZWV1ezxrKwsDRwY+uqH66+/XocOHdKJE2fvSfHJJ5+oQ4cOuuSSS2IoMgArhBvNtMKeIyd0ooor5gBEJurTNJMmTdKsWbM0e/Zs5eXl6Z577lF+fr7Gjh0r6cwplpEjRzYuf8cdd6h79+762c9+pu3bt2vlypX69a9/rZ///Oc655xzrKuJxzC3wDpMIHWXbYfKdNMTKzQw832niwIEkhfbxKgv7R0+fLhKS0v18MMPq7CwUGlpaVq0aJH69esnSSosLFR+fn7j8p/73OeUlZWlu+++WwMGDFD37t11++2365FHHrGuFoBHebHRaM+yHcWSpPJKRkYARCbqMCJJ48aN07hx40I+N2fOnFaPfeUrX2l1agcAYD0/Bly7JfIUJkLjt2kAAICjCCMA2sXRNoKEOx7bjzCCqCSF/QfcJf7GlJFqAHYhjAAOcrLD5+gP8CcvHkgQRgB4GpkK8D7CiE1oL9vnwTDvKV48WgIQDIQRIE4ETQCID2EEsFCiThk4cd8DK+pyuLxSlTV1lr4nAP+J6aZniB+NMqLh1cmm1z76vrqf28XpYgBwOUZGgCY82udHxKm6lZ6sduaDAXgGYQTt8nMHHSvWCQBYhzASAX5hF21pOpuDK1YAOM2LB0uEEQ/w6o82ebXciE8itzsHBoA/EUYikMQdMAAASBjCCABHEPEBNCCMAICPcCILXkQYQcw4sgUAWIEwAqBdHG0DSCTCCOAgN1+Cx8VQCCquBLQfYQSu4dUGwMV5wne4sg3wJ8IIouPRwAD7ePV3dAA3CGoLSxixSVB3sKBxcz9MjgTgVoQRRMfNvS0AwJMII0ATjB4kFrdzBxAKYQTwqeraem0/VM4cDgCuRxgBfOquv63XD59apb+t22/r53r1qijAL7x4+EEYQbvoW5wXS+OybOcRSdKcNfssLQsAWI0wAtfgdAJiwTwUwPsIIzaJp7lkYMI+fslDfqkHgGAgjABwBHkJQAPCCGLGREUAgBUIIwAAwFGEEQAAXCKopy8JI3ANr5728epVQNGsbzfX0a2/5OviVQa4DmEEsBD9D4B4uDNaJx5hBAAAOIowEgFuqgQAQOIQRhAdj87rCLKGKO22uRXuKg0AJxFGIuC2RhzOKz1Z7XQRAMA3CCNADO5+ZYPTRQCAkLx4JRdhxCFuvlQS7duQf9zpIrSJs2kAvIQw4gHEFiQCgRiAWxBGAACAowgjHsCIOxgeA+BnhBHEzC0hiX4aALyNMBJAWw+W6bF3d+hEVa3TRQEAQJ2cLgDsd8tfVkuSTlbV6g+3pjlcGuc1HVlhTicA2I+RkQDbUVTudBGacctpn2iRX+zDJcuAPxFGAAsxsgKnccm2tyUFNHETRpAwdn2ngvnVjRydEwC3I4ygXfRlYBcAkEiEEZvQocNrvDJabIhKgOcRRgB4BqEe8CfCCBAQXhnpABA8hBEAAOAowgiiwsG1N7j1dIZbywXAWYSRAIu3Y6BfAQBYgTCCdjHXIHG4EgRwH9o8+xFGAC+Io3EM6h0dAXgHYcQm8fQHbu1LXFoshBHtablEz+9ob7+uratPbAEAuAZhJAIMpcNxceyCXrwd/G/f3KKvTVmsg8dPO10UwFZe/L5agTAC13DrCFA0/FAHN/j7unxV1dbrxVV7nS4KABsQRhwSzOzrfwE9qAFgkaDO8YopjMyYMUP9+/dX165dlZ6erlWrVkX0ujVr1qhTp076+te/HsvHOiaJ2RG2oCMHgGCKOozMmzdPEydO1IMPPqjc3FwNGjRIQ4YMUX5+fpuvKysr08iRI3XzzTfHXFgAQNvI9PCiqMPItGnTNHr0aI0ZM0aXX365pk+frr59+2rmzJltvu6uu+7SHXfcoYyMjHY/o6qqSuXl5c3+ADswWRkA7BdVGKmurlZOTo4GDx7c7PHBgwcrOzs77Oteeuklffrpp5oyZUpEn5OZmamUlJTGv759+0ZTTAAWi+YUWkBPeQOIQ1RhpKSkRHV1dUpNTW32eGpqqoqKikK+ZteuXfrNb36juXPnqlOnThF9zuTJk1VWVtb4V1BQEE0xEaFEjwHYNdeGsYzEiDVUMPcHcJr3voSRpYMWWs72NcaEnAFcV1enO+64Qw899JAuu+yyiN8/OTlZycnJsRQNCea9XRxexmkzIBiiCiM9evRQx44dW42CFBcXtxotkaSKigqtX79eubm5Gj9+vCSpvr5exhh16tRJS5Ys0U033RRH8QFEquXxQlAvIQTgPlGdpunSpYvS09OVlZXV7PGsrCwNHDiw1fLnnXeetmzZoo0bNzb+jR07Vl/+8pe1ceNGXXvttfGVHgAAeF7Up2kmTZqkESNGaMCAAcrIyNDzzz+v/Px8jR07VtKZ+R4HDx7Uyy+/rA4dOigtLa3Z63v27KmuXbu2ejxoSk9WO10EhMA9ZRKLky4AQok6jAwfPlylpaV6+OGHVVhYqLS0NC1atEj9+vWTJBUWFrZ7zxFI72wu1DN3OF0KBAEBAIgOk7DtF9ME1nHjxmncuHEhn5szZ06br506daqmTp0ay8fCBZqOGzDlIH6MxAAAv01jm3iSNt0VEB5HsYD3EUbgGl4daaEztI9X9xEAbSOMIGHsukeEu/qn6OrMfTQANOWu9sw+hJEAMxzSe0ZQtxS7KJzACJz9CCMAALhUUA4aCSMALMVRJYBoEUaAgArKERcA9yOMAD7khZjBPVYSg4wJL+4DMd30DN5kjNG2Q+VOFwMexFU/ABKJkZEAmb1mn275y2qni+FqdLpnMXIBcDrTLoSRAHlpzV6niwAHuKktPVxeGdXyTIYFgoEwEgGOlmPDkbW7uGFrHI3y16rdFKQAJA5hBIAjCPkAGhBGEDNGPhC1z/IH5+GB0Fp+M5ICcq6Sq2ki4JdOt2X7T3cAJxypqNKtz6zRweOnnS4KAJdgZATtsi+KeTX0nY11bjzgd9uB1bMrPrU0iLitfkA8gro7E0Y8wOlhuqb9Kw0/2tNeIKurd2FiA+Aowgg8j64NOIsDBngRYQSAZ7jxNBiA+BFGAFiKI/MzuHQZiBxhBPAALoUF4GeEEQBIAL/cEgDe48VjF8II4KBENRpNu0EvNkwAgoUw4gFWDdEz1O9vz6741OkiOILdujnWR/wY07IfYSTAaLT85bF3dzhdBACICWEEQLsIrgASiTASYO67BJMez03ct38A8CvCCGJGZ+UtTv+sQCyY5wQEA2EkQGjWQ2va38XS99FfAv7F99sehBG4iPeO3Fui3UosDw7uAFEJ6j5OGEHC2PWlCuh3FwB8gzACBJQX55AAQROUeVOEEQ+wqtMIyD4Nh3EbdMBZXjzOIIwAPmSa/b93UyijN3CCd78x3kUYQVToGqwVaV9LpwzAzwgjAcZpG7hdUM6XA1by4teGMBIBLw9zw15uHr+ItmN3qkE7dPy0Mx8MwDGEkQAJFapOVtWqpq7egdIgGkEZITh2sloDH/vA6WIAsBlhJMBOVtXqa1MW61t/pPGHO+wqPuF0EWCB2rp6zf1wv3YXVzhdFHhEJ6cL4AV+vVSxoeE/XF7lcEm8LZ65pcdO1VhXEJdo67RmMMZ3nOWGdfzqxwX63ZtbJUn7HhvqcGngBYyMICpuaOhacmOZIjVz+acqOHrK6WIgCpU1dcreXcLpzTbk5h9zugjwGMII4LCleYedLgKiMP6VDbpj1of647s7nC4KfCgg08NaIYwAcfJr48GtTUJbmlcsSXp57X6HS4JEYde3H2EEruGHzs/NucSLN05z8/oEEsGDX1NLEEaAJpxoCJxqe5y4XDgolygDiA5hBIBreOmokJshAtYhjNjEDQ1XrAelHuof4saBe/z8eik8gokmwR6EEQct3X5Y2btL2l2Opt3fvDiXI1ZBqiuAyHHTMweNeXm9JP/eFIhuJzL0z/AbRscQLUZGACAGnNJDIlixX7lhWkC0CCMAAMBRhJEA8V5WDgYGtMNj9ME5xhgVV1SGfO5EVa0qa+psLlEwBWWeFWHEBbx074VgfC1sFpDGBt7y2Ls7dM3/vK+5Hza/02xlTZ3SpizWFVMXO1Qy+BFhBIBtvBS82+OfmoT23Mo9kqSH397e7PF9pSclSTV1fl8DsBNhBHBYJOMiQW32391aqBNVtU4XA0CCEUYCxEcHpQiIkhPVmvBqrtPFAJBghBEXcHtIcHnxHBfv+mHKSNs+2FHsdBG8xe0NChACYQSuEWuf7Ka+PJI5EZnv5jX7d6JvEGXJfQtc0r95bVsDiAxhBLBRZU2dnluxx+liRMSNIzZ0/wiaoIRewghgo6fe3+V0ERIunhDjwvwDwAaEkQgk+ta69uXeYCRsN/t439FWjyViBMKtB1MuLRba0HKbNT2tGJSjdiQeYcQm/HAU4C90w4B1CCNwjaA27sRUwF3cOF/K72IKIzNmzFD//v3VtWtXpaena9WqVWGXXbBggb73ve/poosu0nnnnaeMjAwtXsxthKPBF8Pf/LZ9GbmH3/Zpr/HidzDqMDJv3jxNnDhRDz74oHJzczVo0CANGTJE+fn5IZdfuXKlvve972nRokXKycnRjTfeqGHDhik3lxsZwRpe+t5xug4AWusU7QumTZum0aNHa8yYMZKk6dOna/HixZo5c6YyMzNbLT99+vRm/3700Uf11ltv6e2339bVV18d8jOqqqpUVVXV+O/y8vJoi2mpxN8HwsiOwXovpmUEi5eOqPk+BYNd7XPj59n2Se4S1chIdXW1cnJyNHjw4GaPDx48WNnZ2RG9R319vSoqKnThhReGXSYzM1MpKSmNf3379o2mmICnBGm0pL2a0sEDwRRVGCkpKVFdXZ1SU1ObPZ6amqqioqKI3uOJJ57QyZMndfvtt4ddZvLkySorK2v8KygoiKaYcAkrj3Jr6+r1yeEK719KGGqdBCeLBPaoz9Pa2Ghe/zrCPaI+TSNJSS16GWNMq8dCefXVVzV16lS99dZb6tmzZ9jlkpOTlZycHEvR4FOTXt+khZsO6bdDL9eYQV9wuji+EMl3FggiQpb9ohoZ6dGjhzp27NhqFKS4uLjVaElL8+bN0+jRo/X666/ru9/9bvQl9TG79nsrPqdpB2ZnZ7Zw0yFJ0rMrPrXtM/2ChtU+2w6V6TuPL9M7mwudLgrgKVGFkS5duig9PV1ZWVnNHs/KytLAgQPDvu7VV1/VnXfeqVdeeUVDhw6NraQel+i7uPqBV4/Tm3b2sWxlr9Ybrf1q7gbtKz2lX72ywemiWIcdFDaI+jTNpEmTNGLECA0YMEAZGRl6/vnnlZ+fr7Fjx0o6M9/j4MGDevnllyWdCSIjR47Uk08+qeuuu65xVOWcc85RSkqKhVUB3C/Wdp3RDW84XVPndBFcgfwSu6Cuu6jDyPDhw1VaWqqHH35YhYWFSktL06JFi9SvXz9JUmFhYbN7jjz33HOqra3Vr371K/3qV79qfHzUqFGaM2dO/DVAoPixU2buxlmsivi54SvihjLAW2KawDpu3DiNGzcu5HMtA8by5ctj+YhA8WMHi8j5rf8lUPgb2zexgtod8Ns0ANrFnKfI+PKeMW1d2mtfKeBzhBHARk4eVUb72b7sWAG4EmEEnuLHI7EgDXtzSjLxArQ7wUcIIy7g9iFwGrfESnQYCVLYaVBbV6+SE1XtL2ixIK5ruI+7e5TQCCOeQAvnF3ad+gh6p/jjmdka8MhS7SyqcKwMbj/IANyEMOIJ1jRqnv9dF/mjDi0xN6Mpa9bF5gNlkqQ3Nx605P0QHfZoRIswAvict/KbpwrbCp0wEBvCiAt4q7MAECRtnW7y40ilxGlOJxBGABuFauScavj82pE0FYAqwmeC8L0MhTDiQnX1RsXllZa/rx928UTUoemRX0DbAViEW/v7jxebBC/uhYQRFxr91491zaPvK3t3idNFaZPVO7xX23HCTOTi3cYe3UUAtIMwEgG7L9FbvvOIJGlO9j5bP9dqdh0ler2DimQ9eTWoOY3La+E1LduDoOzBhBHAQm4JDaFGaLwxauOSFYi4uOV7AO8gjLhYtH3HkYoq/eFf27W72LkbPSVayE7W/mIEAqMKkFrfB4ecgUQgjPjIvf/YpBdX79UPn1wd8nlvHBkHTySNO9sOkXLDrsL+ap2ghD/CiAtY9cXdfOC4JKm6rt6aN/S4tzYe1JS3tqq+PrEtYzR3UHXTpb1AJNq8z4iN5YC/dXK6AF7g1O26ObqIz3+/tlGSdE3/7hp6ZW9nC9OGispap4vQLvbFyBAsES/uMwL41NFT1U4XoU1zP9xvy+dE21H64jdzbG7XA9qPwGW8uBsSRgIkqIk7mh7CiSPb+gCdVQvqLgigbYQRm9AIQwo92sDQ/lmxrAs3hewgbMsg1BH2I4y4QPuXUPLtbxBLx+NkV5X9aYmeWbY74ZNo/cLqXMFat14k24jAgmgRRnwkUd9/GvTY3fHCh3p88U69s6Uw7DI03AiyT4+c0A+mr9S/Nh9yuihwEGHE1aKLAYSG0Nwwip9/9JQkgoffb6Tmx+3b1vfHiu/Wva9v0o6iCo1/JTf+N4NnEUYCxN/dgHf54qoVIEYnq9x/aTsSjzDiAm44co+F1UeBkXTKsayqRE9wjPdXe91yNF1ZU6f/f2a2pi/9xOmihBXt6nXT5FYA4RFGbBLbVQLWl8PN/DyE31an6JIsogUbDmr9/mOavnSXY2VwSzCLFaNcQGwIIz5CMxiafyOOtapr6yx5Hzfthy1/jh3xY5UiEQgjgA3aHOVySevux46b0zSIjf++C25HGHEB25pLP7TLMdQhmv7Iib4rSM2e309j+DDPxcRP25k8aw/CCBLGP81RYlnRgVlxUzU6UkTLv/O8/Fov9yKM+Ahfn9ASvV5ORPGru4k6FbJwU/gbRoU7VdGyLH7MIhzVIoi8uN8TRlzMjfuTHzuseN0x68N2l2lzykgEn9HeEei+0pMRvAsSzQ3fDzd0RP4dMUGiEEZcwKpJdm5oCBPNjU1cda1Pfna3jVEbu9Z7EPZhNOfG77STgro+CCMuxpUA1nDDemwoQqjO1opTN5FMGIx2UmEizijZfcTs/Jb3IyIjrEcYCRKb2xA6gtZCrRMrNosVwYEuBpHhmw3rEUY8wOmrHNzUSblhlCMWTp5Dj3Tkxar9LJ6aenPrnuXHe7XEwk+X9sIehJEIJLojCffuVn+q3c2DV4OD3YI0qsEu4T1tbTK2J6xCGEHC2NVQtRd63NRghpwz4pIo4ZZyxMKK+6zAGd7d62Alwghi5sSv9rodXWJihdpDnnx/l656aIn2lnB5M/wnKG0KYSRA7D6fbdvloO3Uyw33PGj7t2nif/+Wq8DROSo2v7amzqiiqlaPL94Rxycjct4/aHCzoK5dwogL2HUawe65dW45PfLooh2aufxTp4sRllsaH6/PvQy1v7llHwQiFdRdljASAT+cPnCCG0YkGvzxPWePmhvWRKgO35IJrF5PEhZwQ/BgKwCxIYzYJJaG0urG9fipGmvfEJ4Q6VVNdKQAnEIY8ZG2jo5r6+y/ZXkijlRdcPBrObeMvDXdfcpO+yO4uml0LkiiGahjC0EijLiDDd/GagfCSKRKT1RpR1G508WwRejbwdtejHZ9vPeo00WImiuChwu3Zby4X5D3uOK7EKVOThcA4Xlvd4pN+iNLnS5C4rXRoEcSRipr2g6T1tw47eybeHHfo88EvIuRER9p6wjGiVMBiTii8mOH45bTNE0l6mi4vdDERFz3c9MmWvHJEd36zBrtOlzhdFEQJ8IIYubHYJCo4U03rqpWgaNJJ+PG8lqpvLJGd770kRZsOGDp+7qhn3bDEL1dbcOo2R9pY8Fx/XLuBovfuekoofPrMwgII4gKE9Pi4+Yj/6Yla9mZOD1v4I3c9kNDNCV8ZtluLd95RJNe3xR7oQKi5T7rxoOQYyernS4C4kQYcQGrkrebOzrpzNHoik+OOHJlj9PaasCtnu9hzeeYhBzlt9eRhQs998yLLTSE+7xyn1wthDNcmI9i5sawZwfCCBKm5ZfqjhfWadTsj/Tcyj2Njx0ur4zuPX3V7FgnlkDTck26Pcy2J7pGPDF19fo69JvKmjrd/8/NWrKtyOmioB2EERdrOEq0onmzs42s+Wzko2Vw2HrwzOW7Tc/TX/vo+/YVzAaxnM5wQwd2urquzdM0VklslIz83SNd5WWnarTtUFmM5fG/cPuJ/T89Ebogc7L3ad76Av2fv+XYWyBEjTCCmIVqcGat2qN/f/BdrdtTGvZ1HRzqfJ2e9yCFuc9Igt43GnM/3G9BKbwj0vX1rT9+oKFPrdbH+7x33xWvsGL/D/fNLiqLbuRVOhNAtxe6575Hzh+q2IMwkiDGGM1evTfCZRNcmAQJVe5H3smTJP36n5vC1su5MOLIx5757ASPCRwur2rjs8M83uSJypq6iF4TiXg2r12jRJF+TEVVrSTp/bziyN431gJ5iF1f34WbDun19QURLWvlCM31f/xAv3tza/QvtIgLBkodwU3PEuSdLYV6+F/bnS5G3E5V16owynkd0pnGIVyHFs+XrWWjs+twhXL2H4vstbF/bEJZ0fjMXhNZ8A2nvtXVM3G9nSO8WGYvSHTfGGqz1dbVa8KruZKk73z5IvXs1rXt9wiz8WO5h8+JzwIo7EUYSZBPivxxE55rH31fFZXRfzmNCd85WDky8r0/rwz5eF6IYdYzDZazozIhf7XX3qKEVG9Ms7J5caJwNCV2443mcFbTbVlRWaue3SJfvqmgjjJ4EadpEqTOJ4dpsQQRqe35GR1s2Ovun7+51WORnK5oakP+MeWXnrKuUK5ztuLGNG+4fbL7huXFTqq4vFIlJ8KfjvMrv++L7XHDBHc7EEYSJJpbafjxu2aa/LclO+aMhGrAQj32zLLd+srv3mv1+N6Sk/rxjGzd8PgyS8rz1sZDlrxPorQMj/tKTuq2mdm2fX5dvVFdy3NFUSo/XaNZq/ZEdLl4ovbASHbt46eqo55Mfbq6Ttc8+r4GPLJU9XGup2g53xdGUN9wc0asLYgtghq+OE2TIPU+3aOatoNtVTFRp2kiXauhPiLUqYfHF+8M+fodFs+mP3j8dNjnEn3kUxjBFQVnTmCdLccTWZ9E/P4VlTWqN1LKOZ3PvFcMu/53p61QvTH68/CvR//iz6zff0zr9x/TKx/lt7tstOs80tNWnxw+0ebzH+w4rJ/PWa8R1/XTH25Ni/jzj1ScHRGprqtX1w4dI35tvNr8nodZL/Hu0ibCduZsOUJzPkg5xIPdDyMjCRLvUZ4U2ZewvNLeO0lGWq+2wlgHixqI7N0lYZ9zqg3aWHBcv39rq8qiuMOnU2XdU3Ky8f9jzc719UZXTF2iqx5aoqraM1fkxPJWe0tOan/pKR09Ef9tvfccaVqv0KVpKKuVdkYwT+yP754Jv39bF92l1B07nt1LrGhb4mH3/hpPdb1yisPyu1J7o9rNEEYiEMtkPrtGRh5YsKXx/9va/6wqT6QNoVHzDu72Z9c2/r9Vp2numPVh+CdDfEY0qyDWtVVRWauX1+7XA29s0d2fXQ3QnkS0l5FM0PzTe2dHhVpOYI3U6SaXBDccvcezq7271do7ZYbbXZftOGLp50hnRoiaqq5t3cHU1jd/bMuBMj2zbHfIZZvq2GTj2D0fzYn+vGmbG0n7G/5qGveqrKnT7uITOlJRpasfztL//ad1v5PU2Y6JeRbzXokdEMt3P5rzuvHcjKut+x98euSEbvjTMs37ON+y85C1kYYR07wR+ajJTaMiaUxfWrM35CV27TXaDSJphEojnAy4YMOBqG969c7mQr29KbJ5IkvzinX7c2sTOheg5X1EWrLyo+O5Eme+xb+iWxPmiLNjtMNzbVRp4mu5GvrUqlb75sf7Wl9y3nQ93/v6Jg17erUeX7xTL6/d1+bHNy1ue/uJnVcK/WO9tdurgVWnaRLJGKNth8oibpNa+q/n1+m701Zo9F8/VkVVrV63cF02HUnzCsLIZ/6+br/ufX2T6uqNjDHK2X8s5FD7HS+s0x0vrGv3/SpCdKQvNPlNlpaMMSosaz6vYPXuEp2qrm2zo2jaqLZc7HdvblX+0VO6f/4WC0dGIv3imbCNSG7+cf32zS1tDk0+9PZ2TV24LfoC6swR+saC461L1KI86Y8sDfseTZed9Pom/eeza/XWxoMJGd6XpI/2HtW+0pOqqz9zs7zth+Kbs9LyaPbKh5boZBv3TzAyEf/yqTFGr36Ur7zC8mb7nBuHxDuECR1Rh5E2vLnxkLYdKteaT8OfNmzQdGSxafBqb3s3rUd7o5MWVq2Ve1/f1OzW6lNi/I5GI662K4Hr4u/r9mvoU6v1y7+3f6t5Y0yrU+oNbdTmA5H93EBboefN3INa3OT3dzo32Ql+/9ZWvbByj3735lZX3IU6nJjCyIwZM9S/f3917dpV6enpWrVqVZvLr1ixQunp6eratau+8IUv6Nlnn42psIlSW1ev3765VfM3HNB7W4u0eFuRbpuZraFPnalX0y9D9qelyv60VLf8ZZXe3VKowrLTqqo9M9zW1LpPm98Ofe6H+fqfRXlhy5D57g5lZH6gv2bva/b4/JwDbX4Z641RZU2djp+qbrWzNj0qjGQXLK6o1OnqM51t2emakJMum46M7Cg624D+7+KdWrbj7ChNvZGOtDHy8Pd1+Vqw4WCb5Vm+M/SoT3vnV296YnnIxxuO2GP9Qv73axv19Ae74zq/29ZH1xvp1Y/y9fC/tuuHT7X+Tu0uPqFnlu3WqeozoaKypk7FYa4caTnqU11br9c+Dn83S2OkmSs+Dfv86l1nO9q3Nxdq8oItGvLkqpDr0k3tXaiOubKmTvlHQ1+y3TSwNaxnKbLvT21d20vtLKoIGyQavuPvbC7U7NV7tbZF+9EhitM0oULhql1H9L1pK/TR3qOqqzcqO13T7mhZU/M+zteCDQc0f8OBVm1dqH1g5Sfhg9nbmw5p68HIf+/n/vmb271C6lR1XchyNB0lMsao7FT4uVw1dfWa+Fpkp1YlafaafZKk93e0f3feCa9t1JVTl0R0kBHqgGfOmr267Lfv6smlu1o9d6SiShPnbdRdf8tpHDXr2OQ0zctr9+t/FuXpb+v2hzxIc4skE2XLPG/ePI0YMUIzZszQ9ddfr+eee06zZs3S9u3bdemll7Zafu/evUpLS9MvfvEL3XXXXVqzZo3GjRunV199VbfddltEn1leXq6UlBSVlZXpvPPOi6a47frZSx9p2c7w5497dktWcUXwru13i32PDdWWA2Ua9vRqp4sCOOLa/hfqw72J/22ci88/R4/cmqY52fs0amA//XzO+oR/5tRhX9UTSz5pHEm+69tf0HMrwo8g/3bo5Xp0UZ6lpxUj9bnkTo2njtvaJt/7aqqeH5GulbtKNGr2R3YWMWa//M4Xdf8PvpKQ9460/446jFx77bX6xje+oZkzZzY+dvnll+vWW29VZmZmq+Xvv/9+LVy4UHl5Z0cFxo4dq02bNmnt2rWtlpekqqoqVVWdDQBlZWW69NJLVVBQYGkYmbxgs97eVGjZ+wEA4FUvjBygjC92t/Q9y8vL1bdvXx0/flwpKSnhFzRRqKqqMh07djQLFixo9viECRPMDTfcEPI1gwYNMhMmTGj22IIFC0ynTp1MdXV1yNdMmTLF6LMLMvjjjz/++OOPP2//FRQUtJkvorrpWUlJierq6pSamtrs8dTUVBUVhb4kr6ioKOTytbW1KikpUe/evVu9ZvLkyZo0aVLjv+vr63X06FF1797d0klyDYnN6hEXN6PO1NmvqDN19isv19kYo4qKCvXp06fN5WK6A2vLQGCMaTMkhFo+1OMNkpOTlZyc3Oyx888/P4aSRua8887z3AaOF3UOBuocDNQ5GLxa5zZPz3wmqqtpevTooY4dO7YaBSkuLm41+tGgV69eIZfv1KmTune39twUAADwnqjCSJcuXZSenq6srKxmj2dlZWngwIEhX5ORkdFq+SVLlmjAgAHq3LlzlMUFAAB+E/V9RiZNmqRZs2Zp9uzZysvL0z333KP8/HyNHTtW0pn5HiNHjmxcfuzYsdq/f78mTZqkvLw8zZ49Wy+++KLuu+8+62oRo+TkZE2ZMqXVKSE/o87BQJ2DgToHQxDqHPWlvdKZm5796U9/UmFhodLS0vTnP/9ZN9xwgyTpzjvv1L59+7R8+fLG5VesWKF77rlH27ZtU58+fXT//fc3hhcAABBsMYURAAAAq/DbNAAAwFGEEQAA4CjCCAAAcBRhBAAAOCrQYWTGjBnq37+/unbtqvT0dK1a1fpn291o6tSpSkpKavbXq1evxueNMZo6dar69Omjc845R9/5zne0bdu2Zu9RVVWlu+++Wz169NC5556rH/3oRzpw4ECzZY4dO6YRI0YoJSVFKSkpGjFihI4fP25HFbVy5UoNGzZMffr0UVJSkt58881mz9tZx/z8fA0bNkznnnuuevTooQkTJqi6utr2Ot95552ttvt1113n2TpnZmbqm9/8prp166aePXvq1ltv1c6dO5st47ftHEmd/badZ86cqSuvvLLx7qEZGRl69913G5/32zaOpM5+28aWaO/H8fzqtddeM507dzYvvPCC2b59u/nv//5vc+6555r9+/c7XbR2TZkyxXzta18zhYWFjX/FxcWNzz/22GOmW7duZv78+WbLli1m+PDhpnfv3qa8vLxxmbFjx5qLL77YZGVlmQ0bNpgbb7zRXHXVVaa2trZxmR/84AcmLS3NZGdnm+zsbJOWlmZuueUWW+q4aNEi8+CDD5r58+cbSeaNN95o9rxddaytrTVpaWnmxhtvNBs2bDBZWVmmT58+Zvz48bbXedSoUeYHP/hBs+1eWlrabBkv1fn73/++eemll8zWrVvNxo0bzdChQ82ll15qTpw40biM37ZzJHX223ZeuHCheeedd8zOnTvNzp07zQMPPGA6d+5stm7daozx3zaOpM5+28ZWCGwYueaaa8zYsWObPfaVr3zF/OY3v3GoRJGbMmWKueqqq0I+V19fb3r16mUee+yxxscqKytNSkqKefbZZ40xxhw/ftx07tzZvPbaa43LHDx40HTo0MG89957xhhjtm/fbiSZdevWNS6zdu1aI8ns2LEjAbUKr2XHbGcdFy1aZDp06GAOHjzYuMyrr75qkpOTTVlZWULqa0zrOhtzpgH7j//4j7Cv8Xqdi4uLjSSzYsUKY0wwtnPLOhvj/+1sjDEXXHCBmTVrViC2cYOGOhsTjG0crUCepqmurlZOTo4GDx7c7PHBgwcrOzvboVJFZ9euXerTp4/69++v//qv/9KePXskSXv37lVRUVGzuiUnJ+vb3/52Y91ycnJUU1PTbJk+ffooLS2tcZm1a9cqJSVF1157beMy1113nVJSUhxfR3bWce3atUpLS2v2i5Pf//73VVVVpZycnITWM5Tly5erZ8+euuyyy/SLX/xCxcXFjc95vc5lZWWSpAsvvFBSMLZzyzo38Ot2rqur02uvvaaTJ08qIyMjENu4ZZ0b+HUbxyqmX+31upKSEtXV1bX6cb/U1NRWP+rnRtdee61efvllXXbZZTp8+LAeeeQRDRw4UNu2bWssf6i67d+/X5JUVFSkLl266IILLmi1TMPri4qK1LNnz1af3bNnT8fXkZ11LCoqavU5F1xwgbp06WL7ehgyZIj+8z//U/369dPevXv1u9/9TjfddJNycnKUnJzs6TobYzRp0iR961vfUlpaWmM5GsrflF+2c6g6S/7czlu2bFFGRoYqKyv1uc99Tm+88Ya++tWvNnaaftzG4eos+XMbxyuQYaRBUlJSs38bY1o95kZDhgxp/P8rrrhCGRkZ+uIXv6i//vWvjZOgYqlby2VCLe+mdWRXHd2yHoYPH974/2lpaRowYID69eund955Rz/+8Y/Dvs4LdR4/frw2b96s1atXt3rOr9s5XJ39uJ2//OUva+PGjTp+/Ljmz5+vUaNGacWKFWHL4YdtHK7OX/3qV325jeMVyNM0PXr0UMeOHVslw+Li4lYp0gvOPfdcXXHFFdq1a1fjVTVt1a1Xr16qrq7WsWPH2lzm8OHDrT7ryJEjjq8jO+vYq1evVp9z7Ngx1dTUOL4eevfurX79+mnXrl2SvFvnu+++WwsXLtSyZct0ySWXND7u5+0crs6h+GE7d+nSRV/60pc0YMAAZWZm6qqrrtKTTz7p620crs6h+GEbxyuQYaRLly5KT09XVlZWs8ezsrI0cOBAh0oVu6qqKuXl5al3797q37+/evXq1axu1dXVWrFiRWPd0tPT1blz52bLFBYWauvWrY3LZGRkqKysTB999FHjMh9++KHKysocX0d21jEjI0Nbt25VYWFh4zJLlixRcnKy0tPTE1rP9pSWlqqgoEC9e/eW5L06G2M0fvx4LViwQB988IH69+/f7Hk/buf26hyK17dzKMYYVVVV+XIbh9NQ51D8uI2jZsMkWVdquLT3xRdfNNu3bzcTJ0405557rtm3b5/TRWvXvffea5YvX2727Nlj1q1bZ2655RbTrVu3xrI/9thjJiUlxSxYsMBs2bLF/OQnPwl5qdwll1xili5dajZs2GBuuummkJeNXXnllWbt2rVm7dq15oorrrDt0t6KigqTm5trcnNzjSQzbdo0k5ub23jptV11bLg07uabbzYbNmwwS5cuNZdccklCLo1rq84VFRXm3nvvNdnZ2Wbv3r1m2bJlJiMjw1x88cWerfMvf/lLk5KSYpYvX97sEsdTp041LuO37dxenf24nSdPnmxWrlxp9u7dazZv3mweeOAB06FDB7NkyRJjjP+2cXt19uM2tkJgw4gxxjzzzDOmX79+pkuXLuYb3/hGs8vr3KzhOvzOnTubPn36mB//+Mdm27Ztjc/X19ebKVOmmF69epnk5GRzww03mC1btjR7j9OnT5vx48ebCy+80JxzzjnmlltuMfn5+c2WKS0tNT/96U9Nt27dTLdu3cxPf/pTc+zYMTuqaJYtW2YktfobNWqU7XXcv3+/GTp0qDnnnHPMhRdeaMaPH28qKyttrfOpU6fM4MGDzUUXXWQ6d+5sLr30UjNq1KhW9fFSnUPVVZJ56aWXGpfx23Zur85+3M4///nPG9vZiy66yNx8882NQcQY/23j9ursx21shSRjjLFvHAYAAKC5QM4ZAQAA7kEYAQAAjiKMAAAARxFGAACAowgjAADAUYQRAADgKMIIAABwFGEEAAA4ijACAAAcRRgBAACOIowAAABH/T8N5CwyCqzeawAAAABJRU5ErkJggg==", "text/plain": [ "
" ] @@ -1279,7 +1268,7 @@ "outputs": [ { "data": { - "image/png": "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3ilFesGpp56qCy+8ULt37/Y/VdNV3QYOHKiGhgYdOXKky2EOHjzYaV6HDh2yvoziWceBAwd2ms+RI0fU2NhofTkMGjRIQ4cO1e7duyV5t87333+/3n33XX300Uc699xz/Z8n8noOVudAEmE99+3bV1/4whc0fPhwZWdn6+KLL9bvf//7hF7HweocSCKs4+5KyjDSt29fZWRkKCcnp93nOTk5GjFihKVSRa++vl4FBQUaNGiQhg0bpoEDB7arW0NDg5YtW+avW0ZGhvr06dNumNLSUm3dutU/TGZmpnw+n9atW+cfZu3atfL5fNaXUTzrmJmZqa1bt6q0tNQ/zOLFi5WSkqKMjIyY1jOUyspKlZSUaNCgQZK8V2djjMaNG6f58+frww8/1LBhw9p9n4jrOVSdA/H6eg7EGKP6+vqEXMfBtNY5kERcxxGLw02yrtT6aO+MGTPM9u3bzQMPPGBOPfVUs2/fPttFC+mhhx4yS5cuNXv37jVr1qwxN998sznttNP8ZX/mmWdMamqqmT9/vtmyZYv5/ve/H/BRuXPPPdcsWbLEbNy40Vx33XUBHxu76KKLTG5ursnNzTUXXnhh3B7trampMfn5+SY/P99IMpMnTzb5+fn+R6/jVcfWR+Ouv/56s3HjRrNkyRJz7rnnxuTRuK7qXFNTYx566CGzevVqU1hYaD766COTmZlpzjnnHM/W+cc//rFJTU01S5cubfeIY21trX+YRFvPoeqciOt54sSJZvny5aawsNB8/PHH5tFHHzU9e/Y0ixcvNsYk3joOVedEXMdOSNowYowxL730khk6dKjp27evueSSS9o9Xudmrc/h9+nTxwwePNh8+9vfNtu2bfN/39LSYiZNmmQGDhxoUlJSzNVXX222bNnSbhrHjx8348aNM2eccYY55ZRTzM0332yKi4vbDVNZWWl+8IMfmNNOO82cdtpp5gc/+IE5cuRIPKpoPvroIyOp09/YsWPjXseioiJz0003mVNOOcWcccYZZty4caauri6uda6trTUjR440Z511lunTp48577zzzNixYzvVx0t1DlRXSea1117zD5No6zlUnRNxPd91113+dvass84y119/vT+IGJN46zhUnRNxHTuhhzHGxK8fBgAAoL2kvGcEAAC4B2EEAABYRRgBAABWEUYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVv1/syesQD3AFYkAAAAASUVORK5CYII=\n", + "image/png": "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3ilFesGpp56qCy+8ULt37/Y/VdNV3QYOHKiGhgYdOXKky2EOHjzYaV6HDh2yvoziWceBAwd2ms+RI0fU2NhofTkMGjRIQ4cO1e7duyV5t87333+/3n33XX300Uc699xz/Z8n8noOVudAEmE99+3bV1/4whc0fPhwZWdn6+KLL9bvf//7hF7HweocSCKs4+5KyjDSt29fZWRkKCcnp93nOTk5GjFihKVSRa++vl4FBQUaNGiQhg0bpoEDB7arW0NDg5YtW+avW0ZGhvr06dNumNLSUm3dutU/TGZmpnw+n9atW+cfZu3atfL5fNaXUTzrmJmZqa1bt6q0tNQ/zOLFi5WSkqKMjIyY1jOUyspKlZSUaNCgQZK8V2djjMaNG6f58+frww8/1LBhw9p9n4jrOVSdA/H6eg7EGKP6+vqEXMfBtNY5kERcxxGLw02yrtT6aO+MGTPM9u3bzQMPPGBOPfVUs2/fPttFC+mhhx4yS5cuNXv37jVr1qwxN998sznttNP8ZX/mmWdMamqqmT9/vtmyZYv5/ve/H/BRuXPPPdcsWbLEbNy40Vx33XUBHxu76KKLTG5ursnNzTUXXnhh3B7trampMfn5+SY/P99IMpMnTzb5+fn+R6/jVcfWR+Ouv/56s3HjRrNkyRJz7rnnxuTRuK7qXFNTYx566CGzevVqU1hYaD766COTmZlpzjnnHM/W+cc//rFJTU01S5cubfeIY21trX+YRFvPoeqciOt54sSJZvny5aawsNB8/PHH5tFHHzU9e/Y0ixcvNsYk3joOVedEXMdOSNowYowxL730khk6dKjp27evueSSS9o9Xudmrc/h9+nTxwwePNh8+9vfNtu2bfN/39LSYiZNmmQGDhxoUlJSzNVXX222bNnSbhrHjx8348aNM2eccYY55ZRTzM0332yKi4vbDVNZWWl+8IMfmNNOO82cdtpp5gc/+IE5cuRIPKpoPvroIyOp09/YsWPjXseioiJz0003mVNOOcWcccYZZty4caauri6uda6trTUjR440Z511lunTp48577zzzNixYzvVx0t1DlRXSea1117zD5No6zlUnRNxPd91113+dvass84y119/vT+IGJN46zhUnRNxHTuhhzHGxK8fBgAAoL2kvGcEAAC4B2EEAABYRRgBAABWEUYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVv1/syesQD3AFYkAAAAASUVORK5CYII=", "text/plain": [ "
" ] From 79b0e8498341e5eaa2fc820785f4ef4f31db5447 Mon Sep 17 00:00:00 2001 From: Andrei Solodovnikov Date: Fri, 20 Oct 2023 10:02:59 +0300 Subject: [PATCH 065/103] Fix typo in README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cc0bf07..f93e2d4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ openWakeWord is an open-source wakeword library that can be used to create voice # Demo -You can try an online demo of the included pre-trained models via HuggingFace Spaces [right here!](https://huggingface.co/spaces/davidscripka/openWakeWord). +You can try an online demo of the included pre-trained models via HuggingFace Spaces [right here](https://huggingface.co/spaces/davidscripka/openWakeWord)! Note that real-time detection of a microphone stream can occasionally behave strangely in Spaces. For the most reliable testing, perform a local installation as described below. @@ -262,4 +262,4 @@ I am very grateful for the encouraging and positive response from the open-sourc # License -All of the code in this repository is licensed under the **Apache 2.0** license. All of the included pre-trained models are licensed under the [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/) license due to the inclusion of datasets with unknown or restrictive licensing as part of the training data. If you are interested in pre-trained models with more permissive licensing, please raise an issue and we will try to add them to a future release. \ No newline at end of file +All of the code in this repository is licensed under the **Apache 2.0** license. All of the included pre-trained models are licensed under the [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/) license due to the inclusion of datasets with unknown or restrictive licensing as part of the training data. If you are interested in pre-trained models with more permissive licensing, please raise an issue and we will try to add them to a future release. From ef118bd271f12bc805d18e925d2dd9ffd4598a4a Mon Sep 17 00:00:00 2001 From: dscripka Date: Tue, 24 Oct 2023 21:27:08 -0400 Subject: [PATCH 066/103] fixed bug in generate_adversarial_clips function [skip ci] --- openwakeword/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openwakeword/data.py b/openwakeword/data.py index 6e90969..c43da5d 100755 --- a/openwakeword/data.py +++ b/openwakeword/data.py @@ -962,7 +962,7 @@ def generate_adversarial_texts(input_text: str, N: int, include_partial_phrase: query_exps = [] phones = phones.split() adversarial_words = [] - if len(phones) == 2: + if len(phones) <= 2: query_exps.append(" ".join(phones)) else: query_exps.extend(phoneme_replacement(phones, max_replace=max(0, len(phones)-2), replace_char="(.){1,3}")) From e9fd49d5331a39fb4d55fe399c4758558e417279 Mon Sep 17 00:00:00 2001 From: dscripka Date: Wed, 25 Oct 2023 12:29:50 -0400 Subject: [PATCH 067/103] set opset version for onnx model export [skip ci] --- openwakeword/train.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index 411fc43..4df0798 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -309,7 +309,7 @@ class Model(nn.Module): # Save ONNX model logging.info(f"####\nSaving ONNX mode as '{os.path.join(output_dir, model_name + '.onnx')}'") model_to_save = copy.deepcopy(model) - torch.onnx.export(model_to_save.to("cpu"), torch.rand(self.input_shape)[None, ], os.path.join(output_dir, model_name + ".onnx")) + torch.onnx.export(model_to_save.to("cpu"), torch.rand(self.input_shape)[None, ], os.path.join(output_dir, model_name + ".onnx"), opset_version=13) return None From da8c3c9ec839c6824abdb6501abae7f3306325b6 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Wed, 25 Oct 2023 18:51:40 -0400 Subject: [PATCH 068/103] Updated requirements for full installation automatic training notebook example [skip ci] --- notebooks/automatic_model_training.ipynb | 71 ++++++++++++++++++++++-- setup.py | 4 +- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/notebooks/automatic_model_training.ipynb b/notebooks/automatic_model_training.ipynb index e1169d6..51ed036 100644 --- a/notebooks/automatic_model_training.ipynb +++ b/notebooks/automatic_model_training.ipynb @@ -68,11 +68,35 @@ "!git clone https://github.com/rhasspy/piper-sample-generator\n", "!wget -O piper-sample-generator/models/en_US-libritts_r-medium.pt 'https://github.com/rhasspy/piper-sample-generator/releases/download/v2.0.0/en_US-libritts_r-medium.pt'\n", "!pip install piper-phonemize\n", + "!pip install webrtcvad\n", "\n", "# install openwakeword (full installation to support training)\n", - "!git clone --branch auto_training https://github.com/dscripka/openwakeword\n", - "!pip install -e ./openwakeword[full]\n", - "!cd openwakeword\n" + "!git clone https://github.com/dscripka/openwakeword\n", + "!pip install -e ./openwakeword\n", + "!cd openwakeword\n", + "\n", + "# install other dependencies\n", + "!pip install mutagen==1.47.0\n", + "!pip install torchinfo==1.8.0\n", + "!pip install torchmetrics==1.2.0\n", + "!pip install speechbrain==0.5.14\n", + "!pip install audiomentations==0.33.0\n", + "!pip install torch-audiomentations==0.11.0\n", + "!pip install acoustics==0.2.6\n", + "!pip install tensorflow-cpu==2.8.1\n", + "!pip install tensorflow_probability==0.16.0\n", + "!pip install onnx_tf==1.10.0\n", + "!pip install pronouncing==0.2.0\n", + "!pip install datasets==2.14.6\n", + "!pip install deep-phonemizer==0.0.19\n", + "\n", + "# Download required models (workaround for Colab)\n", + "import os\n", + "os.makedirs(\"./openwakeword/openwakeword/resources/models\")\n", + "!wget https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/embedding_model.onnx -O ./openwakeword/openwakeword/resources/models/embedding_model.onnx\n", + "!wget https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/embedding_model.tflite -O ./openwakeword/openwakeword/resources/models/embedding_model.tflite\n", + "!wget https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/melspectrogram.onnx -O ./openwakeword/openwakeword/resources/models/melspectrogram.onnx\n", + "!wget https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/melspectrogram.tflite -O ./openwakeword/openwakeword/resources/models/melspectrogram.tflite\n" ] }, { @@ -390,10 +414,49 @@ "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --train_model" ] }, + { + "cell_type": "code", + "source": [ + "# Step 4 (Optional): On Google Colab, sometimes the .tflite model isn't saved correctly\n", + "# If so, run this cell to retry\n", + "\n", + "# Manually save to tflite as this doesn't work right in colab\n", + "def convert_onnx_to_tflite(onnx_model_path, output_path):\n", + " \"\"\"Converts an ONNX version of an openwakeword model to the Tensorflow tflite format.\"\"\"\n", + " # imports\n", + " import onnx\n", + " import logging\n", + " import tempfile\n", + " from onnx_tf.backend import prepare\n", + " import tensorflow as tf\n", + "\n", + " # Convert to tflite from onnx model\n", + " onnx_model = onnx.load(onnx_model_path)\n", + " tf_rep = prepare(onnx_model, device=\"CPU\")\n", + " with tempfile.TemporaryDirectory() as tmp_dir:\n", + " tf_rep.export_graph(os.path.join(tmp_dir, \"tf_model\"))\n", + " converter = tf.lite.TFLiteConverter.from_saved_model(os.path.join(tmp_dir, \"tf_model\"))\n", + " tflite_model = converter.convert()\n", + "\n", + " logging.info(f\"####\\nSaving tflite mode to '{output_path}'\")\n", + " with open(output_path, 'wb') as f:\n", + " f.write(tflite_model)\n", + "\n", + " return None\n", + "\n", + "convert_onnx_to_tflite(f\"my_custom_model/{config['model_name']}.onnx\", f\"my_custom_model/{config['model_name']}.tflite\")\n" + ], + "metadata": { + "id": "JSKWWLalnYzR" + }, + "id": "JSKWWLalnYzR", + "execution_count": null, + "outputs": [] + }, { "cell_type": "markdown", "source": [ - "After the model finishes training, the auto training script will automatically convert it to ONNX and tflite versions, saving them as `.onnx/tflite` in the present working directory, where `` is defined in the YAML training config file. Either version can be used as normal with `openwakeword`. I recommend testing them with the [`detect_from_microphone.py`](https://github.com/dscripka/openWakeWord/blob/main/examples/detect_from_microphone.py) example script to see how the model performs!" + "After the model finishes training, the auto training script will automatically convert it to ONNX and tflite versions, saving them as `my_custom_model/.onnx/tflite` in the present working directory, where `` is defined in the YAML training config file. Either version can be used as normal with `openwakeword`. I recommend testing them with the [`detect_from_microphone.py`](https://github.com/dscripka/openWakeWord/blob/main/examples/detect_from_microphone.py) example script to see how the model performs!" ], "metadata": { "id": "f9OyUW3ltOSs" diff --git a/setup.py b/setup.py index 6210b55..ca9704a 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ setuptools.setup( ], 'full': [ 'mutagen>=1.46.0,<2', - 'torch>=1.13.1,<2', + 'torch>=1.13.1,<3', 'torchaudio>=0.13.1,<1', 'torchinfo>=1.8.0,<2', 'torchmetrics>=0.11.4,<1', @@ -64,7 +64,7 @@ setuptools.setup( 'pytest-mypy>=0.10.0,<1', 'acoustics>=0.2.6,<1', 'pyyaml>=6.0,<7', - 'tensorflow==2.8.1', + 'tensorflow-cpu==2.8.1', 'tensorflow_probability==0.16.0', 'protobuf>=3.20,<4', 'onnx_tf==1.10.0', From 8fc9e2ed0c635cc528f0310ab9f9da4a9b6d663e Mon Sep 17 00:00:00 2001 From: David Scripka Date: Tue, 7 Nov 2023 06:25:00 -0500 Subject: [PATCH 069/103] Add utility functions to train.py and change how checkpoints are saved during training --- openwakeword/train.py | 225 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 184 insertions(+), 41 deletions(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index 4df0798..b4e2311 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -18,12 +18,13 @@ from pathlib import Path import openwakeword from openwakeword.data import generate_adversarial_texts, augment_clips, mmap_batch_generator from openwakeword.utils import compute_features_from_generator +from openwakeword.utils import AudioFeatures # Base model class for an openwakeword model class Model(nn.Module): def __init__(self, n_classes=1, input_shape=(16, 96), model_type="dnn", - layer_dim=128, seconds_per_example=None): + layer_dim=128, n_blocks=1, seconds_per_example=None): super().__init__() # Store inputs as attributes @@ -40,17 +41,46 @@ class Model(nn.Module): # Define model (currently on fully-connected network supported) if model_type == "dnn": - self.model = nn.Sequential( - nn.Flatten(), - nn.Linear(input_shape[0]*input_shape[1], layer_dim), - nn.LayerNorm(layer_dim), - nn.ReLU(), - nn.Linear(layer_dim, layer_dim), - nn.LayerNorm(layer_dim), - nn.ReLU(), - nn.Linear(layer_dim, n_classes), - nn.Sigmoid() if n_classes == 1 else nn.ReLU(), - ) + # self.model = nn.Sequential( + # nn.Flatten(), + # nn.Linear(input_shape[0]*input_shape[1], layer_dim), + # nn.LayerNorm(layer_dim), + # nn.ReLU(), + # nn.Linear(layer_dim, layer_dim), + # nn.LayerNorm(layer_dim), + # nn.ReLU(), + # nn.Linear(layer_dim, n_classes), + # nn.Sigmoid() if n_classes == 1 else nn.ReLU(), + # ) + + class FCNBlock(nn.Module): + def __init__(self, layer_dim): + super().__init__() + self.fcn_layer = nn.Linear(layer_dim, layer_dim) + self.relu = nn.ReLU() + self.layer_norm = nn.LayerNorm(layer_dim) + + def forward(self, x): + return self.relu(self.layer_norm(self.fcn_layer(x))) + + class Net(nn.Module): + def __init__(self, input_shape, layer_dim, n_blocks=1, n_classes=1): + super().__init__() + self.flatten = nn.Flatten() + self.layer1 = nn.Linear(input_shape[0]*input_shape[1], layer_dim) + self.relu1 = nn.ReLU() + self.layernorm1 = nn.LayerNorm(layer_dim) + self.blocks = nn.ModuleList([FCNBlock(layer_dim) for i in range(n_blocks)]) + self.last_layer = nn.Linear(layer_dim, n_classes) + self.last_act = nn.Sigmoid() if n_classes == 1 else nn.ReLU() + + def forward(self, x): + x = self.relu1(self.layernorm1(self.layer1(self.flatten(x)))) + for block in self.blocks: + x = block(x) + x = self.last_act(self.last_layer(x)) + return x + self.model = Net(input_shape, layer_dim, n_blocks=n_blocks, n_classes=n_classes) elif model_type == "rnn": class Net(nn.Module): def __init__(self, input_shape, n_classes=1): @@ -163,7 +193,7 @@ class Model(nn.Module): return self.model(x) def summary(self): - return torchinfo.summary(self.model, input_size=(1,) + self.input_shape) + return torchinfo.summary(self.model, input_size=(1,) + self.input_shape, device='cpu') def average_models(self, models=None): """Averages the weights of the provided models together to make a new model""" @@ -192,6 +222,39 @@ class Model(nn.Module): return averaged_model + def _select_best_model(self, false_positive_validate_data, val_set_hrs=11.3, max_fp_per_hour=0.5, min_recall=0.20): + """ + Select the top model based on the false positive rate on the validation data + + Args: + false_positive_validate_data (torch.DataLoader): A dataloader with validation data + n (int): The number of models to select + + Returns: + list: A list of the top n models + """ + # Get false positive rates for each model + false_positive_rates = [0]*len(self.best_models) + for batch in false_positive_validate_data: + x_val, y_val = batch[0].to(self.device), batch[1].to(self.device) + for mdl_ndx, model in tqdm(enumerate(self.best_models), total=len(self.best_models), desc="Find best checkpoints by false positive rate"): + with torch.no_grad(): + val_ps = model(x_val) + false_positive_rates[mdl_ndx] = false_positive_rates[mdl_ndx] + self.fp(val_ps, y_val[..., None]).detach().cpu().numpy() + false_positive_rates = [fp/val_set_hrs for fp in false_positive_rates] + + candidate_model_ndx = [ndx for ndx, fp in enumerate(false_positive_rates) if fp <= max_fp_per_hour] + candidate_model_recall = [self.best_model_scores[ndx]["val_recall"] for ndx in candidate_model_ndx] + if max(candidate_model_recall) <= min_recall: + logging.warning(f"No models with recall >= {min_recall} found!") + return None + else: + best_model = self.best_models[candidate_model_ndx[np.argmax(candidate_model_recall)]] + best_model_training_step = self.best_model_scores[candidate_model_ndx[np.argmax(candidate_model_recall)]]["training_step_ndx"] + print(f"Best model from training step {best_model_training_step} out of {len(candidate_model_ndx)} models has recall of {np.max(candidate_model_recall)} and false positive rate of {false_positive_rates[candidate_model_ndx[np.argmax(candidate_model_recall)]]}") + + return best_model + def auto_train(self, X_train, X_val, false_positive_val_data, steps=50000, max_negative_weight=1000, target_fp_per_hour=0.2): """A sequence of training steps that produce relatively strong models @@ -299,6 +362,57 @@ class Model(nn.Module): return combined_model + def predict_on_features(self, features, model=None): + """ + Predict on Tensors of openWakeWord features corresponding to single audio clips + + Args: + features (torch.Tensor): A Tensor of openWakeWord features with shape (batch, features) + model (torch.nn.Module): A Pytorch model to use for prediction (default None, which will use self.model) + + Returns: + torch.Tensor: An array of predictions of shape (batch, prediction), where 0 is negative and 1 is positive + """ + if len(features) < 3: + features = features[None, ] + + features = features.to(self.device) + predictions = [] + for x in tqdm(features, desc="Predicting on clips"): + x = x[None, ] + batch = [] + for i in range(0, x.shape[1]-16, 1): # step size of 1 (80 ms) + batch.append(x[:, i:i+16, :]) + batch = torch.vstack(batch) + if model is None: + preds = self.model(batch) + else: + preds = model(batch) + predictions.append(preds.detach().cpu().numpy()[None, ]) + + return np.vstack(predictions) + + def predict_on_clips(self, clips, model=None): + """ + Predict on Tensors of 16-bit 16 khz audio data + + Args: + clips (np.ndarray): A Numpy array of audio clips with shape (batch, samples) + model (torch.nn.Module): A Pytorch model to use for prediction (default None, which will use self.model) + + Returns: + np.ndarray: An array of predictions of shape (batch, prediction), where 0 is negative and 1 is positive + """ + + # Get features from clips + F = AudioFeatures(device='cpu', ncpu=4) + features = F.embed_clips(clips, batch_size=16) + + # Predict on features + preds = self.predict_on_features(torch.from_numpy(features), model=model) + + return preds + def export_model(self, model, model_name, output_dir): """Saves the trained openwakeword model to both onnx and tflite formats""" @@ -314,7 +428,7 @@ class Model(nn.Module): return None def train_model(self, X, max_steps, warmup_steps, hold_steps, X_val=None, - false_positive_val_data=None, + false_positive_val_data=None, positive_test_clips=None, negative_weight_schedule=[1], val_steps=[250], lr=0.0001, val_set_hrs=1): # Move models and main class to target device @@ -324,6 +438,8 @@ class Model(nn.Module): # Train model accumulation_steps = 1 accumulated_samples = 0 + accumulated_predictions = torch.Tensor([]).to(self.device) + accumulated_labels = torch.Tensor([]).to(self.device) for step_ndx, data in tqdm(enumerate(X, 0), total=max_steps, desc="Training"): # get the inputs; data is a list of [inputs, labels] x, y = data[0].to(self.device), data[1].to(self.device) @@ -360,26 +476,34 @@ class Model(nn.Module): w[pos_ndcs] = 1 w = w[..., None] - # Do backpropagation, with gradient accumulation if the batch-size after selecting high loss examples is too small - loss = self.loss(predictions, y_ if self.n_classes == 1 else y, w.to(self.device)) - loss = loss/accumulation_steps - accumulated_samples += predictions.shape[0] - if accumulated_samples < 128: - accumulation_steps += 1 - else: - loss.backward() - self.optimizer.step() - accumulation_steps = 1 - accumulated_samples = 0 + if predictions.shape[0] != 0: + # Do backpropagation, with gradient accumulation if the batch-size after selecting high loss examples is too small + loss = self.loss(predictions, y_ if self.n_classes == 1 else y, w.to(self.device)) + loss = loss/accumulation_steps + accumulated_samples += predictions.shape[0] - # Compute training metrics and log them - fp = self.fp(predictions, y_ if self.n_classes == 1 else y) - self.n_fp += fp + if predictions.shape[0] >= 128: + accumulated_predictions = predictions + accumulated_labels = y_ + if accumulated_samples < 128: + accumulation_steps += 1 + accumulated_predictions = torch.cat((accumulated_predictions, predictions)) + accumulated_labels = torch.cat((accumulated_labels, y_)) + else: + loss.backward() + self.optimizer.step() + accumulation_steps = 1 + accumulated_samples = 0 - self.history["loss"].append(loss.detach().cpu().numpy()) - self.history["recall"].append(self.recall(predictions, y_).detach().cpu().numpy()) - if self.n_classes != 1: - self.history["accuracy"].append(self.acc(predictions, y).detach().cpu().numpy()) + self.history["loss"].append(loss.detach().cpu().numpy()) + + # Compute training metrics and log them + fp = self.fp(accumulated_predictions, accumulated_labels if self.n_classes == 1 else y) + self.n_fp += fp + self.history["recall"].append(self.recall(accumulated_predictions, accumulated_labels).detach().cpu().numpy()) + + accumulated_predictions = torch.Tensor([]).to(self.device) + accumulated_labels = torch.Tensor([]).to(self.device) # Run validation and log validation metrics if step_ndx in val_steps and step_ndx > 1 and false_positive_val_data is not None: @@ -393,27 +517,46 @@ class Model(nn.Module): val_fp_per_hr = (val_fp/val_set_hrs).detach().cpu().numpy() self.history["val_fp_per_hr"].append(val_fp_per_hr) + # Get recall on test clips + if step_ndx in val_steps and step_ndx > 1 and positive_test_clips is not None: + tp = 0 + fn = 0 + for val_step_ndx, data in enumerate(positive_test_clips): + with torch.no_grad(): + x_val = data[0].to(self.device) + batch = [] + for i in range(0, x_val.shape[1]-16, 1): + batch.append(x_val[:, i:i+16, :]) + batch = torch.vstack(batch) + preds = self.model(batch) + if any(preds >= 0.5): + tp += 1 + else: + fn += 1 + self.history["positive_test_clips_recall"].append(tp/(tp + fn)) + if step_ndx in val_steps and step_ndx > 1 and X_val is not None: - # Get accuracy for balanced test examples of positive and negative clips + # Get metrics for balanced test examples of positive and negative clips for val_step_ndx, data in enumerate(X_val): with torch.no_grad(): x_val, y_val = data[0].to(self.device), data[1].to(self.device) val_predictions = self.model(x_val) val_recall = self.recall(val_predictions, y_val[..., None]).detach().cpu().numpy() val_acc = self.accuracy(val_predictions, y_val[..., None].to(torch.int64)) + val_fp = self.fp(val_predictions, y_val[..., None]) self.history["val_accuracy"].append(val_acc.detach().cpu().numpy()) self.history["val_recall"].append(val_recall) + self.history["val_n_fp"].append(val_fp.detach().cpu().numpy()) - # Save models with a validation score above/below the 90th percentile - # of the validation scores up to that point - if val_fp_per_hr <= np.percentile(self.history["val_fp_per_hr"], 10) and \ - self.history["val_accuracy"][-1] >= np.percentile(self.history["val_accuracy"], 90) and \ - self.history["val_recall"][-1] >= np.percentile(self.history["val_recall"], 90): - # logging.info("Saving checkpoint with metrics >= to targets!") + # Save models with a validation score above/below the 90th percentile + # of the validation scores up to that point + if step_ndx in val_steps and step_ndx > 1: + if self.history["val_n_fp"][-1] <= np.percentile(self.history["val_n_fp"], 50) and \ + self.history["val_recall"][-1] >= np.percentile(self.history["val_recall"], 5): + logging.info("Saving checkpoint with metrics >= to targets!") self.best_models.append(copy.deepcopy(self.model)) - self.best_model_scores.append({"val_fp_per_hr": val_fp_per_hr, "val_accuracy": self.history["val_accuracy"][-1], + self.best_model_scores.append({"training_step_ndx": step_ndx, "val_n_fp": self.history["val_n_fp"][-1], "val_recall": self.history["val_recall"][-1]}) - self.best_val_fp = val_fp_per_hr self.best_val_recall = self.history["val_recall"][-1] self.best_val_accuracy = self.history["val_accuracy"][-1] From a2522e29fe3e7ff8c99e66c62faa7783c669d86f Mon Sep 17 00:00:00 2001 From: David Scripka Date: Tue, 7 Nov 2023 06:36:59 -0500 Subject: [PATCH 070/103] Updated CLI args --- examples/capture_activations.py | 35 ++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/examples/capture_activations.py b/examples/capture_activations.py index fae900e..8f8e80d 100644 --- a/examples/capture_activations.py +++ b/examples/capture_activations.py @@ -68,10 +68,26 @@ parser.add_argument( default=False, required=False ) +parser=argparse.ArgumentParser() parser.add_argument( - "--model", - help="The model to use for openWakeWord, leave blank to use all available models", + "--chunk_size", + help="How much audio (in number of 16khz samples) to predict on at once", + type=int, + default=1280, + required=False +) +parser.add_argument( + "--model_path", + help="The path of a specific model to load", type=str, + default="", + required=False +) +parser.add_argument( + "--inference_framework", + help="The inference framework to use (either 'onnx' or 'tflite'", + type=str, + default='tflite', required=False ) parser.add_argument( @@ -87,25 +103,26 @@ args=parser.parse_args() FORMAT = pyaudio.paInt16 CHANNELS = 1 RATE = 16000 -CHUNK = 1280 +CHUNK = args.chunk_size audio = pyaudio.PyAudio() mic_stream = audio.open(format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK) # Load pre-trained openwakeword models -if args.model: +if args.model_path: model_paths = openwakeword.get_pretrained_model_paths() for path in model_paths: - if args.model in path: + if args.model_path in path: model_path = path if model_path: owwModel = Model( - wakeword_model_paths=[model_path], + wakeword_models=[model_path], enable_speex_noise_suppression=args.noise_suppression, - vad_threshold = args.vad_threshold - ) + vad_threshold = args.vad_threshold, + inference_framework=args.inference_framework + ) else: - print(f'Could not find model \"{args.model}\"') + print(f'Could not find model \"{args.model_path}\"') exit() else: owwModel = Model( From 8376848be5db4574145bab10c0312ccf1aebd6e2 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Wed, 8 Nov 2023 07:48:56 -0500 Subject: [PATCH 071/103] Basic web streaming example [skip ci] --- examples/web/README.md | 15 ++++ examples/web/streaming_client.html | 112 +++++++++++++++++++++++++++++ examples/web/streaming_server.py | 108 ++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 examples/web/README.md create mode 100644 examples/web/streaming_client.html create mode 100644 examples/web/streaming_server.py diff --git a/examples/web/README.md b/examples/web/README.md new file mode 100644 index 0000000..2f9c21b --- /dev/null +++ b/examples/web/README.md @@ -0,0 +1,15 @@ +# Examples + +This folder contains examples of using openWakeWord with web applications. + +## Websocket Streaming + +As openWakeWord does not have a native Javascript port, using it within a web browswer is best accomplished with websocket streaming of the audio data from the browser to a simple Python application. To install the requirements for this example: + +``` +pip install aiohttp +``` + +The `streaming_client.html` page shows a simple implementation of audio capture and streamimng from a microphone and streaming in a browser, and the `streaming_server.py` file is the corresponding websocket server that passes the audio into openWakeWord. + +To run the example, execute `python streaming_server.py` (add the `--help` argument to see options) and navigate to `localhost:9000` in your browser. \ No newline at end of file diff --git a/examples/web/streaming_client.html b/examples/web/streaming_client.html new file mode 100644 index 0000000..3bf5005 --- /dev/null +++ b/examples/web/streaming_client.html @@ -0,0 +1,112 @@ + + + + + + + Websocket Microphone Streaming + + +

Streaming Audio to openWakeWord Using Websockets

+ + + + + \ No newline at end of file diff --git a/examples/web/streaming_server.py b/examples/web/streaming_server.py new file mode 100644 index 0000000..c386bbe --- /dev/null +++ b/examples/web/streaming_server.py @@ -0,0 +1,108 @@ +# Copyright 2023 David Scripka. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +####################################################################################### + +# This example scripts runs openWakeWord in a simple web server receiving audio +# from a web page using websockets. + +####################################################################################### + +# Imports +import aiohttp +from aiohttp import web +import numpy as np +from openwakeword import Model +import resampy +import argparse + +# Define websocket handler +async def websocket_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + + # Start listening for websocket messages + async for msg in ws: + # Get the sample rate of the microphone from the browser + if msg.type == aiohttp.WSMsgType.TEXT: + sample_rate = int(msg.data) + elif msg.type == aiohttp.WSMsgType.ERROR: + print(f"WebSocket error: {ws.exception()}") + else: + # Get audio data from websocket + audio_bytes = msg.data + + # Add extra bytes of silence if needed + if len(msg.data) % 2 == 1: + audio_bytes += (b'\x00') + + # Convert audio to correct format and sample rate + data = np.frombuffer(audio_bytes, dtype=np.int16) + if sample_rate != 16000: + data = resampy.resample(data, sample_rate, 16000) + + # Get openWakeWord predictions and set to browser client + predictions = owwModel.predict(data) + + activations = [] + for key in predictions: + if predictions[key] >= 0.5: + activations.append(key) + + if activations != []: + await ws.send_str(str(activations)) + + return ws + +# Define static file handler +async def static_file_handler(request): + return web.FileResponse('./streaming_client.html') + +app = web.Application() +app.add_routes([web.get('/ws', websocket_handler), web.get('/', static_file_handler)]) + +if __name__ == '__main__': + # Parse CLI arguments + parser=argparse.ArgumentParser() + parser.add_argument( + "--chunk_size", + help="How much audio (in number of samples) to predict on at once", + type=int, + default=1280, + required=False + ) + parser.add_argument( + "--model_path", + help="The path of a specific model to load", + type=str, + default="", + required=False + ) + parser.add_argument( + "--inference_framework", + help="The inference framework to use (either 'onnx' or 'tflite'", + type=str, + default='tflite', + required=False + ) + args=parser.parse_args() + + # Load openWakeWord models + if args.model_path != "": + owwModel = Model(wakeword_models=[args.model_path], inference_framework=args.inference_framework) + else: + owwModel = Model(inference_framework=args.inference_framework) + + # Start webapp + web.run_app(app, host='localhost', port=9000) \ No newline at end of file From 58ec0943806b5907b2a3218eb0935b1cdf1c92b3 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Thu, 9 Nov 2023 08:36:43 -0500 Subject: [PATCH 072/103] Added table for detections and styling to websocket example --- examples/web/streaming_client.html | 216 ++++++++++++++++++++--------- examples/web/streaming_server.py | 6 +- 2 files changed, 155 insertions(+), 67 deletions(-) diff --git a/examples/web/streaming_client.html b/examples/web/streaming_client.html index 3bf5005..44c98d8 100644 --- a/examples/web/streaming_client.html +++ b/examples/web/streaming_client.html @@ -1,31 +1,127 @@ - Websocket Microphone Streaming +

Streaming Audio to openWakeWord Using Websockets

- + + + + + + + + + + + +
WakewordDetected
\ No newline at end of file diff --git a/examples/web/streaming_server.py b/examples/web/streaming_server.py index c386bbe..449d251 100644 --- a/examples/web/streaming_server.py +++ b/examples/web/streaming_server.py @@ -26,12 +26,16 @@ import numpy as np from openwakeword import Model import resampy import argparse +import json # Define websocket handler async def websocket_handler(request): ws = web.WebSocketResponse() await ws.prepare(request) + # Send loaded models + await ws.send_str(json.dumps({"loaded_models": list(owwModel.models.keys())})) + # Start listening for websocket messages async for msg in ws: # Get the sample rate of the microphone from the browser @@ -61,7 +65,7 @@ async def websocket_handler(request): activations.append(key) if activations != []: - await ws.send_str(str(activations)) + await ws.send_str(json.dumps({"activations": activations})) return ws From a46d1e1f5199211dcb4f5aa2459499f295f3dd5c Mon Sep 17 00:00:00 2001 From: David Scripka Date: Thu, 9 Nov 2023 20:14:17 -0500 Subject: [PATCH 073/103] Updated readmes for new examples [skip ci] --- README.md | 7 ++++++- examples/web/README.md | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f93e2d4..97784f8 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ openWakeWord is an open-source wakeword library that can be used to create voice # Updates +**2023/11/09** +- Added example scripts under `examples/web` that demonstrate streaming audio from a web application into openWakeWord. + **2023/10/11** - Significant improvements to the process of [training new models](#training-new-models), including an example Google Colab notebook demonstrating how to train a basic wake word model in <1 hour. @@ -240,9 +243,11 @@ Future release road maps may have non-english support. In particular, [Mycroft.A **Can openWakeWord be run in a browser with javascript?** - While the ONNX runtime [does support javascript](https://onnxruntime.ai/docs/get-started/with-javascript.html), much of the other functionality required for openWakeWord models would need to be ported. This is not currently on the roadmap, but please open an issue/start a discussion if this feature is of particular interest. +- As a potential work-around for some applications, the example scripts in `examples/web` demonstrate how audio can be captured in a browser and streaming via websockets into openWakeWord running in a Python backend server. +- Other potential options could include projects like `pyodide` (see [here](https://github.com/pyodide/pyodide/issues/4220)) for a related issue. **Is there a C++ version of openWakeWord?** -- While the ONNX runtime [also has a C++ API](https://onnxruntime.ai/docs/get-started/with-cpp.html), there isn't an official C++ implementation of the full openWakeWord library. However, [@synesthesiam](https://github.com/synesthesiam) has created a [C++ version](https://github.com/rhasspy/openWakeWord-cpp) of openWakeWord with basic functionality implemented. +- While the ONNX runtime [also has a C++ API](https://onnxruntime.ai/docs/get-started/with-cpp.html), there isn't an official C++ implementation of the full openWakeWord library. However, [@synesthesiam](https://github.com/synesthesiam) has created a [C++ version of openWakeWord](https://github.com/rhasspy/openWakeWord-cpp) with basic functionality implemented. **Why are there three separate models instead of just one?** - Separating the models was an intentional choice to provide flexibility and optimize the efficiency of the end-to-end prediction process. For example, with separate melspectrogram, embedding, and prediction models, each one can operate on different size inputs of audio to optimize overall latency and share computations between models. It certainly is possible to make a combined model with all of the steps integrated, though, if that was a requirement of a particular use case. diff --git a/examples/web/README.md b/examples/web/README.md index 2f9c21b..bd4e970 100644 --- a/examples/web/README.md +++ b/examples/web/README.md @@ -8,8 +8,14 @@ As openWakeWord does not have a native Javascript port, using it within a web br ``` pip install aiohttp +pip install resampy ``` The `streaming_client.html` page shows a simple implementation of audio capture and streamimng from a microphone and streaming in a browser, and the `streaming_server.py` file is the corresponding websocket server that passes the audio into openWakeWord. -To run the example, execute `python streaming_server.py` (add the `--help` argument to see options) and navigate to `localhost:9000` in your browser. \ No newline at end of file +To run the example, execute `python streaming_server.py` (add the `--help` argument to see options) and navigate to `localhost:9000` in your browser. + +Note that this example is illustrative only, and integration of this approach with other web applications may have different requirements. In particular, some key considerations: + +- This example captures PCM audio from the web browser and streams full 16-bit integer representations of ~250 ms audio chunks over the websocket connection. In practice, bandwidth efficient streams of compressed audio may be more suitable for some applications. +- The browser captures audio at the native sampling rate of the capture device, which can require re-sampling prior to passing the audio data to openWakeWord. This example uses the `resampy` library which has a good balance between performance and quality, but other resampling approaches that optimize different aspects may be more suitable for some applications. \ No newline at end of file From b85645ed68575d2e6535a8060da0454888a7c38a Mon Sep 17 00:00:00 2001 From: David Scripka Date: Thu, 9 Nov 2023 20:27:28 -0500 Subject: [PATCH 074/103] Added missing attribution [skip ci] --- examples/web/streaming_client.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/web/streaming_client.html b/examples/web/streaming_client.html index 44c98d8..c2df273 100644 --- a/examples/web/streaming_client.html +++ b/examples/web/streaming_client.html @@ -121,7 +121,8 @@ } }; - // Create microphone capture stream + // Create microphone capture stream for 16-bit PCM audio data + // Code based on the excellent tutorial by Ragy Morkas: https://medium.com/@ragymorkos/gettineg-monochannel-16-bit-signed-integer-pcm-audio-samples-from-the-microphone-in-the-browser-8d4abf81164d navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || From fad5ee7db2fce9e152ed5c89bbece553cb848a1d Mon Sep 17 00:00:00 2001 From: dscripka Date: Fri, 10 Nov 2023 07:42:17 -0500 Subject: [PATCH 075/103] flake8 fix --- openwakeword/train.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index 4df0798..4bd9f07 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -309,7 +309,8 @@ class Model(nn.Module): # Save ONNX model logging.info(f"####\nSaving ONNX mode as '{os.path.join(output_dir, model_name + '.onnx')}'") model_to_save = copy.deepcopy(model) - torch.onnx.export(model_to_save.to("cpu"), torch.rand(self.input_shape)[None, ], os.path.join(output_dir, model_name + ".onnx"), opset_version=13) + torch.onnx.export(model_to_save.to("cpu"), torch.rand(self.input_shape)[None, ], + os.path.join(output_dir, model_name + ".onnx"), opset_version=13) return None From 484b2ca17e9b1d75d7dbb4a0c7e4515506c12259 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Tue, 21 Nov 2023 10:55:16 -0500 Subject: [PATCH 076/103] updated for current versions of pip and setuptools --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 23c373c..b7b33d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", ] +dynamic = ["dependencies", "optional-dependencies"] [project.urls] "Homepage" = "https://github.com/dscripka/openWakeWord" \ No newline at end of file From f6644b8c925a2fde67ef03352f7589734b6f1d23 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Tue, 21 Nov 2023 11:12:22 -0500 Subject: [PATCH 077/103] flake8 fixes and new function --- openwakeword/train.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index b4e2311..55595a9 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -225,7 +225,7 @@ class Model(nn.Module): def _select_best_model(self, false_positive_validate_data, val_set_hrs=11.3, max_fp_per_hour=0.5, min_recall=0.20): """ Select the top model based on the false positive rate on the validation data - + Args: false_positive_validate_data (torch.DataLoader): A dataloader with validation data n (int): The number of models to select @@ -237,7 +237,8 @@ class Model(nn.Module): false_positive_rates = [0]*len(self.best_models) for batch in false_positive_validate_data: x_val, y_val = batch[0].to(self.device), batch[1].to(self.device) - for mdl_ndx, model in tqdm(enumerate(self.best_models), total=len(self.best_models), desc="Find best checkpoints by false positive rate"): + for mdl_ndx, model in tqdm(enumerate(self.best_models), total=len(self.best_models), + desc="Find best checkpoints by false positive rate"): with torch.no_grad(): val_ps = model(x_val) false_positive_rates[mdl_ndx] = false_positive_rates[mdl_ndx] + self.fp(val_ps, y_val[..., None]).detach().cpu().numpy() @@ -251,7 +252,9 @@ class Model(nn.Module): else: best_model = self.best_models[candidate_model_ndx[np.argmax(candidate_model_recall)]] best_model_training_step = self.best_model_scores[candidate_model_ndx[np.argmax(candidate_model_recall)]]["training_step_ndx"] - print(f"Best model from training step {best_model_training_step} out of {len(candidate_model_ndx)} models has recall of {np.max(candidate_model_recall)} and false positive rate of {false_positive_rates[candidate_model_ndx[np.argmax(candidate_model_recall)]]}") + logging.info(f"Best model from training step {best_model_training_step} out of {len(candidate_model_ndx)}" + f"models has recall of {np.max(candidate_model_recall)} and false positive rate of" + f" {false_positive_rates[candidate_model_ndx[np.argmax(candidate_model_recall)]]}") return best_model @@ -423,7 +426,8 @@ class Model(nn.Module): # Save ONNX model logging.info(f"####\nSaving ONNX mode as '{os.path.join(output_dir, model_name + '.onnx')}'") model_to_save = copy.deepcopy(model) - torch.onnx.export(model_to_save.to("cpu"), torch.rand(self.input_shape)[None, ], os.path.join(output_dir, model_name + ".onnx"), opset_version=13) + torch.onnx.export(model_to_save.to("cpu"), torch.rand(self.input_shape)[None, ], + os.path.join(output_dir, model_name + ".onnx"), opset_version=13) return None @@ -695,7 +699,7 @@ if __name__ == '__main__': for target_phrase in config["target_phrase"]: adversarial_texts.extend(generate_adversarial_texts( input_text=target_phrase, - N=config["n_samples"], + N=config["n_samples"]//len(config["target_phrase"]), include_partial_phrase=1.0, include_input_words=0.2)) generate_samples(text=adversarial_texts, max_samples=config["n_samples"]-n_current_samples, @@ -718,7 +722,7 @@ if __name__ == '__main__': for target_phrase in config["target_phrase"]: adversarial_texts.extend(generate_adversarial_texts( input_text=target_phrase, - N=config["n_samples_val"], + N=config["n_samples_val"]//len(config["target_phrase"]), include_partial_phrase=1.0, include_input_words=0.2)) generate_samples(text=adversarial_texts, max_samples=config["n_samples_val"]-n_current_samples, From c3bc8d6170aaa4a135fbdc2a1436bf5542209fad Mon Sep 17 00:00:00 2001 From: David Scripka Date: Tue, 21 Nov 2023 16:35:10 -0500 Subject: [PATCH 078/103] bug fix in checkpoint metric saving [skip ci] --- openwakeword/train.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index 55595a9..465bdb5 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -560,7 +560,8 @@ class Model(nn.Module): logging.info("Saving checkpoint with metrics >= to targets!") self.best_models.append(copy.deepcopy(self.model)) self.best_model_scores.append({"training_step_ndx": step_ndx, "val_n_fp": self.history["val_n_fp"][-1], - "val_recall": self.history["val_recall"][-1]}) + "val_recall": self.history["val_recall"][-1]}, + "val_accuracy": self.history["val_accuracy"][-1]) self.best_val_recall = self.history["val_recall"][-1] self.best_val_accuracy = self.history["val_accuracy"][-1] From 2c3a4839a2a03872ffcd07e05cc4cc28a9e5dd17 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Tue, 21 Nov 2023 16:39:10 -0500 Subject: [PATCH 079/103] typo [skip ci] --- openwakeword/train.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index 465bdb5..b512cd0 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -560,8 +560,8 @@ class Model(nn.Module): logging.info("Saving checkpoint with metrics >= to targets!") self.best_models.append(copy.deepcopy(self.model)) self.best_model_scores.append({"training_step_ndx": step_ndx, "val_n_fp": self.history["val_n_fp"][-1], - "val_recall": self.history["val_recall"][-1]}, - "val_accuracy": self.history["val_accuracy"][-1]) + "val_recall": self.history["val_recall"][-1], + "val_accuracy": self.history["val_accuracy"][-1]}) self.best_val_recall = self.history["val_recall"][-1] self.best_val_accuracy = self.history["val_accuracy"][-1] From 08f31225a215b57d409dbb2495cb75f7f0cb7753 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Tue, 21 Nov 2023 16:44:24 -0500 Subject: [PATCH 080/103] remove logging statement [skip ci] --- openwakeword/train.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index b512cd0..6f1ed63 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -557,7 +557,7 @@ class Model(nn.Module): if step_ndx in val_steps and step_ndx > 1: if self.history["val_n_fp"][-1] <= np.percentile(self.history["val_n_fp"], 50) and \ self.history["val_recall"][-1] >= np.percentile(self.history["val_recall"], 5): - logging.info("Saving checkpoint with metrics >= to targets!") + # logging.info("Saving checkpoint with metrics >= to targets!") self.best_models.append(copy.deepcopy(self.model)) self.best_model_scores.append({"training_step_ndx": step_ndx, "val_n_fp": self.history["val_n_fp"][-1], "val_recall": self.history["val_recall"][-1], From ee9270f93a0759fd9bb0d4298b3e108be4ef3a8a Mon Sep 17 00:00:00 2001 From: David Scripka Date: Tue, 21 Nov 2023 17:09:11 -0500 Subject: [PATCH 081/103] bug fix [skip ci] --- openwakeword/train.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index 6f1ed63..7e468bf 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -561,7 +561,8 @@ class Model(nn.Module): self.best_models.append(copy.deepcopy(self.model)) self.best_model_scores.append({"training_step_ndx": step_ndx, "val_n_fp": self.history["val_n_fp"][-1], "val_recall": self.history["val_recall"][-1], - "val_accuracy": self.history["val_accuracy"][-1]}) + "val_accuracy": self.history["val_accuracy"][-1], + "val_fp_per_hr": self.history.get("val_fp_per_hr", [0])[-1]}) self.best_val_recall = self.history["val_recall"][-1] self.best_val_accuracy = self.history["val_accuracy"][-1] From 716cfe2667d2533cdfb611c044fd44fe62b333c3 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 14 Jan 2024 14:00:07 -0500 Subject: [PATCH 082/103] Update README.md with link to models from HA community [skip ci] --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 97784f8..c1b2bba 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,8 @@ openWakeWord includes an automated utility that greatly simplifies the process o 2) A more detailed [notebook](notebooks/automatic_model_training.ipynb) (also on [Google Colab](https://colab.research.google.com/drive/1yyFH-fpguX2BTAW8wSQxTrJnJTM-0QAd?usp=sharing)) that describes the training process in more details, and enables more customization. This can produce high quality models, but requires more development experience. +For a collection of models trained using the notebooks above by the Home Assistant Community (and with much gratitude to @fwartner), see the excellent repository [here](https://github.com/fwartner/home-assistant-wakewords-collection). + For users interested in understanding the fundamental concepts behind model training there is a more detailed, educational [tutorial notebook](notebooks/training_models.ipynb) also available. However, this specific notebook is not intended for training production models, and the automated process above is recommended for that purpose. Fundamentally, a new model requires two data generation and collection steps: From db418ab9f309d6c5cbbbdcc93409992687431861 Mon Sep 17 00:00:00 2001 From: Dmitry Lipatov Date: Sun, 14 Jan 2024 23:31:07 +0300 Subject: [PATCH 083/103] Fix chunksize in utils.py when batch is less than ncpu value --- openwakeword/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openwakeword/utils.py b/openwakeword/utils.py index c032c5a..6572e23 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -269,8 +269,9 @@ class AudioFeatures(): result = self._get_melspectrogram(batch) elif pool: + chunksize = batch.shape[0]//ncpu if batch.shape[0]>=ncpu else 1 result = np.array(pool.map(self._get_melspectrogram, - batch, chunksize=batch.shape[0]//ncpu)) + batch, chunksize=chunksize)) melspecs[i:i+batch_size, :, :] = result.squeeze() @@ -330,8 +331,9 @@ class AudioFeatures(): result = self.embedding_model_predict(batch) elif pool: + chunksize = batch.shape[0]//ncpu if batch.shape[0]>=ncpu else 1 result = np.array(pool.map(self._get_embeddings_from_melspec, - batch, chunksize=batch.shape[0]//ncpu)) + batch, chunksize=chunksize)) for j, ndx2 in zip(range(0, result.shape[0], n_frames), ndcs): embeddings[ndx2, :, :] = result[j:j+n_frames] From 17a0f72a28fdcece6beab8c4376f746d6580f92c Mon Sep 17 00:00:00 2001 From: dscripka Date: Wed, 17 Jan 2024 19:31:54 -0500 Subject: [PATCH 084/103] added example notebook showing how to convert the original embedding model from google into a more standard format [skip ci] --- ...erting_google_speech_embedding_model.ipynb | 1113 +++++++++++++++++ 1 file changed, 1113 insertions(+) create mode 100644 notebooks/converting_google_speech_embedding_model.ipynb diff --git a/notebooks/converting_google_speech_embedding_model.ipynb b/notebooks/converting_google_speech_embedding_model.ipynb new file mode 100644 index 0000000..4df8ea3 --- /dev/null +++ b/notebooks/converting_google_speech_embedding_model.ipynb @@ -0,0 +1,1113 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "838ffa12", + "metadata": {}, + "source": [ + "This notebook demonstrates how the speech embedding model from Google (https://www.kaggle.com/models/google/speech-embedding/frameworks/tensorFlow1/variations/speech-embedding/versions/1) is re-implemented in Keras manually, which can then be converted to ONNX and tflite formats for use in openWakeWord.\n", + "\n", + "Note that Keras was used here, but in theory other deep learning frameworks (e.g., PyTorch) could work as well." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "893d29dc", + "metadata": { + "ExecuteTime": { + "end_time": "2024-01-18T00:26:11.649261Z", + "start_time": "2024-01-18T00:26:10.190666Z" + } + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2024-01-17 19:26:10.372628: W tensorflow/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libcudart.so.11.0'; dlerror: libcudart.so.11.0: cannot open shared object file: No such file or directory\n", + "2024-01-17 19:26:10.372640: I tensorflow/stream_executor/cuda/cudart_stub.cc:29] Ignore above cudart dlerror if you do not have a GPU set up on your machine.\n" + ] + } + ], + "source": [ + "# Imports\n", + "\n", + "import os\n", + "import numpy as np\n", + "import scipy\n", + "import tensorflow as tf\n", + "import tensorflow_hub as hub # install with `pip install tensorflow_hub`\n", + "import matplotlib.pyplot as plt" + ] + }, + { + "cell_type": "markdown", + "id": "fe3054ae", + "metadata": {}, + "source": [ + "# Load Orignal Model from TFHub" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "fa2bc0d3", + "metadata": { + "ExecuteTime": { + "end_time": "2024-01-18T00:26:12.257919Z", + "start_time": "2024-01-18T00:26:11.650661Z" + } + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2024-01-17 19:26:11.857817: I tensorflow/stream_executor/cuda/cuda_gpu_executor.cc:936] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero\n", + "2024-01-17 19:26:11.858167: W tensorflow/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libcudart.so.11.0'; dlerror: libcudart.so.11.0: cannot open shared object file: No such file or directory\n", + "2024-01-17 19:26:11.858193: W tensorflow/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libcublas.so.11'; dlerror: libcublas.so.11: cannot open shared object file: No such file or directory\n", + "2024-01-17 19:26:11.858215: W tensorflow/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libcublasLt.so.11'; dlerror: libcublasLt.so.11: cannot open shared object file: No such file or directory\n", + "2024-01-17 19:26:11.858237: W tensorflow/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libcufft.so.10'; dlerror: libcufft.so.10: cannot open shared object file: No such file or directory\n", + "2024-01-17 19:26:11.858258: W tensorflow/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libcurand.so.10'; dlerror: libcurand.so.10: cannot open shared object file: No such file or directory\n", + "2024-01-17 19:26:11.858278: W tensorflow/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libcusolver.so.11'; dlerror: libcusolver.so.11: cannot open shared object file: No such file or directory\n", + "2024-01-17 19:26:11.858299: W tensorflow/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libcusparse.so.11'; dlerror: libcusparse.so.11: cannot open shared object file: No such file or directory\n", + "2024-01-17 19:26:11.858320: W tensorflow/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libcudnn.so.8'; dlerror: libcudnn.so.8: cannot open shared object file: No such file or directory\n", + "2024-01-17 19:26:11.858325: W tensorflow/core/common_runtime/gpu/gpu_device.cc:1850] Cannot dlopen some GPU libraries. Please make sure the missing libraries mentioned above are installed properly if you would like to use GPU. Follow the guide at https://www.tensorflow.org/install/gpu for how to download and setup the required libraries for your platform.\n", + "Skipping registering GPU devices...\n", + "2024-01-17 19:26:11.858458: I tensorflow/core/platform/cpu_feature_guard.cc:151] This TensorFlow binary is optimized with oneAPI Deep Neural Network Library (oneDNN) to use the following CPU instructions in performance-critical operations: AVX2 AVX512F FMA\n", + "To enable them in other operations, rebuild TensorFlow with the appropriate compiler flags.\n" + ] + } + ], + "source": [ + "# Load the original speech embedding model (now hosted on Kaggle) as a KerasLayer object\n", + "\n", + "embedding_model_url = \"https://tfhub.dev/google/speech_embedding/1\"\n", + "embedding_model = hub.KerasLayer(embedding_model_url, trainable=False)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "6769931f", + "metadata": { + "ExecuteTime": { + "end_time": "2024-01-18T00:26:12.375204Z", + "start_time": "2024-01-18T00:26:12.259632Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Embedding Output Shape: (1, 1, 1, 96)\n" + ] + } + ], + "source": [ + "# Get predictions from the embedding model for a chunk of ~775 ms audio data (at 16khz)\n", + "# This is the minimum input size for the model per the documentation here: https://www.kaggle.com/models/google/speech-embedding/frameworks/tensorFlow1/variations/speech-embedding/versions/1\n", + "\n", + "# Load sample clip, and select a 775 ms chunk and normalize between -1 and 1\n", + "sr, sample_data = scipy.io.wavfile.read(\"../tests/data/hey_mycroft_test.wav\")\n", + "sample_data = (sample_data[0:12400][None,]/32767).astype(np.float32)\n", + "embeddings = embedding_model(sample_data)\n", + "print(\"Embedding Output Shape:\", embeddings.shape)" + ] + }, + { + "cell_type": "markdown", + "id": "e8acfba9", + "metadata": {}, + "source": [ + "# Convert original model to tflite" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "b4ee1693", + "metadata": { + "ExecuteTime": { + "end_time": "2024-01-18T00:26:14.554068Z", + "start_time": "2024-01-18T00:26:12.376427Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARNING:tensorflow:Compiled the loaded model, but the compiled metrics have yet to be built. `model.compile_metrics` will be empty until you train or evaluate the model.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING:tensorflow:Compiled the loaded model, but the compiled metrics have yet to be built. `model.compile_metrics` will be empty until you train or evaluate the model.\n", + "2024-01-17 19:26:12.970115: W tensorflow/python/util/util.cc:368] Sets are not currently considered sequences, but this may change in the future, so consider avoiding using them.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO:tensorflow:Assets written to: google_speech_embedding_fixed_input/assets\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO:tensorflow:Assets written to: google_speech_embedding_fixed_input/assets\n", + "2024-01-17 19:26:14.061242: W tensorflow/compiler/mlir/lite/python/tf_tfl_flatbuffer_helpers.cc:357] Ignored output_format.\n", + "2024-01-17 19:26:14.061261: W tensorflow/compiler/mlir/lite/python/tf_tfl_flatbuffer_helpers.cc:360] Ignored drop_control_dependency.\n", + "2024-01-17 19:26:14.061688: I tensorflow/cc/saved_model/reader.cc:43] Reading SavedModel from: google_speech_embedding_fixed_input\n", + "2024-01-17 19:26:14.066816: I tensorflow/cc/saved_model/reader.cc:78] Reading meta graph with tags { serve }\n", + "2024-01-17 19:26:14.066828: I tensorflow/cc/saved_model/reader.cc:119] Reading SavedModel debug info (if present) from: google_speech_embedding_fixed_input\n", + "2024-01-17 19:26:14.076781: I tensorflow/cc/saved_model/loader.cc:228] Restoring SavedModel bundle.\n", + "2024-01-17 19:26:14.173155: I tensorflow/cc/saved_model/loader.cc:212] Running initialization op on SavedModel bundle at path: google_speech_embedding_fixed_input\n", + "2024-01-17 19:26:14.236537: I tensorflow/cc/saved_model/loader.cc:301] SavedModel load for tags { serve }; Status: success: OK. Took 174850 microseconds.\n", + "2024-01-17 19:26:14.288965: I tensorflow/compiler/mlir/tensorflow/utils/dump_mlir_util.cc:237] disabling MLIR crash reproducer, set env var `MLIR_CRASH_REPRODUCER_DIRECTORY` to enable.\n", + "2024-01-17 19:26:14.405541: W tensorflow/compiler/mlir/lite/flatbuffer_export.cc:1881] Graph contains the following resource op(s), that use(s) resource type. Currently, the resource type is not natively supported in TFLite. Please consider not using the resource type if there are issues with either TFLite converter or TFLite runtime:\n", + "Resource ops: TensorArrayGatherV3, TensorArrayReadV3, TensorArrayScatterV3, TensorArrayV3, TensorArrayWriteV3\n", + "Details:\n", + "\ttf.TensorArrayGatherV3(tensor<2x!tf_type.resource>>, tensor, tensor) -> (tensor) : {device = \"\", element_shape = #tf_type.shape}\n", + "\ttf.TensorArrayReadV3(tensor<2x!tf_type.resource>>, tensor, tensor) -> (tensor<*xf32>) : {device = \"\"}\n", + "\ttf.TensorArrayScatterV3(tensor<2x!tf_type.resource>>, tensor, tensor, tensor) -> (tensor) : {device = \"\"}\n", + "\ttf.TensorArrayV3(tensor) -> (tensor<2x!tf_type.resource>>, tensor) : {clear_after_read = true, device = \"\", dtype = f32, dynamic_size = false, element_shape = #tf_type.shape<*>, identical_element_shapes = true, tensor_array_name = \"\"}\n", + "\ttf.TensorArrayWriteV3(tensor<2x!tf_type.resource>>, tensor, tensor, tensor) -> (tensor) : {device = \"\"}\n", + "2024-01-17 19:26:14.405561: W tensorflow/compiler/mlir/lite/flatbuffer_export.cc:1892] TFLite interpreter needs to link Flex delegate in order to run the model since it contains the following Select TFop(s):\n", + "Flex ops: FlexTensorArrayGatherV3, FlexTensorArrayReadV3, FlexTensorArrayScatterV3, FlexTensorArrayV3, FlexTensorArrayWriteV3\n", + "Details:\n", + "\ttf.TensorArrayGatherV3(tensor<2x!tf_type.resource>>, tensor, tensor) -> (tensor) : {device = \"\", element_shape = #tf_type.shape}\n", + "\ttf.TensorArrayReadV3(tensor<2x!tf_type.resource>>, tensor, tensor) -> (tensor<*xf32>) : {device = \"\"}\n", + "\ttf.TensorArrayScatterV3(tensor<2x!tf_type.resource>>, tensor, tensor, tensor) -> (tensor) : {device = \"\"}\n", + "\ttf.TensorArrayV3(tensor) -> (tensor<2x!tf_type.resource>>, tensor) : {clear_after_read = true, device = \"\", dtype = f32, dynamic_size = false, element_shape = #tf_type.shape<*>, identical_element_shapes = true, tensor_array_name = \"\"}\n", + "\ttf.TensorArrayWriteV3(tensor<2x!tf_type.resource>>, tensor, tensor, tensor) -> (tensor) : {device = \"\"}\n", + "See instructions: https://www.tensorflow.org/lite/guide/ops_select\n" + ] + } + ], + "source": [ + "# Build model with specific input size, and save\n", + "inputs = tf.keras.Input((12400,))\n", + "x = embedding_model(inputs)\n", + "model = tf.keras.Model(inputs=inputs, outputs=x)\n", + "model.save(\"google_speech_embedding_fixed_input\")\n", + "\n", + "speech_embedding_dir = \"google_speech_embedding_fixed_input\"\n", + "# speech_embedding_dir = \"google_speech_embedding_savedmodel/\"\n", + "\n", + "converter = tf.lite.TFLiteConverter.from_saved_model(speech_embedding_dir)#, tags=[\"train\"])\n", + "# convert = tf.lite.TFLiteConverter.from_keras_model(embedding_model)\n", + "converter.target_spec.supported_ops = [\n", + " tf.lite.OpsSet.TFLITE_BUILTINS, tf.lite.OpsSet.SELECT_TF_OPS\n", + "]\n", + "# converter.allow_custom_ops = True\n", + "\n", + "tflite_model = converter.convert()\n", + "with open(speech_embedding_dir + '/speech_embeddings.tflite', 'wb') as f:\n", + " f.write(tflite_model)\n" + ] + }, + { + "cell_type": "markdown", + "id": "927ebfda", + "metadata": {}, + "source": [ + "# Comparing Log-Mel Features" + ] + }, + { + "cell_type": "markdown", + "id": "48c7138b", + "metadata": {}, + "source": [ + "The speech embedding model from Google computes it's own input features from raw audio, which is convenient, but not ideal as it combines pre-processing with the model in a way that makes the model less understandable. In particular, this is (to my knowledge) the total information provided about the feature creation:\n", + "\n", + "From the model page [here:](https://www.kaggle.com/models/google/speech-embedding/frameworks/tensorFlow1/variations/speech-embedding/versions/1)\n", + "```\n", + "The module computes its own 32 dimensional log-mel features from the provided audio samples using the following parameters:\n", + "\n", + " stft window size: 25ms\n", + " stft window step: 10ms\n", + " mel band limits: 60Hz - 3800Hz\n", + " mel frequency bins: 32\n", + "```\n", + "\n", + "And then this excerpt from the corresponding [paper](https://arxiv.org/abs/2002.01322):\n", + "\n", + "```\n", + "Our model is designed for deployment in an environment\n", + "where both memory and compute power are very limited,\n", + "such as on a digital signal processor (DSP). It runs on top of a\n", + "low footprint feature extractor that provides a 32 dimensional\n", + "log mel feature vector covering the frequency range from\n", + "60 Hz to 3800 Hz, quantized to 8 bits every 10 ms\n", + "```\n", + "\n", + "It seems likely that this implementation is simply a [spectrogram](https://librosa.org/doc/main/generated/librosa.feature.melspectrogram.html) with [log scaling](https://librosa.org/doc/main/generated/librosa.power_to_db.html), but the investigation below shows that this may note be the case.\n", + "\n", + "If you have a theory as to what the original model is doing, or why a standard log-mel spectrogram does not match, please open an issue on the [openWakeWord](https://github.com/dscripka/openWakeWord), I would love learn more about this!" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "6599a6a0", + "metadata": { + "ExecuteTime": { + "end_time": "2024-01-18T00:26:14.703602Z", + "start_time": "2024-01-18T00:26:14.555114Z" + } + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: Created TensorFlow Lite delegate for select TF ops.\n", + "2024-01-17 19:26:14.557849: I tensorflow/stream_executor/cuda/cuda_gpu_executor.cc:936] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero\n", + "2024-01-17 19:26:14.558198: W tensorflow/core/common_runtime/gpu/gpu_device.cc:1850] Cannot dlopen some GPU libraries. Please make sure the missing libraries mentioned above are installed properly if you would like to use GPU. Follow the guide at https://www.tensorflow.org/install/gpu for how to download and setup the required libraries for your platform.\n", + "Skipping registering GPU devices...\n", + "INFO: TfLiteFlexDelegate delegate: 4 nodes delegated out of 76 nodes with 2 partitions.\n", + "\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Embedding model features shape: (32, 76)\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Use the converted tflite model and get intermediate outputs to extract the log-mel features\n", + "\n", + "interpreter = tf.lite.Interpreter(\n", + " model_path=os.path.join(speech_embedding_dir, \"speech_embeddings.tflite\"),\n", + " num_threads=1,\n", + " experimental_preserve_all_tensors=True\n", + ")\n", + "interpreter.allocate_tensors()\n", + "\n", + "# Get input and output tensors\n", + "input_details = interpreter.get_input_details()\n", + "output_details = interpreter.get_output_details()\n", + "interpreter.set_tensor(input_details[0]['index'], sample_data)\n", + "interpreter.invoke()\n", + "\n", + "spec = interpreter.get_tensor(65) # This index is the log-mel features, to my knowledge\n", + "spec = spec.squeeze().T # transform for visualization\n", + "print(\"Embedding model features shape:\", spec.shape)\n", + "\n", + "_ = plt.imshow(spec)" + ] + }, + { + "cell_type": "markdown", + "id": "dc88eeaa", + "metadata": {}, + "source": [ + "This certainly *looks* like a log-mel spectrogram, and we can compute the same from the reference Librosa implementation for comparison." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "5363ba83", + "metadata": { + "ExecuteTime": { + "end_time": "2024-01-18T00:26:26.195376Z", + "start_time": "2024-01-18T00:26:25.418886Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Librosa features shape: (32, 76)\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import librosa\n", + "\n", + "S = librosa.feature.melspectrogram(y=sample_data, win_length=int(0.025*16000), \n", + " hop_length=int(0.010*16000), n_fft=512, center=True,\n", + " sr=16000, n_mels=32, fmin=60, fmax=3800, power=2)#, norm=None)\n", + "\n", + "S = librosa.power_to_db(S).squeeze()[:, 1:-1] # convert to logmel and remove edge columns from center=True\n", + "\n", + "print(\"Librosa features shape:\", spec.shape)\n", + "_ = plt.imshow(S)" + ] + }, + { + "cell_type": "markdown", + "id": "f03308c2", + "metadata": {}, + "source": [ + "Visually, these mel-spectrograms are very similar, but on closer inspection there are differences. Plotting at a single time slice better shows the difference:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "d80091c0", + "metadata": { + "ExecuteTime": { + "end_time": "2024-01-18T00:26:30.675702Z", + "start_time": "2024-01-18T00:26:30.579560Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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Bz9wPOaBBx5g48aNfPzxx3zxxRds2LCBXbt2ufS8DQ0NzJs3j7CwMDZv3sw333xDaGgo8+fPbzFDs379ejIzM1m/fj2vv/46K1asaBHQLF68mLfffpt//vOfHD58mBdeeIHQ0FAMBgO33XYbr732Wovnfe2115g5cyZDhgxxapwWi4XXXnuNyMhI0tPTOzz2//7v//jVr37Fnj17GDZsGDfccAONjY0ALF26FIvFwqZNm9i/fz9/+9vfCA0NdfLVEkI46LxRnxCiC26++WbFZDIpISEhjsu1116rKIqizJo1S5kwYUKL4//85z8rl112WYvbcnNzFUA5evSoUllZqQQEBCjvvfee4/6SkhIlKChIueeeexy3AcrKlStbnCciIsKxq+p//vMfJT09XbHZbI77LRaLEhQUpKxZs8Yx9tTUVKWxsdFxzHXXXadcf/31iqIoytGjRxVAWbt2bZs/+5kzZxSTyaRs375dURRFqa+vV2JjY5UVK1a0+3o99NBDitFodLxWBoNBCQ8PVz7//PMWxzX/+bKyshRAefnllx33Hzx4UAGUw4cPK4qiKGPGjFEefvjhdp9XCOEcPz0DISFE11188cU899xzju9DQkIc1ydNmtTi2L1797J+/fo2/2rPzMyktraW+vp6pk2b5rg9Ojq601mD1vbu3cuJEycICwtrcXtdXR2ZmZmO70eNGoXJZHJ8n5iYyP79+wF1ycVkMjFr1qw2nyMpKYkrrriCV199lalTp/K///0Pi8XCdddd1+HY0tPT+e9//wtAZWUl7777Ltdddx3r169n8uTJ7T5u7NixLcYJUFhYyPDhw/nFL37BXXfdxRdffMHcuXP5/ve/3+J4IYRzJBgRopcKCQlpd1mieWACUFVVxZVXXsnf/va3845NTEx0ugrHYDCgKEqL25rnelRVVTFp0iTefPPN8x7br18/x3V/f//zzmuz2QAICgrqdBy33347N910E0888QSvvfYa119/PcHBwR0+JiAgoMXrNWHCBFatWsWTTz7JG2+80e7jmo/VYDAAOMZ6++23M2/ePD799FO++OILli9fzuOPP87Pf/7zTn8GIUQTyRkRwgdMnDiRgwcPMnDgQIYMGdLiEhISwuDBg/H392f79u2Ox5SWlrZI7gQ1oMjLy3N8f/z4cWpqalo8z/Hjx4mLizvveSIiIpwa65gxY7DZbOeV3TZ3+eWXExISwnPPPcfq1au57bbbnH0pWjCZTNTW1nbpsZrk5GTuvPNOPvroI+6//35eeumlbp1PCF8kwYgQPmDp0qWcO3eOG264ge+++47MzEzWrFnDrbfeitVqJTQ0lCVLlvDAAw/w1VdfceDAAW655RaMxpa/Ii655BKefvppdu/ezY4dO7jzzjtbzBzceOONxMbGsnDhQjZv3kxWVhYbNmzgF7/4BadPn3ZqrAMHDuTmm2/mtttuY9WqVY5zvPfee45jTCYTt9xyC8uWLWPo0KFkZGR0et7Gxkby8/PJz8/n+PHj/OUvf+HQoUMsXLjQyVfxfPfeey9r1qwhKyuLXbt2sX79ekaMGNHl8wnhqyQYEcIHJCUl8c0332C1WrnssssYM2YM9957L5GRkY6A47HHHmPGjBlceeWVzJ07l4suuui83JPHH3+c5ORkZsyYwY9+9CN+9atftVgeCQ4OZtOmTaSkpHDNNdcwYsQIlixZQl1dHeHh4U6P97nnnuPaa6/lZz/7GcOHD+eOO+6gurq6xTFLliyhvr6eW2+91alzHjx4kMTERBITExk/fjzvvfcezz33HIsXL3Z6XK1ZrVaWLl3KiBEjmD9/PsOGDePZZ5/t8vmE8FUGpfUCsBBC2M2ePZvx48fz5JNP6j2U82zevJk5c+aQm5tLfHy83sMRQnSDJLAKIXoVi8VCUVERDz/8MNddd50EIkL0AbJMI4ToVd5++21SU1MpKyvj0Ucf1Xs4Qgg3kGUaIYQQQuhKZkaEEEIIoSsJRoQQQgihKwlGhBBCCKErCUaEEEIIoSsJRoQQQgihKwlGhBBCCKErCUaEEEIIoSsJRoQQQgihKwlGhBBCCKGr/w/C90ZQj9ZRIgAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Plot single time slice from both melspectrograms\n", + "_ = plt.plot(spec[:, 33])\n", + "_ = plt.plot(S[:, 33]/10 + 2) # apply simple scalar transformation to better align the points\n", + "_ = plt.xlabel(\"Frequency Bins\")" + ] + }, + { + "cell_type": "markdown", + "id": "98dce06c", + "metadata": {}, + "source": [ + "While the overall trend and specific frequency features are very similar between the two, they are not exact. After some time investigating this difference, eventually I moved on to other tasks with openWakeWord, and assumed that the similarity of the spectrograms would mean the downstream model performance would be relatively unnaffected. This assumption seems to have been largely true, and typically the performance difference between the openWakeWord implementation and the original Google embedding model is small.\n", + "\n", + "For completeness, below is the implementation of a melspectrogram using just PyTorch, so that it can be converted to ONNX/tflite for more efficient computation on a wide range of devices. This code was based on the implementation from [torchlibrosa](https://github.com/qiuqiangkong/torchlibrosa) and is identical to the librosa reference implementation to within rounding error." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "07e24374", + "metadata": {}, + "outputs": [], + "source": [ + "# torchlibrosa version of melspectrogram\n", + "\n", + "import torch\n", + "import torchlibrosa as tl\n", + "import numpy as np\n", + "\n", + "batch_size = 1\n", + "sample_rate = 16000\n", + "win_length = 400\n", + "hop_length = 160\n", + "n_mels = 32\n", + "nfft=512\n", + "\n", + "batch_audio = torch.empty(batch_size, 32000).uniform_(-1, 1) # (batch_size, sample_rate)\n", + "\n", + "def f(self, input):\n", + " r\"\"\"Power to db, this function is the pytorch implementation of \n", + " librosa.power_to_lb.\n", + " \"\"\"\n", + " ref_value = self.ref\n", + " log_spec = 10.0 * torch.log(torch.clamp(input, min=self.amin, max=np.inf))/torch.log(torch.tensor(10))\n", + " log_spec -= 10.0 * torch.log(torch.maximum(torch.tensor(self.amin), torch.tensor(ref_value)))/torch.log(torch.tensor(10))\n", + "\n", + " if self.top_db is not None:\n", + " if self.top_db < 0:\n", + " raise librosa.util.exceptions.ParameterError('top_db must be non-negative')\n", + " log_spec = torch.clamp(log_spec, min=log_spec.max() - self.top_db, max=np.inf)\n", + "\n", + " return log_spec\n", + "\n", + "tl.stft.LogmelFilterBank.power_to_db = f\n", + "\n", + "# TorchLibrosa feature extractor the same as librosa.feature.melspectrogram()\n", + "feature_extractor = torch.nn.Sequential(\n", + " tl.Spectrogram(\n", + " center=False,\n", + " n_fft=nfft,\n", + " hop_length=hop_length,\n", + " win_length=win_length,\n", + " ), tl.LogmelFilterBank(\n", + " n_fft=nfft,\n", + " sr=sample_rate,\n", + " n_mels=n_mels,\n", + " fmin=60,\n", + " fmax=3800,\n", + " is_log=True, # Default is true\n", + " ))\n", + "\n", + "# export to onnx\n", + "torch.onnx.export(feature_extractor, batch_audio, \"torchlibrosa_onnx_melspectrogram.onnx\",\n", + " opset_version=12, input_names = ['input'], output_names = ['output'], \n", + " dynamic_axes={\"input\": {0: 'batch_size', 1: 'samples'}, \"output\": {0: 'time'}})\n" + ] + }, + { + "cell_type": "markdown", + "id": "bbf87b19", + "metadata": {}, + "source": [ + "# Create New Model with Keras" + ] + }, + { + "cell_type": "markdown", + "id": "6bda8bd3", + "metadata": {}, + "source": [ + "After separting the log-mel feature calculution from the embedding model, we can now re-produce the rest of the model manually in Keras.\n", + "\n", + "Note that for many of the layers below, the hard-coded values and parameters were obtained by inspecting the tflite version of the original embedding model, using a tool like [Netron](http://www.netron.app)." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "4fca0fa3", + "metadata": { + "ExecuteTime": { + "end_time": "2024-01-18T00:27:56.152072Z", + "start_time": "2024-01-18T00:27:55.876595Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Model: \"model_1\"\n", + "__________________________________________________________________________________________________\n", + " Layer (type) Output Shape Param # Connected to \n", + "==================================================================================================\n", + " input_2 (InputLayer) [(None, 76, 32, 1)] 0 [] \n", + " \n", + " zero_padding2d (ZeroPadding2D) (None, 76, 34, 1) 0 ['input_2[0][0]'] \n", + " \n", + " conv2d (Conv2D) (None, 74, 32, 24) 216 ['zero_padding2d[0][0]'] \n", + " \n", + " batch_normalization (BatchNorm (None, 74, 32, 24) 96 ['conv2d[0][0]'] \n", + " alization) \n", + " \n", + " tf.math.multiply (TFOpLambda) (None, 74, 32, 24) 0 ['batch_normalization[0][0]'] \n", + " \n", + " tf.math.truediv (TFOpLambda) (None, 74, 32, 24) 0 ['tf.math.multiply[0][0]'] \n", + " \n", + " tf.math.maximum (TFOpLambda) (None, 74, 32, 24) 0 ['tf.math.truediv[0][0]', \n", + " 'batch_normalization[0][0]'] \n", + " \n", + " tf.math.maximum_1 (TFOpLambda) (None, 74, 32, 24) 0 ['tf.math.maximum[0][0]'] \n", + " \n", + " conv2d_1 (Conv2D) (None, 74, 32, 24) 1728 ['tf.math.maximum_1[0][0]'] \n", + " \n", + " batch_normalization_1 (BatchNo (None, 74, 32, 24) 96 ['conv2d_1[0][0]'] \n", + " rmalization) \n", + " \n", + " tf.math.multiply_1 (TFOpLambda (None, 74, 32, 24) 0 ['batch_normalization_1[0][0]'] \n", + " ) \n", + " \n", + " tf.math.truediv_1 (TFOpLambda) (None, 74, 32, 24) 0 ['tf.math.multiply_1[0][0]'] \n", + " \n", + " tf.math.maximum_2 (TFOpLambda) (None, 74, 32, 24) 0 ['tf.math.truediv_1[0][0]', \n", + " 'batch_normalization_1[0][0]'] \n", + " \n", + " tf.math.maximum_3 (TFOpLambda) (None, 74, 32, 24) 0 ['tf.math.maximum_2[0][0]'] \n", + " \n", + " conv2d_2 (Conv2D) (None, 72, 32, 24) 1728 ['tf.math.maximum_3[0][0]'] \n", + " \n", + " batch_normalization_2 (BatchNo (None, 72, 32, 24) 96 ['conv2d_2[0][0]'] \n", + " rmalization) \n", + " \n", + " tf.math.multiply_2 (TFOpLambda (None, 72, 32, 24) 0 ['batch_normalization_2[0][0]'] \n", + " ) \n", + " \n", + " tf.math.truediv_2 (TFOpLambda) (None, 72, 32, 24) 0 ['tf.math.multiply_2[0][0]'] \n", + " \n", + " tf.math.maximum_4 (TFOpLambda) (None, 72, 32, 24) 0 ['tf.math.truediv_2[0][0]', \n", + " 'batch_normalization_2[0][0]'] \n", + " \n", + " tf.math.maximum_5 (TFOpLambda) (None, 72, 32, 24) 0 ['tf.math.maximum_4[0][0]'] \n", + " \n", + " max_pooling2d (MaxPooling2D) (None, 36, 16, 24) 0 ['tf.math.maximum_5[0][0]'] \n", + " \n", + " conv2d_3 (Conv2D) (None, 36, 16, 48) 3456 ['max_pooling2d[0][0]'] \n", + " \n", + " batch_normalization_3 (BatchNo (None, 36, 16, 48) 192 ['conv2d_3[0][0]'] \n", + " rmalization) \n", + " \n", + " tf.math.multiply_3 (TFOpLambda (None, 36, 16, 48) 0 ['batch_normalization_3[0][0]'] \n", + " ) \n", + " \n", + " tf.math.truediv_3 (TFOpLambda) (None, 36, 16, 48) 0 ['tf.math.multiply_3[0][0]'] \n", + " \n", + " tf.math.maximum_6 (TFOpLambda) (None, 36, 16, 48) 0 ['tf.math.truediv_3[0][0]', \n", + " 'batch_normalization_3[0][0]'] \n", + " \n", + " tf.math.maximum_7 (TFOpLambda) (None, 36, 16, 48) 0 ['tf.math.maximum_6[0][0]'] \n", + " \n", + " conv2d_4 (Conv2D) (None, 34, 16, 48) 6912 ['tf.math.maximum_7[0][0]'] \n", + " \n", + " batch_normalization_4 (BatchNo (None, 34, 16, 48) 192 ['conv2d_4[0][0]'] \n", + " rmalization) \n", + " \n", + " tf.math.multiply_4 (TFOpLambda (None, 34, 16, 48) 0 ['batch_normalization_4[0][0]'] \n", + " ) \n", + " \n", + " tf.math.truediv_4 (TFOpLambda) (None, 34, 16, 48) 0 ['tf.math.multiply_4[0][0]'] \n", + " \n", + " tf.math.maximum_8 (TFOpLambda) (None, 34, 16, 48) 0 ['tf.math.truediv_4[0][0]', \n", + " 'batch_normalization_4[0][0]'] \n", + " \n", + " tf.math.maximum_9 (TFOpLambda) (None, 34, 16, 48) 0 ['tf.math.maximum_8[0][0]'] \n", + " \n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " conv2d_5 (Conv2D) (None, 34, 16, 48) 6912 ['tf.math.maximum_9[0][0]'] \n", + " \n", + " batch_normalization_5 (BatchNo (None, 34, 16, 48) 192 ['conv2d_5[0][0]'] \n", + " rmalization) \n", + " \n", + " tf.math.multiply_5 (TFOpLambda (None, 34, 16, 48) 0 ['batch_normalization_5[0][0]'] \n", + " ) \n", + " \n", + " tf.math.truediv_5 (TFOpLambda) (None, 34, 16, 48) 0 ['tf.math.multiply_5[0][0]'] \n", + " \n", + " tf.math.maximum_10 (TFOpLambda (None, 34, 16, 48) 0 ['tf.math.truediv_5[0][0]', \n", + " ) 'batch_normalization_5[0][0]'] \n", + " \n", + " tf.math.maximum_11 (TFOpLambda (None, 34, 16, 48) 0 ['tf.math.maximum_10[0][0]'] \n", + " ) \n", + " \n", + " conv2d_6 (Conv2D) (None, 32, 16, 48) 6912 ['tf.math.maximum_11[0][0]'] \n", + " \n", + " batch_normalization_6 (BatchNo (None, 32, 16, 48) 192 ['conv2d_6[0][0]'] \n", + " rmalization) \n", + " \n", + " tf.math.multiply_6 (TFOpLambda (None, 32, 16, 48) 0 ['batch_normalization_6[0][0]'] \n", + " ) \n", + " \n", + " tf.math.truediv_6 (TFOpLambda) (None, 32, 16, 48) 0 ['tf.math.multiply_6[0][0]'] \n", + " \n", + " tf.math.maximum_12 (TFOpLambda (None, 32, 16, 48) 0 ['tf.math.truediv_6[0][0]', \n", + " ) 'batch_normalization_6[0][0]'] \n", + " \n", + " tf.math.maximum_13 (TFOpLambda (None, 32, 16, 48) 0 ['tf.math.maximum_12[0][0]'] \n", + " ) \n", + " \n", + " max_pooling2d_1 (MaxPooling2D) (None, 32, 8, 48) 0 ['tf.math.maximum_13[0][0]'] \n", + " \n", + " conv2d_7 (Conv2D) (None, 32, 8, 72) 10368 ['max_pooling2d_1[0][0]'] \n", + " \n", + " batch_normalization_7 (BatchNo (None, 32, 8, 72) 288 ['conv2d_7[0][0]'] \n", + " rmalization) \n", + " \n", + " tf.math.multiply_7 (TFOpLambda (None, 32, 8, 72) 0 ['batch_normalization_7[0][0]'] \n", + " ) \n", + " \n", + " tf.math.truediv_7 (TFOpLambda) (None, 32, 8, 72) 0 ['tf.math.multiply_7[0][0]'] \n", + " \n", + " tf.math.maximum_14 (TFOpLambda (None, 32, 8, 72) 0 ['tf.math.truediv_7[0][0]', \n", + " ) 'batch_normalization_7[0][0]'] \n", + " \n", + " tf.math.maximum_15 (TFOpLambda (None, 32, 8, 72) 0 ['tf.math.maximum_14[0][0]'] \n", + " ) \n", + " \n", + " conv2d_8 (Conv2D) (None, 30, 8, 72) 15552 ['tf.math.maximum_15[0][0]'] \n", + " \n", + " batch_normalization_8 (BatchNo (None, 30, 8, 72) 288 ['conv2d_8[0][0]'] \n", + " rmalization) \n", + " \n", + " tf.math.multiply_8 (TFOpLambda (None, 30, 8, 72) 0 ['batch_normalization_8[0][0]'] \n", + " ) \n", + " \n", + " tf.math.truediv_8 (TFOpLambda) (None, 30, 8, 72) 0 ['tf.math.multiply_8[0][0]'] \n", + " \n", + " tf.math.maximum_16 (TFOpLambda (None, 30, 8, 72) 0 ['tf.math.truediv_8[0][0]', \n", + " ) 'batch_normalization_8[0][0]'] \n", + " \n", + " tf.math.maximum_17 (TFOpLambda (None, 30, 8, 72) 0 ['tf.math.maximum_16[0][0]'] \n", + " ) \n", + " \n", + " conv2d_9 (Conv2D) (None, 30, 8, 72) 15552 ['tf.math.maximum_17[0][0]'] \n", + " \n", + " batch_normalization_9 (BatchNo (None, 30, 8, 72) 288 ['conv2d_9[0][0]'] \n", + " rmalization) \n", + " \n", + " tf.math.multiply_9 (TFOpLambda (None, 30, 8, 72) 0 ['batch_normalization_9[0][0]'] \n", + " ) \n", + " \n", + " tf.math.truediv_9 (TFOpLambda) (None, 30, 8, 72) 0 ['tf.math.multiply_9[0][0]'] \n", + " \n", + " tf.math.maximum_18 (TFOpLambda (None, 30, 8, 72) 0 ['tf.math.truediv_9[0][0]', \n", + " ) 'batch_normalization_9[0][0]'] \n", + " \n", + " tf.math.maximum_19 (TFOpLambda (None, 30, 8, 72) 0 ['tf.math.maximum_18[0][0]'] \n", + " ) \n", + " \n", + " conv2d_10 (Conv2D) (None, 28, 8, 72) 15552 ['tf.math.maximum_19[0][0]'] \n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n", + " batch_normalization_10 (BatchN (None, 28, 8, 72) 288 ['conv2d_10[0][0]'] \n", + " ormalization) \n", + " \n", + " tf.math.multiply_10 (TFOpLambd (None, 28, 8, 72) 0 ['batch_normalization_10[0][0]'] \n", + " a) \n", + " \n", + " tf.math.truediv_10 (TFOpLambda (None, 28, 8, 72) 0 ['tf.math.multiply_10[0][0]'] \n", + " ) \n", + " \n", + " tf.math.maximum_20 (TFOpLambda (None, 28, 8, 72) 0 ['tf.math.truediv_10[0][0]', \n", + " ) 'batch_normalization_10[0][0]'] \n", + " \n", + " tf.math.maximum_21 (TFOpLambda (None, 28, 8, 72) 0 ['tf.math.maximum_20[0][0]'] \n", + " ) \n", + " \n", + " max_pooling2d_2 (MaxPooling2D) (None, 14, 4, 72) 0 ['tf.math.maximum_21[0][0]'] \n", + " \n", + " conv2d_11 (Conv2D) (None, 14, 4, 96) 20736 ['max_pooling2d_2[0][0]'] \n", + " \n", + " batch_normalization_11 (BatchN (None, 14, 4, 96) 384 ['conv2d_11[0][0]'] \n", + " ormalization) \n", + " \n", + " tf.math.multiply_11 (TFOpLambd (None, 14, 4, 96) 0 ['batch_normalization_11[0][0]'] \n", + " a) \n", + " \n", + " tf.math.truediv_11 (TFOpLambda (None, 14, 4, 96) 0 ['tf.math.multiply_11[0][0]'] \n", + " ) \n", + " \n", + " tf.math.maximum_22 (TFOpLambda (None, 14, 4, 96) 0 ['tf.math.truediv_11[0][0]', \n", + " ) 'batch_normalization_11[0][0]'] \n", + " \n", + " tf.math.maximum_23 (TFOpLambda (None, 14, 4, 96) 0 ['tf.math.maximum_22[0][0]'] \n", + " ) \n", + " \n", + " conv2d_12 (Conv2D) (None, 12, 4, 96) 27648 ['tf.math.maximum_23[0][0]'] \n", + " \n", + " batch_normalization_12 (BatchN (None, 12, 4, 96) 384 ['conv2d_12[0][0]'] \n", + " ormalization) \n", + " \n", + " tf.math.multiply_12 (TFOpLambd (None, 12, 4, 96) 0 ['batch_normalization_12[0][0]'] \n", + " a) \n", + " \n", + " tf.math.truediv_12 (TFOpLambda (None, 12, 4, 96) 0 ['tf.math.multiply_12[0][0]'] \n", + " ) \n", + " \n", + " tf.math.maximum_24 (TFOpLambda (None, 12, 4, 96) 0 ['tf.math.truediv_12[0][0]', \n", + " ) 'batch_normalization_12[0][0]'] \n", + " \n", + " tf.math.maximum_25 (TFOpLambda (None, 12, 4, 96) 0 ['tf.math.maximum_24[0][0]'] \n", + " ) \n", + " \n", + " conv2d_13 (Conv2D) (None, 12, 4, 96) 27648 ['tf.math.maximum_25[0][0]'] \n", + " \n", + " batch_normalization_13 (BatchN (None, 12, 4, 96) 384 ['conv2d_13[0][0]'] \n", + " ormalization) \n", + " \n", + " tf.math.multiply_13 (TFOpLambd (None, 12, 4, 96) 0 ['batch_normalization_13[0][0]'] \n", + " a) \n", + " \n", + " tf.math.truediv_13 (TFOpLambda (None, 12, 4, 96) 0 ['tf.math.multiply_13[0][0]'] \n", + " ) \n", + " \n", + " tf.math.maximum_26 (TFOpLambda (None, 12, 4, 96) 0 ['tf.math.truediv_13[0][0]', \n", + " ) 'batch_normalization_13[0][0]'] \n", + " \n", + " tf.math.maximum_27 (TFOpLambda (None, 12, 4, 96) 0 ['tf.math.maximum_26[0][0]'] \n", + " ) \n", + " \n", + " conv2d_14 (Conv2D) (None, 10, 4, 96) 27648 ['tf.math.maximum_27[0][0]'] \n", + " \n", + " batch_normalization_14 (BatchN (None, 10, 4, 96) 384 ['conv2d_14[0][0]'] \n", + " ormalization) \n", + " \n", + " tf.math.multiply_14 (TFOpLambd (None, 10, 4, 96) 0 ['batch_normalization_14[0][0]'] \n", + " a) \n", + " \n", + " tf.math.truediv_14 (TFOpLambda (None, 10, 4, 96) 0 ['tf.math.multiply_14[0][0]'] \n", + " ) \n", + " \n", + " tf.math.maximum_28 (TFOpLambda (None, 10, 4, 96) 0 ['tf.math.truediv_14[0][0]', \n", + " ) 'batch_normalization_14[0][0]'] \n", + " \n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " tf.math.maximum_29 (TFOpLambda (None, 10, 4, 96) 0 ['tf.math.maximum_28[0][0]'] \n", + " ) \n", + " \n", + " max_pooling2d_3 (MaxPooling2D) (None, 10, 2, 96) 0 ['tf.math.maximum_29[0][0]'] \n", + " \n", + " conv2d_15 (Conv2D) (None, 10, 2, 96) 27648 ['max_pooling2d_3[0][0]'] \n", + " \n", + " batch_normalization_15 (BatchN (None, 10, 2, 96) 384 ['conv2d_15[0][0]'] \n", + " ormalization) \n", + " \n", + " tf.math.multiply_15 (TFOpLambd (None, 10, 2, 96) 0 ['batch_normalization_15[0][0]'] \n", + " a) \n", + " \n", + " tf.math.truediv_15 (TFOpLambda (None, 10, 2, 96) 0 ['tf.math.multiply_15[0][0]'] \n", + " ) \n", + " \n", + " tf.math.maximum_30 (TFOpLambda (None, 10, 2, 96) 0 ['tf.math.truediv_15[0][0]', \n", + " ) 'batch_normalization_15[0][0]'] \n", + " \n", + " tf.math.maximum_31 (TFOpLambda (None, 10, 2, 96) 0 ['tf.math.maximum_30[0][0]'] \n", + " ) \n", + " \n", + " conv2d_16 (Conv2D) (None, 8, 2, 96) 27648 ['tf.math.maximum_31[0][0]'] \n", + " \n", + " batch_normalization_16 (BatchN (None, 8, 2, 96) 384 ['conv2d_16[0][0]'] \n", + " ormalization) \n", + " \n", + " tf.math.multiply_16 (TFOpLambd (None, 8, 2, 96) 0 ['batch_normalization_16[0][0]'] \n", + " a) \n", + " \n", + " tf.math.truediv_16 (TFOpLambda (None, 8, 2, 96) 0 ['tf.math.multiply_16[0][0]'] \n", + " ) \n", + " \n", + " tf.math.maximum_32 (TFOpLambda (None, 8, 2, 96) 0 ['tf.math.truediv_16[0][0]', \n", + " ) 'batch_normalization_16[0][0]'] \n", + " \n", + " tf.math.maximum_33 (TFOpLambda (None, 8, 2, 96) 0 ['tf.math.maximum_32[0][0]'] \n", + " ) \n", + " \n", + " conv2d_17 (Conv2D) (None, 8, 2, 96) 27648 ['tf.math.maximum_33[0][0]'] \n", + " \n", + " batch_normalization_17 (BatchN (None, 8, 2, 96) 384 ['conv2d_17[0][0]'] \n", + " ormalization) \n", + " \n", + " tf.math.multiply_17 (TFOpLambd (None, 8, 2, 96) 0 ['batch_normalization_17[0][0]'] \n", + " a) \n", + " \n", + " tf.math.truediv_17 (TFOpLambda (None, 8, 2, 96) 0 ['tf.math.multiply_17[0][0]'] \n", + " ) \n", + " \n", + " tf.math.maximum_34 (TFOpLambda (None, 8, 2, 96) 0 ['tf.math.truediv_17[0][0]', \n", + " ) 'batch_normalization_17[0][0]'] \n", + " \n", + " tf.math.maximum_35 (TFOpLambda (None, 8, 2, 96) 0 ['tf.math.maximum_34[0][0]'] \n", + " ) \n", + " \n", + " conv2d_18 (Conv2D) (None, 6, 2, 96) 27648 ['tf.math.maximum_35[0][0]'] \n", + " \n", + " batch_normalization_18 (BatchN (None, 6, 2, 96) 384 ['conv2d_18[0][0]'] \n", + " ormalization) \n", + " \n", + " tf.math.multiply_18 (TFOpLambd (None, 6, 2, 96) 0 ['batch_normalization_18[0][0]'] \n", + " a) \n", + " \n", + " tf.math.truediv_18 (TFOpLambda (None, 6, 2, 96) 0 ['tf.math.multiply_18[0][0]'] \n", + " ) \n", + " \n", + " tf.math.maximum_36 (TFOpLambda (None, 6, 2, 96) 0 ['tf.math.truediv_18[0][0]', \n", + " ) 'batch_normalization_18[0][0]'] \n", + " \n", + " tf.math.maximum_37 (TFOpLambda (None, 6, 2, 96) 0 ['tf.math.maximum_36[0][0]'] \n", + " ) \n", + " \n", + " max_pooling2d_4 (MaxPooling2D) (None, 3, 1, 96) 0 ['tf.math.maximum_37[0][0]'] \n", + " \n", + " conv2d_19 (Conv2D) (None, 1, 1, 96) 27648 ['max_pooling2d_4[0][0]'] \n", + " \n", + "==================================================================================================\n", + "Total params: 332,088\n", + "Trainable params: 329,448\n", + "Non-trainable params: 2,640\n", + "__________________________________________________________________________________________________\n" + ] + } + ], + "source": [ + "# Recreate the embedding model after the melspectrogram layers\n", + "# That is, have the melspectrogram of the audio as the input instead of the raw audio\n", + "\n", + "# A custom function for the leaky relu activation function, to make exporting to ONNX/tflite easier\n", + "def MyLeakyReLU(alpha = 0.20000000298023224*2):\n", + " return lambda x : tf.keras.backend.maximum(alpha * x/2, x)\n", + "\n", + "# Define convolutional block helper functions\n", + "def batch_norm_and_activation(x):\n", + " x = tf.keras.layers.BatchNormalization()(x)\n", + " x = MyLeakyReLU()(x)\n", + " x = tf.maximum(x, -0.4000000059604645)\n", + " return x\n", + "\n", + "# Define contraint for zero mean conv2d layer\n", + "class CenterAround(tf.keras.constraints.Constraint):\n", + " \"\"\"Constrains weight tensors to be centered around `ref_value`.\"\"\"\n", + " def __init__(self, ref_value):\n", + " self.ref_value = ref_value\n", + "\n", + " def __call__(self, w):\n", + " mean = tf.reduce_mean(w, axis=(0,1))\n", + " return w - mean + self.ref_value\n", + "\n", + "\n", + "# Contruct inputs\n", + "inputs = tf.keras.Input((76, 32, 1)) # melspectrogram shape when provided with 12400 samples at 16 khz\n", + "\n", + "# Input conv block\n", + "x = tf.keras.layers.ZeroPadding2D((0,1))(inputs)\n", + "x = tf.keras.layers.Conv2D(24, (3,3), use_bias=False, kernel_constraint=CenterAround(0.0),\n", + " activation='relu', padding='valid')(x)\n", + "x = batch_norm_and_activation(x)\n", + "\n", + "# Conv block #1\n", + "x = tf.keras.layers.Conv2D(24, (1,3), use_bias=False, padding='same')(x)\n", + "x = batch_norm_and_activation(x)\n", + "x = tf.keras.layers.Conv2D(24, (3,1), use_bias=False, padding='valid')(x)\n", + "x = batch_norm_and_activation(x)\n", + "x = tf.keras.layers.MaxPool2D((2,2), (2,2), padding='valid')(x)\n", + "x = tf.keras.layers.Conv2D(48, (1,3), use_bias=False, padding='same')(x)\n", + "x = batch_norm_and_activation(x)\n", + "x = tf.keras.layers.Conv2D(48, (3,1), use_bias=False, padding='valid')(x)\n", + "x = batch_norm_and_activation(x)\n", + "\n", + "# Conv block #2\n", + "x = tf.keras.layers.Conv2D(48, (1,3), use_bias=False, padding='same')(x)\n", + "x = batch_norm_and_activation(x)\n", + "x = tf.keras.layers.Conv2D(48, (3,1), use_bias=False, padding='valid')(x)\n", + "x = batch_norm_and_activation(x)\n", + "x = tf.keras.layers.MaxPool2D((1,2), (1,2), padding='same')(x)\n", + "x = tf.keras.layers.Conv2D(72, (1,3), use_bias=False, padding='same')(x)\n", + "x = batch_norm_and_activation(x)\n", + "x = tf.keras.layers.Conv2D(72, (3,1), use_bias=False, padding='valid')(x)\n", + "x = batch_norm_and_activation(x)\n", + "\n", + "# Conv block #3\n", + "x = tf.keras.layers.Conv2D(72, (1,3), use_bias=False, padding='same')(x)\n", + "x = batch_norm_and_activation(x)\n", + "x = tf.keras.layers.Conv2D(72, (3,1), use_bias=False, padding='valid')(x)\n", + "x = batch_norm_and_activation(x)\n", + "x = tf.keras.layers.MaxPool2D((2,2), (2,2), padding='valid')(x)\n", + "x = tf.keras.layers.Conv2D(96, (1,3), use_bias=False, padding='same')(x)\n", + "x = batch_norm_and_activation(x)\n", + "x = tf.keras.layers.Conv2D(96, (3,1), use_bias=False, padding='valid')(x)\n", + "x = batch_norm_and_activation(x)\n", + "\n", + "# Conv block #4\n", + "x = tf.keras.layers.Conv2D(96, (1,3), use_bias=False, padding='same')(x)\n", + "x = batch_norm_and_activation(x)\n", + "x = tf.keras.layers.Conv2D(96, (3,1), use_bias=False, padding='valid')(x)\n", + "x = batch_norm_and_activation(x)\n", + "x = tf.keras.layers.MaxPool2D((1,2), (1,2), padding='valid')(x)\n", + "x = tf.keras.layers.Conv2D(96, (1,3), use_bias=False, padding='same')(x)\n", + "x = batch_norm_and_activation(x)\n", + "x = tf.keras.layers.Conv2D(96, (3,1), use_bias=False, padding='valid')(x)\n", + "x = batch_norm_and_activation(x)\n", + "\n", + "# Conv block #5\n", + "x = tf.keras.layers.Conv2D(96, (1,3), use_bias=False, padding='same')(x)\n", + "x = batch_norm_and_activation(x)\n", + "x = tf.keras.layers.Conv2D(96, (3,1), use_bias=False, padding='valid')(x)\n", + "x = batch_norm_and_activation(x)\n", + "x = tf.keras.layers.MaxPool2D((2,2), (2,2), padding=\"valid\")(x)\n", + "x = tf.keras.layers.Conv2D(96, (3,1), use_bias=False, padding='valid')(x)\n", + "\n", + "# Build the keras model\n", + "reimplemented_model = tf.keras.Model(inputs=inputs, outputs=x)\n", + "reimplemented_model.summary()\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "3a83d707", + "metadata": { + "ExecuteTime": { + "end_time": "2024-01-18T00:28:05.030126Z", + "start_time": "2024-01-18T00:28:05.008907Z" + } + }, + "outputs": [], + "source": [ + "# Manually set the weights of the new Keras model with those from the original embedding model\n", + "\n", + "# Set weights for all layers\n", + "reimplemented_model.set_weights(embedding_model.get_weights())\n", + "\n", + "# Adjust weights of specific layer that needs to be centered around 0.0\n", + "reimplemented_model.layers[2].set_weights([CenterAround(0.0)(reimplemented_model.layers[2].weights[0])])\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eba224b4", + "metadata": {}, + "outputs": [], + "source": [ + "# Convert the new keras model to tflite format (optional for this notebook)\n", + "converter = tf.lite.TFLiteConverter.from_keras_model(model)\n", + "tflite_model = converter.convert()\n", + "\n", + "# Save the model.\n", + "with open('embedding_model.tflite', 'wb') as f:\n", + " f.write(tflite_model)" + ] + }, + { + "cell_type": "markdown", + "id": "7cfc246b", + "metadata": {}, + "source": [ + "# Compare Predictions" + ] + }, + { + "cell_type": "markdown", + "id": "4907f757", + "metadata": {}, + "source": [ + "Now that we have a re-implemented embedding model, we can verify that the predictions are the same as the original. Note that as discussed previously, the log-mel feature calculation is different, so we will start from the original audio features obtained via tflite and calculate the final embeddings from there." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "6d6ba4b5", + "metadata": { + "ExecuteTime": { + "end_time": "2024-01-18T00:29:27.288771Z", + "start_time": "2024-01-18T00:29:27.165734Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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3/vehxUrVmDFihX42Mc+hsceewxHH320t/0rr7yCM844A8lkEr29vbj44ouRyWS8+x3HwbXXXouFCxdC0zQce+yxuPfee0Ov+cQTT+DYY49FIpHACSecgF//+tcVy1OPPfYY3va2tyGZTGLRokW47LLLkM1my/5tsizj4x//OG6//Xbvtp07d+Khhx7Cxz/+8aLtb7nlFixfvhyqqmLlypX40Y9+FLp/w4YNOPXUU5FIJLB69Wr88Y9/LHqOHTt24LzzzkNXVxd6enrwgQ98AFu3bi27n1PBG0/+D9684d9Q+N2X6v7cHBwcHFEc0DkqUeRNG6uvua8lr/36tWchpVZ+u0dGRnDffffhX//1X5FMJkP3DQwM4Pzzz8fdd9+N733ve1543Q033IBrrrkG//iP/xj7nFu2bMGHP/xhXH755fj0pz+NF198EX/3d39XcV82bdqEX//61/jd736H0dFRnHfeefja176Gf/3XfwUAZLNZXHnllTj66KORyWRwzTXX4EMf+hDWrl1bVYvr2rVr8cYbb+BnP/tZye3Z35jNZnHWWWfh5JNPxrPPPot9+/bh05/+NC699FLceeedAIDvfOc7+Ld/+zfceuutWLNmDW6//Xa8//3vx2uvvYYVK1ZgYmIC73vf+/AXf/EX+OlPf4pt27bhiiuuqPgenH322fjqV7+K22+/HUNDQ7j00ktx6aWX4o477ij72IsuuginnXYavvOd7yCVSuHOO+/E2Wefjf7+/tB299xzDy6//HJ8+9vfxplnnonf/e53uPDCC7Fw4UKcfvrpcBwH55xzDvr7+/H0009jfHy8aL9N0/Ten0cffRSyLOOrX/0qzj77bLz88st1TZnVJ8gQUc3OVNiSg4ODY/o4qBSV2YANGzbAdd1YfwNApk+Pjo5iaGjIu+2MM87A5z//eSxfvhzLly8vesytt96KlStX4pvf/CZWrlyJj370o7jgggsq7ovjOLjzzjtx5JFH4m1vexs+8YlPhAZInnvuuTjnnHNw6KGH4thjj8Xtt9+OV155Ba+//npVfyvzxKxcudK7bd++fWhra/P+fe973wMA/PSnP0WhUMAPf/hDHHnkkTjjjDPw3e9+Fz/60Y+86ds33HADrrrqKnz0ox/FypUr8fWvfx3HHnssvv3tb3vPIQgCfvCDH2D16tV497vfjS984Qtl9/H666/H+eefjyuuuAIrVqzAW97yFtx000344Q9/iEKhUPaxa9aswSGHHIJf/vKXcF0Xd955Jy666KKi7W644QZccMEF+Ju/+RscdthhuPLKK3HOOefghhtuAAD86U9/wp///Gf88Ic/xDHHHINTTz0V1113Xeg57r77bjiOg//4j//AUUcdhVWrVuGOO+7A9u3b8dBDD5Xdz1rh6GQchQCnrs/LwcHBEYeDSlFJKhJev/aslr12LailVHTCCSeUvX/dunU48cQTQ7e96U1vqvi8S5cuRXt7u/f7vHnzsG/fPu/3DRs24JprrsHTTz+N/fv3w3HIhWv79u01GXmD6O3t9cowp512GgzDAAC88cYbOOaYY5BOp71tTznlFDiOg3Xr1iGZTGL37t045ZRTQs93yimn4KWXXgJA3oejjz46lLZa6X146aWX8PLLL+MnP/mJd5vrunAcB1u2bClJKBkuuugi3HHHHVi8eDGy2Sz+4i/+At/97ndD27zxxhu4+OKLi/b7O9/5jnf/okWLMH/+fO/+6HTxl156CRs3bgx9XgBQKBRqKsdVA8cgREVy+ZBPDg6OxuOgIiqCIFRVfmklDj30UAiCgDfeeAMf+tCHiu5/44030N3djblz53q3BS/e9YSiKKHfBUHwyAgAvO9978OSJUvwgx/8APPnz4fjODjyyCM9clEJK1asAEAIxJo1awCQ6b2HHnooANRkBm4UMpkMPvvZz+Kyyy4ruq8aU+z555+PL37xi/inf/onfOITn2jY35TJZHD88ceHCBVD8LtSD7gG8eeI4ESFg4Oj8eClnxmG3t5evPOd78T3vvc95PP50H2Dg4P4yU9+go985CM1DVdcuXIlnnvuudBtzz777LT2c3h4GOvWrcPf//3f4x3veIdXkqoFa9asweGHH44bbrghRIDisGrVKrz00kshE+vjjz8OURSxcuVKdHR0YP78+Xj88cdDj3v88cexevVqAOR9eOWVV6Drund/pffhuOOOw+uvv45DDz206F81vo+enh68//3vx8MPPxxb9mF/W7n9XrVqFXbs2IE9e/Z49z/11FNF+7lhwwb09fUV7WdnZ2fF/awFgkm+lyJXVDg4OJoATlRmIL773e9C13WcddZZeOSRR7Bjxw7ce++9eOc734kFCxZ4ZtZq8dnPfhZ//vOfcdVVV2H9+vX4z//8T8+A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c845+NCHPoSuri784Q9/wObNm3HuuefilFNOwUc+8hHvcd/85jfx5S9/Gf/8z/+M++67D5dffjkOO+wwvPOd7yx6DdM0cdZZZ+Hkk0/Go48+ClmW8dWvfhVnn302Xn75ZaiqCgB44IEHsHDhQjzyyCN4/PHH8alPfQpPPPEETj31VDz99NO4++678dnPfhbvfOc7sXDhwqqf98EHH/RUjI0bN+IjH/kIjj32WHzmM5/Br371KxxzzDG4+OKL8ZnPfMbb502bNuHss8/GV7/6Vdx+++0YGhrCpZdeiksvvdQjSxdccAF2796NBx98EIqi4LLLLsO+ffuq+vwuuugifPGLX8RXvvIVAERNOf/884u2+853voN/+7d/w6233oo1a9bg9ttvx/vf/3689tprWLFiBTKZDN773vfine98J3784x9jy5YtuPzyy0PPMTY2hjPOOAOf/vSnceONNyKfz3uDFx944IGq9rccjHw29Ds303JwNA+2Scr/Cg6u4+7gIiqzCL/73e/Q1tYGy7Kg6zpEUcR3v/tdAICu67juuuvwpz/9CSeffDIA4JBDDsFjjz2GW2+9tSRRMU0Tt9xyC5YvXw4A+PCHP4wf/ehH2Lt3L9ra2rB69WqcfvrpePDBB0NE5ZRTTsGXvvQlAMBhhx2Gxx9/HDfeeGMsUbn77rvhOA7+4z/+w1Ms7rjjDnR1deGhhx7Cu971LgBAT08PbrrpJoiiiJUrV+Ib3/gGcrkcvvzlLwMArr76anzta1/DY489ho9+9KNVP293dze++93vQpIkHH744XjPe96D+++/H5/5zGfQ09MDSZLQ3t6OgYEBb5+vv/56nH/++Z7BdMWKFbjpppvw9re/Hbfccgu2b9+O//mf/8EzzzyDE088EQBw2223YdWqVVV9lu9973vxf//v/8UjjzyC448/Hv/5n/+Jxx57DLfffntouxtuuAFXXXUVPvrRjwIAvv71r+PBBx/Et7/9bdx888346U9/CsdxcNtttyGRSOCII47Azp078dd//dfec3z3u9/FmjVrcN1113m33X777Vi0aBHWr1+Pww47rKp9LgWzwD0qHBytgmOSjjvV5UTlwIWSIspGq167Bpx++um45ZZbkM1mceONN0KWZZx77rkAgI0bNyKXyxURBcMwsGbNmpLPmUqlPJICAP39/Vi6dCna2tpCt0WV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57e//S0DwPbt29epZaaGqakpdvrpp7O9e/ey973vfYFQoeO+fHzxi19kl156adPHfd9no6Oj7Otf/3pw38TEBLMsi33/+99vxxJTyzXXXMM++clP1t330Y9+lF133XWMMTr2cYa2fiLYto39+/fjyiuvDO5TVRVXXnkl9u3b18GVpZ/JyUkAwIoVKwAA+/fvh+M4dZ/F5s2bMTY2Rp/FErB7925cc801dccXoOO+nPzTP/0Ttm/fjmuvvRarVq3Ceeedh7/9278NHn/llVcwPj5ed+zz+TwuvPBCOvaL5OKLL8ajjz6KF198EQDw61//Go8//jg+8IEPAKBjH2cSn5681Lz99tvwPA8jIyN194+MjOCFF17o0KrSj+/7uPXWW3HJJZfg7LPPBgCMj4/DNE0MDAzUPXdkZATj4+MdWGV6ePjhh/Hv//7vePrpp2c8Rsd9+Xj55ZfxrW99C5/97GfxpS99CU8//TQ+85nPwDRNXH/99cHxbfT9Q8d+cdx2220oFArYvHkzNE2D53m46667cN111wEAHfsYQ0KFiAW7d+/GwYMH8fjjj3d6KannyJEjuOWWW7B3715kMplOL6er8H0f27dvx1e/+lUAwHnnnYeDBw/i29/+Nq6//voOry7d/PCHP8RDDz2Ev//7v8dZZ52FAwcO4NZbb8Xq1avp2Mcc2vqJMDw8DE3TZkw4HD9+HKOjox1aVbr59Kc/jZ/+9Kf4l3/5F6xZsya4f3R0FLZtY2Jiou759Fksjv379+PNN9/E+eefD13Xoes6fvnLX+Kb3/wmdF3HyMgIHfdl4pRTTsG73vWuuvu2bNmC119/HQCC40vfP0vP5z//edx222342Mc+hnPOOQd/9Ed/hD/90z/Fnj17ANCxjzMkVCKYpolt27bh0UcfDe7zfR+PPvooduzY0cGVpQ/GGD796U/jH/7hH/DYY49hw4YNdY9v27YNhmHUfRaHDh3C66+/Tp/FIrjiiivw3HPP4cCBA8G/7du347rrrgt+puO+PFxyySUzRvBffPFFrFu3DgCwYcMGjI6O1h37QqGAJ598ko79IimVSlDV+lOepmnwfR8AHftY0+lu3jjy8MMPM8uy2He+8x32m9/8ht10001sYGCAjY+Pd3ppqeJP/uRPWD6fZ7/4xS/YG2+8EfwrlUrBc26++WY2NjbGHnvsMfbMM8+wHTt2sB07dnRw1ekkPPXDGB335eKpp55iuq6zu+66i7300kvsoYceYrlcjn3ve98LnnP33XezgYEB9pOf/IQ9++yz7MMf/jCNyC4B119/PTv11FOD8eQf//jHbHh4mH3hC18InkPHPp6QUGnCfffdx8bGxphpmuyCCy5gTzzxRKeXlDoANPz34IMPBs8pl8vsU5/6FBscHGS5XI595CMfYW+88UbnFp1SokKFjvvy8c///M/s7LPPZpZlsc2bN7P777+/7nHf99mXv/xlNjIywizLYldccQU7dOhQh1abHgqFArvlllvY2NgYy2QybOPGjeyOO+5g1Wo1eA4d+3iiMBay5SMIgiAIgogR1KNCEARBEERsIaFCEARBEERsIaFCEARBEERsIaFCEARBEERsIaFCEARBEERsIaFCEARBEERsIaFCEARBEERsIaFCEARBEERsIaFCEARBEERsIaFCEARBEERsIaFCEARBEERs+f/7Y/qsaZ4WVAAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Get original embedding model prediction\n", + "original_embeddings = embedding_model(sample_data)\n", + "\n", + "# Reshape original log-mel inputs from tflite model above and pass to re-implemented model\n", + "reimplemented_embeddings = reimplemented_model(spec.T[None, ..., None])\n", + "\n", + "# Plot final output embeddings for the sample data\n", + "_ = plt.plot(original_embeddings.numpy().flatten(), label=\"Original Google Model\")\n", + "_ = plt.plot(reimplemented_embeddings.numpy().flatten(), label=\"Reimplemented Model\")\n", + "_ = plt.legend()" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "ea6f9936", + "metadata": { + "ExecuteTime": { + "end_time": "2024-01-18T00:29:29.894151Z", + "start_time": "2024-01-18T00:29:29.882876Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "0.00010585785" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Check maximum absolute difference in the output embeddings to confirm practical equivalence\n", + "np.abs(original_embeddings.numpy().flatten() - reimplemented_embeddings.numpy().flatten()).max()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "openwakeword_dev", + "language": "python", + "name": "openwakeword_dev" + }, + "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.16" + }, + "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 +} From e3f1046ebbfdb78ccbaa21cda0912ab56dc754b2 Mon Sep 17 00:00:00 2001 From: dscripka Date: Thu, 18 Jan 2024 20:16:41 -0500 Subject: [PATCH 085/103] fix flake8 issue --- openwakeword/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openwakeword/utils.py b/openwakeword/utils.py index 6572e23..8da8048 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -269,7 +269,7 @@ class AudioFeatures(): result = self._get_melspectrogram(batch) elif pool: - chunksize = batch.shape[0]//ncpu if batch.shape[0]>=ncpu else 1 + chunksize = batch.shape[0]//ncpu if batch.shape[0] >= ncpu else 1 result = np.array(pool.map(self._get_melspectrogram, batch, chunksize=chunksize)) @@ -331,7 +331,7 @@ class AudioFeatures(): result = self.embedding_model_predict(batch) elif pool: - chunksize = batch.shape[0]//ncpu if batch.shape[0]>=ncpu else 1 + chunksize = batch.shape[0]//ncpu if batch.shape[0] >= ncpu else 1 result = np.array(pool.map(self._get_embeddings_from_melspec, batch, chunksize=chunksize)) From c63384489e48040f0a7f30a2e5d3ba4bb047c6c7 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 11 Feb 2024 12:04:41 -0500 Subject: [PATCH 086/103] Added basic debounce logic for model.predict --- openwakeword/model.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/openwakeword/model.py b/openwakeword/model.py index 6ae820c..97d1303 100755 --- a/openwakeword/model.py +++ b/openwakeword/model.py @@ -227,7 +227,8 @@ class Model(): """Reset the prediction buffer""" self.prediction_buffer = defaultdict(partial(deque, maxlen=30)) - def predict(self, x: np.ndarray, patience: dict = {}, threshold: dict = {}, timing: bool = False): + def predict(self, x: np.ndarray, patience: dict = {}, + threshold: dict = {}, debounce_time: float = 0.0, timing: bool = False): """Predict with all of the wakeword models on the input audio frames Args: @@ -242,9 +243,11 @@ class Model(): model names and the values are the number of frames. Can reduce false-positive detections at the cost of a lower true-positive rate. By default, this behavior is disabled. - threshold (dict): The threshold values to use when the `patience` behavior is enabled. + threshold (dict): The threshold values to use when the `patience` or `debounce_time` behavior is enabled. Must be provided as an a dictionary where the keys are the model names and the values are the thresholds. + debounce_time (float): The time (in seconds) to wait before returning another non-zero prediction + after a non-zero prediction. Can preven multiple detections of the same wake-word. timing (bool): Whether to return timing information of the models. Can be useful to debug and assess how efficiently models are running on the current hardware. @@ -333,16 +336,22 @@ class Model(): timing_dict["models"][mdl] = time.time() - model_start # Update scores based on thresholds or patience arguments - if patience != {}: + if patience != {} or debounce_time > 0: if threshold == {}: raise ValueError("Error! When using the `patience` argument, threshold " "values must be provided via the `threshold` argument!") + if patience != {} and debounce_time > 0: + raise ValueError("Error! The `patience` and `debounce_time` arguments cannot be used together!") for mdl in predictions.keys(): parent_model = self.get_parent_model_from_label(mdl) if parent_model in patience.keys(): scores = np.array(self.prediction_buffer[mdl])[-patience[parent_model]:] if (scores >= threshold[parent_model]).sum() < patience[parent_model]: predictions[mdl] = 0.0 + if debounce_time > 0: + n_frames = int(debounce_time*1000/80) + if (np.array(self.prediction_buffer[mdl])[-n_frames:] >= threshold[parent_model]).sum() > 0: + predictions[mdl] = 0.0 # (optionally) get voice activity detection scores and update model scores if self.vad_threshold > 0: From 68e88c1350113a1e70afc8cb64e8d9d120db619f Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 11 Feb 2024 12:08:03 -0500 Subject: [PATCH 087/103] Added/fixed reset methods --- openwakeword/model.py | 4 +++- openwakeword/utils.py | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/openwakeword/model.py b/openwakeword/model.py index 97d1303..8f2ef42 100755 --- a/openwakeword/model.py +++ b/openwakeword/model.py @@ -224,8 +224,10 @@ class Model(): return parent_model def reset(self): - """Reset the prediction buffer""" + """Reset the prediction and audio feature buffers. Useful for re-initializing the model, though may not be efficient + when called too frequently.""" self.prediction_buffer = defaultdict(partial(deque, maxlen=30)) + self.preprocessor.reset() def predict(self, x: np.ndarray, patience: dict = {}, threshold: dict = {}, debounce_time: float = 0.0, timing: bool = False): diff --git a/openwakeword/utils.py b/openwakeword/utils.py index 8da8048..4964706 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -160,7 +160,7 @@ class AudioFeatures(): self.embedding_model_predict = tflite_embedding_predict - # Create databuffers + # Create databuffers with empty/random data self.raw_data_buffer: Deque = deque(maxlen=sr*10) self.melspectrogram_buffer = np.ones((76, 32)) # n_frames x num_features self.melspectrogram_max_len = 10*97 # 97 is the number of frames in 1 second of 16hz audio @@ -169,6 +169,14 @@ class AudioFeatures(): self.feature_buffer = self._get_embeddings(np.random.randint(-1000, 1000, 16000*4).astype(np.int16)) self.feature_buffer_max_len = 120 # ~10 seconds of feature buffer history + def reset(self): + """Reset the internal buffers""" + self.raw_data_buffer.clear() + self.melspectrogram_buffer = np.ones((76, 32)) + self.accumulated_samples = 0 + self.raw_data_remainder = np.empty(0) + self.feature_buffer = self._get_embeddings(np.random.randint(-1000, 1000, 16000*4).astype(np.int16)) + def _get_melspectrogram(self, x: Union[np.ndarray, List], melspec_transform: Callable = lambda x: x/10 + 2): """ Function to compute the mel-spectrogram of the provided audio samples. From 528f4bff2cb78f3aae594c6da127a99f312ae5cf Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 11 Feb 2024 12:45:59 -0500 Subject: [PATCH 088/103] tests for debounce functionality --- openwakeword/model.py | 27 +++++++++++++++++---------- tests/test_models.py | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/openwakeword/model.py b/openwakeword/model.py index 8f2ef42..6029963 100755 --- a/openwakeword/model.py +++ b/openwakeword/model.py @@ -327,11 +327,10 @@ class Model(): )[0][-1] predictions[cls] = verifier_prediction - # Update prediction buffer, and zero predictions for first 5 frames during model initialization + # Zero predictions for first 5 frames during model initialization for cls in predictions.keys(): if len(self.prediction_buffer[cls]) < 5: predictions[cls] = 0.0 - self.prediction_buffer[cls].append(predictions[cls]) # Get timing information if timing: @@ -346,14 +345,22 @@ class Model(): raise ValueError("Error! The `patience` and `debounce_time` arguments cannot be used together!") for mdl in predictions.keys(): parent_model = self.get_parent_model_from_label(mdl) - if parent_model in patience.keys(): - scores = np.array(self.prediction_buffer[mdl])[-patience[parent_model]:] - if (scores >= threshold[parent_model]).sum() < patience[parent_model]: - predictions[mdl] = 0.0 - if debounce_time > 0: - n_frames = int(debounce_time*1000/80) - if (np.array(self.prediction_buffer[mdl])[-n_frames:] >= threshold[parent_model]).sum() > 0: - predictions[mdl] = 0.0 + if predictions[mdl] != 0.0: + if parent_model in patience.keys(): + scores = np.array(self.prediction_buffer[mdl])[-patience[parent_model]:] + if (scores >= threshold[parent_model]).sum() < patience[parent_model]: + predictions[mdl] = 0.0 + elif debounce_time > 0: + if parent_model in threshold.keys(): + n_frames = int(np.ceil(debounce_time/(n_prepared_samples/16000))) + recent_predictions = np.array(self.prediction_buffer[mdl])[-n_frames:] + if predictions[mdl] >= threshold[parent_model] and \ + (recent_predictions >= threshold[parent_model]).sum() > 0: + predictions[mdl] = 0.0 + + # Update prediction buffer + for mdl in predictions.keys(): + self.prediction_buffer[mdl].append(predictions[mdl]) # (optionally) get voice activity detection scores and update model scores if self.vad_threshold > 0: diff --git a/tests/test_models.py b/tests/test_models.py index e728065..fb6defd 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -208,6 +208,25 @@ class TestModels: ) assert 1 == 1 + def test_models_with_debounce(self): + # Load model with defaults + owwModel = openwakeword.Model() + + # Get test clip + os.path.join("tests", "data", "alexa_test.wav") + + # Predict with chunks of 1280 with and without debounce + predictions = owwModel.predict_clip(os.path.join("tests", "data", "alexa_test.wav"), + debounce_time=0, threshold={"alexa_v0.1": 0.5}) + scores = np.array([i['alexa'] for i in predictions]) + + predictions = owwModel.predict_clip(os.path.join("tests", "data", "alexa_test.wav"), + debounce_time=1.25, threshold={"alexa": 0.5}) + scores_with_debounce = np.array([i['alexa'] for i in predictions]) + print(scores, scores_with_debounce) + assert (scores >= 0.5).sum() > 1 + assert (scores_with_debounce >= 0.5).sum() == 1 + def test_models_with_vad(self): # Load model with defaults owwModel = openwakeword.Model(vad_threshold=0.5) From e9eade7aacb67154a1bde1b0e766f881eb73fcc6 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 11 Feb 2024 15:08:27 -0500 Subject: [PATCH 089/103] Added tests for reset methods --- tests/test_models.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index fb6defd..b3907ff 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -39,6 +39,7 @@ import platform import pickle import tempfile import mock +import wave # Download models needed for tests openwakeword.utils.download_models() @@ -212,9 +213,6 @@ class TestModels: # Load model with defaults owwModel = openwakeword.Model() - # Get test clip - os.path.join("tests", "data", "alexa_test.wav") - # Predict with chunks of 1280 with and without debounce predictions = owwModel.predict_clip(os.path.join("tests", "data", "alexa_test.wav"), debounce_time=0, threshold={"alexa_v0.1": 0.5}) @@ -227,6 +225,32 @@ class TestModels: assert (scores >= 0.5).sum() > 1 assert (scores_with_debounce >= 0.5).sum() == 1 + def test_model_reset(self): + # Load the model + owwModel = openwakeword.Model() + + # Get test clip and load it + clip = os.path.join("tests", "data", "alexa_test.wav") + with wave.open(clip, mode='rb') as f: + data = np.frombuffer(f.readframes(f.getnframes()), dtype=np.int16) + + # Predict frame by frame + for i in range(0, len(data), 1280): + prediction = owwModel.predict(data[i:i+1280]) + if prediction['alexa'] > 0.5: + break + + # Assert that next prediction is still > 0.5 + prediction = owwModel.predict(data[i:i+1280]) + assert prediction['alexa'] > 0.5 + + # Reset the model + owwModel.reset() + + # Assert that next prediction is < 0.5 + prediction = owwModel.predict(data[i:i+1280]) + assert prediction['alexa'] < 0.5 + def test_models_with_vad(self): # Load model with defaults owwModel = openwakeword.Model(vad_threshold=0.5) From dd5c0f031c2420c51353b4432c4c990bbca43f56 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 11 Feb 2024 15:25:06 -0500 Subject: [PATCH 090/103] increment version [skip ci] --- pyproject.toml | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b7b33d8..3cadc0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ testpaths = [ [project] name = "openwakeword" -version = "0.5.1" +version = "0.6.1" authors = [ { name="David Scripka", email="david.scripka@gmail.com" }, ] diff --git a/setup.py b/setup.py index ca9704a..dbb3b38 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def build_additional_requires(): setuptools.setup( name="openwakeword", - version="0.5.1", + version="0.6.1", install_requires=[ 'onnxruntime>=1.10.0,<2', 'tflite-runtime>=2.8.0,<3; platform_system == "Linux"', From a83fde20892a354f93a890c9c65f0327b721839f Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 11 Feb 2024 15:34:56 -0500 Subject: [PATCH 091/103] fix version [skip ci] --- CHANGELOG.md | 12 ++++++++++++ pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0484d60..a69c7ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Change Log +## v0.6.0 - 2023/06/15 + +### Added + +* Various bug fixes, and some new functionality in `model.py` to control repeated detections + +### Changed + +* Models are no longer included in the PyPi package, and must be downloaded separately + +### Removed + ## v0.5.0 - 2023/06/15 ### Added diff --git a/pyproject.toml b/pyproject.toml index 3cadc0e..d420f04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ testpaths = [ [project] name = "openwakeword" -version = "0.6.1" +version = "0.6.0" authors = [ { name="David Scripka", email="david.scripka@gmail.com" }, ] diff --git a/setup.py b/setup.py index dbb3b38..df0a666 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def build_additional_requires(): setuptools.setup( name="openwakeword", - version="0.6.1", + version="0.6.0", install_requires=[ 'onnxruntime>=1.10.0,<2', 'tflite-runtime>=2.8.0,<3; platform_system == "Linux"', From c8ef6912c5feccf1037b852d9bc6c7ed644135ba Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 11 Feb 2024 15:47:55 -0500 Subject: [PATCH 092/103] updated Readme for release [skip ci] --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c1b2bba..d6f6566 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ openWakeWord is an open-source wakeword library that can be used to create voice # Updates +**2024/02/11** +- v0.6.0 of openWakeWord released. See the [changelog](CHANGELOG.md) for a full descriptions of new features and changes. + **2023/11/09** - Added example scripts under `examples/web` that demonstrate streaming audio from a web application into openWakeWord. @@ -140,7 +143,7 @@ The table below lists each model, examples of the word/phrases it is trained to | current weather | "what's the weather" | [docs](docs/models/weather.md) | | timers | "set a 10 minute timer" | [docs](docs/models/timers.md) | -Based on the methods discussed in [performance testing](#performance-and-evaluation), each included model aims to meet the target performance criteria of <5% false-reject rates and <0.5/hour false-accept rates with appropriate threshold tuning. These levels are subjective, but hopefully are below the annoyance threshold where the average user becomes frustrated with a system that often misses intended activations and/or causes disruption by activating too frequently at undesired times. For example, at these performance levels a user could expect to have the model process continuous mixed content audio of several hours with at most a few false activations, and have a failed intended activation in only 1/20 attempts (and a failed retry in only 1/400 attempts). +Based on the methods discussed in [performance testing](#performance-and-evaluation), each included model aims to meet the target performance criteria of <5% false-reject rates and <0.5/hour false-accept rates with appropriate threshold tuning. These levels are subjective, but hopefully are below the annoyance threshold where the average user becomes frustrated with a system that often misses intended activations and/or causes disruption by activating too frequently at undesired times. For example, at these performance levels a user could expect to have the model process continuous mixed content audio of several hours with at most a few false activations, and have a failed intended activation in only 1/20 attempts (and a failed retry in only 1/400 attempts). If you have a new wake word or phrase that you would like to see included in the next release, please open an issue, and we'll do a best to train a model! The focus of these requests and future release will be on words and phrases that have broad general usage versus highly specific application. @@ -222,7 +225,7 @@ openWakeWord includes an automated utility that greatly simplifies the process o 2) A more detailed [notebook](notebooks/automatic_model_training.ipynb) (also on [Google Colab](https://colab.research.google.com/drive/1yyFH-fpguX2BTAW8wSQxTrJnJTM-0QAd?usp=sharing)) that describes the training process in more details, and enables more customization. This can produce high quality models, but requires more development experience. -For a collection of models trained using the notebooks above by the Home Assistant Community (and with much gratitude to @fwartner), see the excellent repository [here](https://github.com/fwartner/home-assistant-wakewords-collection). +For a collection of models trained using the notebooks above by the Home Assistant Community (and with much gratitude to @fwartner), see the excellent repository [here](https://github.com/fwartner/home-assistant-wakewords-collection). For users interested in understanding the fundamental concepts behind model training there is a more detailed, educational [tutorial notebook](notebooks/training_models.ipynb) also available. However, this specific notebook is not intended for training production models, and the automated process above is recommended for that purpose. From 6ed9fe7d792bc9100dd5a26ef030b4f387f09fa8 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 11 Feb 2024 15:57:56 -0500 Subject: [PATCH 093/103] Update README.md [skip ci] --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d6f6566..ad77c50 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ openWakeWord is an open-source wakeword library that can be used to create voice # Updates **2024/02/11** -- v0.6.0 of openWakeWord released. See the [changelog](CHANGELOG.md) for a full descriptions of new features and changes. +- v0.6.0 of openWakeWord released. See the [releases](https://github.com/dscripka/openWakeWord/releases) for a full descriptions of new features and changes. **2023/11/09** - Added example scripts under `examples/web` that demonstrate streaming audio from a web application into openWakeWord. @@ -21,7 +21,7 @@ openWakeWord is an open-source wakeword library that can be used to create voice - Significant improvements to the process of [training new models](#training-new-models), including an example Google Colab notebook demonstrating how to train a basic wake word model in <1 hour. **2023/06/15** -- v0.5.0 of openWakeWord released. See the [changelog](CHANGELOG.md) for a full descriptions of new features and changes. +- v0.5.0 of openWakeWord released. See the [releases](https://github.com/dscripka/openWakeWord/releases) for a full descriptions of new features and changes. # Demo From dbf2da833e6179448bacf5e2d770bdd7e3c4366f Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 11 Feb 2024 16:11:49 -0500 Subject: [PATCH 094/103] fixed typo in error message --- openwakeword/custom_verifier_model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openwakeword/custom_verifier_model.py b/openwakeword/custom_verifier_model.py index e6c07d1..8846624 100644 --- a/openwakeword/custom_verifier_model.py +++ b/openwakeword/custom_verifier_model.py @@ -152,9 +152,9 @@ def train_custom_verifier( for i in tqdm(positive_reference_clips, desc="Processing positive reference clips")] ) if positive_features.shape[0] == 0: - raise ValueError("The positive features were created! Make sure that" + raise ValueError("The positive features were not created! Make sure that" " the positive reference clips contain the appropriate audio" - " for the desired model") + " for the desired model.") # Get features from negative reference clips negative_features = np.vstack( From bcbfaabdc03b5ae5e01bedce54af8c3867cb3e22 Mon Sep 17 00:00:00 2001 From: dscripka Date: Sun, 11 Feb 2024 16:25:18 -0500 Subject: [PATCH 095/103] Update README.md [skip ci] --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index ad77c50..ba652a1 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,9 @@ Future release road maps may have non-english support. In particular, [Mycroft.A **Is there a C++ version of openWakeWord?** - While the ONNX runtime [also has a C++ API](https://onnxruntime.ai/docs/get-started/with-cpp.html), there isn't an official C++ implementation of the full openWakeWord library. However, [@synesthesiam](https://github.com/synesthesiam) has created a [C++ version of openWakeWord](https://github.com/rhasspy/openWakeWord-cpp) with basic functionality implemented. +**Is openWakeWord suitable for edge devices and microcontrollers?** +- openWakeWord is generally small and efficient, but likely not enough to be suitable for deployment on very low power edge devices. For example, some experimentation by other openWakeWord users & contributors indicates that it may still take several seconds to process a single 80 ms frame on an [ESP32-S3](https://www.espressif.com/en/products/socs/esp32-s3) with quantized openWakeWord models. Instead, I would recommend the excellent [microWakeWord](https://github.com/kahrendt/microWakeWord) library from @kahrendt. It uses a similar synthetic-only training data approach and can produce high quality models that are efficient enough to run on very low power edge devices. + **Why are there three separate models instead of just one?** - Separating the models was an intentional choice to provide flexibility and optimize the efficiency of the end-to-end prediction process. For example, with separate melspectrogram, embedding, and prediction models, each one can operate on different size inputs of audio to optimize overall latency and share computations between models. It certainly is possible to make a combined model with all of the steps integrated, though, if that was a requirement of a particular use case. From 65cf92737d7ebd7b0d503860e2222dff46f45c32 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Mon, 19 Feb 2024 07:24:07 -0500 Subject: [PATCH 096/103] fix link for audioset data --- notebooks/automatic_model_training.ipynb | 994 ++++++++++++----------- 1 file changed, 502 insertions(+), 492 deletions(-) diff --git a/notebooks/automatic_model_training.ipynb b/notebooks/automatic_model_training.ipynb index 51ed036..6ce18a9 100644 --- a/notebooks/automatic_model_training.ipynb +++ b/notebooks/automatic_model_training.ipynb @@ -1,494 +1,504 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "c1eab0b3", - "metadata": { - "id": "c1eab0b3" - }, - "source": [ - "# Introduction" - ] - }, - { - "cell_type": "markdown", - "id": "882058c5", - "metadata": { - "id": "882058c5" - }, - "source": [ - "This notebook demonstrates how to train custom openWakeWord models using pre-defined datasets and an automated process for dataset generation and training. While not guaranteed to always produce the best performing model, the methods shown in this notebook often produce baseline models with releatively strong performance.\n", - "\n", - "Manual data preparation and model training (e.g., see the [training models](training_models.ipynb) notebook) remains an option for when full control over the model development process is needed.\n", - "\n", - "At a high level, the automatic training process takes advantages of several techniques to try and produce a good model, including:\n", - "\n", - "- Early-stopping and checkpoint averaging (similar to [stochastic weight averaging](https://arxiv.org/abs/1803.05407)) to search for the best models found during training, according to the validation data\n", - "- Variable learning rates with cosine decay and multiple cycles\n", - "- Adaptive batch construction to focus on only high-loss examples when the model begins to converge, combined with gradient accumulation to ensure that batch sizes are still large enough for stable training\n", - "- Cycical weight schedules for negative examples to help the model reduce false-positive rates\n", - "\n", - "See the contents of the `train.py` file for more details." - ] - }, - { - "cell_type": "markdown", - "id": "e08d031b", - "metadata": { - "id": "e08d031b" - }, - "source": [ - "# Environment Setup" - ] - }, - { - "cell_type": "markdown", - "id": "aee78c37", - "metadata": { - "id": "aee78c37" - }, - "source": [ - "To begin, we'll need to install the requirements for training custom models. In particular, a relatively recent version of Pytorch and custom fork of the [piper-sample-generator](https://github.com/dscripka/piper-sample-generator) library for generating synthetic examples for the custom model.\n", - "\n", - "**Important Note!** Currently, automated model training is only supported on linux systems due to the requirements of the text to speech library used for synthetic sample generation (Piper). It may be possible to use Piper on Windows/Mac systems, but that has not (yet) been tested." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4b1227eb", - "metadata": { - "id": "4b1227eb" - }, - "outputs": [], - "source": [ - "## Environment setup\n", - "\n", - "# install piper-sample-generator (currently only supports linux systems)\n", - "!git clone https://github.com/rhasspy/piper-sample-generator\n", - "!wget -O piper-sample-generator/models/en_US-libritts_r-medium.pt 'https://github.com/rhasspy/piper-sample-generator/releases/download/v2.0.0/en_US-libritts_r-medium.pt'\n", - "!pip install piper-phonemize\n", - "!pip install webrtcvad\n", - "\n", - "# install openwakeword (full installation to support training)\n", - "!git clone https://github.com/dscripka/openwakeword\n", - "!pip install -e ./openwakeword\n", - "!cd openwakeword\n", - "\n", - "# install other dependencies\n", - "!pip install mutagen==1.47.0\n", - "!pip install torchinfo==1.8.0\n", - "!pip install torchmetrics==1.2.0\n", - "!pip install speechbrain==0.5.14\n", - "!pip install audiomentations==0.33.0\n", - "!pip install torch-audiomentations==0.11.0\n", - "!pip install acoustics==0.2.6\n", - "!pip install tensorflow-cpu==2.8.1\n", - "!pip install tensorflow_probability==0.16.0\n", - "!pip install onnx_tf==1.10.0\n", - "!pip install pronouncing==0.2.0\n", - "!pip install datasets==2.14.6\n", - "!pip install deep-phonemizer==0.0.19\n", - "\n", - "# Download required models (workaround for Colab)\n", - "import os\n", - "os.makedirs(\"./openwakeword/openwakeword/resources/models\")\n", - "!wget https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/embedding_model.onnx -O ./openwakeword/openwakeword/resources/models/embedding_model.onnx\n", - "!wget https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/embedding_model.tflite -O ./openwakeword/openwakeword/resources/models/embedding_model.tflite\n", - "!wget https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/melspectrogram.onnx -O ./openwakeword/openwakeword/resources/models/melspectrogram.onnx\n", - "!wget https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/melspectrogram.tflite -O ./openwakeword/openwakeword/resources/models/melspectrogram.tflite\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d4c1056e", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-04T13:42:01.183840Z", - "start_time": "2023-09-04T13:41:59.752153Z" - }, - "id": "d4c1056e" - }, - "outputs": [], - "source": [ - "# Imports\n", - "\n", - "import os\n", - "import numpy as np\n", - "import torch\n", - "import sys\n", - "from pathlib import Path\n", - "import uuid\n", - "import yaml\n", - "import datasets\n", - "import scipy\n", - "from tqdm import tqdm\n" - ] - }, - { - "cell_type": "markdown", - "id": "e9d7a05a", - "metadata": { - "id": "e9d7a05a" - }, - "source": [ - "# Download Data" - ] - }, - { - "cell_type": "markdown", - "id": "c52f75cc", - "metadata": { - "id": "c52f75cc" - }, - "source": [ - "When training new openWakeWord models using the automated procedure, four specific types of data are required:\n", - "\n", - "1) Synthetic examples of the target word/phrase generated with text-to-speech models\n", - "\n", - "2) Synthetic examples of adversarial words/phrases generated with text-to-speech models\n", - "\n", - "3) Room impulse reponses and noise/background audio data to augment the synthetic examples and make them more realistic\n", - "\n", - "4) Generic \"negative\" audio data that is very unlikely to contain examples of the target word/phrase in the context where the model should detect it. This data can be the original audio data, or precomputed openWakeWord features ready for model training.\n", - "\n", - "5) Validation data to use for early-stopping when training the model.\n", - "\n", - "For the purposes of this notebook, all five of these sources will either be generated manually or can be obtained from HuggingFace thanks to their excellent `datasets` library and extremely generous hosting policy. Also note that while only a portion of some datasets are downloaded, for the best possible performance it is recommended to download the entire dataset and keep a local copy for future training runs." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d25a93b1", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-04T01:07:17.746749Z", - "start_time": "2023-09-04T01:07:17.740846Z" - }, - "id": "d25a93b1" - }, - "outputs": [], - "source": [ - "# Download room impulse responses collected by MIT\n", - "# https://mcdermottlab.mit.edu/Reverb/IR_Survey.html\n", - "\n", - "output_dir = \"./mit_rirs\"\n", - "if not os.path.exists(output_dir):\n", - " os.mkdir(output_dir)\n", - "rir_dataset = datasets.load_dataset(\"davidscripka/MIT_environmental_impulse_responses\", split=\"train\", streaming=True)\n", - "\n", - "# Save clips to 16-bit PCM wav files\n", - "for row in tqdm(rir_dataset):\n", - " name = row['audio']['path'].split('/')[-1]\n", - " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2c0e178b", - "metadata": { - "id": "2c0e178b" - }, - "outputs": [], - "source": [ - "## Download noise and background audio\n", - "\n", - "# Audioset Dataset (https://research.google.com/audioset/dataset/index.html)\n", - "# Download one part of the audioset .tar files, extract, and convert to 16khz\n", - "# For full-scale training, it's recommended to download the entire dataset from\n", - "# https://huggingface.co/datasets/agkphysics/AudioSet, and\n", - "# even potentially combine it with other background noise datasets (e.g., FSD50k, Freesound, etc.)\n", - "\n", - "if not os.path.exists(\"audioset\"):\n", - " os.mkdir(\"audioset\")\n", - "\n", - "fname = \"bal_train09.tar\"\n", - "out_dir = f\"audioset/{fname}\"\n", - "link = \"https://huggingface.co/datasets/agkphysics/AudioSet/resolve/main/\" + fname\n", - "!wget -O {out_dir} {link}\n", - "!cd audioset && tar -xvf bal_train09.tar\n", - "\n", - "output_dir = \"./audioset_16k\"\n", - "if not os.path.exists(output_dir):\n", - " os.mkdir(output_dir)\n", - "\n", - "# Convert audioset files to 16khz sample rate\n", - "audioset_dataset = datasets.Dataset.from_dict({\"audio\": [str(i) for i in Path(\"audioset/audio\").glob(\"**/*.flac\")]})\n", - "audioset_dataset = audioset_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000))\n", - "for row in tqdm(audioset_dataset):\n", - " name = row['audio']['path'].split('/')[-1].replace(\".flac\", \".wav\")\n", - " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n", - "\n", - "# Free Music Archive dataset (https://github.com/mdeff/fma)\n", - "output_dir = \"./fma\"\n", - "if not os.path.exists(output_dir):\n", - " os.mkdir(output_dir)\n", - "fma_dataset = datasets.load_dataset(\"rudraml/fma\", name=\"small\", split=\"train\", streaming=True)\n", - "fma_dataset = iter(fma_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000)))\n", - "\n", - "n_hours = 1 # use only 1 hour of clips for this example notebook, recommend increasing for full-scale training\n", - "for i in tqdm(range(n_hours*3600//30)): # this works because the FMA dataset is all 30 second clips\n", - " row = next(fma_dataset)\n", - " name = row['audio']['path'].split('/')[-1].replace(\".mp3\", \".wav\")\n", - " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n", - " i += 1\n", - " if i == n_hours*3600//30:\n", - " break\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d01ec467", - "metadata": { - "id": "d01ec467" - }, - "outputs": [], - "source": [ - "# Download pre-computed openWakeWord features for training and validation\n", - "\n", - "# training set (~2,000 hours from the ACAV100M Dataset)\n", - "# See https://huggingface.co/datasets/davidscripka/openwakeword_features for more information\n", - "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/openwakeword_features_ACAV100M_2000_hrs_16bit.npy\n", - "\n", - "# validation set for false positive rate estimation (~11 hours)\n", - "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/validation_set_features.npy" - ] - }, - { - "cell_type": "markdown", - "id": "cfe82647", - "metadata": { - "id": "cfe82647" - }, - "source": [ - "# Define Training Configuration" - ] - }, - { - "cell_type": "markdown", - "id": "b2e71329", - "metadata": { - "id": "b2e71329" - }, - "source": [ - "For automated model training openWakeWord uses a specially designed training script and a [YAML](https://yaml.org/) configuration file that defines all of the information required for training a new wake word/phrase detection model.\n", - "\n", - "It is strongly recommended that you review [the example config file](../examples/custom_model.yml), as each value is fully documented there. For the purposes of this notebook, we'll read in the YAML file to modify certain configuration parameters before saving a new YAML file for training our example model. Specifically:\n", - "\n", - "- We'll train a detection model for the phrase \"hey sebastian\"\n", - "- We'll only generate 5,000 positive and negative examples (to save on time for this example)\n", - "- We'll only generate 1,000 validation positive and negative examples for early stopping (again to save time)\n", - "- The model will only be trained for 10,000 steps (larger datasets will benefit from longer training)\n", - "- We'll reduce the target metrics to account for the small dataset size and limited training.\n", - "\n", - "On the topic of target metrics, there are *not* specific guidelines about what these metrics should be in practice, and you will need to conduct testing in your target deployment environment to establish good thresholds. However, from very limited testing the default values in the config file (accuracy >= 0.7, recall >= 0.5, false-positive rate <= 0.2 per hour) seem to produce models with reasonable performance.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fb0b6e4f", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-04T18:11:33.893397Z", - "start_time": "2023-09-04T18:11:33.878938Z" - }, - "id": "fb0b6e4f" - }, - "outputs": [], - "source": [ - "# Load default YAML config file for training\n", - "config = yaml.load(open(\"openwakeword/examples/custom_model.yml\", 'r').read(), yaml.Loader)\n", - "config" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "482cf2d0", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-04T15:07:00.859210Z", - "start_time": "2023-09-04T15:07:00.841472Z" - }, - "id": "482cf2d0" - }, - "outputs": [], - "source": [ - "# Modify values in the config and save a new version\n", - "\n", - "config[\"target_phrase\"] = [\"hey sebastian\"]\n", - "config[\"model_name\"] = config[\"target_phrase\"][0].replace(\" \", \"_\")\n", - "config[\"n_samples\"] = 1000\n", - "config[\"n_samples_val\"] = 1000\n", - "config[\"steps\"] = 10000\n", - "config[\"target_accuracy\"] = 0.6\n", - "config[\"target_recall\"] = 0.25\n", - "\n", - "config[\"background_paths\"] = ['./audioset_16k', './fma'] # multiple background datasets are supported\n", - "config[\"false_positive_validation_data_path\"] = \"validation_set_features.npy\"\n", - "config[\"feature_data_files\"] = {\"ACAV100M_sample\": \"openwakeword_features_ACAV100M_2000_hrs_16bit.npy\"}\n", - "\n", - "with open('my_model.yaml', 'w') as file:\n", - " documents = yaml.dump(config, file)" - ] - }, - { - "cell_type": "markdown", - "id": "aa6b2ab0", - "metadata": { - "id": "aa6b2ab0" - }, - "source": [ - "# Train the Model" - ] - }, - { - "cell_type": "markdown", - "id": "a51202c0", - "metadata": { - "id": "a51202c0" - }, - "source": [ - "With the data downloaded and training configuration set, we can now start training the model. We'll do this in parts to better illustrate the sequence, but you can also execute every step at once for a fully automated process." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f01531fa", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-04T13:50:08.803326Z", - "start_time": "2023-09-04T13:50:06.790241Z" - }, - "id": "f01531fa" - }, - "outputs": [], - "source": [ - "# Step 1: Generate synthetic clips\n", - "# For the number of clips we are using, this should take ~10 minutes on a free Google Colab instance with a T4 GPU\n", - "# If generation fails, you can simply run this command again as it will continue generating until the\n", - "# number of files meets the targets specified in the config file\n", - "\n", - "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --generate_clips" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "afeedae4", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-04T13:56:08.781018Z", - "start_time": "2023-09-04T13:55:40.203515Z" - }, - "id": "afeedae4" - }, - "outputs": [], - "source": [ - "# Step 2: Augment the generated clips\n", - "\n", - "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --augment_clips" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9ad81ea0", - "metadata": { - "ExecuteTime": { - "end_time": "2023-09-04T15:11:14.742260Z", - "start_time": "2023-09-04T15:07:03.755159Z" - }, - "id": "9ad81ea0" - }, - "outputs": [], - "source": [ - "# Step 3: Train model\n", - "\n", - "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --train_model" - ] - }, - { - "cell_type": "code", - "source": [ - "# Step 4 (Optional): On Google Colab, sometimes the .tflite model isn't saved correctly\n", - "# If so, run this cell to retry\n", - "\n", - "# Manually save to tflite as this doesn't work right in colab\n", - "def convert_onnx_to_tflite(onnx_model_path, output_path):\n", - " \"\"\"Converts an ONNX version of an openwakeword model to the Tensorflow tflite format.\"\"\"\n", - " # imports\n", - " import onnx\n", - " import logging\n", - " import tempfile\n", - " from onnx_tf.backend import prepare\n", - " import tensorflow as tf\n", - "\n", - " # Convert to tflite from onnx model\n", - " onnx_model = onnx.load(onnx_model_path)\n", - " tf_rep = prepare(onnx_model, device=\"CPU\")\n", - " with tempfile.TemporaryDirectory() as tmp_dir:\n", - " tf_rep.export_graph(os.path.join(tmp_dir, \"tf_model\"))\n", - " converter = tf.lite.TFLiteConverter.from_saved_model(os.path.join(tmp_dir, \"tf_model\"))\n", - " tflite_model = converter.convert()\n", - "\n", - " logging.info(f\"####\\nSaving tflite mode to '{output_path}'\")\n", - " with open(output_path, 'wb') as f:\n", - " f.write(tflite_model)\n", - "\n", - " return None\n", - "\n", - "convert_onnx_to_tflite(f\"my_custom_model/{config['model_name']}.onnx\", f\"my_custom_model/{config['model_name']}.tflite\")\n" - ], - "metadata": { - "id": "JSKWWLalnYzR" - }, - "id": "JSKWWLalnYzR", - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "markdown", - "source": [ - "After the model finishes training, the auto training script will automatically convert it to ONNX and tflite versions, saving them as `my_custom_model/.onnx/tflite` in the present working directory, where `` is defined in the YAML training config file. Either version can be used as normal with `openwakeword`. I recommend testing them with the [`detect_from_microphone.py`](https://github.com/dscripka/openWakeWord/blob/main/examples/detect_from_microphone.py) example script to see how the model performs!" - ], - "metadata": { - "id": "f9OyUW3ltOSs" - }, - "id": "f9OyUW3ltOSs" - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "name": "python3" - }, - "language_info": { - "name": "python" - }, - "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": {}, - "toc_section_display": true, - "toc_window_display": false - }, - "colab": { - "provenance": [] - } + "cells": [ + { + "cell_type": "markdown", + "id": "c1eab0b3", + "metadata": { + "id": "c1eab0b3" + }, + "source": [ + "# Introduction" + ] }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file + { + "cell_type": "markdown", + "id": "882058c5", + "metadata": { + "id": "882058c5" + }, + "source": [ + "This notebook demonstrates how to train custom openWakeWord models using pre-defined datasets and an automated process for dataset generation and training. While not guaranteed to always produce the best performing model, the methods shown in this notebook often produce baseline models with releatively strong performance.\n", + "\n", + "Manual data preparation and model training (e.g., see the [training models](training_models.ipynb) notebook) remains an option for when full control over the model development process is needed.\n", + "\n", + "At a high level, the automatic training process takes advantages of several techniques to try and produce a good model, including:\n", + "\n", + "- Early-stopping and checkpoint averaging (similar to [stochastic weight averaging](https://arxiv.org/abs/1803.05407)) to search for the best models found during training, according to the validation data\n", + "- Variable learning rates with cosine decay and multiple cycles\n", + "- Adaptive batch construction to focus on only high-loss examples when the model begins to converge, combined with gradient accumulation to ensure that batch sizes are still large enough for stable training\n", + "- Cycical weight schedules for negative examples to help the model reduce false-positive rates\n", + "\n", + "See the contents of the `train.py` file for more details." + ] + }, + { + "cell_type": "markdown", + "id": "e08d031b", + "metadata": { + "id": "e08d031b" + }, + "source": [ + "# Environment Setup" + ] + }, + { + "cell_type": "markdown", + "id": "aee78c37", + "metadata": { + "id": "aee78c37" + }, + "source": [ + "To begin, we'll need to install the requirements for training custom models. In particular, a relatively recent version of Pytorch and custom fork of the [piper-sample-generator](https://github.com/dscripka/piper-sample-generator) library for generating synthetic examples for the custom model.\n", + "\n", + "**Important Note!** Currently, automated model training is only supported on linux systems due to the requirements of the text to speech library used for synthetic sample generation (Piper). It may be possible to use Piper on Windows/Mac systems, but that has not (yet) been tested." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4b1227eb", + "metadata": { + "id": "4b1227eb" + }, + "outputs": [], + "source": [ + "## Environment setup\n", + "\n", + "# install piper-sample-generator (currently only supports linux systems)\n", + "!git clone https://github.com/rhasspy/piper-sample-generator\n", + "!wget -O piper-sample-generator/models/en_US-libritts_r-medium.pt 'https://github.com/rhasspy/piper-sample-generator/releases/download/v2.0.0/en_US-libritts_r-medium.pt'\n", + "!pip install piper-phonemize\n", + "!pip install webrtcvad\n", + "\n", + "# install openwakeword (full installation to support training)\n", + "!git clone https://github.com/dscripka/openwakeword\n", + "!pip install -e ./openwakeword\n", + "!cd openwakeword\n", + "\n", + "# install other dependencies\n", + "!pip install mutagen==1.47.0\n", + "!pip install torchinfo==1.8.0\n", + "!pip install torchmetrics==1.2.0\n", + "!pip install speechbrain==0.5.14\n", + "!pip install audiomentations==0.33.0\n", + "!pip install torch-audiomentations==0.11.0\n", + "!pip install acoustics==0.2.6\n", + "!pip install tensorflow-cpu==2.8.1\n", + "!pip install tensorflow_probability==0.16.0\n", + "!pip install onnx_tf==1.10.0\n", + "!pip install pronouncing==0.2.0\n", + "!pip install datasets==2.14.6\n", + "!pip install deep-phonemizer==0.0.19\n", + "\n", + "# Download required models (workaround for Colab)\n", + "import os\n", + "os.makedirs(\"./openwakeword/openwakeword/resources/models\")\n", + "!wget https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/embedding_model.onnx -O ./openwakeword/openwakeword/resources/models/embedding_model.onnx\n", + "!wget https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/embedding_model.tflite -O ./openwakeword/openwakeword/resources/models/embedding_model.tflite\n", + "!wget https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/melspectrogram.onnx -O ./openwakeword/openwakeword/resources/models/melspectrogram.onnx\n", + "!wget https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/melspectrogram.tflite -O ./openwakeword/openwakeword/resources/models/melspectrogram.tflite\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d4c1056e", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T13:42:01.183840Z", + "start_time": "2023-09-04T13:41:59.752153Z" + }, + "id": "d4c1056e" + }, + "outputs": [], + "source": [ + "# Imports\n", + "\n", + "import os\n", + "import numpy as np\n", + "import torch\n", + "import sys\n", + "from pathlib import Path\n", + "import uuid\n", + "import yaml\n", + "import datasets\n", + "import scipy\n", + "from tqdm import tqdm\n" + ] + }, + { + "cell_type": "markdown", + "id": "e9d7a05a", + "metadata": { + "id": "e9d7a05a" + }, + "source": [ + "# Download Data" + ] + }, + { + "cell_type": "markdown", + "id": "c52f75cc", + "metadata": { + "id": "c52f75cc" + }, + "source": [ + "When training new openWakeWord models using the automated procedure, four specific types of data are required:\n", + "\n", + "1) Synthetic examples of the target word/phrase generated with text-to-speech models\n", + "\n", + "2) Synthetic examples of adversarial words/phrases generated with text-to-speech models\n", + "\n", + "3) Room impulse reponses and noise/background audio data to augment the synthetic examples and make them more realistic\n", + "\n", + "4) Generic \"negative\" audio data that is very unlikely to contain examples of the target word/phrase in the context where the model should detect it. This data can be the original audio data, or precomputed openWakeWord features ready for model training.\n", + "\n", + "5) Validation data to use for early-stopping when training the model.\n", + "\n", + "For the purposes of this notebook, all five of these sources will either be generated manually or can be obtained from HuggingFace thanks to their excellent `datasets` library and extremely generous hosting policy. Also note that while only a portion of some datasets are downloaded, for the best possible performance it is recommended to download the entire dataset and keep a local copy for future training runs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d25a93b1", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T01:07:17.746749Z", + "start_time": "2023-09-04T01:07:17.740846Z" + }, + "id": "d25a93b1" + }, + "outputs": [], + "source": [ + "# Download room impulse responses collected by MIT\n", + "# https://mcdermottlab.mit.edu/Reverb/IR_Survey.html\n", + "\n", + "output_dir = \"./mit_rirs\"\n", + "if not os.path.exists(output_dir):\n", + " os.mkdir(output_dir)\n", + "rir_dataset = datasets.load_dataset(\"davidscripka/MIT_environmental_impulse_responses\", split=\"train\", streaming=True)\n", + "\n", + "# Save clips to 16-bit PCM wav files\n", + "for row in tqdm(rir_dataset):\n", + " name = row['audio']['path'].split('/')[-1]\n", + " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2c0e178b", + "metadata": { + "id": "2c0e178b" + }, + "outputs": [], + "source": [ + "## Download noise and background audio\n", + "\n", + "# Audioset Dataset (https://research.google.com/audioset/dataset/index.html)\n", + "# Download one part of the audioset .tar files, extract, and convert to 16khz\n", + "# For full-scale training, it's recommended to download the entire dataset from\n", + "# https://huggingface.co/datasets/agkphysics/AudioSet, and\n", + "# even potentially combine it with other background noise datasets (e.g., FSD50k, Freesound, etc.)\n", + "\n", + "if not os.path.exists(\"audioset\"):\n", + " os.mkdir(\"audioset\")\n", + "\n", + "fname = \"bal_train09.tar\"\n", + "out_dir = f\"audioset/{fname}\"\n", + "link = \"https://huggingface.co/datasets/agkphysics/AudioSet/resolve/main/data\" + fname\n", + "!wget -O {out_dir} {link}\n", + "!cd audioset && tar -xvf bal_train09.tar\n", + "\n", + "output_dir = \"./audioset_16k\"\n", + "if not os.path.exists(output_dir):\n", + " os.mkdir(output_dir)\n", + "\n", + "# Convert audioset files to 16khz sample rate\n", + "audioset_dataset = datasets.Dataset.from_dict({\"audio\": [str(i) for i in Path(\"audioset/audio\").glob(\"**/*.flac\")]})\n", + "audioset_dataset = audioset_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000))\n", + "for row in tqdm(audioset_dataset):\n", + " name = row['audio']['path'].split('/')[-1].replace(\".flac\", \".wav\")\n", + " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n", + "\n", + "# Free Music Archive dataset (https://github.com/mdeff/fma)\n", + "output_dir = \"./fma\"\n", + "if not os.path.exists(output_dir):\n", + " os.mkdir(output_dir)\n", + "fma_dataset = datasets.load_dataset(\"rudraml/fma\", name=\"small\", split=\"train\", streaming=True)\n", + "fma_dataset = iter(fma_dataset.cast_column(\"audio\", datasets.Audio(sampling_rate=16000)))\n", + "\n", + "n_hours = 1 # use only 1 hour of clips for this example notebook, recommend increasing for full-scale training\n", + "for i in tqdm(range(n_hours*3600//30)): # this works because the FMA dataset is all 30 second clips\n", + " row = next(fma_dataset)\n", + " name = row['audio']['path'].split('/')[-1].replace(\".mp3\", \".wav\")\n", + " scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n", + " i += 1\n", + " if i == n_hours*3600//30:\n", + " break\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d01ec467", + "metadata": { + "id": "d01ec467" + }, + "outputs": [], + "source": [ + "# Download pre-computed openWakeWord features for training and validation\n", + "\n", + "# training set (~2,000 hours from the ACAV100M Dataset)\n", + "# See https://huggingface.co/datasets/davidscripka/openwakeword_features for more information\n", + "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/openwakeword_features_ACAV100M_2000_hrs_16bit.npy\n", + "\n", + "# validation set for false positive rate estimation (~11 hours)\n", + "!wget https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/validation_set_features.npy" + ] + }, + { + "cell_type": "markdown", + "id": "cfe82647", + "metadata": { + "id": "cfe82647" + }, + "source": [ + "# Define Training Configuration" + ] + }, + { + "cell_type": "markdown", + "id": "b2e71329", + "metadata": { + "id": "b2e71329" + }, + "source": [ + "For automated model training openWakeWord uses a specially designed training script and a [YAML](https://yaml.org/) configuration file that defines all of the information required for training a new wake word/phrase detection model.\n", + "\n", + "It is strongly recommended that you review [the example config file](../examples/custom_model.yml), as each value is fully documented there. For the purposes of this notebook, we'll read in the YAML file to modify certain configuration parameters before saving a new YAML file for training our example model. Specifically:\n", + "\n", + "- We'll train a detection model for the phrase \"hey sebastian\"\n", + "- We'll only generate 5,000 positive and negative examples (to save on time for this example)\n", + "- We'll only generate 1,000 validation positive and negative examples for early stopping (again to save time)\n", + "- The model will only be trained for 10,000 steps (larger datasets will benefit from longer training)\n", + "- We'll reduce the target metrics to account for the small dataset size and limited training.\n", + "\n", + "On the topic of target metrics, there are *not* specific guidelines about what these metrics should be in practice, and you will need to conduct testing in your target deployment environment to establish good thresholds. However, from very limited testing the default values in the config file (accuracy >= 0.7, recall >= 0.5, false-positive rate <= 0.2 per hour) seem to produce models with reasonable performance.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fb0b6e4f", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T18:11:33.893397Z", + "start_time": "2023-09-04T18:11:33.878938Z" + }, + "id": "fb0b6e4f" + }, + "outputs": [], + "source": [ + "# Load default YAML config file for training\n", + "config = yaml.load(open(\"openwakeword/examples/custom_model.yml\", 'r').read(), yaml.Loader)\n", + "config" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "482cf2d0", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T15:07:00.859210Z", + "start_time": "2023-09-04T15:07:00.841472Z" + }, + "id": "482cf2d0" + }, + "outputs": [], + "source": [ + "# Modify values in the config and save a new version\n", + "\n", + "config[\"target_phrase\"] = [\"hey sebastian\"]\n", + "config[\"model_name\"] = config[\"target_phrase\"][0].replace(\" \", \"_\")\n", + "config[\"n_samples\"] = 1000\n", + "config[\"n_samples_val\"] = 1000\n", + "config[\"steps\"] = 10000\n", + "config[\"target_accuracy\"] = 0.6\n", + "config[\"target_recall\"] = 0.25\n", + "\n", + "config[\"background_paths\"] = ['./audioset_16k', './fma'] # multiple background datasets are supported\n", + "config[\"false_positive_validation_data_path\"] = \"validation_set_features.npy\"\n", + "config[\"feature_data_files\"] = {\"ACAV100M_sample\": \"openwakeword_features_ACAV100M_2000_hrs_16bit.npy\"}\n", + "\n", + "with open('my_model.yaml', 'w') as file:\n", + " documents = yaml.dump(config, file)" + ] + }, + { + "cell_type": "markdown", + "id": "aa6b2ab0", + "metadata": { + "id": "aa6b2ab0" + }, + "source": [ + "# Train the Model" + ] + }, + { + "cell_type": "markdown", + "id": "a51202c0", + "metadata": { + "id": "a51202c0" + }, + "source": [ + "With the data downloaded and training configuration set, we can now start training the model. We'll do this in parts to better illustrate the sequence, but you can also execute every step at once for a fully automated process." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f01531fa", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T13:50:08.803326Z", + "start_time": "2023-09-04T13:50:06.790241Z" + }, + "id": "f01531fa" + }, + "outputs": [], + "source": [ + "# Step 1: Generate synthetic clips\n", + "# For the number of clips we are using, this should take ~10 minutes on a free Google Colab instance with a T4 GPU\n", + "# If generation fails, you can simply run this command again as it will continue generating until the\n", + "# number of files meets the targets specified in the config file\n", + "\n", + "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --generate_clips" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "afeedae4", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T13:56:08.781018Z", + "start_time": "2023-09-04T13:55:40.203515Z" + }, + "id": "afeedae4" + }, + "outputs": [], + "source": [ + "# Step 2: Augment the generated clips\n", + "\n", + "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --augment_clips" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9ad81ea0", + "metadata": { + "ExecuteTime": { + "end_time": "2023-09-04T15:11:14.742260Z", + "start_time": "2023-09-04T15:07:03.755159Z" + }, + "id": "9ad81ea0" + }, + "outputs": [], + "source": [ + "# Step 3: Train model\n", + "\n", + "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --train_model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "JSKWWLalnYzR", + "metadata": { + "id": "JSKWWLalnYzR" + }, + "outputs": [], + "source": [ + "# Step 4 (Optional): On Google Colab, sometimes the .tflite model isn't saved correctly\n", + "# If so, run this cell to retry\n", + "\n", + "# Manually save to tflite as this doesn't work right in colab\n", + "def convert_onnx_to_tflite(onnx_model_path, output_path):\n", + " \"\"\"Converts an ONNX version of an openwakeword model to the Tensorflow tflite format.\"\"\"\n", + " # imports\n", + " import onnx\n", + " import logging\n", + " import tempfile\n", + " from onnx_tf.backend import prepare\n", + " import tensorflow as tf\n", + "\n", + " # Convert to tflite from onnx model\n", + " onnx_model = onnx.load(onnx_model_path)\n", + " tf_rep = prepare(onnx_model, device=\"CPU\")\n", + " with tempfile.TemporaryDirectory() as tmp_dir:\n", + " tf_rep.export_graph(os.path.join(tmp_dir, \"tf_model\"))\n", + " converter = tf.lite.TFLiteConverter.from_saved_model(os.path.join(tmp_dir, \"tf_model\"))\n", + " tflite_model = converter.convert()\n", + "\n", + " logging.info(f\"####\\nSaving tflite mode to '{output_path}'\")\n", + " with open(output_path, 'wb') as f:\n", + " f.write(tflite_model)\n", + "\n", + " return None\n", + "\n", + "convert_onnx_to_tflite(f\"my_custom_model/{config['model_name']}.onnx\", f\"my_custom_model/{config['model_name']}.tflite\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "f9OyUW3ltOSs", + "metadata": { + "id": "f9OyUW3ltOSs" + }, + "source": [ + "After the model finishes training, the auto training script will automatically convert it to ONNX and tflite versions, saving them as `my_custom_model/.onnx/tflite` in the present working directory, where `` is defined in the YAML training config file. Either version can be used as normal with `openwakeword`. I recommend testing them with the [`detect_from_microphone.py`](https://github.com/dscripka/openWakeWord/blob/main/examples/detect_from_microphone.py) example script to see how the model performs!" + ] + } + ], + "metadata": { + "colab": { + "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.9.12" + }, + "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": {}, + "toc_section_display": true, + "toc_window_display": false + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From fe57debecc64d891084a8831f981ffb7110a3b58 Mon Sep 17 00:00:00 2001 From: David Scripka Date: Mon, 19 Feb 2024 07:26:46 -0500 Subject: [PATCH 097/103] missing slash [skip ci] --- notebooks/automatic_model_training.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebooks/automatic_model_training.ipynb b/notebooks/automatic_model_training.ipynb index 6ce18a9..83309f2 100644 --- a/notebooks/automatic_model_training.ipynb +++ b/notebooks/automatic_model_training.ipynb @@ -207,7 +207,7 @@ "\n", "fname = \"bal_train09.tar\"\n", "out_dir = f\"audioset/{fname}\"\n", - "link = \"https://huggingface.co/datasets/agkphysics/AudioSet/resolve/main/data\" + fname\n", + "link = \"https://huggingface.co/datasets/agkphysics/AudioSet/resolve/main/data/\" + fname\n", "!wget -O {out_dir} {link}\n", "!cd audioset && tar -xvf bal_train09.tar\n", "\n", From c40fe924ffa12e9ddf24a3e5fcdeb4fd58ab07eb Mon Sep 17 00:00:00 2001 From: dscripka Date: Thu, 22 Feb 2024 21:14:20 -0500 Subject: [PATCH 098/103] fixed bug in train.py when using longer training examples than normal [skip ci] --- openwakeword/train.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index 7e468bf..f564254 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -813,13 +813,13 @@ if __name__ == '__main__': # Create openwakeword model if args.train_model is True: F = openwakeword.utils.AudioFeatures(device='cpu') - input_shape = F.get_embedding_shape(config["total_length"]//16000) # training data is always 16 khz + input_shape = np.load(os.path.join(feature_save_dir, "positive_features_test.npy")).shape[1:] oww = Model(n_classes=1, input_shape=input_shape, model_type=config["model_type"], layer_dim=config["layer_size"], seconds_per_example=1280*input_shape[0]/16000) # Create data transform function for batch generation to handle differ clip lengths (todo: write tests for this) - def f(x, n=16): + def f(x, n=input_shape[0]): """Simple transformation function to ensure negative data is the appropriate shape for the model size""" if n > x.shape[1] or n < x.shape[1]: x = np.vstack(x) From e5113c20228b0adafa8445b83a1d30031458d403 Mon Sep 17 00:00:00 2001 From: Mike Gray Date: Sat, 8 Jun 2024 20:46:32 -0500 Subject: [PATCH 099/103] fix: custom verifier type hints fix: be sure to close pickle file after opening --- openwakeword/custom_verifier_model.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/openwakeword/custom_verifier_model.py b/openwakeword/custom_verifier_model.py index 8846624..1c8c55f 100644 --- a/openwakeword/custom_verifier_model.py +++ b/openwakeword/custom_verifier_model.py @@ -13,17 +13,19 @@ # limitations under the License. # Imports -import os -from tqdm import tqdm import collections -import openwakeword +import os +import pickle +from typing import List, Union + import numpy as np import scipy -import pickle - from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline from sklearn.preprocessing import FunctionTransformer, StandardScaler +from tqdm import tqdm + +import openwakeword # Define functions to prepare data for speaker dependent verifier model @@ -112,8 +114,8 @@ def train_verifier_model(features: np.ndarray, labels: np.ndarray): def train_custom_verifier( - positive_reference_clips: str, - negative_reference_clips: str, + positive_reference_clips: List[Union[str, os.PathLike]], + negative_reference_clips: List[Union[str, os.PathLike]], output_path: str, model_name: str, **kwargs @@ -123,11 +125,11 @@ def train_custom_verifier( from a single user. Args: - positive_reference_clips (str): The path to a directory containing single-channel 16khz, 16-bit WAV files + positive_reference_clips (List[Union[str, os.PathLike]]): The path(s) to single-channel 16khz, 16-bit WAV files of the target wake word/phrase. - negative_reference_clips (str): The path to a directory containing single-channel 16khz, 16-bit WAV files + negative_reference_clips (List[Union[str, os.PathLike]]): The path(s) to single-channel 16khz, 16-bit WAV files of miscellaneous speech not containing the target wake word/phrase. - output_path (str): The location to save the trained verifier model (as a scikit-learn .joblib file) + output_path (str): The location to save the trained verifier model (as a Python pickle file (.pkl)) model_name (str): The name or path of the trained openWakeWord model that the verifier model will be based on. If only a name, it must be one of the pre-trained models included in the openWakeWord release. @@ -171,4 +173,5 @@ def train_custom_verifier( # Save logistic regression model to specified output location print("Done!") - pickle.dump(lr_model, open(output_path, "wb")) + with open(output_path, "wb") as f: + pickle.dump(lr_model, f) From 97a48a211dd1bd89bd96877ea9ca6f7f756352e9 Mon Sep 17 00:00:00 2001 From: dscripka Date: Tue, 2 Sep 2025 20:22:42 -0400 Subject: [PATCH 100/103] Remove outdated colab link. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ba652a1..2f393c2 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ openWakeWord includes an automated utility that greatly simplifies the process o 1) A simple [Google Colab](https://colab.research.google.com/drive/1q1oe2zOyZp7UsB3jJiQ1IFn8z5YfjwEb?usp=sharing) notebook with an easy to use interface and simple end-to-end process. This allows anyone to produce a custom model very quickly (<1 hour) and doesn't require any development experience, but the performance of the model may be low in some deployment scenarios. -2) A more detailed [notebook](notebooks/automatic_model_training.ipynb) (also on [Google Colab](https://colab.research.google.com/drive/1yyFH-fpguX2BTAW8wSQxTrJnJTM-0QAd?usp=sharing)) that describes the training process in more details, and enables more customization. This can produce high quality models, but requires more development experience. +2) A more detailed [notebook](notebooks/automatic_model_training.ipynb) that describes the training process in more details, and enables more customization. This can produce high quality models, but requires more development experience. For a collection of models trained using the notebooks above by the Home Assistant Community (and with much gratitude to @fwartner), see the excellent repository [here](https://github.com/fwartner/home-assistant-wakewords-collection). From a818cd9f176a0de32def8fccd22c51b6471befe9 Mon Sep 17 00:00:00 2001 From: "david.scripka" Date: Fri, 17 Oct 2025 22:00:16 -0400 Subject: [PATCH 101/103] Bump python version to 3.10+, switch tflite to new ai-edge-litert --- .gitignore | 15 +++++++++++++ openwakeword/custom_verifier_model.py | 2 +- openwakeword/data.py | 4 ++-- openwakeword/model.py | 6 +++--- openwakeword/utils.py | 6 +++--- pyproject.toml | 2 +- setup.py | 31 +++++++++++++++------------ tests/test_models.py | 7 +++++- 8 files changed, 48 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index b6e4761..1e20d39 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,18 @@ dmypy.json # Pyre type checker .pyre/ + +# Most notebooks +notebooks/ +!notebooks/automatic_model_training.ipynb +!notebooks/converting_google_speech_embedding_model.ipynb +!notebooks/performance_metrics.ipynb +!notebooks/training_models.ipynb +!training_tutorial_data + +# Most example files +examples/ +!examples/audio/activation.wav + +# archive files +archive/ diff --git a/openwakeword/custom_verifier_model.py b/openwakeword/custom_verifier_model.py index 1c8c55f..3a9396e 100644 --- a/openwakeword/custom_verifier_model.py +++ b/openwakeword/custom_verifier_model.py @@ -62,7 +62,7 @@ def get_reference_clip_features( # Get predictions for _ in range(N): # Load clip - if type(reference_clip) == str: + if isinstance(reference_clip, str): sr, dat = scipy.io.wavfile.read(reference_clip) else: dat = reference_clip diff --git a/openwakeword/data.py b/openwakeword/data.py index c43da5d..7a95306 100755 --- a/openwakeword/data.py +++ b/openwakeword/data.py @@ -803,8 +803,8 @@ class mmap_batch_generator: self.n_per_class = {} for lbl, shape in self.shapes.items(): dummy_data = np.random.random((10, self.shapes[lbl][1], self.shapes[lbl][2])) - if self.data_transform_funcs.get(lbl, None): - scale_factor = self.data_transform_funcs.get(lbl, None)(dummy_data).shape[0]/10 + if (transform_func := self.data_transform_funcs.get(lbl, None)): + scale_factor = transform_func(dummy_data).shape[0]/10 ratio = self.shapes[lbl][0]/sum([i[0] for i in self.shapes.values()]) self.n_per_class[lbl] = max(1, int(int(batch_size*ratio)/scale_factor)) diff --git a/openwakeword/model.py b/openwakeword/model.py index 6029963..1296ac1 100755 --- a/openwakeword/model.py +++ b/openwakeword/model.py @@ -111,7 +111,7 @@ class Model(): # Do imports for inference framework if inference_framework == "tflite": try: - import tflite_runtime.interpreter as tflite + import ai_edge_litert.interpreter as tflite def tflite_predict(tflite_interpreter, input_index, output_index, x): tflite_interpreter.set_tensor(input_index, x) @@ -127,8 +127,8 @@ class Model(): inference_framework = "onnx" wakeword_models = [i.replace('.tflite', '.onnx') for i in wakeword_models] else: - raise ValueError("Tried to import the tflite runtime for provided tflite models, but it was not found. " - "Please install it using `pip install tflite-runtime`") + raise ValueError("Tried to import the LiteRT runtime for provided LiteRT models, but it was not found. " + "Please install it using `pip install ai-edge-litert`") if inference_framework == "onnx": try: diff --git a/openwakeword/utils.py b/openwakeword/utils.py index 4964706..5f64e03 100644 --- a/openwakeword/utils.py +++ b/openwakeword/utils.py @@ -94,10 +94,10 @@ class AudioFeatures(): elif inference_framework == "tflite": try: - import tflite_runtime.interpreter as tflite + import ai_edge_litert.interpreter as tflite except ImportError: - raise ValueError("Tried to import the TFLite runtime, but it was not found." - "Please install it using `pip install tflite-runtime`") + raise ValueError("Tried to import the LiteRT runtime, but it was not found." + "Please install it using `pip install ai-edge-litert`") if melspec_model_path == "": melspec_model_path = os.path.join(pathlib.Path(__file__).parent.resolve(), diff --git a/pyproject.toml b/pyproject.toml index d420f04..470f653 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ authors = [ ] description = "An open-source audio wake word (or phrase) detection framework with a focus on performance and simplicity" readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: Apache Software License", diff --git a/setup.py b/setup.py index df0a666..f6df9f7 100644 --- a/setup.py +++ b/setup.py @@ -4,19 +4,20 @@ import setuptools with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() + # Build extras_requires based on platform def build_additional_requires(): - py_version = platform.python_version()[0:3].replace('.', "") - if platform.system() == "Linux" and platform.machine() == "x86_64": - additional_requires=[ - f"speexdsp_ns @ https://github.com/dscripka/openWakeWord/releases/download/v0.1.1/speexdsp_ns-0.1.2-cp{py_version}-cp{py_version}-linux_x86_64.whl", - ] - elif platform.system() == "Linux" and platform.machine() == "aarch64": - additional_requires=[ - f"speexdsp_ns @ https://github.com/dscripka/openWakeWord/releases/download/v0.1.1/speexdsp_ns-0.1.2-cp{py_version}-cp{py_version}-linux_aarch64.whl", - ], - elif platform.system() == "Windows" and platform.machine() == "x86_64": - additional_requires=[ + # py_version = platform.python_version()[0:3].replace('.', "") + # if platform.system() == "Linux" and platform.machine() == "x86_64": + # additional_requires=[ + # f"speexdsp_ns @ https://github.com/dscripka/openWakeWord/releases/download/v0.1.1/speexdsp_ns-0.1.2-cp{py_version}-cp{py_version}-linux_x86_64.whl", + # ] + # elif platform.system() == "Linux" and platform.machine() == "aarch64": + # additional_requires=[ + # f"speexdsp_ns @ https://github.com/dscripka/openWakeWord/releases/download/v0.1.1/speexdsp_ns-0.1.2-cp{py_version}-cp{py_version}-linux_aarch64.whl", + # ], + if platform.system() == "Windows" and platform.machine() == "x86_64": + additional_requires = [ 'PyAudioWPatch' ] else: @@ -24,12 +25,14 @@ def build_additional_requires(): return additional_requires + setuptools.setup( name="openwakeword", version="0.6.0", install_requires=[ 'onnxruntime>=1.10.0,<2', - 'tflite-runtime>=2.8.0,<3; platform_system == "Linux"', + 'ai-edge-litert>=2.0.2,<3; platform_system == "Linux" or platform_system == "Darwin"', + 'speexdsp-ns>=0.1.2,<1; platform_system == "Linux"', 'tqdm>=4.0,<5.0', 'scipy>=1.3,<2', 'scikit-learn>=1,<2', @@ -40,7 +43,7 @@ setuptools.setup( 'pytest>=7.2.0,<8', 'pytest-cov>=2.10.1,<3', 'pytest-flake8>=1.1.1,<2', - 'flake8>=4.0,<4.1', + 'flake8>=5.0,<7.1', 'pytest-mypy>=0.10.0,<1', 'types-requests', 'types-PyYAML', @@ -90,5 +93,5 @@ setuptools.setup( ], packages=setuptools.find_packages(), include_package_data=True, - python_requires=">=3.7", + python_requires=">=3.10", ) \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py index b3907ff..92a1d05 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -53,12 +53,17 @@ class TestModels: os.path.join("openwakeword", "resources", "models", "alexa_v0.1.onnx") ], inference_framework="onnx") + # Prediction on random data + prediction = owwModel.predict(np.random.randint(-1000, 1000, 1280).astype(np.int16)) + assert prediction["alexa_v0.1"] >= 0 and prediction["alexa_v0.1"] <= 1 + owwModel = openwakeword.Model(wakeword_models=[ os.path.join("openwakeword", "resources", "models", "alexa_v0.1.tflite") ], inference_framework="tflite") # Prediction on random data - owwModel.predict(np.random.randint(-1000, 1000, 1280).astype(np.int16)) + prediction = owwModel.predict(np.random.randint(-1000, 1000, 1280).astype(np.int16)) + assert prediction["alexa_v0.1"] >= 0 and prediction["alexa_v0.1"] <= 1 def test_predict_with_different_frame_sizes(self): # Test with binary model From b02327d87c40c7593362d4b02483eb24812605ef Mon Sep 17 00:00:00 2001 From: "david.scripka" Date: Fri, 17 Oct 2025 22:09:07 -0400 Subject: [PATCH 102/103] bump workflow python versions --- .github/workflows/build_and_publish_to_pypi.yml | 4 ++-- .github/workflows/tests.yml | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build_and_publish_to_pypi.yml b/.github/workflows/build_and_publish_to_pypi.yml index bee4a13..3235de7 100755 --- a/.github/workflows/build_and_publish_to_pypi.yml +++ b/.github/workflows/build_and_publish_to_pypi.yml @@ -13,10 +13,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - - name: Set up Python 3.8 + - name: Set up Python 3.10 uses: actions/setup-python@v3 with: - python-version: "3.8" + python-version: "3.10" - name: Install pypa/build run: >- python -m diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ffc7c50..a82d633 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8"] + python-version: ["3.10"] steps: - uses: actions/checkout@v3 @@ -28,7 +28,6 @@ jobs: sudo apt-get install libspeexdsp-dev python -m pip install --upgrade pip pip install -e .[test] - pip install https://github.com/dscripka/openWakeWord/releases/download/v0.1.1/speexdsp_ns-0.1.2-cp38-cp38-linux_x86_64.whl - name: Test with pytest run: | pytest @@ -37,7 +36,7 @@ jobs: runs-on: windows-latest strategy: matrix: - python-version: ["3.8"] + python-version: ["3.10"] steps: - uses: actions/checkout@v3 From 368c03716d1e92591906a84949bc477f3a834455 Mon Sep 17 00:00:00 2001 From: "david.scripka" Date: Tue, 30 Dec 2025 11:47:22 -0500 Subject: [PATCH 103/103] put onnx to tflite conversion behind flag in training code --- openwakeword/train.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/openwakeword/train.py b/openwakeword/train.py index f564254..708cdb2 100755 --- a/openwakeword/train.py +++ b/openwakeword/train.py @@ -630,6 +630,13 @@ if __name__ == '__main__': default="False", required=False ) + parser.add_argument( + "--convert_to_tflite", + help="Convert the trained ONNX model to TFLite format", + action="store_true", + default="False", + required=False + ) args = parser.parse_args() config = yaml.load(open(args.training_config, 'r').read(), yaml.Loader) @@ -898,5 +905,6 @@ if __name__ == '__main__': oww.export_model(model=best_model, model_name=config["model_name"], output_dir=config["output_dir"]) # Convert the model from onnx to tflite format - convert_onnx_to_tflite(os.path.join(config["output_dir"], config["model_name"] + ".onnx"), - os.path.join(config["output_dir"], config["model_name"] + ".tflite")) + if args.convert_to_tflite: + convert_onnx_to_tflite(os.path.join(config["output_dir"], config["model_name"] + ".onnx"), + os.path.join(config["output_dir"], config["model_name"] + ".tflite"))