From 1c869f47ccb45ad5fa170d557d4101f0915e5353 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 6 Oct 2021 10:07:05 +0200 Subject: [PATCH 01/63] add init AudioDevice --- haiopy/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/haiopy/__init__.py b/haiopy/__init__.py index ab59fe2..e43d843 100644 --- a/haiopy/__init__.py +++ b/haiopy/__init__.py @@ -3,3 +3,11 @@ __author__ = """The pyfar developers""" __email__ = 'marco.berzborn@akustik.rwth-aachen.de' __version__ = '0.1.0' + + +from devices import AudioDevice + + +__all__ = [ + 'AudioDevice' +] From 3505e19872e66e64e3b741eb13ee0f9732c526c4 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 6 Oct 2021 10:14:36 +0200 Subject: [PATCH 02/63] fix import --- haiopy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haiopy/__init__.py b/haiopy/__init__.py index e43d843..7ad5d4f 100644 --- a/haiopy/__init__.py +++ b/haiopy/__init__.py @@ -5,7 +5,7 @@ __version__ = '0.1.0' -from devices import AudioDevice +from .devices import AudioDevice __all__ = [ From a12d420f8e3945c5802800e3c08729145a5628f5 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 7 Oct 2021 17:51:25 +0200 Subject: [PATCH 03/63] wip: devices --- haiopy/devices.py | 207 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 177 insertions(+), 30 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index bd2a3c1..aa13f40 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -1,4 +1,10 @@ +from os import stat +import sys import sounddevice as sd +import queue +import numpy as np +from arrayqueues import ArrayQueue +from abc import (ABCMeta, abstractmethod, abstractproperty) def list_devices(): @@ -7,35 +13,38 @@ def list_devices(): class _Device(object): def __init__( - self, - id, - sampling_rate, - block_size, - dtype): + self): super().__init__() - @property + @abstractproperty def name(self): - raise NotImplementedError('Abstract method') + pass + @abstractmethod def playback(): pass + @abstractmethod def record(): pass + @abstractmethod def playback_record(): pass + @abstractmethod def initialize_playback(): pass + @abstractmethod def initialize_record(): pass + @abstractmethod def initialize_playback_record(): pass + @abstractmethod def abort(): pass @@ -44,9 +53,9 @@ class AudioDevice(_Device): def __init__( self, id, - sampling_rate, - block_size, - dtype, + sampling_rate=44100, + block_size=512, + dtype='float32', latency=None, extra_settings=None, # finished_callback=None, @@ -55,19 +64,116 @@ def __init__( never_drop_input=None, prime_output_buffers_using_stream_callback=None ): - super().__init__(id, sampling_rate, block_size, dtype) + super().__init__() + + id = sd.query_devices(id)['name'] + self._name = sd.query_devices(id)['name'] + + n_channels_input = sd.query_devices(id)['max_input_channels'] + n_channels_output = sd.query_devices(id)['max_output_channels'] + sd.check_input_settings( + device=id, + channels=n_channels_input, + dtype=dtype, + extra_settings=extra_settings, + samplerate=sampling_rate) + + sd.check_output_settings( + device=id, + channels=n_channels_output, + dtype=dtype, + extra_settings=extra_settings, + samplerate=sampling_rate) + + self.id = id + self.dtype = dtype + self._block_size = block_size + self._sampling_rate = sampling_rate + self._extra_settings = extra_settings + + self._callback = None + self._stream = None + + self._record_queue = None + self.initialize_playback_queue() @property - def stream(): - pass + def n_channels_input(self): + return sd.query_devices(self.id)['max_input_channels'] - @staticmethod - def callback(): - pass + @property + def n_channels_output(self): + return sd.query_devices(self.id)['max_output_channels'] + + def check_settings( + self, sampling_rate, dtype, extra_settings,): + sd.check_input_settings( + device=self.id, + channels=self.n_channels_input, + dtype=dtype, + extra_settings=extra_settings, + samplerate=sampling_rate) + + sd.check_output_settings( + device=self.id, + channels=self.n_channels_output, + dtype=dtype, + extra_settings=extra_settings, + samplerate=sampling_rate) - def playback(data): - # fill queue, stream.start() - pass + @property + def name(self): + return self._name + + @property + def sampling_rate(self): + return self._sampling_rate + + @sampling_rate.setter + def sampling_rate(self, value): + self.check_settings(value, self.dtype, self._extra_settings) + + @property + def block_size(self): + return self._block_size + + @property + def stream(self): + return self._stream + + def finished_callback(self) -> None: + self.stream.stop() + # self.stream.callback = self._callback + + def output_callback(self, outdata, frames, time, status) -> None: + assert frames == self.block_size + if status.output_underflow: + print('Output underflow: increase blocksize?', file=sys.stderr) + raise sd.CallbackAbort('Buffer underflow') + assert not status + + if self.playback_queue.empty(): + print('Buffer is empty: Are we finished?', file=sys.stderr) + raise sd.CallbackStop("Buffer empty") + # self.stream.abort() + else: + data = self._playback_queue.get() + outdata[:] = data.T + + def playback(self, data, start=True): + if data.ndim > 2: + raise ValueError( + "The data cannot can not have more than 2 dimensions.") + n_channels_data = data.shape[0] + + # queue size in mega bytes + qsize = data.itemsize * data.size / 1000000 + self.initialize_playback_queue(qsize) + self.initialize_playback(n_channels_data) + self.write_queue(data) + + if start is True: + self.start() def record(n_samples): # stream start, read into the queue @@ -77,20 +183,61 @@ def playback_record(data): # see combination above pass - def initialize_playback(channels): - # init queue, define callback, init stream - pass + @property + def playback_queue(self): + return self._playback_queue + + def initialize_playback_queue(self, qsize=32): + self._playback_queue = ArrayQueue(qsize) + + def write_queue(self, data): + if self._playback_queue is None: + # self.initialize_playback_queue() + raise ValueError("The Queue need to be initialized first.") + + n_blocks = int(np.floor(data.shape[-1]/self.block_size)) + + for idb in range(n_blocks): + sdx = np.arange(idb*self.block_size, (idb+1)*self.block_size) + self._playback_queue.put(data[..., sdx]) + + def initialize_playback(self, n_channels): + self.initialize_playback_queue() + ostream = sd.OutputStream( + self.sampling_rate, + self.block_size, + self.id, + n_channels, + self.dtype, + callback=self.output_callback, + finished_callback=self.finished_callback + ) + + self._stream = ostream + + def start(self): + if not self.stream.closed: + if not self.stream.active: + self.stream.start() + else: + print("Stream is already active.", file=sys.stderr) + else: + print("Stream is closed. Try re-initializing.", file=sys.stderr) def initialize_record(channels): - pass + raise NotImplementedError() def initialize_playback_record(input_channels, output_channels): - pass + raise NotImplementedError() - def abort(): - # abort - pass + def abort(self): + if self.stream.active is True: + self.stream.abort() - def close(): - # remove stream - pass + def close(self): + if self.stream is not None: + self.stream.close() + + def stop(self): + if self.stream.active is True: + self.stream.stop() From 434ef955eb0449c74d1a4b0baf86feaa05cfae4f Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 7 Oct 2021 17:51:41 +0200 Subject: [PATCH 04/63] ignore private folder in tests --- setup.cfg | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 930a60b..33e9721 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,5 +22,6 @@ exclude = docs test = pytest [tool:pytest] -collect_ignore = ['setup.py'] +collect_ignore = ['setup.py', 'private'] +norecursedirs = private build dist *.egg venv *vendor* From daa520df8f7d2fcd4a630d358aeb14336661996a Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 8 Oct 2021 16:32:23 +0200 Subject: [PATCH 05/63] docstrings --- haiopy/devices.py | 117 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 5 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index aa13f40..c129cfe 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -1,3 +1,4 @@ +from multiprocessing import Event from os import stat import sys import sounddevice as sd @@ -97,16 +98,31 @@ def __init__( self._record_queue = None self.initialize_playback_queue() + self._stream_finished = Event() + @property def n_channels_input(self): + """The number of input channels supported by the device""" return sd.query_devices(self.id)['max_input_channels'] @property def n_channels_output(self): + """The number of output channels supported by the device""" return sd.query_devices(self.id)['max_output_channels'] def check_settings( self, sampling_rate, dtype, extra_settings,): + """Check if settings are compatible with the physical devices. + + Parameters + ---------- + sampling_rate : int + The audio sampling rate + dtype : np.float32, np.int8, np.int16, np.int32 + The audio buffer data type + extra_settings : extra settings + Audio API specific settings. + """ sd.check_input_settings( device=self.id, channels=self.n_channels_input, @@ -123,10 +139,14 @@ def check_settings( @property def name(self): + """The name of the device + """ return self._name @property def sampling_rate(self): + """The sampling rate of the audio device. + """ return self._sampling_rate @sampling_rate.setter @@ -135,17 +155,48 @@ def sampling_rate(self, value): @property def block_size(self): + """The block size of the audio buffer. + """ return self._block_size @property def stream(self): + """The sounddevice audio stream. + """ return self._stream def finished_callback(self) -> None: + """Custom callback after a audio stream has finished.""" + pass + + def _finished_callback(self) -> None: + """Portaudio callback after a audio stream has finished. + """ + self._stream_finished.set() + self.finished_callback() self.stream.stop() - # self.stream.callback = self._callback def output_callback(self, outdata, frames, time, status) -> None: + """Portudio callback for output streams + + Parameters + ---------- + outdata : array + Output buffer view + frames : int + Length of the buffer + time : PaTimestamp + Timestamp of the callback event + status : sounddevice.CallbackFlags + Portaudio status flags + + Raises + ------ + sd.CallbackAbort + Abort the playback if a buffer underflow occurs. + sd.CallbackStop + Stop the playback if the output queue is empty. + """ assert frames == self.block_size if status.output_underflow: print('Output underflow: increase blocksize?', file=sys.stderr) @@ -155,12 +206,25 @@ def output_callback(self, outdata, frames, time, status) -> None: if self.playback_queue.empty(): print('Buffer is empty: Are we finished?', file=sys.stderr) raise sd.CallbackStop("Buffer empty") - # self.stream.abort() else: data = self._playback_queue.get() outdata[:] = data.T def playback(self, data, start=True): + """Playback an array of audio data. + This method initializes the playback device for the given audio + data and starts playback. After playback is finished, the device + is automatically stopped. + + Parameters + ---------- + data : array, float32, int8, int16, int32 + Playback data with dimensions (n_channels, n_samples). + start : bool, optional + If ``True``, the playback is started right away, if ``False``. + The default is ``True`` + + """ if data.ndim > 2: raise ValueError( "The data cannot can not have more than 2 dimensions.") @@ -185,24 +249,53 @@ def playback_record(data): @property def playback_queue(self): + """The playback queue, storing audio data in blocks which is read + by the playback callback. + """ return self._playback_queue def initialize_playback_queue(self, qsize=32): + """Initialize an empty playback queue. + + Parameters + ---------- + qsize : int, optional + The queue size in mega-bytes, by default 32 + """ + if self._playback_queue is not None: + self._playback_queue = None self._playback_queue = ArrayQueue(qsize) def write_queue(self, data): + """Fill the playback queue with audio data. + + Parameters + ---------- + data : array, float32, int32, int16, int8 + The audio data as numpy array + + """ if self._playback_queue is None: - # self.initialize_playback_queue() raise ValueError("The Queue need to be initialized first.") n_blocks = int(np.floor(data.shape[-1]/self.block_size)) for idb in range(n_blocks): sdx = np.arange(idb*self.block_size, (idb+1)*self.block_size) + if self._playback_queue.check_full(): + raise MemoryError( + "The input queue is full. ", + "Try initializing a larger queue.") self._playback_queue.put(data[..., sdx]) def initialize_playback(self, n_channels): - self.initialize_playback_queue() + """Initialize the playback stream for a given number of channels. + + Parameters + ---------- + n_channels : int + The number of output channels for which the stream is opened. + """ ostream = sd.OutputStream( self.sampling_rate, self.block_size, @@ -210,14 +303,17 @@ def initialize_playback(self, n_channels): n_channels, self.dtype, callback=self.output_callback, - finished_callback=self.finished_callback + finished_callback=self._finished_callback ) self._stream = ostream def start(self): + """Start the audio stream + """ if not self.stream.closed: if not self.stream.active: + self._stream_finished.clear() self.stream.start() else: print("Stream is already active.", file=sys.stderr) @@ -230,14 +326,25 @@ def initialize_record(channels): def initialize_playback_record(input_channels, output_channels): raise NotImplementedError() + def wait(self): + """Wait for the audio stream to finish. + """ + self._stream_finished.wait(timeout=None) + def abort(self): + """Stop the audio steam without finishing remaining buffers. + """ if self.stream.active is True: self.stream.abort() def close(self): + """Close the audio device and release the sound card lock. + """ if self.stream is not None: self.stream.close() def stop(self): + """Stop the audio stream after finishing the current buffer. + """ if self.stream.active is True: self.stream.stop() From 321943678d2cfb125063df1313a26e1deccc047f Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 21 Sep 2022 18:09:07 +0200 Subject: [PATCH 06/63] remove arrayqueue --- haiopy/devices.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index c129cfe..b5c604a 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -1,4 +1,5 @@ from multiprocessing import Event +import asyncio from os import stat import sys import sounddevice as sd @@ -6,6 +7,7 @@ import numpy as np from arrayqueues import ArrayQueue from abc import (ABCMeta, abstractmethod, abstractproperty) +from queue import Queue def list_devices(): @@ -53,13 +55,12 @@ def abort(): class AudioDevice(_Device): def __init__( self, - id, + id=sd.default.device, sampling_rate=44100, block_size=512, dtype='float32', latency=None, extra_settings=None, - # finished_callback=None, clip_off=None, dither_off=None, never_drop_input=None, @@ -86,6 +87,8 @@ def __init__( extra_settings=extra_settings, samplerate=sampling_rate) + self._loop = asyncio.get_event_loop() + self.id = id self.dtype = dtype self._block_size = block_size @@ -96,6 +99,7 @@ def __init__( self._stream = None self._record_queue = None + self._playback_queue = None self.initialize_playback_queue() self._stream_finished = Event() @@ -167,6 +171,7 @@ def stream(self): def finished_callback(self) -> None: """Custom callback after a audio stream has finished.""" + print("I'm finished.") pass def _finished_callback(self) -> None: @@ -231,8 +236,9 @@ def playback(self, data, start=True): n_channels_data = data.shape[0] # queue size in mega bytes - qsize = data.itemsize * data.size / 1000000 - self.initialize_playback_queue(qsize) + # qsize = data.itemsize * data.size / 1000000 + # self.initialize_playback_queue(qsize) + self.initialize_playback_queue(0) self.initialize_playback(n_channels_data) self.write_queue(data) @@ -264,7 +270,7 @@ def initialize_playback_queue(self, qsize=32): """ if self._playback_queue is not None: self._playback_queue = None - self._playback_queue = ArrayQueue(qsize) + self._playback_queue = Queue(0) def write_queue(self, data): """Fill the playback queue with audio data. @@ -282,7 +288,8 @@ def write_queue(self, data): for idb in range(n_blocks): sdx = np.arange(idb*self.block_size, (idb+1)*self.block_size) - if self._playback_queue.check_full(): + # if self._playback_queue.check_full(): + if self._playback_queue.full(): raise MemoryError( "The input queue is full. ", "Try initializing a larger queue.") From 7edfcc72dd22ad828bcceb170a86d9b1580a6aaa Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 21 Sep 2022 18:09:43 +0200 Subject: [PATCH 07/63] allow python >=3.8 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a460d19..4801246 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ test_requirements = ['pytest>=3'] -python_requirements = '>=3.7, <=3.8' +python_requirements = '>=3.8' setup( author="The pyfar developers", From ae5047327a24bbecd374e533701e0c6733654849 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 22 Sep 2022 15:08:17 +0200 Subject: [PATCH 08/63] start using generator functions --- haiopy/devices.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index b5c604a..b5dc3f1 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -5,10 +5,12 @@ import sounddevice as sd import queue import numpy as np -from arrayqueues import ArrayQueue +# from arrayqueues import ArrayQueue from abc import (ABCMeta, abstractmethod, abstractproperty) from queue import Queue +from asyncio import Queue as AQueue + def list_devices(): pass @@ -208,12 +210,22 @@ def output_callback(self, outdata, frames, time, status) -> None: raise sd.CallbackAbort('Buffer underflow') assert not status - if self.playback_queue.empty(): - print('Buffer is empty: Are we finished?', file=sys.stderr) + try: + outdata[:] = next(self.buffer_generator).T + except StopIteration: raise sd.CallbackStop("Buffer empty") - else: - data = self._playback_queue.get() - outdata[:] = data.T + + def init_buffer_generator(self, data): + + def buffer_generator(): + n_blocks = int(np.floor(data.shape[-1]/self.block_size)) + + res = np.lib.stride_tricks.as_strided( + data, (*data.shape[:-1], n_blocks, self.block_size)) + for i in range(n_blocks): + yield res[:, i, :] + + self.buffer_generator = buffer_generator() def playback(self, data, start=True): """Playback an array of audio data. From 62df20641a3a566b029cb47bd0ac80b0e6ab440d Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 22 Sep 2022 17:18:49 +0200 Subject: [PATCH 09/63] experimenting with simple generators for arrays --- haiopy/generators.py | 54 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 haiopy/generators.py diff --git a/haiopy/generators.py b/haiopy/generators.py new file mode 100644 index 0000000..642914d --- /dev/null +++ b/haiopy/generators.py @@ -0,0 +1,54 @@ +import numpy as np + + +class Buffer(object): + + def __init__(self, block_size) -> None: + if type(block_size) != int: + raise ValueError("The block size needs to be an integer") + self._buffer = None + self._block_size = block_size + + def __iter__(self): + raise NotImplementedError() + + def __next__(self): + raise NotImplementedError() + + +class ArrayBuffer(Buffer): + + def __init__(self, data, block_size) -> None: + super().__init__(block_size) + + n_samples = data.shape[-1] + if np.mod(n_samples, block_size) > 0: + pad_samples = block_size - np.mod(n_samples, block_size) + pad_array = np.zeros((data.shape[0], 2), dtype=int) + pad_array[-1][-1] = pad_samples + data = np.pad(data, pad_array) + + self._block_size = block_size + self._n_blocks = int(np.ceil(data.shape[-1] / block_size)) + + self._buffer = np.lib.stride_tricks.as_strided( + data.copy(), + (*data.shape[:-1], self._n_blocks, block_size)) + + self._index = 0 + + +class OutputArrayBuffer(ArrayBuffer): + + def __init__(self, data, block_size) -> None: + super().__init__(self, data, block_size) + + def __next__(self): + return self.next() + + def next(self): + if self._index < self._n_blocks: + current = self._index + self._index += 1 + return self._buffer[..., current, :] + raise StopIteration("Buffer is empty") From b530857db0bd4173d58888cdf2ba2b54b76a25e4 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 23 Sep 2022 12:40:12 +0200 Subject: [PATCH 10/63] WIP: continued work on generators --- haiopy/generators.py | 96 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 18 deletions(-) diff --git a/haiopy/generators.py b/haiopy/generators.py index 642914d..b4a4e69 100644 --- a/haiopy/generators.py +++ b/haiopy/generators.py @@ -4,51 +4,111 @@ class Buffer(object): def __init__(self, block_size) -> None: + self._check_block_size(block_size) + self._block_size = block_size + self._buffer = None + + def _check_block_size(self, block_size): if type(block_size) != int: raise ValueError("The block size needs to be an integer") - self._buffer = None + + def _set_block_size(self, block_size): + self._check_block_size(block_size) self._block_size = block_size + @property + def block_size(self): + return self._block_size + + @block_size.setter + def block_size(self, block_size): + self._set_block_size(block_size) + def __iter__(self): raise NotImplementedError() def __next__(self): + return self.next() + + def next(self): raise NotImplementedError() class ArrayBuffer(Buffer): - def __init__(self, data, block_size) -> None: + def __init__(self, block_size, data) -> None: super().__init__(block_size) + self.data = data + self._index = 0 + def _pad_data(self, data): n_samples = data.shape[-1] - if np.mod(n_samples, block_size) > 0: - pad_samples = block_size - np.mod(n_samples, block_size) + if np.mod(n_samples, self._block_size) > 0: + pad_samples = self.block_size - np.mod(n_samples, self.block_size) pad_array = np.zeros((data.shape[0], 2), dtype=int) pad_array[-1][-1] = pad_samples - data = np.pad(data, pad_array) + padded = np.pad(data, pad_array) + else: + padded = data - self._block_size = block_size - self._n_blocks = int(np.ceil(data.shape[-1] / block_size)) + return padded - self._buffer = np.lib.stride_tricks.as_strided( - data.copy(), - (*data.shape[:-1], self._n_blocks, block_size)) + @property + def n_blocks(self): + return self._n_blocks - self._index = 0 + @property + def index(self): + return self._index + @property + def data(self): + return self._data -class OutputArrayBuffer(ArrayBuffer): + def _set_block_size(self, block_size): + super()._set_block_size(block_size) + self._update_data() - def __init__(self, data, block_size) -> None: - super().__init__(self, data, block_size) + def _update_data(self): + self._n_blocks = int(np.ceil(self.data.shape[-1] / self.block_size)) + self._strided_data = np.lib.stride_tricks.as_strided( + self.data, + (*self.data.shape[:-1], self.n_blocks, self.block_size)) - def __next__(self): - return self.next() + @data.setter + def data(self, data): + self._data = self._pad_data(data) + self._update_data() def next(self): if self._index < self._n_blocks: current = self._index self._index += 1 - return self._buffer[..., current, :] - raise StopIteration("Buffer is empty") + return self._strided_data[..., current, :] + raise StopIteration("The buffer is empty.") + + +class OutputArrayBuffer(ArrayBuffer): + + def __init__(self, block_size, data) -> None: + super().__init__(self, block_size, data) + + # def next(self): + # if self._index < self._n_blocks: + # current = self._index + # self._index += 1 + # return self._buffer[..., current, :] + # raise StopIteration("Buffer is empty") + + +class InputArrayBuffer(ArrayBuffer): + + def __init__(self, block_size, data) -> None: + super().__init__(self, block_size, data) + + # def next(self): + # if self._index < self._n_blocks: + # current = self._index + # self._index += 1 + # return self._buffer[..., current, :] + # raise StopIteration("Buffer is empty") From 7e51420c96e5053876568b83d32bca5913f6c066 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 22 Sep 2022 17:18:49 +0200 Subject: [PATCH 11/63] experimenting with simple generators for arrays --- haiopy/generators.py | 54 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 haiopy/generators.py diff --git a/haiopy/generators.py b/haiopy/generators.py new file mode 100644 index 0000000..642914d --- /dev/null +++ b/haiopy/generators.py @@ -0,0 +1,54 @@ +import numpy as np + + +class Buffer(object): + + def __init__(self, block_size) -> None: + if type(block_size) != int: + raise ValueError("The block size needs to be an integer") + self._buffer = None + self._block_size = block_size + + def __iter__(self): + raise NotImplementedError() + + def __next__(self): + raise NotImplementedError() + + +class ArrayBuffer(Buffer): + + def __init__(self, data, block_size) -> None: + super().__init__(block_size) + + n_samples = data.shape[-1] + if np.mod(n_samples, block_size) > 0: + pad_samples = block_size - np.mod(n_samples, block_size) + pad_array = np.zeros((data.shape[0], 2), dtype=int) + pad_array[-1][-1] = pad_samples + data = np.pad(data, pad_array) + + self._block_size = block_size + self._n_blocks = int(np.ceil(data.shape[-1] / block_size)) + + self._buffer = np.lib.stride_tricks.as_strided( + data.copy(), + (*data.shape[:-1], self._n_blocks, block_size)) + + self._index = 0 + + +class OutputArrayBuffer(ArrayBuffer): + + def __init__(self, data, block_size) -> None: + super().__init__(self, data, block_size) + + def __next__(self): + return self.next() + + def next(self): + if self._index < self._n_blocks: + current = self._index + self._index += 1 + return self._buffer[..., current, :] + raise StopIteration("Buffer is empty") From 4c8ec9bd5794b4915f8e791fe1e91f1502d2afdb Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 23 Sep 2022 12:40:12 +0200 Subject: [PATCH 12/63] WIP: continued work on generators --- haiopy/generators.py | 96 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 18 deletions(-) diff --git a/haiopy/generators.py b/haiopy/generators.py index 642914d..b4a4e69 100644 --- a/haiopy/generators.py +++ b/haiopy/generators.py @@ -4,51 +4,111 @@ class Buffer(object): def __init__(self, block_size) -> None: + self._check_block_size(block_size) + self._block_size = block_size + self._buffer = None + + def _check_block_size(self, block_size): if type(block_size) != int: raise ValueError("The block size needs to be an integer") - self._buffer = None + + def _set_block_size(self, block_size): + self._check_block_size(block_size) self._block_size = block_size + @property + def block_size(self): + return self._block_size + + @block_size.setter + def block_size(self, block_size): + self._set_block_size(block_size) + def __iter__(self): raise NotImplementedError() def __next__(self): + return self.next() + + def next(self): raise NotImplementedError() class ArrayBuffer(Buffer): - def __init__(self, data, block_size) -> None: + def __init__(self, block_size, data) -> None: super().__init__(block_size) + self.data = data + self._index = 0 + def _pad_data(self, data): n_samples = data.shape[-1] - if np.mod(n_samples, block_size) > 0: - pad_samples = block_size - np.mod(n_samples, block_size) + if np.mod(n_samples, self._block_size) > 0: + pad_samples = self.block_size - np.mod(n_samples, self.block_size) pad_array = np.zeros((data.shape[0], 2), dtype=int) pad_array[-1][-1] = pad_samples - data = np.pad(data, pad_array) + padded = np.pad(data, pad_array) + else: + padded = data - self._block_size = block_size - self._n_blocks = int(np.ceil(data.shape[-1] / block_size)) + return padded - self._buffer = np.lib.stride_tricks.as_strided( - data.copy(), - (*data.shape[:-1], self._n_blocks, block_size)) + @property + def n_blocks(self): + return self._n_blocks - self._index = 0 + @property + def index(self): + return self._index + @property + def data(self): + return self._data -class OutputArrayBuffer(ArrayBuffer): + def _set_block_size(self, block_size): + super()._set_block_size(block_size) + self._update_data() - def __init__(self, data, block_size) -> None: - super().__init__(self, data, block_size) + def _update_data(self): + self._n_blocks = int(np.ceil(self.data.shape[-1] / self.block_size)) + self._strided_data = np.lib.stride_tricks.as_strided( + self.data, + (*self.data.shape[:-1], self.n_blocks, self.block_size)) - def __next__(self): - return self.next() + @data.setter + def data(self, data): + self._data = self._pad_data(data) + self._update_data() def next(self): if self._index < self._n_blocks: current = self._index self._index += 1 - return self._buffer[..., current, :] - raise StopIteration("Buffer is empty") + return self._strided_data[..., current, :] + raise StopIteration("The buffer is empty.") + + +class OutputArrayBuffer(ArrayBuffer): + + def __init__(self, block_size, data) -> None: + super().__init__(self, block_size, data) + + # def next(self): + # if self._index < self._n_blocks: + # current = self._index + # self._index += 1 + # return self._buffer[..., current, :] + # raise StopIteration("Buffer is empty") + + +class InputArrayBuffer(ArrayBuffer): + + def __init__(self, block_size, data) -> None: + super().__init__(self, block_size, data) + + # def next(self): + # if self._index < self._n_blocks: + # current = self._index + # self._index += 1 + # return self._buffer[..., current, :] + # raise StopIteration("Buffer is empty") From 3e9e766054653fb14ddf145c39c831b06c3a7127 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 23 Sep 2022 12:43:43 +0200 Subject: [PATCH 13/63] initial simple tests for generators --- tests/test_generators.py | 117 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/test_generators.py diff --git a/tests/test_generators.py b/tests/test_generators.py new file mode 100644 index 0000000..371dc2e --- /dev/null +++ b/tests/test_generators.py @@ -0,0 +1,117 @@ +import numpy as np +import numpy.testing as npt +from haiopy.generators import Buffer, ArrayBuffer +import pytest +import pyfar as pf + + +def test_buffer(): + + block_size = 512 + buffer = Buffer(block_size) + + assert buffer._block_size == block_size + + assert buffer.block_size == block_size + + new_block_size = 128 + buffer.block_size = int(new_block_size) + assert buffer._block_size == new_block_size + + with pytest.raises( + ValueError, match='The block size needs to be an integer'): + buffer.block_size = float(10) + + with pytest.raises( + ValueError, match='The block size needs to be an integer'): + Buffer(float(10)) + + # with pytest.raises(NotImplementedError): + # buffer.data + + with pytest.raises(NotImplementedError): + buffer.__next__() + + with pytest.raises(NotImplementedError): + buffer.__iter__() + + +def test_array_buffer(): + + block_size = 512 + n_blocks = 10 + n_samples = block_size*n_blocks + sampling_rate = 44100 + + freq = 440 + + data_pf = pf.signals.sine(freq, n_samples, sampling_rate=sampling_rate) + data = data_pf.time + + buffer = ArrayBuffer(block_size, data) + + assert buffer._n_blocks == n_blocks + assert buffer.n_blocks == n_blocks + + # check if the initial index s correct + assert buffer._index == 0 + assert buffer.index == 0 + + # check if the data arrays are correct + npt.assert_array_equal(buffer._data, data) + npt.assert_array_equal(buffer.data, data) + + # check if the data strides are correct + strided_buffer_data = np.lib.stride_tricks.as_strided( + data, (*data.shape[:-1], n_blocks, block_size)) + npt.assert_array_equal( + buffer._strided_data, strided_buffer_data) + + # check first step + block_data = buffer.__next__() + npt.assert_array_equal(block_data, strided_buffer_data[..., 0, :]) + + # check second step + block_data = buffer.__next__() + npt.assert_array_equal(block_data, strided_buffer_data[..., 1, :]) + + # check if a error is raised if the end of the buffer is reached + with pytest.raises(StopIteration, match="buffer is empty"): + while True: + buffer.__next__() + + +def test_buffer_updates(): + block_size = 512 + n_blocks = 10 + n_samples = block_size*n_blocks + sampling_rate = 44100 + + freq = 440 + + data_pf = pf.signals.sine(freq, n_samples, sampling_rate=sampling_rate) + data = data_pf.time + + data_empty = np.zeros_like(data) + + buffer = ArrayBuffer(block_size, data_empty) + + buffer.data = data + npt.assert_array_equal(buffer._data, data) + npt.assert_array_equal(buffer.data, data) + + # The new block size is 4 times smaller than the old one + new_block_size = 128 + buffer.block_size = new_block_size + + # The data itself is not touched in this case + npt.assert_array_equal(buffer._data, data) + npt.assert_array_equal(buffer.data, data) + + # Stride the array with the new block size + # The new number of blocks is an integer multiple of the old block size + new_n_blocks = n_samples // new_block_size + strided_buffer_data = np.lib.stride_tricks.as_strided( + data, (*data.shape[:-1], new_n_blocks, new_block_size)) + npt.assert_array_equal( + buffer._strided_data, strided_buffer_data) From 14dc523b4060c686a84996f5a944a0dd8619fa2a Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 23 Sep 2022 14:11:27 +0200 Subject: [PATCH 14/63] separate input and output buffers --- haiopy/devices.py | 71 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index b5dc3f1..205b7cb 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -10,6 +10,7 @@ from queue import Queue from asyncio import Queue as AQueue +from haiopy.generators import ArrayBuffer, InputArrayBuffer, OutputArrayBuffer def list_devices(): @@ -211,21 +212,43 @@ def output_callback(self, outdata, frames, time, status) -> None: assert not status try: - outdata[:] = next(self.buffer_generator).T + outdata[:] = next(self._output_buffer).T except StopIteration: raise sd.CallbackStop("Buffer empty") - def init_buffer_generator(self, data): + def input_callback(self, indata, frames, time, status): + assert frames == self.block_size + if status.output_underflow: + print('Output underflow: increase blocksize?', file=sys.stderr) + raise sd.CallbackAbort('Buffer underflow') + assert not status - def buffer_generator(): - n_blocks = int(np.floor(data.shape[-1]/self.block_size)) + try: + next(self._input_buffer)[:] = indata.T + except StopIteration: + raise sd.CallbackStop("Buffer empty") - res = np.lib.stride_tricks.as_strided( - data, (*data.shape[:-1], n_blocks, self.block_size)) - for i in range(n_blocks): - yield res[:, i, :] + def init_input_buffer_generator(self, data): + """Initialize the output buffer. - self.buffer_generator = buffer_generator() + Parameters + ---------- + data : haiopy.ArrayBuffer, generator + The input buffer to which the input data is written block-wise. + """ + self._input_buffer = ArrayBuffer( + self.block_size, data) + + def init_output_buffer_generator(self, data): + """Initialize the output buffer. + + Parameters + ---------- + data : haiopy.ArrayBuffer, generator + The output buffer from which the output data is read block-wise. + """ + self._output_buffer = ArrayBuffer( + self.block_size, data) def playback(self, data, start=True): """Playback an array of audio data. @@ -257,14 +280,6 @@ def playback(self, data, start=True): if start is True: self.start() - def record(n_samples): - # stream start, read into the queue - pass - - def playback_record(data): - # see combination above - pass - @property def playback_queue(self): """The playback queue, storing audio data in blocks which is read @@ -327,6 +342,25 @@ def initialize_playback(self, n_channels): self._stream = ostream + def initialize_record(self, n_channels): + """Initialize the playback stream for a given number of channels. + + Parameters + ---------- + n_channels : int + The number of output channels for which the stream is opened. + """ + ostream = sd.InputStream( + self.sampling_rate, + self.block_size, + self.id, + n_channels, + self.dtype, + callback=self.input_callback, + finished_callback=self._finished_callback + ) + self._stream = ostream + def start(self): """Start the audio stream """ @@ -339,9 +373,6 @@ def start(self): else: print("Stream is closed. Try re-initializing.", file=sys.stderr) - def initialize_record(channels): - raise NotImplementedError() - def initialize_playback_record(input_channels, output_channels): raise NotImplementedError() From f47006ba467df3a6095f7926aeb8befd1fe3235f Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 23 Sep 2022 14:35:40 +0200 Subject: [PATCH 15/63] clelaning up --- haiopy/devices.py | 94 +++-------------------------------------------- 1 file changed, 6 insertions(+), 88 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 205b7cb..30ae5fe 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -3,13 +3,8 @@ from os import stat import sys import sounddevice as sd -import queue -import numpy as np -# from arrayqueues import ArrayQueue from abc import (ABCMeta, abstractmethod, abstractproperty) -from queue import Queue -from asyncio import Queue as AQueue from haiopy.generators import ArrayBuffer, InputArrayBuffer, OutputArrayBuffer @@ -100,10 +95,8 @@ def __init__( self._callback = None self._stream = None - - self._record_queue = None - self._playback_queue = None - self.initialize_playback_queue() + self._input_buffer = None + self._output_buffer = None self._stream_finished = Event() @@ -228,7 +221,7 @@ def input_callback(self, indata, frames, time, status): except StopIteration: raise sd.CallbackStop("Buffer empty") - def init_input_buffer_generator(self, data): + def init_input_buffer(self, data): """Initialize the output buffer. Parameters @@ -239,7 +232,7 @@ def init_input_buffer_generator(self, data): self._input_buffer = ArrayBuffer( self.block_size, data) - def init_output_buffer_generator(self, data): + def init_output_buffer(self, data): """Initialize the output buffer. Parameters @@ -250,78 +243,6 @@ def init_output_buffer_generator(self, data): self._output_buffer = ArrayBuffer( self.block_size, data) - def playback(self, data, start=True): - """Playback an array of audio data. - This method initializes the playback device for the given audio - data and starts playback. After playback is finished, the device - is automatically stopped. - - Parameters - ---------- - data : array, float32, int8, int16, int32 - Playback data with dimensions (n_channels, n_samples). - start : bool, optional - If ``True``, the playback is started right away, if ``False``. - The default is ``True`` - - """ - if data.ndim > 2: - raise ValueError( - "The data cannot can not have more than 2 dimensions.") - n_channels_data = data.shape[0] - - # queue size in mega bytes - # qsize = data.itemsize * data.size / 1000000 - # self.initialize_playback_queue(qsize) - self.initialize_playback_queue(0) - self.initialize_playback(n_channels_data) - self.write_queue(data) - - if start is True: - self.start() - - @property - def playback_queue(self): - """The playback queue, storing audio data in blocks which is read - by the playback callback. - """ - return self._playback_queue - - def initialize_playback_queue(self, qsize=32): - """Initialize an empty playback queue. - - Parameters - ---------- - qsize : int, optional - The queue size in mega-bytes, by default 32 - """ - if self._playback_queue is not None: - self._playback_queue = None - self._playback_queue = Queue(0) - - def write_queue(self, data): - """Fill the playback queue with audio data. - - Parameters - ---------- - data : array, float32, int32, int16, int8 - The audio data as numpy array - - """ - if self._playback_queue is None: - raise ValueError("The Queue need to be initialized first.") - - n_blocks = int(np.floor(data.shape[-1]/self.block_size)) - - for idb in range(n_blocks): - sdx = np.arange(idb*self.block_size, (idb+1)*self.block_size) - # if self._playback_queue.check_full(): - if self._playback_queue.full(): - raise MemoryError( - "The input queue is full. ", - "Try initializing a larger queue.") - self._playback_queue.put(data[..., sdx]) - def initialize_playback(self, n_channels): """Initialize the playback stream for a given number of channels. @@ -337,9 +258,7 @@ def initialize_playback(self, n_channels): n_channels, self.dtype, callback=self.output_callback, - finished_callback=self._finished_callback - ) - + finished_callback=self._finished_callback) self._stream = ostream def initialize_record(self, n_channels): @@ -357,8 +276,7 @@ def initialize_record(self, n_channels): n_channels, self.dtype, callback=self.input_callback, - finished_callback=self._finished_callback - ) + finished_callback=self._finished_callback) self._stream = ostream def start(self): From 69f78a5bac43b802deb7e3818e17d16036f135f9 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 23 Sep 2022 19:07:04 +0200 Subject: [PATCH 16/63] cleaning up --- haiopy/devices.py | 263 +++++++++++++++++++++++++--------------------- 1 file changed, 144 insertions(+), 119 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 30ae5fe..af354ee 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -5,7 +5,8 @@ import sounddevice as sd from abc import (ABCMeta, abstractmethod, abstractproperty) -from haiopy.generators import ArrayBuffer, InputArrayBuffer, OutputArrayBuffer +from haiopy.generators import ( + ArrayBuffer, InputArrayBuffer, OutputArrayBuffer) def list_devices(): @@ -69,24 +70,6 @@ def __init__( id = sd.query_devices(id)['name'] self._name = sd.query_devices(id)['name'] - n_channels_input = sd.query_devices(id)['max_input_channels'] - n_channels_output = sd.query_devices(id)['max_output_channels'] - sd.check_input_settings( - device=id, - channels=n_channels_input, - dtype=dtype, - extra_settings=extra_settings, - samplerate=sampling_rate) - - sd.check_output_settings( - device=id, - channels=n_channels_output, - dtype=dtype, - extra_settings=extra_settings, - samplerate=sampling_rate) - - self._loop = asyncio.get_event_loop() - self.id = id self.dtype = dtype self._block_size = block_size @@ -100,16 +83,6 @@ def __init__( self._stream_finished = Event() - @property - def n_channels_input(self): - """The number of input channels supported by the device""" - return sd.query_devices(self.id)['max_input_channels'] - - @property - def n_channels_output(self): - """The number of output channels supported by the device""" - return sd.query_devices(self.id)['max_output_channels'] - def check_settings( self, sampling_rate, dtype, extra_settings,): """Check if settings are compatible with the physical devices. @@ -159,6 +132,12 @@ def block_size(self): """ return self._block_size + @block_size.setter + def block_size(self, block_size): + self._block_size = block_size + self._stream.blocksize = block_size + self.output_buffer.block_size = block_size + @property def stream(self): """The sounddevice audio stream. @@ -177,6 +156,142 @@ def _finished_callback(self) -> None: self.finished_callback() self.stream.stop() + def start(self): + """Start the audio stream""" + if not self.stream.closed: + if not self.stream.active: + self._stream_finished.clear() + self.stream.start() + else: + print("Stream is already active.", file=sys.stderr) + else: + print("Stream is closed. Try re-initializing.", file=sys.stderr) + + def wait(self): + """Wait for the audio stream to finish.""" + self._stream_finished.wait(timeout=None) + + def abort(self): + """Stop the audio steam without finishing remaining buffers.""" + if self.stream.active is True: + self.stream.abort() + + def close(self): + """Close the audio device and release the sound card lock.""" + if self.stream is not None: + self.stream.close() + + def stop(self): + """Stop the audio stream after finishing the current buffer.""" + if self.stream.active is True: + self.stream.stop() + + +class AudioInputDevice(_Device): + def __init__( + self, + id=sd.default.device, + sampling_rate=44100, + block_size=512, + dtype='float32', + input_buffer=None, + latency=None, + extra_settings=None, + clip_off=None, + dither_off=None, + never_drop_input=None, + prime_output_buffers_using_stream_callback=None + ): + super().__init__( + id=id, + sampling_rate=sampling_rate, + block_size=block_size, + dtype=dtype, + latency=latency) + + n_channels_input = sd.query_devices(id)['max_input_channels'] + sd.check_input_settings( + device=id, + channels=n_channels_input, + dtype=dtype, + extra_settings=extra_settings, + samplerate=sampling_rate) + + @property + def n_channels_input(self): + """The number of input channels supported by the device""" + return sd.query_devices(self.id)['max_input_channels'] + + def _set_block_size(self, block_size): + self.input_buffer.block_size = block_size + + def input_callback(self, indata, frames, time, status): + assert frames == self.block_size + if status.output_underflow: + print('Output underflow: increase blocksize?', file=sys.stderr) + raise sd.CallbackAbort('Buffer underflow') + assert not status + + try: + next(self._input_buffer)[:] = indata.T + except StopIteration: + raise sd.CallbackStop("Buffer empty") + + def initialize_record(self, n_channels): + """Initialize the playback stream for a given number of channels. + + Parameters + ---------- + n_channels : int + The number of output channels for which the stream is opened. + """ + ostream = sd.InputStream( + self.sampling_rate, + self.block_size, + self.id, + n_channels, + self.dtype, + callback=self.input_callback, + finished_callback=self._finished_callback) + self._stream = ostream + + +class AudioOutputDevice(AudioDevice): + + def __init__( + self, + id=sd.default.device, + sampling_rate=44100, + block_size=512, + dtype='float32', + output_buffer=None, + latency=None, + extra_settings=None, + clip_off=None, + dither_off=None, + never_drop_input=None, + prime_output_buffers_using_stream_callback=None + ): + super().__init__( + id=id, + sampling_rate=sampling_rate, + block_size=block_size, + dtype=dtype, + latency=latency) + + n_channels_output = sd.query_devices(id)['max_output_channels'] + sd.check_output_settings( + device=id, + channels=n_channels_output, + dtype=dtype, + extra_settings=extra_settings, + samplerate=sampling_rate) + + @property + def n_channels_output(self): + """The number of output channels supported by the device""" + return sd.query_devices(self.id)['max_output_channels'] + def output_callback(self, outdata, frames, time, status) -> None: """Portudio callback for output streams @@ -209,40 +324,6 @@ def output_callback(self, outdata, frames, time, status) -> None: except StopIteration: raise sd.CallbackStop("Buffer empty") - def input_callback(self, indata, frames, time, status): - assert frames == self.block_size - if status.output_underflow: - print('Output underflow: increase blocksize?', file=sys.stderr) - raise sd.CallbackAbort('Buffer underflow') - assert not status - - try: - next(self._input_buffer)[:] = indata.T - except StopIteration: - raise sd.CallbackStop("Buffer empty") - - def init_input_buffer(self, data): - """Initialize the output buffer. - - Parameters - ---------- - data : haiopy.ArrayBuffer, generator - The input buffer to which the input data is written block-wise. - """ - self._input_buffer = ArrayBuffer( - self.block_size, data) - - def init_output_buffer(self, data): - """Initialize the output buffer. - - Parameters - ---------- - data : haiopy.ArrayBuffer, generator - The output buffer from which the output data is read block-wise. - """ - self._output_buffer = ArrayBuffer( - self.block_size, data) - def initialize_playback(self, n_channels): """Initialize the playback stream for a given number of channels. @@ -260,59 +341,3 @@ def initialize_playback(self, n_channels): callback=self.output_callback, finished_callback=self._finished_callback) self._stream = ostream - - def initialize_record(self, n_channels): - """Initialize the playback stream for a given number of channels. - - Parameters - ---------- - n_channels : int - The number of output channels for which the stream is opened. - """ - ostream = sd.InputStream( - self.sampling_rate, - self.block_size, - self.id, - n_channels, - self.dtype, - callback=self.input_callback, - finished_callback=self._finished_callback) - self._stream = ostream - - def start(self): - """Start the audio stream - """ - if not self.stream.closed: - if not self.stream.active: - self._stream_finished.clear() - self.stream.start() - else: - print("Stream is already active.", file=sys.stderr) - else: - print("Stream is closed. Try re-initializing.", file=sys.stderr) - - def initialize_playback_record(input_channels, output_channels): - raise NotImplementedError() - - def wait(self): - """Wait for the audio stream to finish. - """ - self._stream_finished.wait(timeout=None) - - def abort(self): - """Stop the audio steam without finishing remaining buffers. - """ - if self.stream.active is True: - self.stream.abort() - - def close(self): - """Close the audio device and release the sound card lock. - """ - if self.stream is not None: - self.stream.close() - - def stop(self): - """Stop the audio stream after finishing the current buffer. - """ - if self.stream.active is True: - self.stream.stop() From f526abac7f728a26242e012d34e3e04628e32795 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 23 Sep 2022 22:23:56 +0200 Subject: [PATCH 17/63] cleaning up AudioOutputDevice --- haiopy/devices.py | 55 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index af354ee..96f250c 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -1,6 +1,5 @@ from multiprocessing import Event -import asyncio -from os import stat +import numpy as np import sys import sounddevice as sd from abc import (ABCMeta, abstractmethod, abstractproperty) @@ -135,7 +134,6 @@ def block_size(self): @block_size.setter def block_size(self, block_size): self._block_size = block_size - self._stream.blocksize = block_size self.output_buffer.block_size = block_size @property @@ -263,6 +261,7 @@ def __init__( id=sd.default.device, sampling_rate=44100, block_size=512, + channels=[1], dtype='float32', output_buffer=None, latency=None, @@ -270,8 +269,7 @@ def __init__( clip_off=None, dither_off=None, never_drop_input=None, - prime_output_buffers_using_stream_callback=None - ): + prime_output_buffers_using_stream_callback=None): super().__init__( id=id, sampling_rate=sampling_rate, @@ -287,8 +285,30 @@ def __init__( extra_settings=extra_settings, samplerate=sampling_rate) + self._output_channels = channels + + if output_buffer is None: + OutputArrayBuffer( + self.block_size, + np.zeros( + (self.n_channels_output, self.block_size), + dtype=self.dtype)) + if output_buffer.data.shape[0] != self.n_channels_output: + raise ValueError( + "The shape of the buffer does not match the channel mapping") + self.output_buffer = output_buffer + self.initialize() + + @property + def output_channels(self): + return self._output_channels + @property def n_channels_output(self): + return len(self._output_channels) + + @property + def max_channels_output(self): """The number of output channels supported by the device""" return sd.query_devices(self.id)['max_output_channels'] @@ -324,20 +344,27 @@ def output_callback(self, outdata, frames, time, status) -> None: except StopIteration: raise sd.CallbackStop("Buffer empty") - def initialize_playback(self, n_channels): - """Initialize the playback stream for a given number of channels. - - Parameters - ---------- - n_channels : int - The number of output channels for which the stream is opened. - """ + def initialize(self): + """Initialize the playback stream for a given number of channels.""" ostream = sd.OutputStream( self.sampling_rate, self.block_size, self.id, - n_channels, + self.n_channels_output, self.dtype, callback=self.output_callback, finished_callback=self._finished_callback) self._stream = ostream + + @property + def output_buffer(self): + return self._output_buffer + + @output_buffer.setter + def output_buffer(self, buffer): + if buffer.block_size != self.block_size: + raise ValueError( + "The buffer's block size does not match. ", + f"Needs to be {self.block_size}") + + self._output_buffer = buffer From e20de7b1f065df9cb69b761ae5f4d3606018ec29 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 23 Sep 2022 23:35:44 +0200 Subject: [PATCH 18/63] start writing mocks for query of sounddevices --- tests/test_devices.py | 8 ++++++++ tests/utils.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 tests/test_devices.py create mode 100644 tests/utils.py diff --git a/tests/test_devices.py b/tests/test_devices.py new file mode 100644 index 0000000..25b86b7 --- /dev/null +++ b/tests/test_devices.py @@ -0,0 +1,8 @@ +from haiopy import devices +from . import utils +from unittest.mock import patch + + +@patch('sounddevice.query_devices', new=utils.query_devices) +def test_audio_device(): + devices.AudioDevice(0) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..c467230 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,42 @@ +import pytest +from unittest import mock + + +def default_devices(): + return [0, 0] + + +def query_devices(id=None, kind=None): + if kind == 'input': + return { + 'name': "MockDevice", + 'index': 0, + 'hostapi': 'CoreAudio', + 'max_input_channels': 8, + 'default_low_input_latency': 0.1, + 'default_high_input_latency': 0.15, + 'default_samplerate': 44100 + } + elif kind == 'output': + return { + 'name': "MockInputDevice", + 'index': 0, + 'hostapi': 'CoreAudio', + 'max_output_channels': 8, + 'default_low_output_latency': 0.1, + 'default_high_output_latency': 0.15, + 'default_samplerate': 44100 + } + else: + return { + 'name': "MockOutput", + 'index': 0, + 'hostapi': 'CoreAudio', + 'max_input_channels': 8, + 'max_output_channels': 8, + 'default_low_input_latency': 0.1, + 'default_low_output_latency': 0.1, + 'default_high_input_latency': 0.15, + 'default_high_output_latency': 0.15, + 'default_samplerate': 44100 + } From 6a2d4c0b0290ffcc7cdb0ecb11607dd5df2b5dd8 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 26 Sep 2022 11:15:32 +0200 Subject: [PATCH 19/63] mocks for checking of input and output settings --- tests/test_devices.py | 14 ++++++++++++++ tests/utils.py | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/tests/test_devices.py b/tests/test_devices.py index 25b86b7..a09f72f 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -6,3 +6,17 @@ @patch('sounddevice.query_devices', new=utils.query_devices) def test_audio_device(): devices.AudioDevice(0) + + +@patch('sounddevice.query_devices', new=utils.query_devices) +@patch('sounddevice.check_input_settings', new=utils.check_input_settings) +def test_check_input_settings(): + in_device = devices.AudioInputDevice() + # in_device.check_settings() + + +@patch('sounddevice.query_devices', new=utils.query_devices) +@patch('sounddevice.check_output_settings', new=utils.check_output_settings) +def test_check_output_settings(): + in_device = devices.AudioOutputDevice() + # in_device.check_settings() diff --git a/tests/utils.py b/tests/utils.py index c467230..c7cb3c7 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -40,3 +40,30 @@ def query_devices(id=None, kind=None): 'default_high_output_latency': 0.15, 'default_samplerate': 44100 } + + +def supported_mock_device_parameters(): + return { + 'samplerate': [44.1e3, 48e3, 2*44.1e3, 96e3, 192e3], + 'dtype': ['float32'], + 'channels': [8]} + + +def check_output_settings( + device=None, + channels=None, + dtype=None, + extra_settings=None, + samplerate=None): + """So far this only passes for all settings""" + pass + + +def check_input_settings( + device=None, + channels=None, + dtype=None, + extra_settings=None, + samplerate=None): + """So far this only passes for all settings""" + pass From c2b67a953c28f0516d1a2f3f123505ce255efafa Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 26 Sep 2022 12:50:59 +0200 Subject: [PATCH 20/63] start mocking sounddevice stream and ArrayBuffer --- haiopy/devices.py | 112 +++++++++++++++++++++++++++---------- tests/sounddevice_mocks.py | 23 ++++++++ tests/test_devices.py | 11 ++-- tests/utils.py | 34 +++++++++++ 4 files changed, 145 insertions(+), 35 deletions(-) create mode 100644 tests/sounddevice_mocks.py diff --git a/haiopy/devices.py b/haiopy/devices.py index 96f250c..e3daf78 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -17,6 +17,10 @@ def __init__( self): super().__init__() + @abstractproperty + def id(sellf): + pass + @abstractproperty def name(self): pass @@ -69,7 +73,7 @@ def __init__( id = sd.query_devices(id)['name'] self._name = sd.query_devices(id)['name'] - self.id = id + self._id = id self.dtype = dtype self._block_size = block_size self._sampling_rate = sampling_rate @@ -82,32 +86,36 @@ def __init__( self._stream_finished = Event() - def check_settings( - self, sampling_rate, dtype, extra_settings,): - """Check if settings are compatible with the physical devices. - - Parameters - ---------- - sampling_rate : int - The audio sampling rate - dtype : np.float32, np.int8, np.int16, np.int32 - The audio buffer data type - extra_settings : extra settings - Audio API specific settings. - """ - sd.check_input_settings( - device=self.id, - channels=self.n_channels_input, - dtype=dtype, - extra_settings=extra_settings, - samplerate=sampling_rate) + # def check_settings( + # self, sampling_rate, dtype, extra_settings,): + # """Check if settings are compatible with the physical devices. + + # Parameters + # ---------- + # sampling_rate : int + # The audio sampling rate + # dtype : np.float32, np.int8, np.int16, np.int32 + # The audio buffer data type + # extra_settings : extra settings + # Audio API specific settings. + # """ + # sd.check_input_settings( + # device=self.id, + # channels=self.n_channels_input, + # dtype=dtype, + # extra_settings=extra_settings, + # samplerate=sampling_rate) + + # sd.check_output_settings( + # device=self.id, + # channels=self.n_channels_output, + # dtype=dtype, + # extra_settings=extra_settings, + # samplerate=sampling_rate) - sd.check_output_settings( - device=self.id, - channels=self.n_channels_output, - dtype=dtype, - extra_settings=extra_settings, - samplerate=sampling_rate) + @property + def id(self): + return self._id @property def name(self): @@ -185,10 +193,10 @@ def stop(self): self.stream.stop() -class AudioInputDevice(_Device): +class AudioInputDevice(AudioDevice): def __init__( self, - id=sd.default.device, + id=sd.default.device['input'], sampling_rate=44100, block_size=512, dtype='float32', @@ -215,6 +223,26 @@ def __init__( extra_settings=extra_settings, samplerate=sampling_rate) + def check_settings( + self, sampling_rate=None, dtype=None, extra_settings=None,): + """Check if settings are compatible with the physical devices. + + Parameters + ---------- + sampling_rate : int + The audio sampling rate + dtype : np.float32, np.int8, np.int16, np.int32 + The audio buffer data type + extra_settings : extra settings + Audio API specific settings. + """ + sd.check_input_settings( + device=self.id, + channels=self.n_channels_input, + dtype=dtype, + extra_settings=extra_settings, + samplerate=sampling_rate) + @property def n_channels_input(self): """The number of input channels supported by the device""" @@ -258,7 +286,7 @@ class AudioOutputDevice(AudioDevice): def __init__( self, - id=sd.default.device, + id=sd.default.device['output'], sampling_rate=44100, block_size=512, channels=[1], @@ -277,10 +305,12 @@ def __init__( dtype=dtype, latency=latency) - n_channels_output = sd.query_devices(id)['max_output_channels'] + # n_channels_output = sd.query_devices(id)['max_output_channels'] + max_channel = np.max(channels) + n_channels = len(channels) sd.check_output_settings( device=id, - channels=n_channels_output, + channels=np.max([n_channels, max_channel]), dtype=dtype, extra_settings=extra_settings, samplerate=sampling_rate) @@ -299,6 +329,26 @@ def __init__( self.output_buffer = output_buffer self.initialize() + def check_settings( + self, sampling_rate=None, dtype=None, extra_settings=None): + """Check if settings are compatible with the physical devices. + + Parameters + ---------- + sampling_rate : int + The audio sampling rate + dtype : np.float32, np.int8, np.int16, np.int32 + The audio buffer data type + extra_settings : extra settings + Audio API specific settings. + """ + sd.check_output_settings( + device=self.id, + channels=self.n_channels_output, + dtype=dtype, + extra_settings=extra_settings, + samplerate=sampling_rate) + @property def output_channels(self): return self._output_channels diff --git a/tests/sounddevice_mocks.py b/tests/sounddevice_mocks.py new file mode 100644 index 0000000..4aaef64 --- /dev/null +++ b/tests/sounddevice_mocks.py @@ -0,0 +1,23 @@ +import pytest +from unittest.mock import MagicMock + +import numpy as np +import sounddevice as sd + + +def output_stream_mock(block_size=512, sampling_rate=44100, channels=1): + # pass + stream = MagicMock(spec_set=sd.OutputStream) + stream.samplerate = sampling_rate + stream.blocksize = block_size + stream.device = 0 + stream.channels = channels + stream.dtype = np.float32 + stream.latency = 0.1 + # stream.extra_settings = None + # stream.clip_off = False + # stream.dither_off = False + # stream.never_drop_input = False + # stream.prime_output_buffers_using_stream_callback = False + + return stream diff --git a/tests/test_devices.py b/tests/test_devices.py index a09f72f..fa952cb 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -1,5 +1,6 @@ from haiopy import devices from . import utils +from . import sounddevice_mocks as sdm from unittest.mock import patch @@ -12,11 +13,13 @@ def test_audio_device(): @patch('sounddevice.check_input_settings', new=utils.check_input_settings) def test_check_input_settings(): in_device = devices.AudioInputDevice() - # in_device.check_settings() + in_device.check_settings() @patch('sounddevice.query_devices', new=utils.query_devices) @patch('sounddevice.check_output_settings', new=utils.check_output_settings) -def test_check_output_settings(): - in_device = devices.AudioOutputDevice() - # in_device.check_settings() +@patch('sounddevice.OutputStream', new=sdm.output_stream_mock()) +def test_check_output_settings(empty_buffer_stub): + out_device = devices.AudioOutputDevice( + output_buffer=empty_buffer_stub) + out_device.check_settings() diff --git a/tests/utils.py b/tests/utils.py index c7cb3c7..d3ba09a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,7 @@ import pytest from unittest import mock +import numpy as np +from haiopy.generators import ArrayBuffer, InputArrayBuffer, OutputArrayBuffer def default_devices(): @@ -67,3 +69,35 @@ def check_input_settings( samplerate=None): """So far this only passes for all settings""" pass + + +def array_buffer_stub(block_size=512, data=np.zeros((1, 512))): + """Generate a ArrayBuffer Stub with given block size and data + + Parameters + ---------- + block_size : int + Block size for the sound card callback + data : array_like, float32, int24, int16, int8 + The data of the buffer + """ + if np.mod(data.shape[-1], block_size) != 0: + raise ValueError( + 'The data needs to be an integer multiple of the block size') + + n_blocks = data.shape[-1] // block_size + + def next_block(): + strided = np.lib.stride_tricks.as_strided( + data, (*data.shape[:-1], n_blocks, block_size)) + + for idx in range(n_blocks): + yield strided[..., idx, :] + + buffer = mock.MagicMock(spec_set=ArrayBuffer(block_size, data)) + buffer.data = data + buffer.next = next_block + buffer.n_blocks = n_blocks + buffer.block_size = block_size + + return buffer From f6f89b3397e96d9ab907ade4416dedaf6cc1ec26 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Tue, 27 Sep 2022 10:28:18 +0200 Subject: [PATCH 21/63] add number of channels, use abstract methods and properties, use read-only strides for output buffers --- haiopy/generators.py | 53 ++++++++++++++++++++++++---------------- tests/test_generators.py | 18 ++++++++------ 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/haiopy/generators.py b/haiopy/generators.py index b4a4e69..7b3a781 100644 --- a/haiopy/generators.py +++ b/haiopy/generators.py @@ -1,4 +1,5 @@ import numpy as np +from abc import abstractproperty, abstractmethod class Buffer(object): @@ -24,22 +25,31 @@ def block_size(self): def block_size(self, block_size): self._set_block_size(block_size) + @abstractproperty + def sampling_rate(self): + pass + + @abstractmethod def __iter__(self): - raise NotImplementedError() + pass def __next__(self): return self.next() + @abstractmethod def next(self): raise NotImplementedError() class ArrayBuffer(Buffer): - def __init__(self, block_size, data) -> None: + def __init__(self, block_size, data, sampling_rate) -> None: super().__init__(block_size) - self.data = data + if data.ndim > 2: + raise ValueError("Only two-dimensional arrays are allowed") + self.data = np.atleast_2d(data) self._index = 0 + self._sampling_rate = sampling_rate def _pad_data(self, data): n_samples = data.shape[-1] @@ -53,6 +63,14 @@ def _pad_data(self, data): return padded + @property + def n_channels(self): + return self.data.shape[0] + + @property + def sampling_rate(self): + return self._sampling_rate + @property def n_blocks(self): return self._n_blocks @@ -77,7 +95,7 @@ def _update_data(self): @data.setter def data(self, data): - self._data = self._pad_data(data) + self._data = self._pad_data(np.atleast_2d(data)) self._update_data() def next(self): @@ -90,25 +108,18 @@ def next(self): class OutputArrayBuffer(ArrayBuffer): - def __init__(self, block_size, data) -> None: - super().__init__(self, block_size, data) + def __init__(self, block_size, data, sampling_rate) -> None: + super().__init__(block_size, data, sampling_rate) - # def next(self): - # if self._index < self._n_blocks: - # current = self._index - # self._index += 1 - # return self._buffer[..., current, :] - # raise StopIteration("Buffer is empty") + def _update_data(self): + self._n_blocks = int(np.ceil(self.data.shape[-1] / self.block_size)) + self._strided_data = np.lib.stride_tricks.as_strided( + self.data, + (*self.data.shape[:-1], self.n_blocks, self.block_size), + writeable=False) class InputArrayBuffer(ArrayBuffer): - def __init__(self, block_size, data) -> None: - super().__init__(self, block_size, data) - - # def next(self): - # if self._index < self._n_blocks: - # current = self._index - # self._index += 1 - # return self._buffer[..., current, :] - # raise StopIteration("Buffer is empty") + def __init__(self, block_size, data, sampling_rate) -> None: + super().__init__(block_size, data, sampling_rate) diff --git a/tests/test_generators.py b/tests/test_generators.py index 371dc2e..bc5e1af 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -29,11 +29,11 @@ def test_buffer(): # with pytest.raises(NotImplementedError): # buffer.data - with pytest.raises(NotImplementedError): - buffer.__next__() + # with pytest.raises(NotImplementedError): + # buffer.__next__() - with pytest.raises(NotImplementedError): - buffer.__iter__() + # with pytest.raises(NotImplementedError): + # buffer.__iter__() def test_array_buffer(): @@ -45,10 +45,14 @@ def test_array_buffer(): freq = 440 - data_pf = pf.signals.sine(freq, n_samples, sampling_rate=sampling_rate) + data_pf = pf.signals.sine( + freq, n_samples, sampling_rate=sampling_rate) data = data_pf.time - buffer = ArrayBuffer(block_size, data) + with pytest.raises(ValueError, match='Only two-dimensional'): + ArrayBuffer(block_size, np.zeros((1, 1, block_size)), sampling_rate) + + buffer = ArrayBuffer(block_size, data, sampling_rate) assert buffer._n_blocks == n_blocks assert buffer.n_blocks == n_blocks @@ -94,7 +98,7 @@ def test_buffer_updates(): data_empty = np.zeros_like(data) - buffer = ArrayBuffer(block_size, data_empty) + buffer = ArrayBuffer(block_size, data_empty, sampling_rate) buffer.data = data npt.assert_array_equal(buffer._data, data) From 242f6836cc3415ba0a62d9190602912eb46ed405 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Tue, 27 Sep 2022 14:53:45 +0200 Subject: [PATCH 22/63] implement simple signal buffer analogous to array buffers --- haiopy/generators.py | 66 ++++++++++++++++++++++++++++++++++++++++ tests/test_generators.py | 51 ++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/haiopy/generators.py b/haiopy/generators.py index 7b3a781..6539601 100644 --- a/haiopy/generators.py +++ b/haiopy/generators.py @@ -1,4 +1,5 @@ import numpy as np +import pyfar as pf from abc import abstractproperty, abstractmethod @@ -123,3 +124,68 @@ class InputArrayBuffer(ArrayBuffer): def __init__(self, block_size, data, sampling_rate) -> None: super().__init__(block_size, data, sampling_rate) + + +class SignalBuffer(Buffer): + + def __init__(self, block_size, signal) -> None: + super().__init__(block_size) + if not isinstance(signal, pf.Signal): + raise ValueError("signal must be a pyfar.Signal object.") + if signal.time.ndim > 2: + raise ValueError("Only two-dimensional arrays are allowed") + self._data = self._pad_data(signal) + self._update_data() + self._index = 0 + + def _pad_data(self, data): + n_samples = data.n_samples + if np.mod(n_samples, self._block_size) > 0: + pad_samples = self.block_size - np.mod(n_samples, self.block_size) + padded = pf.dsp.pad_zeros(data, pad_samples, mode='after') + else: + padded = data + + return padded + + @property + def n_channels(self): + return self.data.cshape[0] + + @property + def sampling_rate(self): + return self.data.sampling_rate + + @property + def n_blocks(self): + return self._n_blocks + + @property + def index(self): + return self._index + + @property + def data(self): + return self._data + + def _set_block_size(self, block_size): + super()._set_block_size(block_size) + self._update_data() + + def _update_data(self): + self._n_blocks = int(np.ceil(self.data.n_samples / self.block_size)) + self._strided_data = np.lib.stride_tricks.as_strided( + self.data.time, + (*self.data.cshape, self.n_blocks, self.block_size)) + + # @data.setter + # def data(self, data): + # self._data = self._pad_data(data) + # self._update_data() + + def next(self): + if self._index < self._n_blocks: + current = self._index + self._index += 1 + return self._strided_data[..., current, :] + raise StopIteration("The buffer is empty.") diff --git a/tests/test_generators.py b/tests/test_generators.py index bc5e1af..f54bc6d 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -1,6 +1,6 @@ import numpy as np import numpy.testing as npt -from haiopy.generators import Buffer, ArrayBuffer +from haiopy.generators import Buffer, ArrayBuffer, SignalBuffer import pytest import pyfar as pf @@ -119,3 +119,52 @@ def test_buffer_updates(): data, (*data.shape[:-1], new_n_blocks, new_block_size)) npt.assert_array_equal( buffer._strided_data, strided_buffer_data) + + +def test_signal_buffer(): + sampling_rate = 44100 + n_blocks = 10 + block_size = 512 + n_samples = block_size*n_blocks + sine = pf.signals.sine( + 440, n_samples, amplitude=[1, 1], sampling_rate=sampling_rate) + + with pytest.raises(ValueError, match='two-dimensional'): + SignalBuffer( + block_size, + pf.Signal(np.zeros((2, 3, block_size), 'float32'), sampling_rate)) + + with pytest.raises(ValueError, match='must be a pyfar.Signal'): + SignalBuffer(block_size, [1, 2, 3]) + + buffer = SignalBuffer(block_size, sine) + + assert buffer._n_blocks == n_blocks + assert buffer.n_blocks == n_blocks + + # check if the initial index s correct + assert buffer._index == 0 + assert buffer.index == 0 + + # check if the data arrays are correct + npt.assert_array_equal(buffer._data.time, sine.time) + npt.assert_array_equal(buffer.data.time, sine.time) + + # check if the data strides are correct + strided_buffer_data = np.lib.stride_tricks.as_strided( + sine.time, (*sine.cshape, n_blocks, block_size)) + npt.assert_array_equal( + buffer._strided_data, strided_buffer_data) + + # check first step + block_data = buffer.__next__() + npt.assert_array_equal(block_data, strided_buffer_data[..., 0, :]) + + # check second step + block_data = buffer.__next__() + npt.assert_array_equal(block_data, strided_buffer_data[..., 1, :]) + + # check if a error is raised if the end of the buffer is reached + with pytest.raises(StopIteration, match="buffer is empty"): + while True: + buffer.__next__() From 7cde47bfeab0339a9bd3e2a10b86bb150162193e Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 31 Oct 2022 13:47:59 +0100 Subject: [PATCH 23/63] add active state to generators --- haiopy/generators.py | 42 +++++++++++++++++++++++++++++++++++----- tests/test_generators.py | 14 ++++++++------ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/haiopy/generators.py b/haiopy/generators.py index 6539601..fa9584e 100644 --- a/haiopy/generators.py +++ b/haiopy/generators.py @@ -9,6 +9,7 @@ def __init__(self, block_size) -> None: self._check_block_size(block_size) self._block_size = block_size self._buffer = None + self._is_active = False def _check_block_size(self, block_size): if type(block_size) != int: @@ -41,6 +42,37 @@ def __next__(self): def next(self): raise NotImplementedError() + @property + def is_active(self): + return self._is_active + + def check_if_active(self): + """Check if the buffer is active. + If the buffer is active a BufferError exception is raised. In case the + buffer is currently inactive, the method simply passes without any + return value. + + Raises + ------ + BufferError + Exception is raised if the buffer is currently active. + """ + if self.is_active: + raise BufferError( + "The buffer needs to be inactive to be modified.") + + def _stop(self, msg="Buffer iteration stopped."): + self._is_active = False + raise StopIteration(msg) + + def _start(self): + if self._is_active: + raise BufferError("Buffer is already active.") + self._is_active = True + + def _reset(self): + self._stop() + class ArrayBuffer(Buffer): @@ -142,11 +174,9 @@ def _pad_data(self, data): n_samples = data.n_samples if np.mod(n_samples, self._block_size) > 0: pad_samples = self.block_size - np.mod(n_samples, self.block_size) - padded = pf.dsp.pad_zeros(data, pad_samples, mode='after') + return pf.dsp.pad_zeros(data, pad_samples, mode='after') else: - padded = data - - return padded + return data @property def n_channels(self): @@ -169,10 +199,12 @@ def data(self): return self._data def _set_block_size(self, block_size): + self.check_if_active() super()._set_block_size(block_size) self._update_data() def _update_data(self): + self.check_if_active() self._n_blocks = int(np.ceil(self.data.n_samples / self.block_size)) self._strided_data = np.lib.stride_tricks.as_strided( self.data.time, @@ -188,4 +220,4 @@ def next(self): current = self._index self._index += 1 return self._strided_data[..., current, :] - raise StopIteration("The buffer is empty.") + self._stop("The buffer is empty.") diff --git a/tests/test_generators.py b/tests/test_generators.py index f54bc6d..af2b89c 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -26,14 +26,16 @@ def test_buffer(): ValueError, match='The block size needs to be an integer'): Buffer(float(10)) - # with pytest.raises(NotImplementedError): - # buffer.data + # create new buffer + buffer = Buffer(block_size) + buffer._start() + assert buffer.is_active is True - # with pytest.raises(NotImplementedError): - # buffer.__next__() + # check if the correct error is raised on stopping + with pytest.raises(StopIteration, match="iteration stopped"): + buffer._stop() - # with pytest.raises(NotImplementedError): - # buffer.__iter__() + assert buffer.is_active is False def test_array_buffer(): From bd342112f8c7db79633d5a05ce5591f519c5630d Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 31 Oct 2022 16:39:59 +0100 Subject: [PATCH 24/63] improvements for state checking of buffer classes --- haiopy/generators.py | 15 +++--- tests/test_generators.py | 102 +++++++++++++++++++++++++-------------- 2 files changed, 74 insertions(+), 43 deletions(-) diff --git a/haiopy/generators.py b/haiopy/generators.py index fa9584e..e4d10b9 100644 --- a/haiopy/generators.py +++ b/haiopy/generators.py @@ -36,6 +36,7 @@ def __iter__(self): pass def __next__(self): + self._start() return self.next() @abstractmethod @@ -66,8 +67,6 @@ def _stop(self, msg="Buffer iteration stopped."): raise StopIteration(msg) def _start(self): - if self._is_active: - raise BufferError("Buffer is already active.") self._is_active = True def _reset(self): @@ -196,8 +195,15 @@ def index(self): @property def data(self): + self.check_if_active() return self._data + @data.setter + def data(self, data): + self.check_if_active() + self._data = self._pad_data(data) + self._update_data() + def _set_block_size(self, block_size): self.check_if_active() super()._set_block_size(block_size) @@ -210,11 +216,6 @@ def _update_data(self): self.data.time, (*self.data.cshape, self.n_blocks, self.block_size)) - # @data.setter - # def data(self, data): - # self._data = self._pad_data(data) - # self._update_data() - def next(self): if self._index < self._n_blocks: current = self._index diff --git a/tests/test_generators.py b/tests/test_generators.py index af2b89c..30187ca 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -26,6 +26,10 @@ def test_buffer(): ValueError, match='The block size needs to be an integer'): Buffer(float(10)) + +def test_buffer_state(): + block_size = 512 + # create new buffer buffer = Buffer(block_size) buffer._start() @@ -37,6 +41,18 @@ def test_buffer(): assert buffer.is_active is False + # The buffer will be automatically set to be active after the first call + # to the __next__ method + # The pytest raises is required here, as the sub-class specific next + # method is an abstract class method + with pytest.raises(NotImplementedError): + next(buffer) + assert buffer._is_active is True + + # check_if_active() raises an exception if buffer is active + with pytest.raises(BufferError, match="needs to be inactive"): + buffer.check_if_active() + def test_array_buffer(): @@ -87,42 +103,6 @@ def test_array_buffer(): buffer.__next__() -def test_buffer_updates(): - block_size = 512 - n_blocks = 10 - n_samples = block_size*n_blocks - sampling_rate = 44100 - - freq = 440 - - data_pf = pf.signals.sine(freq, n_samples, sampling_rate=sampling_rate) - data = data_pf.time - - data_empty = np.zeros_like(data) - - buffer = ArrayBuffer(block_size, data_empty, sampling_rate) - - buffer.data = data - npt.assert_array_equal(buffer._data, data) - npt.assert_array_equal(buffer.data, data) - - # The new block size is 4 times smaller than the old one - new_block_size = 128 - buffer.block_size = new_block_size - - # The data itself is not touched in this case - npt.assert_array_equal(buffer._data, data) - npt.assert_array_equal(buffer.data, data) - - # Stride the array with the new block size - # The new number of blocks is an integer multiple of the old block size - new_n_blocks = n_samples // new_block_size - strided_buffer_data = np.lib.stride_tricks.as_strided( - data, (*data.shape[:-1], new_n_blocks, new_block_size)) - npt.assert_array_equal( - buffer._strided_data, strided_buffer_data) - - def test_signal_buffer(): sampling_rate = 44100 n_blocks = 10 @@ -170,3 +150,53 @@ def test_signal_buffer(): with pytest.raises(StopIteration, match="buffer is empty"): while True: buffer.__next__() + + +def test_signal_buffer_updates(): + sampling_rate = 44100 + n_blocks = 10 + block_size = 512 + n_samples = block_size*n_blocks + noise = pf.signals.noise( + n_samples, rms=[1, 1], sampling_rate=sampling_rate) + sine = pf.signals.sine( + 440, n_samples, amplitude=[1, 1], sampling_rate=sampling_rate) + + # Create a new buffer + buffer = SignalBuffer(block_size, noise) + + # Set a new signal as data for the buffer + buffer.data = sine + npt.assert_array_equal(buffer._data.time, sine.time) + npt.assert_array_equal(buffer.data.time, sine.time) + + # The new block size is 4 times smaller than the old one + new_block_size = 128 + buffer.block_size = new_block_size + + # The data itself is not touched in this case + npt.assert_array_equal(buffer._data.time, sine.time) + npt.assert_array_equal(buffer.data.time, sine.time) + + # Stride the array with the new block size + # The new number of blocks is an integer multiple of the old block size + new_n_blocks = n_samples // new_block_size + strided_buffer_data = np.lib.stride_tricks.as_strided( + sine.time, (*sine.time.shape[:-1], new_n_blocks, new_block_size)) + npt.assert_array_equal( + buffer._strided_data, strided_buffer_data) + + # Check if Errors are raised when buffer is in use + next(buffer) + assert buffer._is_active is True + + # Setting the block size is not allowed if the buffer is active + with pytest.raises(BufferError, match="needs to be inactive"): + buffer.block_size = 512 + + # Setting and getting the data is not allowed if the buffer is active + with pytest.raises(BufferError, match="needs to be inactive"): + buffer.data = sine + + with pytest.raises(BufferError, match="needs to be inactive"): + buffer.data From 0c5c5d2c1946a95bccd8fc7bdb9864e661a3ed00 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 31 Oct 2022 16:40:52 +0100 Subject: [PATCH 25/63] testing of sampling_rate and n_channels properties --- tests/test_generators.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_generators.py b/tests/test_generators.py index 30187ca..2e01f48 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -124,6 +124,12 @@ def test_signal_buffer(): assert buffer._n_blocks == n_blocks assert buffer.n_blocks == n_blocks + # test sampling rate getter + assert buffer.sampling_rate == sampling_rate + + # test number of channels + assert buffer.n_channels == 2 + # check if the initial index s correct assert buffer._index == 0 assert buffer.index == 0 From d33dadcbf98d64efcb20434ba527f2ffba41935e Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 31 Oct 2022 16:43:08 +0100 Subject: [PATCH 26/63] remove ArrayBuffer implementations --- haiopy/generators.py | 19 --------------- tests/test_generators.py | 51 +--------------------------------------- 2 files changed, 1 insertion(+), 69 deletions(-) diff --git a/haiopy/generators.py b/haiopy/generators.py index e4d10b9..2c77d75 100644 --- a/haiopy/generators.py +++ b/haiopy/generators.py @@ -138,25 +138,6 @@ def next(self): raise StopIteration("The buffer is empty.") -class OutputArrayBuffer(ArrayBuffer): - - def __init__(self, block_size, data, sampling_rate) -> None: - super().__init__(block_size, data, sampling_rate) - - def _update_data(self): - self._n_blocks = int(np.ceil(self.data.shape[-1] / self.block_size)) - self._strided_data = np.lib.stride_tricks.as_strided( - self.data, - (*self.data.shape[:-1], self.n_blocks, self.block_size), - writeable=False) - - -class InputArrayBuffer(ArrayBuffer): - - def __init__(self, block_size, data, sampling_rate) -> None: - super().__init__(block_size, data, sampling_rate) - - class SignalBuffer(Buffer): def __init__(self, block_size, signal) -> None: diff --git a/tests/test_generators.py b/tests/test_generators.py index 2e01f48..1d785b3 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -1,6 +1,6 @@ import numpy as np import numpy.testing as npt -from haiopy.generators import Buffer, ArrayBuffer, SignalBuffer +from haiopy.generators import Buffer, SignalBuffer import pytest import pyfar as pf @@ -54,55 +54,6 @@ def test_buffer_state(): buffer.check_if_active() -def test_array_buffer(): - - block_size = 512 - n_blocks = 10 - n_samples = block_size*n_blocks - sampling_rate = 44100 - - freq = 440 - - data_pf = pf.signals.sine( - freq, n_samples, sampling_rate=sampling_rate) - data = data_pf.time - - with pytest.raises(ValueError, match='Only two-dimensional'): - ArrayBuffer(block_size, np.zeros((1, 1, block_size)), sampling_rate) - - buffer = ArrayBuffer(block_size, data, sampling_rate) - - assert buffer._n_blocks == n_blocks - assert buffer.n_blocks == n_blocks - - # check if the initial index s correct - assert buffer._index == 0 - assert buffer.index == 0 - - # check if the data arrays are correct - npt.assert_array_equal(buffer._data, data) - npt.assert_array_equal(buffer.data, data) - - # check if the data strides are correct - strided_buffer_data = np.lib.stride_tricks.as_strided( - data, (*data.shape[:-1], n_blocks, block_size)) - npt.assert_array_equal( - buffer._strided_data, strided_buffer_data) - - # check first step - block_data = buffer.__next__() - npt.assert_array_equal(block_data, strided_buffer_data[..., 0, :]) - - # check second step - block_data = buffer.__next__() - npt.assert_array_equal(block_data, strided_buffer_data[..., 1, :]) - - # check if a error is raised if the end of the buffer is reached - with pytest.raises(StopIteration, match="buffer is empty"): - while True: - buffer.__next__() - - def test_signal_buffer(): sampling_rate = 44100 n_blocks = 10 From fc65dfbd2e7468f5abea65c34f43f8fde4ee0691 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 2 Nov 2022 10:12:51 +0100 Subject: [PATCH 27/63] remove ArrayBuffer class implementation --- haiopy/generators.py | 65 -------------------------------------------- 1 file changed, 65 deletions(-) diff --git a/haiopy/generators.py b/haiopy/generators.py index 2c77d75..bcd4e32 100644 --- a/haiopy/generators.py +++ b/haiopy/generators.py @@ -73,71 +73,6 @@ def _reset(self): self._stop() -class ArrayBuffer(Buffer): - - def __init__(self, block_size, data, sampling_rate) -> None: - super().__init__(block_size) - if data.ndim > 2: - raise ValueError("Only two-dimensional arrays are allowed") - self.data = np.atleast_2d(data) - self._index = 0 - self._sampling_rate = sampling_rate - - def _pad_data(self, data): - n_samples = data.shape[-1] - if np.mod(n_samples, self._block_size) > 0: - pad_samples = self.block_size - np.mod(n_samples, self.block_size) - pad_array = np.zeros((data.shape[0], 2), dtype=int) - pad_array[-1][-1] = pad_samples - padded = np.pad(data, pad_array) - else: - padded = data - - return padded - - @property - def n_channels(self): - return self.data.shape[0] - - @property - def sampling_rate(self): - return self._sampling_rate - - @property - def n_blocks(self): - return self._n_blocks - - @property - def index(self): - return self._index - - @property - def data(self): - return self._data - - def _set_block_size(self, block_size): - super()._set_block_size(block_size) - self._update_data() - - def _update_data(self): - self._n_blocks = int(np.ceil(self.data.shape[-1] / self.block_size)) - self._strided_data = np.lib.stride_tricks.as_strided( - self.data, - (*self.data.shape[:-1], self.n_blocks, self.block_size)) - - @data.setter - def data(self, data): - self._data = self._pad_data(np.atleast_2d(data)) - self._update_data() - - def next(self): - if self._index < self._n_blocks: - current = self._index - self._index += 1 - return self._strided_data[..., current, :] - raise StopIteration("The buffer is empty.") - - class SignalBuffer(Buffer): def __init__(self, block_size, signal) -> None: From 1b37d2405be5b0824638a502ec061bc1bfd9b08e Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 2 Nov 2022 11:06:56 +0100 Subject: [PATCH 28/63] rename generators.py to buffers.py --- haiopy/{generators.py => buffers.py} | 0 tests/test_generators.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename haiopy/{generators.py => buffers.py} (100%) diff --git a/haiopy/generators.py b/haiopy/buffers.py similarity index 100% rename from haiopy/generators.py rename to haiopy/buffers.py diff --git a/tests/test_generators.py b/tests/test_generators.py index 1d785b3..3da8be1 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -1,6 +1,6 @@ import numpy as np import numpy.testing as npt -from haiopy.generators import Buffer, SignalBuffer +from haiopy.buffers import Buffer, SignalBuffer import pytest import pyfar as pf From 6d6e2eb403b1d95c8295699d6eb56b637d78eb96 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 2 Nov 2022 11:36:51 +0100 Subject: [PATCH 29/63] rough docstrings for Buffer --- haiopy/buffers.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index bcd4e32..4da34a4 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -4,31 +4,47 @@ class Buffer(object): + """Abstract base class for audio buffers for block-wise iteration. + + The base class primarily implements buffer state related functionality. + """ def __init__(self, block_size) -> None: + """Create a Buffer object with a given block size. + + Parameters + ---------- + block_size : _type_ + _description_ + """ self._check_block_size(block_size) self._block_size = block_size self._buffer = None self._is_active = False def _check_block_size(self, block_size): + """Check if the block size is an integer.""" if type(block_size) != int: raise ValueError("The block size needs to be an integer") def _set_block_size(self, block_size): + """Private block size setter implementing validity checks.""" self._check_block_size(block_size) self._block_size = block_size @property def block_size(self): + """Returns the block size of the buffer in samples""" return self._block_size @block_size.setter def block_size(self, block_size): + """Set the block size in samples. Only integer values are supported""" self._set_block_size(block_size) @abstractproperty def sampling_rate(self): + """Return sampling rate.""" pass @abstractmethod @@ -36,15 +52,19 @@ def __iter__(self): pass def __next__(self): + """Next dunder method for iteration""" self._start() return self.next() @abstractmethod def next(self): + """Next method which for sub-class specific handling of data.""" raise NotImplementedError() @property def is_active(self): + """Return the state of the buffer. + `True` if the buffer is active, `False` if inactive.""" return self._is_active def check_if_active(self): @@ -63,13 +83,19 @@ def check_if_active(self): "The buffer needs to be inactive to be modified.") def _stop(self, msg="Buffer iteration stopped."): + """Stop buffer iteration and set the state to inactive.""" self._is_active = False raise StopIteration(msg) def _start(self): + """Set the state to active. + Additional operations required before iterating the sub-class can be + implemented in the respective sub-class.""" self._is_active = True def _reset(self): + """Stop and reset the buffer. + Resetting the buffer is implemented in the respective sub-class""" self._stop() From 3de3c2c92e9f4fbefecba8dc1ede0b557491b7f2 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 2 Nov 2022 11:56:06 +0100 Subject: [PATCH 30/63] rough documentation of SignalBuffer --- haiopy/buffers.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index 4da34a4..37efbaa 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -100,8 +100,24 @@ def _reset(self): class SignalBuffer(Buffer): + """Buffer to block wise iterate a `pyfar.Signal` + + """ def __init__(self, block_size, signal) -> None: + """Initialize a `SignalBuffer` with a given block size from a + `pyfar.Signal`. + If the number of audio samples is not an integer multiple of the + block size, the last block will be filled with zeros. + + Parameters + ---------- + block_size : int + The block size in samples + signal : pyfar.Signal + The audio data to be block wise iterated. + + """ super().__init__(block_size) if not isinstance(signal, pf.Signal): raise ValueError("signal must be a pyfar.Signal object.") @@ -112,6 +128,18 @@ def __init__(self, block_size, signal) -> None: self._index = 0 def _pad_data(self, data): + """Pad the signal with zeros to avoid partially filled blocks + + Parameters + ---------- + data : pyfar.Signal + The input audio signal. + + Returns + ------- + pyfar.Signal + Zero-padded signal. + """ n_samples = data.n_samples if np.mod(n_samples, self._block_size) > 0: pad_samples = self.block_size - np.mod(n_samples, self.block_size) @@ -121,27 +149,33 @@ def _pad_data(self, data): @property def n_channels(self): + """The number of audio channels as integer.""" return self.data.cshape[0] @property def sampling_rate(self): + """The sampling rate of the underlying data.""" return self.data.sampling_rate @property def n_blocks(self): + """The number of blocks contained in the buffer.""" return self._n_blocks @property def index(self): + """The current block index as integer.""" return self._index @property def data(self): + """Return the underlying signal if the buffer is not active.""" self.check_if_active() return self._data @data.setter def data(self, data): + """Set the underlying signal if the buffer is not active.""" self.check_if_active() self._data = self._pad_data(data) self._update_data() @@ -152,6 +186,10 @@ def _set_block_size(self, block_size): self._update_data() def _update_data(self): + """Update the data block strided of the underlying data. + The function creates a block-wise view of the numpy data array storing + the time domain data. + """ self.check_if_active() self._n_blocks = int(np.ceil(self.data.n_samples / self.block_size)) self._strided_data = np.lib.stride_tricks.as_strided( @@ -159,6 +197,9 @@ def _update_data(self): (*self.data.cshape, self.n_blocks, self.block_size)) def next(self): + """Return the next audio block as numpy array and increment the block + index. + """ if self._index < self._n_blocks: current = self._index self._index += 1 From 1711e86a6df06a035e61ead52f7c6652fae09c01 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 2 Nov 2022 15:32:17 +0100 Subject: [PATCH 31/63] test the __iter__ method --- haiopy/buffers.py | 3 +-- tests/test_generators.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index 37efbaa..fd75772 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -47,9 +47,8 @@ def sampling_rate(self): """Return sampling rate.""" pass - @abstractmethod def __iter__(self): - pass + return self def __next__(self): """Next dunder method for iteration""" diff --git a/tests/test_generators.py b/tests/test_generators.py index 3da8be1..34a8ff0 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -53,6 +53,9 @@ def test_buffer_state(): with pytest.raises(BufferError, match="needs to be inactive"): buffer.check_if_active() + # check iterator dunder + assert iter(buffer) == buffer + def test_signal_buffer(): sampling_rate = 44100 @@ -108,6 +111,16 @@ def test_signal_buffer(): while True: buffer.__next__() + # test the looping blocks + buffer = SignalBuffer(block_size, sine) + for idx, block in enumerate(buffer): + assert buffer.is_active is True + npt.assert_array_equal( + block, strided_buffer_data[..., idx, :]) + + # check if state is set to inactive after loop finished + assert buffer.is_active is False + def test_signal_buffer_updates(): sampling_rate = 44100 From ee2c236182949e7d69f687a9b87af5df762bda50 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 2 Nov 2022 15:33:50 +0100 Subject: [PATCH 32/63] rename generator tests to buffer tests --- tests/{test_generators.py => test_buffers.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_generators.py => test_buffers.py} (100%) diff --git a/tests/test_generators.py b/tests/test_buffers.py similarity index 100% rename from tests/test_generators.py rename to tests/test_buffers.py From 8930e48c74aa67f07022547190258b86d2cef974 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 2 Nov 2022 15:52:22 +0100 Subject: [PATCH 33/63] add minimal example to SignalBuffer --- haiopy/buffers.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index fd75772..33baa16 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -101,6 +101,18 @@ def _reset(self): class SignalBuffer(Buffer): """Buffer to block wise iterate a `pyfar.Signal` + Examples + -------- + + >>> import pyfar as pf + >>> from haiopy.buffers import SignalBuffer + >>> block_size = 512 + >>> sine = pf.signals.sine(440, 4*block_size) + >>> buffer = SignalBuffer(blockk_size, sine) + >>> for block in buffer: + >>> print(block) + + """ def __init__(self, block_size, signal) -> None: From f4eeabafe8a1c74871664e2f7003bfd992002d37 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 2 Nov 2022 15:52:54 +0100 Subject: [PATCH 34/63] add rst files for sphinx --- docs/modules.rst | 4 +++- docs/modules/haiopy.buffers.rst | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 docs/modules/haiopy.buffers.rst diff --git a/docs/modules.rst b/docs/modules.rst index 986b66b..fdaea0d 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -4,4 +4,6 @@ Modules The following gives detailed information about all haiopy modules. .. toctree:: - :maxdepth: 1 + :maxdepth: 1 + + modules/haiopy.buffers diff --git a/docs/modules/haiopy.buffers.rst b/docs/modules/haiopy.buffers.rst new file mode 100644 index 0000000..0a33294 --- /dev/null +++ b/docs/modules/haiopy.buffers.rst @@ -0,0 +1,7 @@ +haiopy.buffers +============== + +.. automodule:: haiopy.buffers + :members: + :undoc-members: + :show-inheritance: From b1cb15363d712f732002284be6b09f9e0ab034bd Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 2 Nov 2022 16:45:29 +0100 Subject: [PATCH 35/63] wip --- haiopy/devices.py | 178 ++++++++++++++++++++++------------------------ 1 file changed, 84 insertions(+), 94 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index e3daf78..e7b600e 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -4,7 +4,7 @@ import sounddevice as sd from abc import (ABCMeta, abstractmethod, abstractproperty) -from haiopy.generators import ( +from haiopy.buffers import ( ArrayBuffer, InputArrayBuffer, OutputArrayBuffer) @@ -14,59 +14,37 @@ def list_devices(): class _Device(object): def __init__( - self): + self, + id, + sampling_rate, + block_size, + dtype): super().__init__() - @abstractproperty - def id(sellf): - pass - - @abstractproperty + @property def name(self): - pass - - @abstractmethod - def playback(): - pass - - @abstractmethod - def record(): - pass - - @abstractmethod - def playback_record(): - pass - - @abstractmethod - def initialize_playback(): - pass + raise NotImplementedError('Abstract method') - @abstractmethod - def initialize_record(): + @abstractproperty + def sampling_rate(self): pass - @abstractmethod - def initialize_playback_record(): + @abstractproperty + def block_size(self): pass - @abstractmethod - def abort(): + @abstractproperty + def dtype(self): pass class AudioDevice(_Device): def __init__( self, - id=sd.default.device, + id=0, sampling_rate=44100, block_size=512, dtype='float32', - latency=None, - extra_settings=None, - clip_off=None, - dither_off=None, - never_drop_input=None, - prime_output_buffers_using_stream_callback=None ): super().__init__() @@ -74,10 +52,10 @@ def __init__( self._name = sd.query_devices(id)['name'] self._id = id - self.dtype = dtype + self._dtype = dtype self._block_size = block_size self._sampling_rate = sampling_rate - self._extra_settings = extra_settings + # self._extra_settings = extra_settings self._callback = None self._stream = None @@ -86,37 +64,14 @@ def __init__( self._stream_finished = Event() - # def check_settings( - # self, sampling_rate, dtype, extra_settings,): - # """Check if settings are compatible with the physical devices. - - # Parameters - # ---------- - # sampling_rate : int - # The audio sampling rate - # dtype : np.float32, np.int8, np.int16, np.int32 - # The audio buffer data type - # extra_settings : extra settings - # Audio API specific settings. - # """ - # sd.check_input_settings( - # device=self.id, - # channels=self.n_channels_input, - # dtype=dtype, - # extra_settings=extra_settings, - # samplerate=sampling_rate) - - # sd.check_output_settings( - # device=self.id, - # channels=self.n_channels_output, - # dtype=dtype, - # extra_settings=extra_settings, - # samplerate=sampling_rate) - @property def id(self): return self._id + @abstractmethod + def check_settings(**kwargs): + pass + @property def name(self): """The name of the device @@ -145,6 +100,10 @@ def block_size(self, block_size): self.output_buffer.block_size = block_size @property + def dtype(self): + return self._dtype + + @abstractproperty def stream(self): """The sounddevice audio stream. """ @@ -153,25 +112,24 @@ def stream(self): def finished_callback(self) -> None: """Custom callback after a audio stream has finished.""" print("I'm finished.") - pass def _finished_callback(self) -> None: - """Portaudio callback after a audio stream has finished. - """ + """Private portaudio callback after a audio stream has finished.""" self._stream_finished.set() self.finished_callback() self.stream.stop() def start(self): """Start the audio stream""" - if not self.stream.closed: - if not self.stream.active: - self._stream_finished.clear() - self.stream.start() - else: - print("Stream is already active.", file=sys.stderr) - else: + if self.stream.closed: print("Stream is closed. Try re-initializing.", file=sys.stderr) + return + + elif not self.stream.active: + self._stream_finished.clear() + self.stream.start() + else: + print("Stream is already active.", file=sys.stderr) def wait(self): """Wait for the audio stream to finish.""" @@ -212,8 +170,7 @@ def __init__( id=id, sampling_rate=sampling_rate, block_size=block_size, - dtype=dtype, - latency=latency) + dtype=dtype) n_channels_input = sd.query_devices(id)['max_input_channels'] sd.check_input_settings( @@ -298,39 +255,40 @@ def __init__( dither_off=None, never_drop_input=None, prime_output_buffers_using_stream_callback=None): - super().__init__( - id=id, - sampling_rate=sampling_rate, - block_size=block_size, - dtype=dtype, - latency=latency) - # n_channels_output = sd.query_devices(id)['max_output_channels'] + # First check the settings before continuing max_channel = np.max(channels) n_channels = len(channels) sd.check_output_settings( device=id, - channels=np.max([n_channels, max_channel]), + channels=np.max([n_channels, max_channel+1]), dtype=dtype, extra_settings=extra_settings, samplerate=sampling_rate) + super().__init__( + id=id, + sampling_rate=sampling_rate, + block_size=block_size, + dtype=dtype) + self._output_channels = channels if output_buffer is None: - OutputArrayBuffer( + output_buffer = OutputArrayBuffer( self.block_size, np.zeros( (self.n_channels_output, self.block_size), dtype=self.dtype)) - if output_buffer.data.shape[0] != self.n_channels_output: - raise ValueError( - "The shape of the buffer does not match the channel mapping") + # if output_buffer.data.shape[0] != self.n_channels_output: + # raise ValueError( + # "The shape of the buffer does not match the channel mapping") self.output_buffer = output_buffer self.initialize() def check_settings( - self, sampling_rate=None, dtype=None, extra_settings=None): + self, sampling_rate=None, n_channels=None, dtype=None, + extra_settings=None): """Check if settings are compatible with the physical devices. Parameters @@ -341,10 +299,16 @@ def check_settings( The audio buffer data type extra_settings : extra settings Audio API specific settings. + + Raises + ------ + PortAudioError + If the settings are incompatible with the device an exception is + raised. """ sd.check_output_settings( device=self.id, - channels=self.n_channels_output, + channels=n_channels, dtype=dtype, extra_settings=extra_settings, samplerate=sampling_rate) @@ -355,8 +319,23 @@ def output_channels(self): @property def n_channels_output(self): + """The total number of output channels. + + Returns + ------- + int + The number of output channels + """ return len(self._output_channels) + @property + def _n_channels_stream(self): + """The number of output channels required for the stream. + This includes a number of unused pre-pended channels which need to be + filled with zeros before writing the portaudio buffer. + """ + return np.max(self._output_channels) + 1 + @property def max_channels_output(self): """The number of output channels supported by the device""" @@ -390,7 +369,13 @@ def output_callback(self, outdata, frames, time, status) -> None: assert not status try: - outdata[:] = next(self._output_buffer).T + full_outdata = np.zeros( + (self._n_channels_stream, self.block_size), + dtype=self.dtype) + + full_outdata[self.output_channels] = next(self._output_buffer) + outdata[:] = full_outdata.T + # outdata[:] = next(self._output_buffer).T except StopIteration: raise sd.CallbackStop("Buffer empty") @@ -400,7 +385,7 @@ def initialize(self): self.sampling_rate, self.block_size, self.id, - self.n_channels_output, + self._n_channels_stream, self.dtype, callback=self.output_callback, finished_callback=self._finished_callback) @@ -417,4 +402,9 @@ def output_buffer(self, buffer): "The buffer's block size does not match. ", f"Needs to be {self.block_size}") + if buffer.n_channels != self.n_channels_output: + raise ValueError( + "The buffer's channel number does not match the channel " + f"mapping. Currently used channels are {self.output_channels}") + self._output_buffer = buffer From e6ca2cfe09edc352969ada8f2e1bced3b563b943 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 2 Nov 2022 17:02:49 +0100 Subject: [PATCH 36/63] rename to InputAudioDevice and OutputAudioDevice --- haiopy/devices.py | 17 +++++++++-------- tests/test_devices.py | 4 ++-- tests/utils.py | 15 +++++++++------ 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index e7b600e..d43774b 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -4,8 +4,8 @@ import sounddevice as sd from abc import (ABCMeta, abstractmethod, abstractproperty) -from haiopy.buffers import ( - ArrayBuffer, InputArrayBuffer, OutputArrayBuffer) +from haiopy.buffers import SignalBuffer +import pyfar as pf def list_devices(): @@ -151,7 +151,7 @@ def stop(self): self.stream.stop() -class AudioInputDevice(AudioDevice): +class InputAudioDevice(AudioDevice): def __init__( self, id=sd.default.device['input'], @@ -239,7 +239,7 @@ def initialize_record(self, n_channels): self._stream = ostream -class AudioOutputDevice(AudioDevice): +class OutputAudioDevice(AudioDevice): def __init__( self, @@ -275,11 +275,12 @@ def __init__( self._output_channels = channels if output_buffer is None: - output_buffer = OutputArrayBuffer( + output_buffer = SignalBuffer( self.block_size, - np.zeros( - (self.n_channels_output, self.block_size), - dtype=self.dtype)) + pf.Signal(np.zeros( + (self.n_channels_output, self.block_size), + dtype=self.dtype), + self.sampling_rate, fft_norm='rms')) # if output_buffer.data.shape[0] != self.n_channels_output: # raise ValueError( # "The shape of the buffer does not match the channel mapping") diff --git a/tests/test_devices.py b/tests/test_devices.py index fa952cb..51287a5 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -12,7 +12,7 @@ def test_audio_device(): @patch('sounddevice.query_devices', new=utils.query_devices) @patch('sounddevice.check_input_settings', new=utils.check_input_settings) def test_check_input_settings(): - in_device = devices.AudioInputDevice() + in_device = devices.InputAudioDevice() in_device.check_settings() @@ -20,6 +20,6 @@ def test_check_input_settings(): @patch('sounddevice.check_output_settings', new=utils.check_output_settings) @patch('sounddevice.OutputStream', new=sdm.output_stream_mock()) def test_check_output_settings(empty_buffer_stub): - out_device = devices.AudioOutputDevice( + out_device = devices.OutputAudioDevice( output_buffer=empty_buffer_stub) out_device.check_settings() diff --git a/tests/utils.py b/tests/utils.py index d3ba09a..8e18f73 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,7 +1,7 @@ import pytest from unittest import mock import numpy as np -from haiopy.generators import ArrayBuffer, InputArrayBuffer, OutputArrayBuffer +from haiopy.buffers import SignalBuffer def default_devices(): @@ -94,10 +94,13 @@ def next_block(): for idx in range(n_blocks): yield strided[..., idx, :] - buffer = mock.MagicMock(spec_set=ArrayBuffer(block_size, data)) - buffer.data = data - buffer.next = next_block - buffer.n_blocks = n_blocks - buffer.block_size = block_size + # buffer = mock.MagicMock(spec_set=ArrayBuffer(block_size, data)) + buffer = SignalBuffer(block_size, data) + + # buffer.data = data + # buffer._strided_data = np.atleast_3d(data) + # buffer.next = np.atleast_3d(next_block) + # buffer.n_blocks = n_blocks + # buffer.block_size = block_size return buffer From 3ac5d68fb593d8a78252f0a233df7ddfa6fd9e65 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 2 Nov 2022 17:16:45 +0100 Subject: [PATCH 37/63] rename id to identifier --- haiopy/devices.py | 52 +++++++++++++----------- tests/test_devices_physical.py | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 23 deletions(-) create mode 100644 tests/test_devices_physical.py diff --git a/haiopy/devices.py b/haiopy/devices.py index d43774b..41a2011 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -15,46 +15,52 @@ def list_devices(): class _Device(object): def __init__( self, - id, + name, sampling_rate, block_size, dtype): super().__init__() + self._name = name + self._sampling_rate = sampling_rate + self._block_size = block_size + self._dtype = dtype @property def name(self): - raise NotImplementedError('Abstract method') + return self._name + + @property + def id(self): + return self._id - @abstractproperty def sampling_rate(self): - pass + return self._sampling_rate - @abstractproperty def block_size(self): - pass + return self._block_size - @abstractproperty def dtype(self): - pass + return self.dtype class AudioDevice(_Device): def __init__( self, - id=0, + identifier=0, sampling_rate=44100, block_size=512, dtype='float32', ): - super().__init__() - id = sd.query_devices(id)['name'] - self._name = sd.query_devices(id)['name'] + identifier = sd.query_devices(identifier)['name'] - self._id = id - self._dtype = dtype - self._block_size = block_size - self._sampling_rate = sampling_rate + super().__init__( + name=sd.query_devices(identifier)['name'], + sampling_rate=sampling_rate, + block_size=block_size, + dtype=dtype + ) + self._id = identifier # self._extra_settings = extra_settings self._callback = None @@ -154,7 +160,7 @@ def stop(self): class InputAudioDevice(AudioDevice): def __init__( self, - id=sd.default.device['input'], + identifier=sd.default.device['input'], sampling_rate=44100, block_size=512, dtype='float32', @@ -167,14 +173,14 @@ def __init__( prime_output_buffers_using_stream_callback=None ): super().__init__( - id=id, + identifier=identifier, sampling_rate=sampling_rate, block_size=block_size, dtype=dtype) - n_channels_input = sd.query_devices(id)['max_input_channels'] + n_channels_input = sd.query_devices(identifier)['max_input_channels'] sd.check_input_settings( - device=id, + device=identifier, channels=n_channels_input, dtype=dtype, extra_settings=extra_settings, @@ -243,7 +249,7 @@ class OutputAudioDevice(AudioDevice): def __init__( self, - id=sd.default.device['output'], + identifier=sd.default.device['output'], sampling_rate=44100, block_size=512, channels=[1], @@ -260,14 +266,14 @@ def __init__( max_channel = np.max(channels) n_channels = len(channels) sd.check_output_settings( - device=id, + device=identifier, channels=np.max([n_channels, max_channel+1]), dtype=dtype, extra_settings=extra_settings, samplerate=sampling_rate) super().__init__( - id=id, + identifier=identifier, sampling_rate=sampling_rate, block_size=block_size, dtype=dtype) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py new file mode 100644 index 0000000..560bf99 --- /dev/null +++ b/tests/test_devices_physical.py @@ -0,0 +1,73 @@ +from haiopy import devices +import sounddevice as sd +from . import utils +from unittest.mock import patch, MagicMock +import asyncio + + +def default_device_multiface_fireface(): + device_list = sd.query_devices() + found = False + for idx, dev in enumerate(device_list): + if 'Fireface' in dev['name'] or 'Multiface' in dev['name']: + found = True + break + if not found: + raise ValueError( + "Please connect Fireface or Multiface, or specify test device.") + + return idx + # default = MagicMock(spec_sec=sd.default) + # default.device = [idx, idx] + # default._default_device = (idx, idx) + + # return default + + +def test_default_device_helper(): + id = default_device_multiface_fireface() + fireface = 'Fireface' in sd.query_devices(id)['name'] + multiface = 'Multiface' in sd.query_devices(id)['name'] + assert fireface or multiface + + +def test_check_input_settings(): + id = default_device_multiface_fireface() + + in_device = devices.InputAudioDevice(id) + in_device.check_settings() + + +def test_check_output_settings(empty_buffer_stub): + id = default_device_multiface_fireface() + + channels = [1] + block_size = 512 + + out_device = devices.OutputAudioDevice( + id, 44100, block_size, channels=channels, dtype='float32', + output_buffer=empty_buffer_stub) + + out_device.check_settings(sampling_rate=23e3) + + sd.check_output_settings(id, samplerate=23e3) + + pass + + +def test_sine_playback(sine_buffer_stub): + + sine_buffer_stub + + out_device = devices.OutputAudioDevice( + identifier=default_device_multiface_fireface(), + output_buffer=sine_buffer_stub, + channels=[3]) + out_device.check_settings() + + out_device.start() + + asyncio.sleep(1) + + + # out_device.close() From 7dea38cff57755d1fb1452f1df079a352c6a62e5 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 2 Nov 2022 17:19:34 +0100 Subject: [PATCH 38/63] explicitly raise error from previous exception --- haiopy/devices.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 41a2011..f807a54 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -223,8 +223,8 @@ def input_callback(self, indata, frames, time, status): try: next(self._input_buffer)[:] = indata.T - except StopIteration: - raise sd.CallbackStop("Buffer empty") + except StopIteration as e: + raise sd.CallbackStop("Buffer empty") from e def initialize_record(self, n_channels): """Initialize the playback stream for a given number of channels. @@ -383,8 +383,8 @@ def output_callback(self, outdata, frames, time, status) -> None: full_outdata[self.output_channels] = next(self._output_buffer) outdata[:] = full_outdata.T # outdata[:] = next(self._output_buffer).T - except StopIteration: - raise sd.CallbackStop("Buffer empty") + except StopIteration as e: + raise sd.CallbackStop("Buffer empty") from e def initialize(self): """Initialize the playback stream for a given number of channels.""" From 13a660f3efa97b5a747791ba1776ef224ab5a88d Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 2 Nov 2022 17:25:26 +0100 Subject: [PATCH 39/63] use SignalBuffer in test utils --- tests/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/utils.py b/tests/utils.py index 8e18f73..ea61af7 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,6 +1,7 @@ import pytest from unittest import mock import numpy as np +import pyfar as pf from haiopy.buffers import SignalBuffer @@ -87,6 +88,8 @@ def array_buffer_stub(block_size=512, data=np.zeros((1, 512))): n_blocks = data.shape[-1] // block_size + sig = pf.Signal(data, 44100, fft_norm='rms') + def next_block(): strided = np.lib.stride_tricks.as_strided( data, (*data.shape[:-1], n_blocks, block_size)) @@ -95,7 +98,7 @@ def next_block(): yield strided[..., idx, :] # buffer = mock.MagicMock(spec_set=ArrayBuffer(block_size, data)) - buffer = SignalBuffer(block_size, data) + buffer = SignalBuffer(block_size, sig) # buffer.data = data # buffer._strided_data = np.atleast_3d(data) From 0ac1c16664ea84a4edf5f7ea78b434a51554cab7 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 3 Nov 2022 10:53:53 +0100 Subject: [PATCH 40/63] update stubs and add conftest --- tests/conftest.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ tests/utils.py | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..13f1cca --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,46 @@ +import pytest +from .utils import signal_buffer_stub +from haiopy.buffers import SignalBuffer +import pyfar as pf + +import numpy as np + + +@pytest.fixture +def empty_buffer_stub(): + """Create a stub representing an empty ArrayBuffer. + + Returns + ------- + ArrayBuffer + Stub of ArrayBuffer + """ + + block_size = 512 + n_blocks = 10 + data = np.zeros((1, n_blocks*block_size), dtype='float32') + + return signal_buffer_stub(block_size, data) + + +@pytest.fixture +def sine_buffer_stub(): + """Create a stub representing an empty ArrayBuffer. + + Returns + ------- + ArrayBuffer + Stub of ArrayBuffer + """ + sampling_rate = 44100 + block_size = 512 + n_blocks = 10 + data = np.zeros((1, n_blocks*block_size), dtype='float32') + t = np.arange(0, 512) + data = np.sin(2*np.pi*t*(block_size + 1)/sampling_rate) + + data = np.tile(data, 100) + + data = np.atleast_2d(data).astype('float32') + + return SignalBuffer(block_size, pf.Signal(data, sampling_rate)) diff --git a/tests/utils.py b/tests/utils.py index ea61af7..6fcf499 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -72,7 +72,7 @@ def check_input_settings( pass -def array_buffer_stub(block_size=512, data=np.zeros((1, 512))): +def signal_buffer_stub(block_size=512, data=np.zeros((1, 512))): """Generate a ArrayBuffer Stub with given block size and data Parameters From a57cc3e5bb98624a9518cc8213a13331e80c3730 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 3 Nov 2022 11:14:01 +0100 Subject: [PATCH 41/63] fix sine buffer and properly wait for the playback to complete --- tests/conftest.py | 12 ++++++------ tests/test_devices_physical.py | 15 +++++---------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 13f1cca..1b1c7e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,13 +34,13 @@ def sine_buffer_stub(): """ sampling_rate = 44100 block_size = 512 - n_blocks = 10 + n_blocks = 86 data = np.zeros((1, n_blocks*block_size), dtype='float32') - t = np.arange(0, 512) - data = np.sin(2*np.pi*t*(block_size + 1)/sampling_rate) - - data = np.tile(data, 100) + t = np.arange(0, block_size*n_blocks) + data = np.sin(2*np.pi*t*(block_size + 1)/sampling_rate)*10**(-6/20) data = np.atleast_2d(data).astype('float32') + buffer = SignalBuffer(block_size, pf.Signal(data, sampling_rate)) + duration = block_size*n_blocks/sampling_rate - return SignalBuffer(block_size, pf.Signal(data, sampling_rate)) + return buffer, duration diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index 560bf99..c5f74ed 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -2,7 +2,7 @@ import sounddevice as sd from . import utils from unittest.mock import patch, MagicMock -import asyncio +import time def default_device_multiface_fireface(): @@ -52,22 +52,17 @@ def test_check_output_settings(empty_buffer_stub): sd.check_output_settings(id, samplerate=23e3) - pass - def test_sine_playback(sine_buffer_stub): - sine_buffer_stub + buffer = sine_buffer_stub[0] + duration = sine_buffer_stub[1] out_device = devices.OutputAudioDevice( identifier=default_device_multiface_fireface(), - output_buffer=sine_buffer_stub, + output_buffer=buffer, channels=[3]) out_device.check_settings() out_device.start() - - asyncio.sleep(1) - - - # out_device.close() + time.sleep(duration) From 2fa3223bdba7308f25d0d41ee17a541c12c0c6b0 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 3 Nov 2022 15:02:21 +0100 Subject: [PATCH 42/63] rename id to identifier to avoid re-using built in functions --- tests/test_devices_physical.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index c5f74ed..2eece8b 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -25,32 +25,32 @@ def default_device_multiface_fireface(): def test_default_device_helper(): - id = default_device_multiface_fireface() - fireface = 'Fireface' in sd.query_devices(id)['name'] - multiface = 'Multiface' in sd.query_devices(id)['name'] + identifier = default_device_multiface_fireface() + fireface = 'Fireface' in sd.query_devices(identifier)['name'] + multiface = 'Multiface' in sd.query_devices(identifier)['name'] assert fireface or multiface def test_check_input_settings(): - id = default_device_multiface_fireface() + identifier = default_device_multiface_fireface() - in_device = devices.InputAudioDevice(id) + in_device = devices.InputAudioDevice(identifier) in_device.check_settings() def test_check_output_settings(empty_buffer_stub): - id = default_device_multiface_fireface() + identifier = default_device_multiface_fireface() channels = [1] block_size = 512 out_device = devices.OutputAudioDevice( - id, 44100, block_size, channels=channels, dtype='float32', + identifier, 44100, block_size, channels=channels, dtype='float32', output_buffer=empty_buffer_stub) out_device.check_settings(sampling_rate=23e3) - sd.check_output_settings(id, samplerate=23e3) + sd.check_output_settings(identifier, samplerate=23e3) def test_sine_playback(sine_buffer_stub): From 69c3569e8f1d602d44f949d4823eb917fef6de36 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 3 Nov 2022 17:23:55 +0100 Subject: [PATCH 43/63] improve check_settings method and add tests --- haiopy/devices.py | 17 +++++++++-- tests/test_devices_physical.py | 53 +++++++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index f807a54..a0ba8d1 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -187,11 +187,17 @@ def __init__( samplerate=sampling_rate) def check_settings( - self, sampling_rate=None, dtype=None, extra_settings=None,): + self, + n_channels=None, + sampling_rate=None, + dtype=None, + extra_settings=None): """Check if settings are compatible with the physical devices. Parameters ---------- + n_channels : int + The number of channels to be used sampling_rate : int The audio sampling rate dtype : np.float32, np.int8, np.int16, np.int32 @@ -201,7 +207,7 @@ def check_settings( """ sd.check_input_settings( device=self.id, - channels=self.n_channels_input, + channels=n_channels, dtype=dtype, extra_settings=extra_settings, samplerate=sampling_rate) @@ -294,12 +300,17 @@ def __init__( self.initialize() def check_settings( - self, sampling_rate=None, n_channels=None, dtype=None, + self, + n_channels=None, + sampling_rate=None, + dtype=None, extra_settings=None): """Check if settings are compatible with the physical devices. Parameters ---------- + n_channels : int + The number of channels to be used sampling_rate : int The audio sampling rate dtype : np.float32, np.int8, np.int16, np.int32 diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index 2eece8b..d9af14a 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -3,20 +3,21 @@ from . import utils from unittest.mock import patch, MagicMock import time +import pytest def default_device_multiface_fireface(): device_list = sd.query_devices() found = False - for idx, dev in enumerate(device_list): - if 'Fireface' in dev['name'] or 'Multiface' in dev['name']: + for identifier, device in enumerate(device_list): + if 'Fireface' in device['name'] or 'Multiface' in device['name']: found = True break if not found: raise ValueError( "Please connect Fireface or Multiface, or specify test device.") - return idx + return identifier, device # default = MagicMock(spec_sec=sd.default) # default.device = [idx, idx] # default._default_device = (idx, idx) @@ -25,32 +26,64 @@ def default_device_multiface_fireface(): def test_default_device_helper(): - identifier = default_device_multiface_fireface() + identifier, device = default_device_multiface_fireface() fireface = 'Fireface' in sd.query_devices(identifier)['name'] multiface = 'Multiface' in sd.query_devices(identifier)['name'] assert fireface or multiface + if fireface: + assert device['max_input_channels'] == 18 + assert device['max_output_channels'] == 18 + def test_check_input_settings(): - identifier = default_device_multiface_fireface() + identifier, config = default_device_multiface_fireface() + + default_sampling_rate = config['default_samplerate'] + # Create device in_device = devices.InputAudioDevice(identifier) - in_device.check_settings() + + # Check sampling rate + in_device.check_settings(sampling_rate=default_sampling_rate) + with pytest.raises(sd.PortAudioError, match="Invalid"): + in_device.check_settings(sampling_rate=10) + + # Check the dtype, apparently this raises a ValueError if invalid + in_device.check_settings(dtype='float32') + with pytest.raises(ValueError, match="Invalid"): + in_device.check_settings(dtype=float) + + # Check number of channels + in_device.check_settings(n_channels=config['max_input_channels']) + with pytest.raises(sd.PortAudioError, match="Invalid"): + in_device.check_settings(config['max_input_channels']+10) def test_check_output_settings(empty_buffer_stub): - identifier = default_device_multiface_fireface() + identifier, config = default_device_multiface_fireface() - channels = [1] + channels = [3] block_size = 512 out_device = devices.OutputAudioDevice( identifier, 44100, block_size, channels=channels, dtype='float32', output_buffer=empty_buffer_stub) - out_device.check_settings(sampling_rate=23e3) + # Check sampling rate + out_device.check_settings(sampling_rate=config['default_samplerate']) + with pytest.raises(sd.PortAudioError, match="Invalid"): + out_device.check_settings(sampling_rate=10) + + # Check the dtype, apparently this raises a ValueError if invalid + out_device.check_settings(dtype='float32') + with pytest.raises(ValueError, match="Invalid"): + out_device.check_settings(dtype=float) - sd.check_output_settings(identifier, samplerate=23e3) + # Check number of channels + out_device.check_settings(n_channels=config['max_output_channels']) + with pytest.raises(sd.PortAudioError, match="Invalid"): + out_device.check_settings(config['max_output_channels']+10) def test_sine_playback(sine_buffer_stub): From e852fc64655338e920e8cee9702988c731df7ca6 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 4 Nov 2022 10:55:39 +0100 Subject: [PATCH 44/63] add proper input channel handling and buffer initialization --- haiopy/devices.py | 70 +++++++++++++++++++++++++--------- tests/conftest.py | 14 +++++-- tests/test_devices_physical.py | 32 ++++++++++++++-- 3 files changed, 91 insertions(+), 25 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index a0ba8d1..72017a4 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -163,6 +163,7 @@ def __init__( identifier=sd.default.device['input'], sampling_rate=44100, block_size=512, + channels=[1], dtype='float32', input_buffer=None, latency=None, @@ -172,20 +173,36 @@ def __init__( never_drop_input=None, prime_output_buffers_using_stream_callback=None ): - super().__init__( - identifier=identifier, - sampling_rate=sampling_rate, - block_size=block_size, - dtype=dtype) - n_channels_input = sd.query_devices(identifier)['max_input_channels'] + max_channel = np.max(channels) + n_channels = len(channels) + sd.check_input_settings( device=identifier, - channels=n_channels_input, + channels=np.max([n_channels, max_channel+1]), dtype=dtype, extra_settings=extra_settings, samplerate=sampling_rate) + super().__init__( + identifier=identifier, + sampling_rate=sampling_rate, + block_size=block_size, + dtype=dtype) + + self._input_channels = channels + self.initialize() + + if input_buffer is None: + input_buffer = SignalBuffer( + self.block_size, + pf.Signal(np.zeros( + (self.n_channels_output, self.block_size), + dtype=self.dtype), + self.sampling_rate, fft_norm='rms')) + + self._input_buffer = input_buffer + def check_settings( self, n_channels=None, @@ -213,9 +230,33 @@ def check_settings( samplerate=sampling_rate) @property - def n_channels_input(self): + def input_channels(self): """The number of input channels supported by the device""" - return sd.query_devices(self.id)['max_input_channels'] + return self._input_channels + + @property + def n_channels_input(self): + """The total number of output channels. + + Returns + ------- + int + The number of output channels + """ + return len(self._input_channels) + + @property + def _n_channels_stream(self): + """The number of output channels required for the stream. + This includes a number of unused pre-pended channels which need to be + filled with zeros before writing the portaudio buffer. + """ + return np.max(self._input_channels) + 1 + + @property + def max_channels_input(self): + """The number of output channels supported by the device""" + return sd.query_devices(self.id, 'input')['max_output_channels'] def _set_block_size(self, block_size): self.input_buffer.block_size = block_size @@ -232,19 +273,14 @@ def input_callback(self, indata, frames, time, status): except StopIteration as e: raise sd.CallbackStop("Buffer empty") from e - def initialize_record(self, n_channels): + def initialize(self): """Initialize the playback stream for a given number of channels. - - Parameters - ---------- - n_channels : int - The number of output channels for which the stream is opened. """ ostream = sd.InputStream( self.sampling_rate, self.block_size, self.id, - n_channels, + self._n_channels_stream, self.dtype, callback=self.input_callback, finished_callback=self._finished_callback) @@ -357,7 +393,7 @@ def _n_channels_stream(self): @property def max_channels_output(self): """The number of output channels supported by the device""" - return sd.query_devices(self.id)['max_output_channels'] + return sd.query_devices(self.id, 'output')['max_output_channels'] def output_callback(self, outdata, frames, time, status) -> None: """Portudio callback for output streams diff --git a/tests/conftest.py b/tests/conftest.py index 1b1c7e4..f04c8da 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,6 @@ from .utils import signal_buffer_stub from haiopy.buffers import SignalBuffer import pyfar as pf - import numpy as np @@ -20,7 +19,10 @@ def empty_buffer_stub(): n_blocks = 10 data = np.zeros((1, n_blocks*block_size), dtype='float32') - return signal_buffer_stub(block_size, data) + buffer = signal_buffer_stub(block_size, data) + duration = block_size*n_blocks/buffer.sampling_rate + + return buffer, duration @pytest.fixture @@ -29,8 +31,12 @@ def sine_buffer_stub(): Returns ------- - ArrayBuffer - Stub of ArrayBuffer + buffer: SignalBuffer + Stub of SignalBuffer + duration: float + Duration of the buffer in seconds. Required if waiting for the buffer + to finish is required. + """ sampling_rate = 44100 block_size = 512 diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index d9af14a..79273f8 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -62,13 +62,14 @@ def test_check_input_settings(): def test_check_output_settings(empty_buffer_stub): identifier, config = default_device_multiface_fireface() - channels = [3] block_size = 512 + buffer = empty_buffer_stub[0] + out_device = devices.OutputAudioDevice( identifier, 44100, block_size, channels=channels, dtype='float32', - output_buffer=empty_buffer_stub) + output_buffer=buffer) # Check sampling rate out_device.check_settings(sampling_rate=config['default_samplerate']) @@ -90,12 +91,35 @@ def test_sine_playback(sine_buffer_stub): buffer = sine_buffer_stub[0] duration = sine_buffer_stub[1] + identifier, config = default_device_multiface_fireface() + + sampling_rate = config['default_samplerate'] out_device = devices.OutputAudioDevice( - identifier=default_device_multiface_fireface(), + identifier=identifier, output_buffer=buffer, - channels=[3]) + channels=[0], + sampling_rate=sampling_rate) out_device.check_settings() out_device.start() time.sleep(duration) + + +def test_recoring(empty_buffer_stub): + + buffer = empty_buffer_stub[0] + duration = empty_buffer_stub[1] + + identifier, config = default_device_multiface_fireface() + + in_device = devices.InputAudioDevice( + identifier=identifier, + input_buffer=buffer, + channels=[1]) + in_device.check_settings() + + in_device.start() + time.sleep(duration) + + in_device.stop() From 3509ea3360a3394ac804e6adc99433a51607356d Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 14 Nov 2022 19:12:55 +0100 Subject: [PATCH 45/63] WIP: Adapt Event based buffers --- haiopy/devices.py | 59 +++++++++++++++++++++++++++++++--- tests/test_devices_physical.py | 15 ++++++--- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 72017a4..8f97efc 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -145,16 +145,27 @@ def abort(self): """Stop the audio steam without finishing remaining buffers.""" if self.stream.active is True: self.stream.abort() + self._stop_buffer() def close(self): """Close the audio device and release the sound card lock.""" if self.stream is not None: self.stream.close() + self._stop_buffer() def stop(self): """Stop the audio stream after finishing the current buffer.""" if self.stream.active is True: self.stream.stop() + self._stop_buffer() + + @abstractmethod + def _stop_buffer(self): + raise NotImplementedError() + + @abstractmethod + def _reset_buffer(self): + raise NotImplementedError() class InputAudioDevice(AudioDevice): @@ -197,7 +208,7 @@ def __init__( input_buffer = SignalBuffer( self.block_size, pf.Signal(np.zeros( - (self.n_channels_output, self.block_size), + (self.n_channels_input, self.block_size), dtype=self.dtype), self.sampling_rate, fft_norm='rms')) @@ -269,7 +280,7 @@ def input_callback(self, indata, frames, time, status): assert not status try: - next(self._input_buffer)[:] = indata.T + next(self.input_buffer)[:] = indata[..., self.input_channels].T except StopIteration as e: raise sd.CallbackStop("Buffer empty") from e @@ -286,6 +297,35 @@ def initialize(self): finished_callback=self._finished_callback) self._stream = ostream + @property + def input_buffer(self): + return self._input_buffer + + @input_buffer.setter + def input_buffer(self, buffer): + if buffer.block_size != self.block_size: + raise ValueError( + "The buffer's block size does not match. ", + f"Needs to be {self.block_size}") + + if buffer.n_channels != self.n_channels_input: + raise ValueError( + "The buffer's channel number does not match the channel " + f"mapping. Currently used channels are {self.output_channels}") + + self._input_buffer = buffer + + def _stop_buffer(self): + self.input_buffer._stop() + + def start(self): + super().start() + self.input_buffer._is_active.wait() + + def wait(self): + super().wait() + self.input_buffer._is_finished.wait() + class OutputAudioDevice(AudioDevice): @@ -426,10 +466,8 @@ def output_callback(self, outdata, frames, time, status) -> None: full_outdata = np.zeros( (self._n_channels_stream, self.block_size), dtype=self.dtype) - - full_outdata[self.output_channels] = next(self._output_buffer) + full_outdata[self.output_channels] = next(self.output_buffer) outdata[:] = full_outdata.T - # outdata[:] = next(self._output_buffer).T except StopIteration as e: raise sd.CallbackStop("Buffer empty") from e @@ -462,3 +500,14 @@ def output_buffer(self, buffer): f"mapping. Currently used channels are {self.output_channels}") self._output_buffer = buffer + + def _stop_buffer(self): + self._output_buffer._stop() + + def start(self): + super().start() + self.output_buffer._is_active.wait() + + def wait(self): + super().wait() + self.output_buffer._is_finished.wait() diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index 79273f8..17061b0 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -4,6 +4,7 @@ from unittest.mock import patch, MagicMock import time import pytest +import pyfar as pf def default_device_multiface_fireface(): @@ -103,13 +104,15 @@ def test_sine_playback(sine_buffer_stub): out_device.check_settings() out_device.start() - time.sleep(duration) + assert out_device.output_buffer.is_active is True + out_device.wait() + assert out_device.output_buffer.is_active is False -def test_recoring(empty_buffer_stub): +def test_recording(empty_buffer_stub): buffer = empty_buffer_stub[0] - duration = empty_buffer_stub[1] + assert pf.dsp.rms(buffer.data) < 1e-14 identifier, config = default_device_multiface_fireface() @@ -120,6 +123,8 @@ def test_recoring(empty_buffer_stub): in_device.check_settings() in_device.start() - time.sleep(duration) + assert in_device.input_buffer.is_active is True + in_device.wait() + assert in_device.input_buffer.is_active is False - in_device.stop() + assert pf.dsp.rms(in_device.input_buffer.data) > 1e-10 From ffa95dd81459c9ae9bd9eff9bb4429af72e905c8 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 14 Nov 2022 19:13:39 +0100 Subject: [PATCH 46/63] update stub --- tests/utils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/utils.py b/tests/utils.py index 6fcf499..9ba1894 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -90,12 +90,12 @@ def signal_buffer_stub(block_size=512, data=np.zeros((1, 512))): sig = pf.Signal(data, 44100, fft_norm='rms') - def next_block(): - strided = np.lib.stride_tricks.as_strided( - data, (*data.shape[:-1], n_blocks, block_size)) + # def next_block(): + # strided = np.lib.stride_tricks.as_strided( + # data, (*data.shape[:-1], n_blocks, block_size)) - for idx in range(n_blocks): - yield strided[..., idx, :] + # for idx in range(n_blocks): + # yield strided[..., idx, :] # buffer = mock.MagicMock(spec_set=ArrayBuffer(block_size, data)) buffer = SignalBuffer(block_size, sig) From 47933cd2d274ad4a0c6b3750aeeab1d29dbfd352 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 14 Nov 2022 19:15:10 +0100 Subject: [PATCH 47/63] Use events for setting buffer states which can waited for --- haiopy/buffers.py | 18 +++++++++++++----- tests/test_buffers.py | 19 ++++++++++++++++++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index 33baa16..d58dce6 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -1,6 +1,7 @@ import numpy as np import pyfar as pf from abc import abstractproperty, abstractmethod +from threading import Event class Buffer(object): @@ -20,7 +21,8 @@ def __init__(self, block_size) -> None: self._check_block_size(block_size) self._block_size = block_size self._buffer = None - self._is_active = False + self._is_active = Event() + self._is_finished = Event() def _check_block_size(self, block_size): """Check if the block size is an integer.""" @@ -64,7 +66,7 @@ def next(self): def is_active(self): """Return the state of the buffer. `True` if the buffer is active, `False` if inactive.""" - return self._is_active + return self._is_active.is_set() def check_if_active(self): """Check if the buffer is active. @@ -83,19 +85,21 @@ def check_if_active(self): def _stop(self, msg="Buffer iteration stopped."): """Stop buffer iteration and set the state to inactive.""" - self._is_active = False + self._is_active.clear() + self._is_finished.set() raise StopIteration(msg) def _start(self): """Set the state to active. Additional operations required before iterating the sub-class can be implemented in the respective sub-class.""" - self._is_active = True + self._is_active.set() + self._is_finished.clear() def _reset(self): """Stop and reset the buffer. Resetting the buffer is implemented in the respective sub-class""" - self._stop() + self._stop("Resetting the buffer.") class SignalBuffer(Buffer): @@ -216,3 +220,7 @@ def next(self): self._index += 1 return self._strided_data[..., current, :] self._stop("The buffer is empty.") + + def _reset(self): + self._index = 0 + super()._reset() diff --git a/tests/test_buffers.py b/tests/test_buffers.py index 34a8ff0..8626cdf 100644 --- a/tests/test_buffers.py +++ b/tests/test_buffers.py @@ -122,6 +122,23 @@ def test_signal_buffer(): assert buffer.is_active is False +def test_writing_signal_buffer(): + sampling_rate = 44100 + block_size = 512 + + block_data = np.atleast_2d(np.arange(block_size)) + + sig = pf.Signal(np.zeros(block_size, dtype='float32'), sampling_rate) + buffer = SignalBuffer(block_size, sig) + + next(buffer)[:] = block_data + + # we need to stop the buffer which raises a StopIteration error + with pytest.raises(StopIteration): + buffer._stop() + np.testing.assert_array_equal(buffer.data.time, block_data) + + def test_signal_buffer_updates(): sampling_rate = 44100 n_blocks = 10 @@ -158,7 +175,7 @@ def test_signal_buffer_updates(): # Check if Errors are raised when buffer is in use next(buffer) - assert buffer._is_active is True + assert buffer.is_active is True # Setting the block size is not allowed if the buffer is active with pytest.raises(BufferError, match="needs to be inactive"): From 6302ba5bcac2f83445d73d36906de7123c1cc6be Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 14 Nov 2022 19:18:30 +0100 Subject: [PATCH 48/63] make the buffer base class private --- haiopy/buffers.py | 4 ++-- tests/test_buffers.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index d58dce6..f625bbd 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -4,7 +4,7 @@ from threading import Event -class Buffer(object): +class _Buffer(object): """Abstract base class for audio buffers for block-wise iteration. The base class primarily implements buffer state related functionality. @@ -102,7 +102,7 @@ def _reset(self): self._stop("Resetting the buffer.") -class SignalBuffer(Buffer): +class SignalBuffer(_Buffer): """Buffer to block wise iterate a `pyfar.Signal` Examples diff --git a/tests/test_buffers.py b/tests/test_buffers.py index 8626cdf..c7ac42e 100644 --- a/tests/test_buffers.py +++ b/tests/test_buffers.py @@ -1,6 +1,6 @@ import numpy as np import numpy.testing as npt -from haiopy.buffers import Buffer, SignalBuffer +from haiopy.buffers import _Buffer, SignalBuffer import pytest import pyfar as pf @@ -8,7 +8,7 @@ def test_buffer(): block_size = 512 - buffer = Buffer(block_size) + buffer = _Buffer(block_size) assert buffer._block_size == block_size @@ -24,14 +24,14 @@ def test_buffer(): with pytest.raises( ValueError, match='The block size needs to be an integer'): - Buffer(float(10)) + _Buffer(float(10)) def test_buffer_state(): block_size = 512 # create new buffer - buffer = Buffer(block_size) + buffer = _Buffer(block_size) buffer._start() assert buffer.is_active is True From 9eaaeb4a70f57a44c68a0def48b708c0a87fbe31 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 14 Nov 2022 19:26:39 +0100 Subject: [PATCH 49/63] minor review comments --- haiopy/buffers.py | 4 ++-- tests/test_buffers.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index f625bbd..0aa106d 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -112,7 +112,7 @@ class SignalBuffer(_Buffer): >>> from haiopy.buffers import SignalBuffer >>> block_size = 512 >>> sine = pf.signals.sine(440, 4*block_size) - >>> buffer = SignalBuffer(blockk_size, sine) + >>> buffer = SignalBuffer(block_size, sine) >>> for block in buffer: >>> print(block) @@ -137,7 +137,7 @@ def __init__(self, block_size, signal) -> None: if not isinstance(signal, pf.Signal): raise ValueError("signal must be a pyfar.Signal object.") if signal.time.ndim > 2: - raise ValueError("Only two-dimensional arrays are allowed") + raise ValueError("Only one-dimensional arrays are allowed") self._data = self._pad_data(signal) self._update_data() self._index = 0 diff --git a/tests/test_buffers.py b/tests/test_buffers.py index c7ac42e..4aa7fed 100644 --- a/tests/test_buffers.py +++ b/tests/test_buffers.py @@ -5,7 +5,7 @@ import pyfar as pf -def test_buffer(): +def test_buffer_block_size(): block_size = 512 buffer = _Buffer(block_size) @@ -99,17 +99,17 @@ def test_signal_buffer(): buffer._strided_data, strided_buffer_data) # check first step - block_data = buffer.__next__() + block_data = next(buffer) npt.assert_array_equal(block_data, strided_buffer_data[..., 0, :]) # check second step - block_data = buffer.__next__() + block_data = next(buffer) npt.assert_array_equal(block_data, strided_buffer_data[..., 1, :]) # check if a error is raised if the end of the buffer is reached with pytest.raises(StopIteration, match="buffer is empty"): while True: - buffer.__next__() + next(buffer) # test the looping blocks buffer = SignalBuffer(block_size, sine) From fb6f6f287277b5ef55757f6b0f48d74e757a6c53 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 14 Nov 2022 19:27:25 +0100 Subject: [PATCH 50/63] Fix catching updated error message --- tests/test_buffers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_buffers.py b/tests/test_buffers.py index 4aa7fed..e0e5fb5 100644 --- a/tests/test_buffers.py +++ b/tests/test_buffers.py @@ -65,7 +65,7 @@ def test_signal_buffer(): sine = pf.signals.sine( 440, n_samples, amplitude=[1, 1], sampling_rate=sampling_rate) - with pytest.raises(ValueError, match='two-dimensional'): + with pytest.raises(ValueError, match='one-dimensional'): SignalBuffer( block_size, pf.Signal(np.zeros((2, 3, block_size), 'float32'), sampling_rate)) From 45e8ff417df8e88809e73ae33905e9c81a4d9890 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 14 Nov 2022 19:40:23 +0100 Subject: [PATCH 51/63] added test for creating buffers which require padding --- tests/test_buffers.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_buffers.py b/tests/test_buffers.py index e0e5fb5..7c3a98c 100644 --- a/tests/test_buffers.py +++ b/tests/test_buffers.py @@ -122,6 +122,26 @@ def test_signal_buffer(): assert buffer.is_active is False +def test_signal_buffer_padding(): + sampling_rate = 44100 + n_samples = 800 + + n_blocks = 2 + block_size = 512 + sine = pf.signals.sine( + 440, n_samples, amplitude=[1], sampling_rate=sampling_rate) + + buffer = SignalBuffer(block_size, sine) + + assert buffer.data.n_samples == n_blocks*block_size + + expected_data = np.concatenate(( + np.squeeze(sine.time), + np.zeros(n_blocks*block_size-n_samples, dtype=float))) + + npt.assert_equal(np.squeeze(buffer.data.time), expected_data) + + def test_writing_signal_buffer(): sampling_rate = 44100 block_size = 512 From d374113be1573ef53b37e224608d9891d341a1bc Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 14 Nov 2022 19:49:40 +0100 Subject: [PATCH 52/63] reset will clear the active and finished flags --- haiopy/buffers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index 0aa106d..c2813b4 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -99,7 +99,9 @@ def _start(self): def _reset(self): """Stop and reset the buffer. Resetting the buffer is implemented in the respective sub-class""" - self._stop("Resetting the buffer.") + self._is_active.clear() + self._is_finished.clear() + raise StopIteration("Resetting the buffer.") class SignalBuffer(_Buffer): From b7f69a250e32f0bdb180b1d6e3b727184552352a Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Tue, 15 Nov 2022 09:15:06 +0100 Subject: [PATCH 53/63] add Scarlett 2i4 to valid testing devices --- tests/test_devices_physical.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index 17061b0..f527b7f 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -10,10 +10,14 @@ def default_device_multiface_fireface(): device_list = sd.query_devices() found = False - for identifier, device in enumerate(device_list): - if 'Fireface' in device['name'] or 'Multiface' in device['name']: - found = True - break + + valid_devices = ['Multiface', 'Fireface', 'Scarlett 2i4'] + + for valid_device in valid_devices: + for identifier, device in enumerate(device_list): + if valid_device in device['name']: + found = True + break if not found: raise ValueError( "Please connect Fireface or Multiface, or specify test device.") @@ -30,12 +34,17 @@ def test_default_device_helper(): identifier, device = default_device_multiface_fireface() fireface = 'Fireface' in sd.query_devices(identifier)['name'] multiface = 'Multiface' in sd.query_devices(identifier)['name'] - assert fireface or multiface + scarlett = 'Scarlett 2i4' in sd.query_devices(identifier)['name'] + assert fireface or multiface or scarlett if fireface: assert device['max_input_channels'] == 18 assert device['max_output_channels'] == 18 + if scarlett: + assert device['max_input_channels'] == 2 + assert device['max_output_channels'] == 4 + def test_check_input_settings(): identifier, config = default_device_multiface_fireface() From b09692db6da8def7111e593fb7e48b05e5b97fd1 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Tue, 15 Nov 2022 09:50:43 +0100 Subject: [PATCH 54/63] workaround for portaudio's broadcasting to 2 ch mono playback --- haiopy/devices.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 8f97efc..1fd4feb 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -425,10 +425,15 @@ def n_channels_output(self): @property def _n_channels_stream(self): """The number of output channels required for the stream. + This includes a number of unused pre-pended channels which need to be - filled with zeros before writing the portaudio buffer. + filled with zeros before writing the portaudio buffer. In case of + using only the first channel, portaudio plays back a mono signal, + which will be broadcasted to the first two channels. To avoid this, + the minimum number of channels opened is always two, the unused second + channel is filled with zeros. """ - return np.max(self._output_channels) + 1 + return np.max((2, np.max(self._output_channels) + 1)) @property def max_channels_output(self): From 6677206cab46210de3767f39051489de0a825db5 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Tue, 15 Nov 2022 09:58:45 +0100 Subject: [PATCH 55/63] restructure tests --- tests/test_devices_physical.py | 50 ++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index f527b7f..26b1aaa 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -45,6 +45,10 @@ def test_default_device_helper(): assert device['max_input_channels'] == 2 assert device['max_output_channels'] == 4 +# ----------------------------------------------------------------------------- +# Input Device Tests +# ----------------------------------------------------------------------------- + def test_check_input_settings(): identifier, config = default_device_multiface_fireface() @@ -70,6 +74,31 @@ def test_check_input_settings(): in_device.check_settings(config['max_input_channels']+10) +def test_recording(empty_buffer_stub): + + buffer = empty_buffer_stub[0] + assert pf.dsp.rms(buffer.data) < 1e-14 + + identifier, config = default_device_multiface_fireface() + + in_device = devices.InputAudioDevice( + identifier=identifier, + input_buffer=buffer, + channels=[1]) + in_device.check_settings() + + in_device.start() + assert in_device.input_buffer.is_active is True + in_device.wait() + assert in_device.input_buffer.is_active is False + + assert pf.dsp.rms(in_device.input_buffer.data) > 1e-10 + +# ----------------------------------------------------------------------------- +# Output Device Tests +# ----------------------------------------------------------------------------- + + def test_check_output_settings(empty_buffer_stub): identifier, config = default_device_multiface_fireface() channels = [3] @@ -116,24 +145,3 @@ def test_sine_playback(sine_buffer_stub): assert out_device.output_buffer.is_active is True out_device.wait() assert out_device.output_buffer.is_active is False - - -def test_recording(empty_buffer_stub): - - buffer = empty_buffer_stub[0] - assert pf.dsp.rms(buffer.data) < 1e-14 - - identifier, config = default_device_multiface_fireface() - - in_device = devices.InputAudioDevice( - identifier=identifier, - input_buffer=buffer, - channels=[1]) - in_device.check_settings() - - in_device.start() - assert in_device.input_buffer.is_active is True - in_device.wait() - assert in_device.input_buffer.is_active is False - - assert pf.dsp.rms(in_device.input_buffer.data) > 1e-10 From 5ed40aad1278ec8ba7637a42e3865332d6899860 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Tue, 15 Nov 2022 10:05:56 +0100 Subject: [PATCH 56/63] add method to initialize buffers avoids waiting for buffers to be set to active when starting a stream --- haiopy/devices.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 1fd4feb..9f8fe7a 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -297,6 +297,12 @@ def initialize(self): finished_callback=self._finished_callback) self._stream = ostream + def initialize_buffer(self): + """Initialize the buffer + """ + self.input_buffer._start() + self.input_buffer._is_active.wait() + @property def input_buffer(self): return self._input_buffer @@ -319,8 +325,8 @@ def _stop_buffer(self): self.input_buffer._stop() def start(self): + self.initialize_buffer() super().start() - self.input_buffer._is_active.wait() def wait(self): super().wait() @@ -488,6 +494,10 @@ def initialize(self): finished_callback=self._finished_callback) self._stream = ostream + def initialize_buffer(self): + self.output_buffer._start() + self.output_buffer._is_active.wait() + @property def output_buffer(self): return self._output_buffer @@ -510,8 +520,9 @@ def _stop_buffer(self): self._output_buffer._stop() def start(self): - super().start() + self.output_buffer._start() self.output_buffer._is_active.wait() + super().start() def wait(self): super().wait() From 2e3ea7f0665737cd220274a9b00348accde41e9d Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 15 Mar 2023 11:22:18 +0100 Subject: [PATCH 57/63] replace deprecated @abstractproperty --- haiopy/buffers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index c2813b4..4e21e4a 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -1,6 +1,6 @@ import numpy as np import pyfar as pf -from abc import abstractproperty, abstractmethod +from abc import abstractmethod from threading import Event @@ -44,7 +44,8 @@ def block_size(self, block_size): """Set the block size in samples. Only integer values are supported""" self._set_block_size(block_size) - @abstractproperty + @property + @abstractmethod def sampling_rate(self): """Return sampling rate.""" pass From 6d9264f7ca7718a7b84264213d91e83adaa2897f Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 15 Mar 2023 11:27:04 +0100 Subject: [PATCH 58/63] dont check imports on soundfile imports in draft files --- haiopy/devices.py | 2 +- haiopy/io.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index bd2a3c1..fec93b9 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -1,4 +1,4 @@ -import sounddevice as sd +import sounddevice as sd # noqa: F401 TODO: remove this after implementation def list_devices(): diff --git a/haiopy/io.py b/haiopy/io.py index 9cfa4bc..6feef3d 100644 --- a/haiopy/io.py +++ b/haiopy/io.py @@ -1,4 +1,4 @@ -from . import devices +from . import devices # noqa: F401 TODO: remove this after implementation class _AudioIO(object): From 8b1e370f78898212b935d153253311c52f77f22e Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 15 Mar 2023 16:57:56 +0100 Subject: [PATCH 59/63] Buffer a block of output audio data containing all channels instead of creating one inside the callback --- haiopy/devices.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 9f8fe7a..3033fde 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -474,11 +474,12 @@ def output_callback(self, outdata, frames, time, status) -> None: assert not status try: - full_outdata = np.zeros( - (self._n_channels_stream, self.block_size), - dtype=self.dtype) - full_outdata[self.output_channels] = next(self.output_buffer) - outdata[:] = full_outdata.T + # Write a block to an array with all required output channels + # including zeros for unused channels. Required as sounddevice does + # not support routing matrices + self._stream_block_out[self.output_channels] = next( + self.output_buffer) + outdata[:] = self._stream_block_out.T except StopIteration as e: raise sd.CallbackStop("Buffer empty") from e @@ -493,6 +494,11 @@ def initialize(self): callback=self.output_callback, finished_callback=self._finished_callback) self._stream = ostream + # Init array buffering a block of all required output channels + # including zeros for unused channels. Required as sounddevice does + # not support routing matrices + self._stream_block_out = np.zeros( + (self._n_channels_stream, self.block_size), dtype=self.dtype) def initialize_buffer(self): self.output_buffer._start() From f8b41b87ad2b9d444b0db60e0c36f3abeb729174 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 27 Mar 2023 13:11:52 +0200 Subject: [PATCH 60/63] move active checking when setting the block size to private method --- haiopy/buffers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index 4e21e4a..df01ad8 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -31,6 +31,7 @@ def _check_block_size(self, block_size): def _set_block_size(self, block_size): """Private block size setter implementing validity checks.""" + self.check_if_active() self._check_block_size(block_size) self._block_size = block_size @@ -199,7 +200,6 @@ def data(self, data): self._update_data() def _set_block_size(self, block_size): - self.check_if_active() super()._set_block_size(block_size) self._update_data() From a728592d1fcb48952efc298d934191af71d014ed Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 27 Mar 2023 13:37:49 +0200 Subject: [PATCH 61/63] improve documentation of check_if_active --- haiopy/buffers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index df01ad8..fd71a8d 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -71,10 +71,12 @@ def is_active(self): return self._is_active.is_set() def check_if_active(self): - """Check if the buffer is active. + """Check if the buffer is active and raise an exception if so. If the buffer is active a BufferError exception is raised. In case the buffer is currently inactive, the method simply passes without any - return value. + return value. This method should always be called before attempting to + modify properties of the buffer to prevent undefined behavior during + iteration of the buffer. Raises ------ From 5dc97ccd52ab9fa34bc6986757d7c55dd1a60210 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 27 Mar 2023 13:38:20 +0200 Subject: [PATCH 62/63] document inherited members in buffer classes --- docs/modules/haiopy.buffers.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/modules/haiopy.buffers.rst b/docs/modules/haiopy.buffers.rst index 0a33294..5886ff8 100644 --- a/docs/modules/haiopy.buffers.rst +++ b/docs/modules/haiopy.buffers.rst @@ -3,5 +3,6 @@ haiopy.buffers .. automodule:: haiopy.buffers :members: + :inherited-members: :undoc-members: :show-inheritance: From 667b91c3a01cb03ba480b490aff1b0f3b27c2f51 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 30 Mar 2023 21:44:45 +0200 Subject: [PATCH 63/63] change order of decorators --- haiopy/devices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 8538fe7..28b0ef0 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -109,8 +109,8 @@ def block_size(self, block_size): def dtype(self): return self._dtype - @abstractmethod @property + @abstractmethod def stream(self): """The sounddevice audio stream. """