diff --git a/precise/params.py b/precise/params.py index f4c1d0ff..73bb0cb9 100644 --- a/precise/params.py +++ b/precise/params.py @@ -18,6 +18,7 @@ - Interpretation of the network output to a confidence value """ from math import floor +from typing import Optional import attr import json @@ -68,7 +69,7 @@ class ListenerParams: use_delta = attr.ib() # type: bool vectorizer = attr.ib() # type: int threshold_config = attr.ib() # type: tuple - threshold_center = attr.ib() # type: float + threshold_center = attr.ib() # type: Optional[float] @property def buffer_samples(self): @@ -140,7 +141,7 @@ class Vectorizer: pr = ListenerParams( buffer_t=1.5, window_t=0.1, hop_t=0.05, sample_rate=16000, sample_depth=2, n_fft=512, n_filt=20, n_mfcc=13, use_delta=False, - threshold_config=((6, 4),), threshold_center=0.2, vectorizer=Vectorizer.mfccs + threshold_config=(), threshold_center=None, vectorizer=Vectorizer.mfccs ) # Used to fill in old param files without new attributes diff --git a/precise/pocketsphinx/__init__.py b/precise/pocketsphinx/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/precise/pocketsphinx/scripts/__init__.py b/precise/pocketsphinx/scripts/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/precise/threshold_decoder.py b/precise/threshold_decoder.py index 69213822..8ba15f61 100644 --- a/precise/threshold_decoder.py +++ b/precise/threshold_decoder.py @@ -27,6 +27,13 @@ class ThresholdDecoder: activations using a series of averages and standard deviations to calculate a cumulative probability distribution + Args: + mu_stds: tuple of pairs of (mean, standard deviation) that model the positive network output + center: proportion of activations that a threshold of 0.5 indicates. Pass as None to disable decoding + resolution: precision of cumulative sum estimation. Increases memory usage + min_z: Minimum z score to generate in distribution map + max_z: Maximum z score to generate in distribution map + Background: We could simply take the output of the neural network as the confidence of a given prediction, but this typically jumps quickly between 0.01 and 0.99 even in cases where @@ -36,14 +43,17 @@ class ThresholdDecoder: of 80% means that the network output is greater than roughly 80% of the dataset """ def __init__(self, mu_stds: Tuple[Tuple[float, float]], center=0.5, resolution=200, min_z=-4, max_z=4): - self.min_out = int(min(mu + min_z * std for mu, std in mu_stds)) - self.max_out = int(max(mu + max_z * std for mu, std in mu_stds)) - self.out_range = self.max_out - self.min_out - self.cd = np.cumsum(self._calc_pd(mu_stds, resolution)) + self.min_out = self.max_out = self.out_range = 0 + self.cd = np.array([]) self.center = center + if center is not None: + self.min_out = int(min([mu + min_z * std for mu, std in mu_stds])) + self.max_out = int(max([mu + max_z * std for mu, std in mu_stds])) + self.out_range = self.max_out - self.min_out + self.cd = np.cumsum(self._calc_pd(mu_stds, resolution)) def decode(self, raw_output: float) -> float: - if raw_output == 1.0 or raw_output == 0.0: + if self.center is None or raw_output == 1.0 or raw_output == 0.0: return raw_output if self.out_range == 0: cp = int(raw_output > self.min_out) @@ -57,6 +67,8 @@ def decode(self, raw_output: float) -> float: return 0.5 + 0.5 * (cp - self.center) / (1 - self.center) def encode(self, threshold: float) -> float: + if self.center is None: + return threshold threshold = 0.5 * threshold / self.center if threshold < 0.5: cp = threshold * self.center * 2 diff --git a/precise/util.py b/precise/util.py index 5c928743..d156ea64 100644 --- a/precise/util.py +++ b/precise/util.py @@ -32,14 +32,24 @@ def chunk_audio(audio: np.ndarray, chunk_size: int) -> Generator[np.ndarray, Non yield audio[i - chunk_size:i] +def float_audio_to_int(audio: np.ndarray) -> np.ndarray: + """Converts [-1.0, 1.0] -> [-32768, 32767]""" + return (audio.astype(np.float32, order='C') * (0x7FFF + 0.5) - 0.5).astype(' np.ndarray: + """Converts [-32768, 32767] -> [-1.0, 1.0]""" + return (int_audio + 0.5) / (0x7FFF + 0.5) + + def buffer_to_audio(buffer: bytes) -> np.ndarray: """Convert a raw mono audio byte string to numpy array of floats""" - return np.fromstring(buffer, dtype=' bytes: """Convert a numpy array of floats to raw mono audio""" - return (audio * 32768).astype(' np.ndarray: @@ -61,15 +71,14 @@ def load_audio(file: Any) -> np.ndarray: if wav.rate != pr.sample_rate: raise InvalidAudio('Unsupported sample rate: ' + str(wav.rate)) - data = np.squeeze(wav.data) - return data.astype(np.float32) / float(np.iinfo(data.dtype).max) + return int_audio_to_float(np.squeeze(wav.data)) def save_audio(filename: str, audio: np.ndarray): """Save loaded audio to file using the configured audio parameters""" import wavio - save_audio = (audio * np.iinfo(np.int16).max).astype(np.int16) - wavio.write(filename, save_audio, pr.sample_rate, sampwidth=pr.sample_depth, scale='none') + int_audio = float_audio_to_int(audio) + wavio.write(filename, int_audio, pr.sample_rate, sampwidth=pr.sample_depth, scale='none') def play_audio(filename: str): diff --git a/runner/precise_runner/runner.py b/runner/precise_runner/runner.py index a5d90635..94bbc706 100644 --- a/runner/precise_runner/runner.py +++ b/runner/precise_runner/runner.py @@ -16,7 +16,7 @@ import time from subprocess import PIPE, Popen -from threading import Thread, Event +from threading import Thread, Event, current_thread class Engine(object): @@ -82,6 +82,7 @@ def __init__(self, s=b'', chop_samples=-1): self.buffer = s self.write_event = Event() self.chop_samples = chop_samples + self.eof = False def __len__(self): return len(self.buffer) @@ -95,7 +96,7 @@ def read(self, n=-1, timeout=None): return_time = 1e10 if timeout is None else ( timeout + time.time() ) - while len(self.buffer) < n: + while len(self.buffer) < n and not self.eof: self.write_event.clear() if not self.write_event.wait(return_time - time.time()): return b'' @@ -103,6 +104,10 @@ def read(self, n=-1, timeout=None): self.buffer = self.buffer[n:] return chunk + def close(self): + self.write_event.set() + self.eof = True + def write(self, s): self.buffer += s self.write_event.set() @@ -210,10 +215,12 @@ def start(self): def stop(self): """Stop listening and close stream""" if self.thread: - self.running = False if isinstance(self.stream, ReadWriteStream): - self.stream.write(b'\0' * self.chunk_size) - self.thread.join() + self.stream.close() + else: + self.running = False + if current_thread() is not self.thread: + self.thread.join() self.thread = None self.engine.stop() @@ -234,6 +241,11 @@ def _handle_predictions(self): while self.running: chunk = self.stream.read(self.chunk_size) + if len(chunk) < self.chunk_size: # EOF + self.stop() + self.running = False + return + if self.is_paused: continue diff --git a/test/Dockerfile b/test/Dockerfile index 5d9af2fd..6e366d81 100644 --- a/test/Dockerfile +++ b/test/Dockerfile @@ -11,6 +11,9 @@ COPY requirements/test.txt mycroft-precise/requirements/ RUN pip install -r mycroft-precise/requirements/test.txt COPY requirements/prod.txt mycroft-precise/requirements/ RUN pip install -r mycroft-precise/requirements/prod.txt +RUN ls +RUN pwd +RUN pip install runner COPY . mycroft-precise # Clone the devops repository, which contiains helper scripts for some continuous diff --git a/test/scripts/conftest.py b/test/scripts/conftest.py index c3b763d5..8e73cbf7 100644 --- a/test/scripts/conftest.py +++ b/test/scripts/conftest.py @@ -12,15 +12,19 @@ # 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. +import shutil + import pytest from precise.scripts.train import TrainScript -from test.scripts.test_train import DummyTrainFolder +from test.scripts.test_utils.temp_folder import TempFolder +from test.scripts.test_utils.dummy_train_folder import DummyTrainFolder @pytest.fixture() def train_folder(): - folder = DummyTrainFolder(10) + folder = DummyTrainFolder() + folder.generate_default() try: yield folder finally: @@ -28,5 +32,31 @@ def train_folder(): @pytest.fixture() -def train_script(train_folder): - return TrainScript.create(model=train_folder.model, folder=train_folder.root, epochs=1) +def temp_folder(): + folder = TempFolder() + try: + yield folder + finally: + folder.cleanup() + + +@pytest.fixture(scope='session') +def _trained_model(): + """Session wide model that gets trained once""" + folder = DummyTrainFolder() + folder.generate_default() + script = TrainScript.create(model=folder.model, folder=folder.root, epochs=100) + script.run() + try: + yield folder.model + finally: + folder.cleanup() + + +@pytest.fixture() +def trained_model(_trained_model, temp_folder): + """Copy of session wide model""" + model = temp_folder.path('trained_model.net') + shutil.copy(_trained_model, model) + shutil.copy(_trained_model + '.params', model + '.params') + return model diff --git a/test/scripts/test_add_noise.py b/test/scripts/test_add_noise.py index 2aea0ee2..b25c22a6 100644 --- a/test/scripts/test_add_noise.py +++ b/test/scripts/test_add_noise.py @@ -13,25 +13,13 @@ # See the License for the specific language governing permissions and # limitations under the License. from precise.scripts.add_noise import AddNoiseScript - -from test.scripts.dummy_audio_folder import DummyAudioFolder - - -class DummyNoiseFolder(DummyAudioFolder): - def __init__(self, count=10): - super().__init__(count) - self.source = self.subdir('source') - self.noise = self.subdir('noise') - self.output = self.subdir('output') - - self.generate_samples(self.subdir('source', 'wake-word'), 'ww-{}.wav', 1.0, self.rand(0, 2)) - self.generate_samples(self.subdir('source', 'not-wake-word'), 'nww-{}.wav', 0.0, self.rand(0, 2)) - self.generate_samples(self.noise, 'noise-{}.wav', 0.5, self.rand(10, 20)) +from test.scripts.test_utils.dummy_noise_folder import DummyNoiseFolder class TestAddNoise: def get_base_data(self, count): - folders = DummyNoiseFolder(count) + folders = DummyNoiseFolder() + folders.generate_default(count) base_args = dict( folder=folders.source, noise_folder=folders.noise, output_folder=folders.output @@ -42,10 +30,10 @@ def test_run_basic(self): folders, base_args = self.get_base_data(10) script = AddNoiseScript.create(inflation_factor=1, **base_args) script.run() - assert folders.count_files(folders.output) == 20 + assert folders.count_files(folders.output) == 40 def test_run_basic_2(self): folders, base_args = self.get_base_data(10) script = AddNoiseScript.create(inflation_factor=2, **base_args) script.run() - assert folders.count_files(folders.output) == 40 + assert folders.count_files(folders.output) == 80 diff --git a/test/scripts/test_combined.py b/test/scripts/test_combined.py index 175a8cc8..2148c62d 100644 --- a/test/scripts/test_combined.py +++ b/test/scripts/test_combined.py @@ -18,6 +18,7 @@ from precise.scripts.calc_threshold import CalcThresholdScript from precise.scripts.eval import EvalScript from precise.scripts.graph import GraphScript +from test.scripts.test_utils.dummy_train_folder import DummyTrainFolder def read_content(filename): @@ -25,21 +26,20 @@ def read_content(filename): return f.read() -def test_combined(train_folder, train_script): +def test_combined(train_folder: DummyTrainFolder, trained_model: str): """Test a "normal" development cycle, train, evaluate and calc threshold. """ - train_script.run() - params_file = train_folder.model + '.params' - assert isfile(train_folder.model) + params_file = trained_model + '.params' + assert isfile(trained_model) assert isfile(params_file) EvalScript.create(folder=train_folder.root, - models=[train_folder.model]).run() + models=[trained_model]).run() # Ensure that the graph script generates a numpy savez file out_file = train_folder.path('outputs.npz') graph_script = GraphScript.create(folder=train_folder.root, - models=[train_folder.model], + models=[trained_model], output_file=out_file) graph_script.run() assert isfile(out_file) @@ -47,6 +47,6 @@ def test_combined(train_folder, train_script): # Esure the params are updated after threshold is calculated params_before = read_content(params_file) CalcThresholdScript.create(folder=train_folder.root, - model=train_folder.model, + model=trained_model, input_file=out_file).run() assert params_before != read_content(params_file) diff --git a/test/scripts/test_convert.py b/test/scripts/test_convert.py index 53dec5cc..45b3b12d 100755 --- a/test/scripts/test_convert.py +++ b/test/scripts/test_convert.py @@ -15,10 +15,10 @@ from os.path import isfile from precise.scripts.convert import ConvertScript +from test.scripts.test_utils.temp_folder import TempFolder -def test_convert(train_folder, train_script): - train_script.run() - - ConvertScript.create(model=train_folder.model, out=train_folder.model + '.pb').run() - assert isfile(train_folder.model + '.pb') +def test_convert(temp_folder: TempFolder, trained_model: str): + pb_model = temp_folder.path('model.pb') + ConvertScript.create(model=trained_model, out=pb_model).run() + assert isfile(pb_model) diff --git a/test/scripts/test_engine.py b/test/scripts/test_engine.py index 09129cd3..620b78fe 100755 --- a/test/scripts/test_engine.py +++ b/test/scripts/test_engine.py @@ -21,6 +21,8 @@ from precise.scripts.engine import EngineScript from runner.precise_runner import ReadWriteStream +from test.scripts.test_utils.dummy_train_folder import DummyTrainFolder + class FakeStdin: def __init__(self, data: bytes): @@ -35,18 +37,17 @@ def __init__(self): self.buffer = ReadWriteStream() -def test_engine(train_folder, train_script): +def test_engine(train_folder: DummyTrainFolder, trained_model: str): """ - Test t hat the output format of the engina matches a decimal form in the + Test t hat the output format of the engine matches a decimal form in the range 0.0 - 1.0. """ - train_script.run() with open(glob.glob(join(train_folder.root, 'wake-word', '*.wav'))[0], 'rb') as f: data = f.read() try: sys.stdin = FakeStdin(data) sys.stdout = FakeStdout() - EngineScript.create(model_name=train_folder.model).run() + EngineScript.create(model_name=trained_model).run() assert re.match(rb'[01]\.[0-9]+', sys.stdout.buffer.buffer) finally: sys.stdin = sys.__stdin__ diff --git a/test/scripts/test_listen.py b/test/scripts/test_listen.py new file mode 100644 index 00000000..93201111 --- /dev/null +++ b/test/scripts/test_listen.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# Copyright 2019 Mycroft AI Inc. +# +# 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. +from glob import glob +from os.path import join + +import numpy as np +from precise_runner import ReadWriteStream + +from precise.params import pr +from precise.scripts.listen import ListenScript +from precise.util import audio_to_buffer +from test.scripts.test_utils.temp_folder import TempFolder + + +class TestListen: + def test_listen(self, trained_model: str, temp_folder: TempFolder): + """Run the trained model on input""" + activations_folder = temp_folder.subdir('activations') + script = ListenScript.create(model=trained_model, save_dir=activations_folder) + script.runner.stream = stream = ReadWriteStream() + + script.runner.start() + stream.write(audio_to_buffer(np.random.random(5 * pr.sample_rate) * 2 - 1)) # Write silence + stream.write(audio_to_buffer(np.ones(2 * pr.sample_rate, dtype=float) * 2 - 1)) # Write wake word + stream.write(audio_to_buffer(np.random.random(5 * pr.sample_rate) * 2 - 1)) # Write more silence + script.runner.stop() + + assert len(glob(join(activations_folder, '*.wav'))) == 1 diff --git a/test/scripts/test_train.py b/test/scripts/test_train.py index 20e1ccb7..3e208fcb 100644 --- a/test/scripts/test_train.py +++ b/test/scripts/test_train.py @@ -14,29 +14,14 @@ # limitations under the License. from os.path import isfile -from precise.params import pr from precise.scripts.train import TrainScript -from test.scripts.dummy_audio_folder import DummyAudioFolder - - -class DummyTrainFolder(DummyAudioFolder): - def __init__(self, count=10): - super().__init__(count) - self.generate_samples(self.subdir('wake-word'), 'ww-{}.wav', 1.0, - self.rand(0, 2 * pr.buffer_t)) - self.generate_samples(self.subdir('not-wake-word'), 'nww-{}.wav', 0.0, - self.rand(0, 2 * pr.buffer_t)) - self.generate_samples(self.subdir('test', 'wake-word'), 'ww-{}.wav', - 1.0, self.rand(0, 2 * pr.buffer_t)) - self.generate_samples(self.subdir('test', 'not-wake-word'), - 'nww-{}.wav', 0.0, self.rand(0, 2 * pr.buffer_t)) - self.model = self.path('model.net') +from test.scripts.test_utils.dummy_train_folder import DummyTrainFolder class TestTrain: - def test_run_basic(self): + def test_run_basic(self, train_folder: DummyTrainFolder): """Run a training and check that a model is generated.""" - folders = DummyTrainFolder(10) - script = TrainScript.create(model=folders.model, folder=folders.root) - script.run() - assert isfile(folders.model) + train_script = TrainScript.create(model=train_folder.model, folder=train_folder.root, epochs=10) + train_script.run() + assert isfile(train_folder.model) + assert isfile(train_folder.model + '.params') diff --git a/test/scripts/test_utils/__init__.py b/test/scripts/test_utils/__init__.py new file mode 100644 index 00000000..57014e8e --- /dev/null +++ b/test/scripts/test_utils/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2020 Mycroft AI Inc. +# +# 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. diff --git a/test/scripts/test_utils/dummy_noise_folder.py b/test/scripts/test_utils/dummy_noise_folder.py new file mode 100644 index 00000000..be433eff --- /dev/null +++ b/test/scripts/test_utils/dummy_noise_folder.py @@ -0,0 +1,35 @@ +# Copyright 2020 Mycroft AI Inc. +# +# 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. +import numpy as np + +from test.scripts.test_utils.temp_folder import TempFolder +from test.scripts.test_utils.dummy_train_folder import DummyTrainFolder + + +class DummyNoiseFolder(TempFolder): + def __init__(self): + super().__init__() + self.source = self.subdir('source') + self.noise = self.subdir('noise') + self.output = self.subdir('output') + + self.source_folder = DummyTrainFolder(root=self.source) + self.noise_folder = DummyTrainFolder(root=self.noise) + + def generate_default(self, count=10): + self.source_folder.generate_default(count) + self.noise_folder.generate_samples( + count, [], 'noise-{}.wav', + lambda: np.ones(self.noise_folder.get_duration(), dtype=float) + ) diff --git a/test/scripts/test_utils/dummy_train_folder.py b/test/scripts/test_utils/dummy_train_folder.py new file mode 100644 index 00000000..22517bc7 --- /dev/null +++ b/test/scripts/test_utils/dummy_train_folder.py @@ -0,0 +1,68 @@ +# Copyright 2020 Mycroft AI Inc. +# +# 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. +import random +from os.path import join + +import numpy as np + +from precise.params import pr +from precise.util import save_audio +from test.scripts.test_utils.temp_folder import TempFolder + + +class DummyTrainFolder(TempFolder): + def __init__(self, root=None): + super().__init__(root) + self.model = self.path('model.net') + + def generate_samples(self, count, subfolder, name, generator): + """ + Generate sample audio files in a folder + + The file is generated in the specified folder, with the specified + name and generated value. + + Args: + count: Number of samples to generate + subfolder: String or list of subfolder path + name: Format string used to generate each sample + generator: Function called to get the data for each sample + """ + if isinstance(subfolder, str): + subfolder = [subfolder] + for i in range(count): + save_audio(join(self.subdir(*subfolder), name.format(i)), generator()) + + def get_duration(self): + """Generate a random sample duration""" + return int(random.random() * 2 * pr.buffer_samples) + + def generate_default(self, count=10): + self.generate_samples( + count, 'wake-word', 'ww-{}.wav', + lambda: np.ones(self.get_duration(), dtype=float) + ) + self.generate_samples( + count, 'not-wake-word', 'nww-{}.wav', + lambda: np.random.random(self.get_duration()) * 2 - 1 + ) + self.generate_samples( + count, ('test', 'wake-word'), 'ww-{}.wav', + lambda: np.ones(self.get_duration(), dtype=float) + ) + self.generate_samples( + count, ('test', 'not-wake-word'), 'nww-{}.wav', + lambda: np.random.random(self.get_duration()) * 2 - 1 + ) + self.model = self.path('model.net') \ No newline at end of file diff --git a/test/scripts/dummy_audio_folder.py b/test/scripts/test_utils/temp_folder.py similarity index 61% rename from test/scripts/dummy_audio_folder.py rename to test/scripts/test_utils/temp_folder.py index ae30b764..ecc5ab6b 100644 --- a/test/scripts/dummy_audio_folder.py +++ b/test/scripts/test_utils/temp_folder.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright 2019 Mycroft AI Inc. +# Copyright 2020 Mycroft AI Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,36 +14,18 @@ # limitations under the License. import atexit -import numpy as np import os from os import makedirs from os.path import isdir, join from shutil import rmtree from tempfile import mkdtemp -from precise.params import pr -from precise.util import save_audio - -class DummyAudioFolder: - def __init__(self, count=10): - self.count = count - self.root = mkdtemp() +class TempFolder: + def __init__(self, root=None): + self.root = mkdtemp() if root is None else root atexit.register(self.cleanup) - def rand(self, min, max): - return min + (max - min) * np.random.random() * pr.buffer_t - - def generate_samples(self, folder, name, value, duration): - """Generate sample file. - - The file is generated in the specified folder, with the specified name, - dummy value and duration. - """ - for i in range(self.count): - save_audio(join(folder, name.format(i)), - np.array([value] * int(duration * pr.sample_rate))) - def subdir(self, *parts): folder = self.path(*parts) if not isdir(folder): diff --git a/test/test_util.py b/test/test_util.py new file mode 100644 index 00000000..a58cca51 --- /dev/null +++ b/test/test_util.py @@ -0,0 +1,12 @@ +import numpy as np +from precise.util import audio_to_buffer, buffer_to_audio +from precise.params import pr + + +def test_audio_serialization(): + audio = np.array([-1.0, 1.0] * pr.buffer_samples) + audio2 = buffer_to_audio(audio_to_buffer(audio)) + assert np.abs(audio - audio2).max() < 1.0 / 32767.0 + audio = np.random.random(pr.buffer_samples) * 2.0 - 1.0 + audio2 = buffer_to_audio(audio_to_buffer(audio)) + assert np.abs(audio - audio2).max() < 1.0 / 32767.0