From d0c1dca2d3a2feebf1617ddac11c049729eb4012 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 31 Mar 2023 16:52:04 +0200 Subject: [PATCH 01/57] AudioDevice base class --- haiopy/devices.py | 192 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 134 insertions(+), 58 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index fec93b9..4dd165b 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -1,4 +1,12 @@ -import sounddevice as sd # noqa: F401 TODO: remove this after implementation + +from multiprocessing import Event +import numpy as np +import sys +import sounddevice as sd +from abc import abstractmethod + +from haiopy.buffers import SignalBuffer +import pyfar as pf def list_devices(): @@ -8,89 +16,157 @@ 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 - def playback(): - pass - - def record(): - pass - - def playback_record(): - pass - - def initialize_playback(): - pass + @property + def id(self): + return self._id - def initialize_record(): - pass + def sampling_rate(self): + return self._sampling_rate - def initialize_playback_record(): - pass + def block_size(self): + return self._block_size - def abort(): - pass + def dtype(self): + return self.dtype class AudioDevice(_Device): def __init__( self, - id, - sampling_rate, - block_size, - dtype, - latency=None, - extra_settings=None, - # finished_callback=None, - clip_off=None, - dither_off=None, - never_drop_input=None, - prime_output_buffers_using_stream_callback=None + identifier=0, + sampling_rate=44100, + block_size=512, + dtype='float32', ): - super().__init__(id, sampling_rate, block_size, dtype) + + identifier = sd.query_devices(identifier)['name'] + + 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 + self._stream = None + self._input_buffer = None + self._output_buffer = None + + self._stream_finished = Event() @property - def stream(): - pass + def id(self): + return self._id - @staticmethod - def callback(): + @abstractmethod + def check_settings(**kwargs): pass - def playback(data): - # fill queue, stream.start() - pass + @property + def name(self): + """The name of the device + """ + return self._name - def record(n_samples): - # stream start, read into the queue - pass + @property + def sampling_rate(self): + """The sampling rate of the audio device. + """ + return self._sampling_rate - def playback_record(data): - # see combination above - pass + @sampling_rate.setter + def sampling_rate(self, value): + self.check_settings(value, self.dtype, self._extra_settings) - def initialize_playback(channels): - # init queue, define callback, init stream - pass + @property + def block_size(self): + """The block size of the audio buffer. + """ + return self._block_size - def initialize_record(channels): - pass + @block_size.setter + def block_size(self, block_size): + self._block_size = block_size + self.output_buffer.block_size = block_size - def initialize_playback_record(input_channels, output_channels): - pass + @property + def dtype(self): + return self._dtype + + @property + @abstractmethod + def stream(self): + """The sounddevice audio stream. + """ + return self._stream + + def finished_callback(self) -> None: + """Custom callback after a audio stream has finished.""" + print("I'm finished.") + + def _finished_callback(self) -> None: + """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 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.""" + 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() + 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() - def abort(): - # abort - pass - def close(): - # remove stream - pass From 49baf2a2558205ae318aad34c3e4eb704e001602 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 31 Mar 2023 16:55:54 +0200 Subject: [PATCH 02/57] OutputAudioDevice class implementation --- haiopy/devices.py | 200 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/haiopy/devices.py b/haiopy/devices.py index 4dd165b..497a72f 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -170,3 +170,203 @@ def _reset_buffer(self): raise NotImplementedError() +class OutputAudioDevice(AudioDevice): + + def __init__( + self, + identifier=sd.default.device['output'], + sampling_rate=44100, + block_size=512, + channels=[1], + 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): + + # First check the settings before continuing + max_channel = np.max(channels) + n_channels = len(channels) + sd.check_output_settings( + device=identifier, + 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._output_channels = channels + + if output_buffer is None: + output_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')) + # 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, + 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 + 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=n_channels, + dtype=dtype, + extra_settings=extra_settings, + samplerate=sampling_rate) + + @property + def output_channels(self): + return self._output_channels + + @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. 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((2, np.max(self._output_channels) + 1)) + + @property + def max_channels_output(self): + """The number of output channels supported by the device""" + return sd.query_devices(self.id, 'output')['max_output_channels'] + + 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) + raise sd.CallbackAbort('Buffer underflow') + assert not status + + try: + # 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 + + def initialize(self): + """Initialize the playback stream for a given number of channels.""" + ostream = sd.OutputStream( + self.sampling_rate, + self.block_size, + self.id, + self._n_channels_stream, + self.dtype, + 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() + self.output_buffer._is_active.wait() + + @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}") + + 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 + + def _stop_buffer(self): + self._output_buffer._stop() + + def start(self): + self.output_buffer._start() + self.output_buffer._is_active.wait() + super().start() + + def wait(self): + super().wait() + self.output_buffer._is_finished.wait() From a288342b0a763e080274b30b1b9953d32293aacf Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 31 Mar 2023 17:02:47 +0200 Subject: [PATCH 03/57] add tests --- tests/conftest.py | 52 ++++++++++++ tests/sounddevice_mocks.py | 23 ++++++ tests/test_devices.py | 25 ++++++ tests/test_devices_physical.py | 147 +++++++++++++++++++++++++++++++++ 4 files changed, 247 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/sounddevice_mocks.py create mode 100644 tests/test_devices.py create mode 100644 tests/test_devices_physical.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f04c8da --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,52 @@ +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') + + buffer = signal_buffer_stub(block_size, data) + duration = block_size*n_blocks/buffer.sampling_rate + + return buffer, duration + + +@pytest.fixture +def sine_buffer_stub(): + """Create a stub representing an empty ArrayBuffer. + + Returns + ------- + 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 + n_blocks = 86 + data = np.zeros((1, n_blocks*block_size), dtype='float32') + 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 buffer, duration 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 new file mode 100644 index 0000000..51287a5 --- /dev/null +++ b/tests/test_devices.py @@ -0,0 +1,25 @@ +from haiopy import devices +from . import utils +from . import sounddevice_mocks as sdm +from unittest.mock import patch + + +@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.InputAudioDevice() + in_device.check_settings() + + +@patch('sounddevice.query_devices', new=utils.query_devices) +@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.OutputAudioDevice( + output_buffer=empty_buffer_stub) + out_device.check_settings() diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py new file mode 100644 index 0000000..26b1aaa --- /dev/null +++ b/tests/test_devices_physical.py @@ -0,0 +1,147 @@ +from haiopy import devices +import sounddevice as sd +from . import utils +from unittest.mock import patch, MagicMock +import time +import pytest +import pyfar as pf + + +def default_device_multiface_fireface(): + device_list = sd.query_devices() + found = False + + 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.") + + return identifier, device + # default = MagicMock(spec_sec=sd.default) + # default.device = [idx, idx] + # default._default_device = (idx, idx) + + # return default + + +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'] + 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 + +# ----------------------------------------------------------------------------- +# Input Device Tests +# ----------------------------------------------------------------------------- + + +def test_check_input_settings(): + identifier, config = default_device_multiface_fireface() + + default_sampling_rate = config['default_samplerate'] + + # Create device + in_device = devices.InputAudioDevice(identifier) + + # 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_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] + block_size = 512 + + buffer = empty_buffer_stub[0] + + out_device = devices.OutputAudioDevice( + identifier, 44100, block_size, channels=channels, dtype='float32', + output_buffer=buffer) + + # 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) + + # 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): + + 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=identifier, + output_buffer=buffer, + channels=[0], + sampling_rate=sampling_rate) + out_device.check_settings() + + out_device.start() + assert out_device.output_buffer.is_active is True + out_device.wait() + assert out_device.output_buffer.is_active is False From 47fda870f21186bf11cbbb00c234b1dc52dc1eaa Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 31 Mar 2023 17:10:30 +0200 Subject: [PATCH 04/57] utils module for tests --- tests/utils.py | 109 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/utils.py diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..9ba1894 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,109 @@ +import pytest +from unittest import mock +import numpy as np +import pyfar as pf +from haiopy.buffers import SignalBuffer + + +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 + } + + +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 + + +def signal_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 + + 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)) + + # for idx in range(n_blocks): + # yield strided[..., idx, :] + + # buffer = mock.MagicMock(spec_set=ArrayBuffer(block_size, data)) + buffer = SignalBuffer(block_size, sig) + + # 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 76bed3a6655ffa2a0ac11d48afe36a13aa00af0b Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 31 Mar 2023 17:11:15 +0200 Subject: [PATCH 05/57] clean up --- tests/utils.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/tests/utils.py b/tests/utils.py index 9ba1894..1695fdf 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -86,24 +86,6 @@ def signal_buffer_stub(block_size=512, data=np.zeros((1, 512))): raise ValueError( 'The data needs to be an integer multiple of the block size') - 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)) - - # for idx in range(n_blocks): - # yield strided[..., idx, :] - - # buffer = mock.MagicMock(spec_set=ArrayBuffer(block_size, data)) - buffer = SignalBuffer(block_size, sig) - - # 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 + return SignalBuffer(block_size, sig) From 56fc6ae090f19847cb3f7d5d56e456c13f960796 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 31 Mar 2023 17:18:51 +0200 Subject: [PATCH 06/57] add portaudio to circleci --- .circleci/config.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index d171aad..1357635 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -39,7 +39,7 @@ jobs: - checkout - run: name: Install System Dependencies - command: sudo apt-get update && sudo apt-get install -y libsndfile1 + command: sudo apt-get update && sudo apt-get install -y libsndfile1 portaudio19-dev - python/install-packages: pkg-manager: pip # app-dir: ~/project/package-directory/ # If you're requirements.txt isn't in the root directory. @@ -82,7 +82,7 @@ jobs: - checkout - run: name: Install System Dependencies - command: sudo apt-get update && sudo apt-get install -y libsndfile1 texlive-latex-extra dvipng + command: sudo apt-get update && sudo apt-get install -y libsndfile1 portaudio19-dev texlive-latex-extra dvipng - python/install-packages: pkg-manager: pip # app-dir: ~/project/package-directory/ # If you're requirements.txt isn't in the root directory. @@ -107,7 +107,7 @@ jobs: - checkout - run: name: Install System Dependencies - command: sudo apt-get update && sudo apt-get install -y libsndfile1 + command: sudo apt-get update && sudo apt-get install -y libsndfile1 portaudio19-dev - python/install-packages: pkg-manager: pip # app-dir: ~/project/package-directory/ # If you're requirements.txt isn't in the root directory. From d4eefd31929dfb3bc1fd2b235ad65c3a71f9485e Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 31 Mar 2023 17:24:49 +0200 Subject: [PATCH 07/57] fix multiple buffer stub --- tests/test_devices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_devices.py b/tests/test_devices.py index 51287a5..d5eeff0 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -21,5 +21,5 @@ def test_check_input_settings(): @patch('sounddevice.OutputStream', new=sdm.output_stream_mock()) def test_check_output_settings(empty_buffer_stub): out_device = devices.OutputAudioDevice( - output_buffer=empty_buffer_stub) + output_buffer=empty_buffer_stub[0]) out_device.check_settings() From e53ba7e096294f1261c04fcf7b9cfc0058593f16 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 31 Mar 2023 17:25:36 +0200 Subject: [PATCH 08/57] remove input device related tests --- tests/test_devices.py | 7 ---- tests/test_devices_physical.py | 68 ++++++---------------------------- 2 files changed, 11 insertions(+), 64 deletions(-) diff --git a/tests/test_devices.py b/tests/test_devices.py index d5eeff0..fb8324f 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -9,13 +9,6 @@ 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.InputAudioDevice() - in_device.check_settings() - - @patch('sounddevice.query_devices', new=utils.query_devices) @patch('sounddevice.check_output_settings', new=utils.check_output_settings) @patch('sounddevice.OutputStream', new=sdm.output_stream_mock()) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index 26b1aaa..ab3c936 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -7,11 +7,15 @@ import pyfar as pf -def default_device_multiface_fireface(): +def default_device_multiface_fireface(kind='both'): device_list = sd.query_devices() found = False - valid_devices = ['Multiface', 'Fireface', 'Scarlett 2i4'] + valid_devices = [ + 'Multiface', + 'Fireface', + 'Scarlett 2i4', + 'MADIface'] for valid_device in valid_devices: for identifier, device in enumerate(device_list): @@ -23,11 +27,6 @@ def default_device_multiface_fireface(): "Please connect Fireface or Multiface, or specify test device.") return identifier, device - # default = MagicMock(spec_sec=sd.default) - # default.device = [idx, idx] - # default._default_device = (idx, idx) - - # return default def test_default_device_helper(): @@ -35,7 +34,8 @@ def test_default_device_helper(): fireface = 'Fireface' in sd.query_devices(identifier)['name'] multiface = 'Multiface' in sd.query_devices(identifier)['name'] scarlett = 'Scarlett 2i4' in sd.query_devices(identifier)['name'] - assert fireface or multiface or scarlett + madiface = 'MADIface' in sd.query_devices(identifier)['name'] + assert fireface or multiface or scarlett or madiface if fireface: assert device['max_input_channels'] == 18 @@ -45,54 +45,9 @@ 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() - - default_sampling_rate = config['default_samplerate'] - - # Create device - in_device = devices.InputAudioDevice(identifier) - - # 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_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 + if madiface: + assert device['max_input_channels'] == 196 + assert device['max_output_channels'] == 198 # ----------------------------------------------------------------------------- # Output Device Tests @@ -129,7 +84,6 @@ def test_check_output_settings(empty_buffer_stub): 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'] From e81804813d5bc14c040488ad66b4470a02db24e1 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 3 Apr 2023 21:14:28 +0200 Subject: [PATCH 09/57] simple callback tests --- tests/test_devices.py | 92 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/test_devices.py b/tests/test_devices.py index fb8324f..489f91c 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -2,6 +2,10 @@ from . import utils from . import sounddevice_mocks as sdm from unittest.mock import patch +import sounddevice as sd +import pytest +import numpy as np +from numpy import testing as npt @patch('sounddevice.query_devices', new=utils.query_devices) @@ -16,3 +20,91 @@ def test_check_output_settings(empty_buffer_stub): out_device = devices.OutputAudioDevice( output_buffer=empty_buffer_stub[0]) out_device.check_settings() + + +@patch('sounddevice.query_devices', new=utils.query_devices) +@patch('sounddevice.check_output_settings', new=utils.check_output_settings) +@patch('sounddevice.OutputStream', new=sdm.output_stream_mock()) +def test_check_init(empty_buffer_stub): + out_device = devices.OutputAudioDevice( + output_buffer=empty_buffer_stub[0]) + out_device.check_settings() + + + +@patch('sounddevice.query_devices', new=utils.query_devices) +@patch('sounddevice.check_output_settings', new=utils.check_output_settings) +@patch('sounddevice.OutputStream', new=sdm.output_stream_mock()) +def test_sine_playback(sine_buffer_stub): + buffer = sine_buffer_stub[0] + + config = {'default_samplerate': 44100} + sampling_rate = config['default_samplerate'] + + channels = [1] + + out_device = devices.OutputAudioDevice( + identifier=0, + output_buffer=buffer, + channels=channels, + sampling_rate=sampling_rate) + out_device.check_settings() + + out_device.start() + assert out_device.output_buffer.is_active is True + + # manually call the callback function with an arbitrary outdata array + outdata = np.zeros((512, 2), dtype=np.float32) + # unset all callback flags + status = sd.CallbackFlags() + + # call callback, this would happen in a separate thread controlled by + # portaudio. Once the buffer is empty, the callback should raise an + # sd.CallbackStop exception. + with pytest.raises(sd.CallbackStop, match='Buffer empty'): + bdx = 0 + while True: + out_device.output_callback(outdata, 512, None, status) + npt.assert_allclose( + outdata[:, 1], + buffer._strided_data[0, bdx, :]) + bdx += 1 + + out_device._finished_callback() + assert out_device.output_buffer.is_active is False + + +@patch('sounddevice.query_devices', new=utils.query_devices) +@patch('sounddevice.check_output_settings', new=utils.check_output_settings) +@patch('sounddevice.OutputStream', new=sdm.output_stream_mock()) +def test_callback_errors(sine_buffer_stub): + buffer = sine_buffer_stub[0] + + config = {'default_samplerate': 44100} + sampling_rate = config['default_samplerate'] + + channels = [1] + + out_device = devices.OutputAudioDevice( + identifier=0, + output_buffer=buffer, + channels=channels, + sampling_rate=sampling_rate) + out_device.check_settings() + + out_device.start() + assert out_device.output_buffer.is_active is True + + # manually call the callback function with an arbitrary outdata array + outdata = np.zeros((512, 2), dtype=np.float32) + # unset all callback flags + status = sd.CallbackFlags() + + # No error + out_device.output_callback(outdata, 512, None, status) + + # Buffer underflow + with pytest.raises(sd.CallbackAbort, match='Buffer underflow'): + status = sd.CallbackFlags() + status.output_underflow = True + out_device.output_callback(outdata, 512, None, status) From 8942b61edd7b5bdcbbd1fd58e23c8adbac56d3f0 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Mon, 3 Apr 2023 21:27:53 +0200 Subject: [PATCH 10/57] don't run physical tests on circleci --- tests/test_devices_physical.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index ab3c936..c90b012 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -1,10 +1,7 @@ from haiopy import devices import sounddevice as sd -from . import utils -from unittest.mock import patch, MagicMock -import time import pytest -import pyfar as pf +import os def default_device_multiface_fireface(kind='both'): @@ -29,6 +26,8 @@ def default_device_multiface_fireface(kind='both'): return identifier, device +@pytest.mark.skipif(os.environ.get('CI') == 'true', + reason="CI does not have a soundcard") def test_default_device_helper(): identifier, device = default_device_multiface_fireface() fireface = 'Fireface' in sd.query_devices(identifier)['name'] @@ -54,6 +53,8 @@ def test_default_device_helper(): # ----------------------------------------------------------------------------- +@pytest.mark.skipif(os.environ.get('CI') == 'true', + reason="CI does not have a soundcard") def test_check_output_settings(empty_buffer_stub): identifier, config = default_device_multiface_fireface() channels = [3] @@ -81,6 +82,8 @@ def test_check_output_settings(empty_buffer_stub): out_device.check_settings(config['max_output_channels']+10) +@pytest.mark.skipif(os.environ.get('CI') == 'true', + reason="CI does not have a soundcard") def test_sine_playback(sine_buffer_stub): buffer = sine_buffer_stub[0] From 8e0a796351b22af74eb6674dab6b925f9d862edd Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Tue, 4 Apr 2023 17:01:37 +0200 Subject: [PATCH 11/57] add tests for setting the block size --- haiopy/devices.py | 3 --- tests/test_devices.py | 18 +++++++++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 497a72f..70e2da1 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -212,9 +212,6 @@ def __init__( (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") self.output_buffer = output_buffer self.initialize() diff --git a/tests/test_devices.py b/tests/test_devices.py index 489f91c..8aa1ea4 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -26,15 +26,31 @@ def test_check_output_settings(empty_buffer_stub): @patch('sounddevice.check_output_settings', new=utils.check_output_settings) @patch('sounddevice.OutputStream', new=sdm.output_stream_mock()) def test_check_init(empty_buffer_stub): + buffer = empty_buffer_stub[0] out_device = devices.OutputAudioDevice( output_buffer=empty_buffer_stub[0]) out_device.check_settings() + out_device.output_buffer = buffer + out_device._output_buffer == buffer + out_device.output_buffer == buffer + + # set a buffer with non matching block size + buffer.block_size = 256 + with pytest.raises(ValueError, match='block size does not match'): + out_device.output_buffer = buffer + + # change the block size of the buffer and check if buffers block size is + # set accordingly + new_block_size = 256 + out_device.block_size = new_block_size + out_device._block_size == new_block_size + out_device.output_buffer.block_size == new_block_size @patch('sounddevice.query_devices', new=utils.query_devices) @patch('sounddevice.check_output_settings', new=utils.check_output_settings) -@patch('sounddevice.OutputStream', new=sdm.output_stream_mock()) +@patch('sounddevice.outputstream', new=sdm.output_stream_mock()) def test_sine_playback(sine_buffer_stub): buffer = sine_buffer_stub[0] From 68f42473c4a6233e43a360918663da5ba066359d Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Tue, 4 Apr 2023 17:03:31 +0200 Subject: [PATCH 12/57] sampling rate setter tests --- haiopy/devices.py | 4 ++-- tests/test_devices.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 70e2da1..1504706 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -77,7 +77,7 @@ def id(self): @abstractmethod def check_settings(**kwargs): - pass + raise NotImplementedError('Needs to be implemented in child class.') @property def name(self): @@ -93,7 +93,7 @@ def sampling_rate(self): @sampling_rate.setter def sampling_rate(self, value): - self.check_settings(value, self.dtype, self._extra_settings) + self.check_settings(value, None, None) @property def block_size(self): diff --git a/tests/test_devices.py b/tests/test_devices.py index 8aa1ea4..37c247b 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -47,6 +47,10 @@ def test_check_init(empty_buffer_stub): out_device._block_size == new_block_size out_device.output_buffer.block_size == new_block_size + # set and get sampling rate + out_device.sampling_rate = 44100 + out_device._sampling_rate == 44100 + @patch('sounddevice.query_devices', new=utils.query_devices) @patch('sounddevice.check_output_settings', new=utils.check_output_settings) From 4ec165f3b64434851540b1b7972cc8267bf64b8a Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Tue, 4 Apr 2023 17:04:29 +0200 Subject: [PATCH 13/57] fix case typo --- tests/test_devices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_devices.py b/tests/test_devices.py index 37c247b..3567f46 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -54,7 +54,7 @@ def test_check_init(empty_buffer_stub): @patch('sounddevice.query_devices', new=utils.query_devices) @patch('sounddevice.check_output_settings', new=utils.check_output_settings) -@patch('sounddevice.outputstream', new=sdm.output_stream_mock()) +@patch('sounddevice.OutputStream', new=sdm.output_stream_mock()) def test_sine_playback(sine_buffer_stub): buffer = sine_buffer_stub[0] From 9c714da88fe1e38de9910a3b5555193ab6a62164 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Wed, 5 Apr 2023 16:18:47 +0200 Subject: [PATCH 14/57] buffer_size setter/getter - Move OutputDevice related things to the respective setter - Add checks if the stream is active and raise an exception if so --- haiopy/devices.py | 13 ++++++++++++- tests/test_devices.py | 11 +++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 1504706..8436add 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -104,7 +104,6 @@ def block_size(self): @block_size.setter def block_size(self, block_size): self._block_size = block_size - self.output_buffer.block_size = block_size @property def dtype(self): @@ -356,6 +355,18 @@ def output_buffer(self, buffer): self._output_buffer = buffer + @property + def block_size(self): + return self._block_size + + @block_size.setter + def block_size(self, value): + if self.stream.active is True or self.output_buffer.is_active is True: + raise ValueError( + "The device is currently in use and needs to be closed first") + self.output_buffer.block_size = value + super(OutputAudioDevice, self.__class__).block_size.fset(self, value) + def _stop_buffer(self): self._output_buffer._stop() diff --git a/tests/test_devices.py b/tests/test_devices.py index 3567f46..d060beb 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -51,6 +51,17 @@ def test_check_init(empty_buffer_stub): out_device.sampling_rate = 44100 out_device._sampling_rate == 44100 + # test if setters are blocked when the stream is in use + out_device.stream.active = True + with pytest.raises(ValueError, match='currently in use'): + out_device.block_size = 512 + + # test if setters are blocked when the buffer is in use + out_device.stream.active = False + out_device.output_buffer._start() + with pytest.raises(ValueError, match='currently in use'): + out_device.block_size = 512 + @patch('sounddevice.query_devices', new=utils.query_devices) @patch('sounddevice.check_output_settings', new=utils.check_output_settings) From cf7600fffcdf198437b0c0c3519fad813c140b2a Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Fri, 5 May 2023 10:21:22 +0200 Subject: [PATCH 15/57] Add Focusrite USB ASIO --- .gitignore | 3 ++- tests/test_devices_physical.py | 21 ++++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 43091aa..db958a0 100644 --- a/.gitignore +++ b/.gitignore @@ -102,4 +102,5 @@ ENV/ .mypy_cache/ # IDE settings -.vscode/ \ No newline at end of file +.vscode/ +private diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index c90b012..5d984db 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -12,7 +12,8 @@ def default_device_multiface_fireface(kind='both'): 'Multiface', 'Fireface', 'Scarlett 2i4', - 'MADIface'] + 'MADIface', + 'Focusrite USB ASIO'] for valid_device in valid_devices: for identifier, device in enumerate(device_list): @@ -34,7 +35,8 @@ def test_default_device_helper(): multiface = 'Multiface' in sd.query_devices(identifier)['name'] scarlett = 'Scarlett 2i4' in sd.query_devices(identifier)['name'] madiface = 'MADIface' in sd.query_devices(identifier)['name'] - assert fireface or multiface or scarlett or madiface + focusrite = 'Focusrite USB ASIO' in sd.query_devices(identifier)['name'] + assert fireface or multiface or scarlett or madiface or focusrite if fireface: assert device['max_input_channels'] == 18 @@ -48,6 +50,10 @@ def test_default_device_helper(): assert device['max_input_channels'] == 196 assert device['max_output_channels'] == 198 + if focusrite: + assert device['max_input_channels'] == 2 + assert device['max_output_channels'] == 2 + # ----------------------------------------------------------------------------- # Output Device Tests # ----------------------------------------------------------------------------- @@ -57,7 +63,7 @@ def test_default_device_helper(): reason="CI does not have a soundcard") def test_check_output_settings(empty_buffer_stub): identifier, config = default_device_multiface_fireface() - channels = [3] + channels = [1] block_size = 512 buffer = empty_buffer_stub[0] @@ -81,6 +87,11 @@ def test_check_output_settings(empty_buffer_stub): with pytest.raises(sd.PortAudioError, match="Invalid"): out_device.check_settings(config['max_output_channels']+10) + # Close Output Stream for next Tests + with pytest.raises(StopIteration, match="iteration stopped"): + out_device.close() + + @pytest.mark.skipif(os.environ.get('CI') == 'true', reason="CI does not have a soundcard") @@ -102,3 +113,7 @@ 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 + + # Close Output Stream for next Tests + with pytest.raises(StopIteration, match="iteration stopped"): + out_device.close() From b921821854a5ca35413d6548833bb9bb98eeb01d Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Tue, 16 May 2023 12:01:34 +0200 Subject: [PATCH 16/57] Update test_devices_physical.py --- tests/test_devices_physical.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index 5d984db..644c8fa 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -13,7 +13,8 @@ def default_device_multiface_fireface(kind='both'): 'Fireface', 'Scarlett 2i4', 'MADIface', - 'Focusrite USB ASIO'] + 'Focusrite USB ASIO', + 'ASIO4ALL v2'] for valid_device in valid_devices: for identifier, device in enumerate(device_list): @@ -36,7 +37,10 @@ def test_default_device_helper(): scarlett = 'Scarlett 2i4' in sd.query_devices(identifier)['name'] madiface = 'MADIface' in sd.query_devices(identifier)['name'] focusrite = 'Focusrite USB ASIO' in sd.query_devices(identifier)['name'] - assert fireface or multiface or scarlett or madiface or focusrite + realtek = 'ASIO4ALL v2' in \ + sd.query_devices(identifier)['name'] + assert fireface or multiface or scarlett or madiface or \ + focusrite or realtek if fireface: assert device['max_input_channels'] == 18 @@ -54,6 +58,10 @@ def test_default_device_helper(): assert device['max_input_channels'] == 2 assert device['max_output_channels'] == 2 + if realtek: + assert device['max_input_channels'] == 2 + assert device['max_output_channels'] == 2 + # ----------------------------------------------------------------------------- # Output Device Tests # ----------------------------------------------------------------------------- @@ -63,7 +71,7 @@ def test_default_device_helper(): reason="CI does not have a soundcard") def test_check_output_settings(empty_buffer_stub): identifier, config = default_device_multiface_fireface() - channels = [1] + channels = [0] block_size = 512 buffer = empty_buffer_stub[0] @@ -92,7 +100,6 @@ def test_check_output_settings(empty_buffer_stub): out_device.close() - @pytest.mark.skipif(os.environ.get('CI') == 'true', reason="CI does not have a soundcard") def test_sine_playback(sine_buffer_stub): @@ -105,7 +112,7 @@ def test_sine_playback(sine_buffer_stub): out_device = devices.OutputAudioDevice( identifier=identifier, output_buffer=buffer, - channels=[0], + channels=[1], sampling_rate=sampling_rate) out_device.check_settings() From 9f0b8d0c636ebb3a6d6f0e5326dc369fec87a2db Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Wed, 17 May 2023 14:23:57 +0200 Subject: [PATCH 17/57] Physical Device Tests --- haiopy/devices.py | 2 +- tests/test_devices_physical.py | 60 ++++++++++++++++++++++++++++------ 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 8436add..88927ab 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -93,7 +93,7 @@ def sampling_rate(self): @sampling_rate.setter def sampling_rate(self, value): - self.check_settings(value, None, None) + self.check_settings(None, value, None, None) @property def block_size(self): diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index 644c8fa..ece1179 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -13,8 +13,7 @@ def default_device_multiface_fireface(kind='both'): 'Fireface', 'Scarlett 2i4', 'MADIface', - 'Focusrite USB ASIO', - 'ASIO4ALL v2'] + 'Focusrite USB ASIO'] for valid_device in valid_devices: for identifier, device in enumerate(device_list): @@ -37,10 +36,8 @@ def test_default_device_helper(): scarlett = 'Scarlett 2i4' in sd.query_devices(identifier)['name'] madiface = 'MADIface' in sd.query_devices(identifier)['name'] focusrite = 'Focusrite USB ASIO' in sd.query_devices(identifier)['name'] - realtek = 'ASIO4ALL v2' in \ - sd.query_devices(identifier)['name'] - assert fireface or multiface or scarlett or madiface or \ - focusrite or realtek + + assert fireface or multiface or scarlett or madiface or focusrite if fireface: assert device['max_input_channels'] == 18 @@ -58,10 +55,6 @@ def test_default_device_helper(): assert device['max_input_channels'] == 2 assert device['max_output_channels'] == 2 - if realtek: - assert device['max_input_channels'] == 2 - assert device['max_output_channels'] == 2 - # ----------------------------------------------------------------------------- # Output Device Tests # ----------------------------------------------------------------------------- @@ -124,3 +117,50 @@ def test_sine_playback(sine_buffer_stub): # Close Output Stream for next Tests with pytest.raises(StopIteration, match="iteration stopped"): out_device.close() + + +@pytest.mark.skipif(os.environ.get('CI') == 'true', + reason="CI does not have a soundcard") +def test_check_init(empty_buffer_stub, sine_buffer_stub): + buffer = sine_buffer_stub[0] + identifier, config = default_device_multiface_fireface() + + sampling_rate = config['default_samplerate'] + + out_device = devices.OutputAudioDevice( + identifier=identifier, + output_buffer=empty_buffer_stub[0], + channels=[1], + sampling_rate=sampling_rate) + out_device.check_settings() + assert out_device.output_buffer == empty_buffer_stub[0] + out_device.output_buffer = buffer + assert out_device._output_buffer == buffer + assert out_device.output_buffer == buffer + + # set a buffer with non matching block size + buffer.block_size = 256 + with pytest.raises(ValueError, match='block size does not match'): + out_device.output_buffer = buffer + + # change the block size of the buffer and check if buffers block size is + # set accordingly + new_block_size = 256 + out_device.block_size = new_block_size + assert out_device._block_size == new_block_size + assert out_device.output_buffer.block_size == new_block_size + + # set and get sampling rate + + out_device.sampling_rate = 44100 # Different Sampling Rates invalid + assert out_device._sampling_rate == 44100 + + # test if setters are blocked when the stream is in use + out_device.start() + with pytest.raises(ValueError, match='currently in use'): + out_device.block_size = 512 + out_device.wait() + + # Close Output Stream for next Tests + with pytest.raises(StopIteration, match="iteration stopped"): + out_device.close() From 9cbd3a7ac08703421cf77eeec7b522821a34c059 Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Wed, 17 May 2023 14:25:31 +0200 Subject: [PATCH 18/57] sample_rate setter --- haiopy/devices.py | 1 + 1 file changed, 1 insertion(+) diff --git a/haiopy/devices.py b/haiopy/devices.py index 88927ab..897da14 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -94,6 +94,7 @@ def sampling_rate(self): @sampling_rate.setter def sampling_rate(self, value): self.check_settings(None, value, None, None) + self._samping_rate = value @property def block_size(self): From 4c1b04a49b8bb3924a82b90536dbce6a61fd3551 Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Wed, 4 Oct 2023 09:38:24 +0200 Subject: [PATCH 19/57] Sampling_rate setter Output Device --- haiopy/buffers.py | 5 ++- haiopy/devices.py | 25 ++++++++++- tests/test_devices_physical.py | 80 ++++++++++++++++++++++++++++++++-- 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index d23e13c..b6967ca 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -98,7 +98,10 @@ def _stop(self, msg="Buffer iteration stopped."): """Stop buffer iteration and set the state to inactive.""" self._is_active.clear() self._is_finished.set() - raise StopIteration(msg) + if msg is None: + pass + else: + raise StopIteration(msg) def _start(self): """Set the state to active. diff --git a/haiopy/devices.py b/haiopy/devices.py index 8436add..02eb2d5 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -90,10 +90,11 @@ def sampling_rate(self): """The sampling rate of the audio device. """ return self._sampling_rate - + """ @sampling_rate.setter def sampling_rate(self, value): - self.check_settings(value, None, None) + self.check_settings(None, value, None, None) + """ @property def block_size(self): @@ -163,6 +164,10 @@ def stop(self): @abstractmethod def _stop_buffer(self): raise NotImplementedError() + + @abstractmethod + def _close_stream(self): + raise NotImplementedError() @abstractmethod def _reset_buffer(self): @@ -367,9 +372,25 @@ def block_size(self, value): self.output_buffer.block_size = value super(OutputAudioDevice, self.__class__).block_size.fset(self, value) + @property + def sampling_rate(self): + return self._sampling_rate + + @sampling_rate.setter + def sampling_rate(self, sampling_rate): + self.check_settings(sampling_rate=sampling_rate) + self._close_stream() + self._sampling_rate = sampling_rate + self.initialize() + def _stop_buffer(self): self._output_buffer._stop() + def _close_stream(self): + if self.stream is not None: + self.stream.close() + self._output_buffer._stop(msg=None) + def start(self): self.output_buffer._start() self.output_buffer._is_active.wait() diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index c90b012..9d5c558 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -12,7 +12,8 @@ def default_device_multiface_fireface(kind='both'): 'Multiface', 'Fireface', 'Scarlett 2i4', - 'MADIface'] + 'MADIface', + 'Focusrite USB ASIO'] for valid_device in valid_devices: for identifier, device in enumerate(device_list): @@ -34,7 +35,9 @@ def test_default_device_helper(): multiface = 'Multiface' in sd.query_devices(identifier)['name'] scarlett = 'Scarlett 2i4' in sd.query_devices(identifier)['name'] madiface = 'MADIface' in sd.query_devices(identifier)['name'] - assert fireface or multiface or scarlett or madiface + focusrite = 'Focusrite USB ASIO' in sd.query_devices(identifier)['name'] + + assert fireface or multiface or scarlett or madiface or focusrite if fireface: assert device['max_input_channels'] == 18 @@ -48,6 +51,10 @@ def test_default_device_helper(): assert device['max_input_channels'] == 196 assert device['max_output_channels'] == 198 + if focusrite: + assert device['max_input_channels'] == 2 + assert device['max_output_channels'] == 2 + # ----------------------------------------------------------------------------- # Output Device Tests # ----------------------------------------------------------------------------- @@ -57,7 +64,7 @@ def test_default_device_helper(): reason="CI does not have a soundcard") def test_check_output_settings(empty_buffer_stub): identifier, config = default_device_multiface_fireface() - channels = [3] + channels = [0] block_size = 512 buffer = empty_buffer_stub[0] @@ -81,6 +88,10 @@ def test_check_output_settings(empty_buffer_stub): with pytest.raises(sd.PortAudioError, match="Invalid"): out_device.check_settings(config['max_output_channels']+10) + # Close Output Stream for next Tests + with pytest.raises(StopIteration, match="iteration stopped"): + out_device.close() + @pytest.mark.skipif(os.environ.get('CI') == 'true', reason="CI does not have a soundcard") @@ -94,7 +105,7 @@ def test_sine_playback(sine_buffer_stub): out_device = devices.OutputAudioDevice( identifier=identifier, output_buffer=buffer, - channels=[0], + channels=[1], sampling_rate=sampling_rate) out_device.check_settings() @@ -102,3 +113,64 @@ 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 + + # Close Output Stream for next Tests + with pytest.raises(StopIteration, match="iteration stopped"): + out_device.close() + + +@pytest.mark.skipif(os.environ.get('CI') == 'true', + reason="CI does not have a soundcard") +def test_check_init(empty_buffer_stub, sine_buffer_stub): + buffer = sine_buffer_stub[0] + identifier, config = default_device_multiface_fireface() + + sampling_rate = config['default_samplerate'] + + out_device = devices.OutputAudioDevice( + identifier=identifier, + output_buffer=empty_buffer_stub[0], + channels=[1], + sampling_rate=sampling_rate) + out_device.check_settings() + assert out_device.output_buffer == empty_buffer_stub[0] + + out_device.output_buffer = buffer + assert out_device._output_buffer == buffer + assert out_device.output_buffer == buffer + + # set a buffer with non matching block size + buffer.block_size = 256 + with pytest.raises(ValueError, match='block size does not match'): + out_device.output_buffer = buffer + """ + # Das hier wenn channel setter implementiert ist + buffer.n_channels = 8 + with pytest.raises(ValueError, match='channel number does not match'): + out_device.output_buffer = buffer + """ + + # change the block size of the buffer and check if buffers block size is + # set accordingly + print(out_device.block_size) + new_block_size = 256 + out_device.block_size = new_block_size + assert out_device._block_size == new_block_size + assert out_device.output_buffer.block_size == new_block_size + print(out_device.block_size, out_device.output_buffer.block_size) + """ + # set and get sampling rate + + out_device.sampling_rate = 44100 # Different Sampling Rates invalid + assert out_device._sampling_rate == 44100 + + # test if setters are blocked when the stream is in use + out_device.start() + with pytest.raises(ValueError, match='currently in use'): + out_device.block_size = 512 + out_device.wait() + + # Close Output Stream for next Tests + with pytest.raises(StopIteration, match="iteration stopped"): + out_device.close() + """ \ No newline at end of file From ee3001f3340b3c0fcc5bfa1588fe31884b2c7204 Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Wed, 4 Oct 2023 09:47:54 +0200 Subject: [PATCH 20/57] Resolve merging mistake --- tests/test_devices_physical.py | 61 ---------------------------------- 1 file changed, 61 deletions(-) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index d6644d9..07a55c6 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -106,7 +106,6 @@ def test_sine_playback(sine_buffer_stub): identifier=identifier, output_buffer=buffer, channels=[1], - channels=[1], sampling_rate=sampling_rate) out_device.check_settings() @@ -135,66 +134,6 @@ def test_check_init(empty_buffer_stub, sine_buffer_stub): sampling_rate=sampling_rate) out_device.check_settings() assert out_device.output_buffer == empty_buffer_stub[0] - - out_device.output_buffer = buffer - assert out_device._output_buffer == buffer - assert out_device.output_buffer == buffer - - # set a buffer with non matching block size - buffer.block_size = 256 - with pytest.raises(ValueError, match='block size does not match'): - out_device.output_buffer = buffer - """ - # Das hier wenn channel setter implementiert ist - buffer.n_channels = 8 - with pytest.raises(ValueError, match='channel number does not match'): - out_device.output_buffer = buffer - """ - - # change the block size of the buffer and check if buffers block size is - # set accordingly - new_block_size = 256 - out_device.block_size = new_block_size - assert out_device._block_size == new_block_size - assert out_device.output_buffer.block_size == new_block_size - """ - # set and get sampling rate - - out_device.sampling_rate = 44100 # Different Sampling Rates invalid - assert out_device._sampling_rate == 44100 - - # test if setters are blocked when the stream is in use - out_device.start() - with pytest.raises(ValueError, match='currently in use'): - out_device.block_size = 512 - out_device.wait() - - # Close Output Stream for next Tests - with pytest.raises(StopIteration, match="iteration stopped"): - out_device.close() - """ - - # Close Output Stream for next Tests - with pytest.raises(StopIteration, match="iteration stopped"): - out_device.close() - - -@pytest.mark.skipif(os.environ.get('CI') == 'true', - reason="CI does not have a soundcard") -def test_check_init(empty_buffer_stub, sine_buffer_stub): - buffer = sine_buffer_stub[0] - identifier, config = default_device_multiface_fireface() - - sampling_rate = config['default_samplerate'] - - out_device = devices.OutputAudioDevice( - identifier=identifier, - output_buffer=empty_buffer_stub[0], - channels=[1], - sampling_rate=sampling_rate) - out_device.check_settings() - assert out_device.output_buffer == empty_buffer_stub[0] - out_device.output_buffer = buffer assert out_device._output_buffer == buffer assert out_device.output_buffer == buffer From deeff8bde6c7cbe815ee8c87e4c6edbbd4f418f7 Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Wed, 4 Oct 2023 12:28:04 +0200 Subject: [PATCH 21/57] sampling_rate + block_size setter + tests --- haiopy/devices.py | 19 ++++++++++++++----- tests/test_devices_physical.py | 17 +++++++++-------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 02eb2d5..674105c 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -101,10 +101,11 @@ def block_size(self): """The block size of the audio buffer. """ return self._block_size - + """ @block_size.setter def block_size(self, block_size): self._block_size = block_size + """ @property def dtype(self): @@ -164,7 +165,7 @@ def stop(self): @abstractmethod def _stop_buffer(self): raise NotImplementedError() - + @abstractmethod def _close_stream(self): raise NotImplementedError() @@ -365,12 +366,16 @@ def block_size(self): return self._block_size @block_size.setter - def block_size(self, value): + def block_size(self, block_size): if self.stream.active is True or self.output_buffer.is_active is True: raise ValueError( "The device is currently in use and needs to be closed first") - self.output_buffer.block_size = value - super(OutputAudioDevice, self.__class__).block_size.fset(self, value) + self._close_stream() + # self.output_buffer._set_block_size(block_size) + self._block_size = block_size + self.output_buffer.block_size = block_size + self.initialize() + # super(OutputAudioDevice, self.__class__).block_size.fset(self, value) @property def sampling_rate(self): @@ -378,9 +383,13 @@ def sampling_rate(self): @sampling_rate.setter def sampling_rate(self, sampling_rate): + if self.stream.active is True or self.output_buffer.is_active is True: + raise ValueError( + "The device is currently in use and needs to be closed first") self.check_settings(sampling_rate=sampling_rate) self._close_stream() self._sampling_rate = sampling_rate + self.output_buffer.sampling_rate = sampling_rate self.initialize() def _stop_buffer(self): diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index 07a55c6..ce55ef2 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -137,12 +137,11 @@ def test_check_init(empty_buffer_stub, sine_buffer_stub): out_device.output_buffer = buffer assert out_device._output_buffer == buffer assert out_device.output_buffer == buffer - + """ # set a buffer with non matching block size buffer.block_size = 256 with pytest.raises(ValueError, match='block size does not match'): out_device.output_buffer = buffer - """ # Das hier wenn channel setter implementiert ist buffer.n_channels = 8 with pytest.raises(ValueError, match='channel number does not match'): @@ -151,25 +150,27 @@ def test_check_init(empty_buffer_stub, sine_buffer_stub): # change the block size of the buffer and check if buffers block size is # set accordingly - print(out_device.block_size) + """ new_block_size = 256 out_device.block_size = new_block_size assert out_device._block_size == new_block_size assert out_device.output_buffer.block_size == new_block_size - print(out_device.block_size, out_device.output_buffer.block_size) """ # set and get sampling rate - - out_device.sampling_rate = 44100 # Different Sampling Rates invalid - assert out_device._sampling_rate == 44100 + new_sampling_rate = 88200 + out_device.sampling_rate = new_sampling_rate + assert out_device._sampling_rate == new_sampling_rate + assert out_device.sampling_rate == new_sampling_rate + assert out_device.output_buffer.sampling_rate == new_sampling_rate # test if setters are blocked when the stream is in use out_device.start() + """ with pytest.raises(ValueError, match='currently in use'): out_device.block_size = 512 + """ out_device.wait() # Close Output Stream for next Tests with pytest.raises(StopIteration, match="iteration stopped"): out_device.close() - """ \ No newline at end of file From 5e0bb1777a741c2b24e0b46e8f8fe7599bca48b8 Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Wed, 4 Oct 2023 12:29:50 +0200 Subject: [PATCH 22/57] update --- tests/test_devices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_devices.py b/tests/test_devices.py index d060beb..0334e50 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -138,4 +138,4 @@ def test_callback_errors(sine_buffer_stub): with pytest.raises(sd.CallbackAbort, match='Buffer underflow'): status = sd.CallbackFlags() status.output_underflow = True - out_device.output_callback(outdata, 512, None, status) + out_device.output_callback(outdata, 512, None, status) \ No newline at end of file From 8559b8a5e8defd3c4f5995d85f3982f3decda0ed Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Wed, 4 Oct 2023 13:12:35 +0200 Subject: [PATCH 23/57] identifier setter --- haiopy/devices.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/haiopy/devices.py b/haiopy/devices.py index 674105c..dc3d311 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -201,7 +201,9 @@ def __init__( dtype=dtype, extra_settings=extra_settings, samplerate=sampling_rate) + self._extra_settings = extra_settings + self._identifier = identifier super().__init__( identifier=identifier, sampling_rate=sampling_rate, @@ -361,6 +363,26 @@ def output_buffer(self, buffer): self._output_buffer = buffer + @property + def identifier(self): + return self._identifier + + @identifier.setter + def identifier(self, identifier): + if self.stream.active is True or self.output_buffer.is_active is True: + raise ValueError( + "The device is currently in use and needs to be closed first") + self._close_stream() + self._identifier = identifier + self._id = sd.query_devices(identifier)['name'] + max_channel = np.max(self._output_channels) + n_channels = len(self._output_channels) + self.check_settings(n_channels=np.max([n_channels, max_channel+1]), + sampling_rate=self._sampling_rate, + dtype=self._dtype, + extra_settings=self._extra_settings) + self.initialize() + @property def block_size(self): return self._block_size From 70e93565db0cd8afbc29a0f58b2e58e77473afdd Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Wed, 4 Oct 2023 13:46:16 +0200 Subject: [PATCH 24/57] Channel and dtype setter --- haiopy/devices.py | 48 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index dc3d311..258172e 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -41,7 +41,7 @@ def block_size(self): return self._block_size def dtype(self): - return self.dtype + return self._dtype class AudioDevice(_Device): @@ -331,7 +331,7 @@ def initialize(self): self.block_size, self.id, self._n_channels_stream, - self.dtype, + self._dtype, callback=self.output_callback, finished_callback=self._finished_callback) self._stream = ostream @@ -373,14 +373,16 @@ def identifier(self, identifier): raise ValueError( "The device is currently in use and needs to be closed first") self._close_stream() - self._identifier = identifier - self._id = sd.query_devices(identifier)['name'] max_channel = np.max(self._output_channels) n_channels = len(self._output_channels) - self.check_settings(n_channels=np.max([n_channels, max_channel+1]), - sampling_rate=self._sampling_rate, - dtype=self._dtype, - extra_settings=self._extra_settings) + sd.check_output_settings( + device=sd.query_devices(identifier)['name'], + channels=np.max([n_channels, max_channel+1]), + dtype=self._dtype, + extra_settings=self._extra_settings, + samplerate=self._sampling_rate) + self._identifier = identifier + self._id = sd.query_devices(identifier)['name'] self.initialize() @property @@ -414,6 +416,36 @@ def sampling_rate(self, sampling_rate): self.output_buffer.sampling_rate = sampling_rate self.initialize() + @property + def channels(self): + return self._output_channels + + @channels.setter + def channels(self, channels): + if self.stream.active is True or self.output_buffer.is_active is True: + raise ValueError( + "The device is currently in use and needs to be closed first") + self._close_stream() + max_channel = np.max(channels) + n_channels = len(channels) + self.check_settings(n_channels=np.max([n_channels, max_channel+1])) + self._output_channels = channels + self.initialize() + + @property + def dtype(self): + return self._dtype + + @dtype.setter + def dtype(self, dtype): + if self.stream.active is True or self.output_buffer.is_active is True: + raise ValueError( + "The device is currently in use and needs to be closed first") + self._close_stream() + self.check_settings(dtype=dtype) + self._dtype = dtype + self.initialize() + def _stop_buffer(self): self._output_buffer._stop() From 0b90997f0eacf0670ecf8d5c1cdefb5ad0d679b7 Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Mon, 9 Oct 2023 15:23:21 +0200 Subject: [PATCH 25/57] initialize _stream_block_out --- haiopy/devices.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 258172e..1f6a4a6 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -326,6 +326,12 @@ def output_callback(self, outdata, frames, time, status) -> None: def initialize(self): """Initialize the playback stream for a given number of channels.""" + # 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) + ostream = sd.OutputStream( self.sampling_rate, self.block_size, @@ -335,11 +341,6 @@ 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() @@ -381,7 +382,7 @@ def identifier(self, identifier): dtype=self._dtype, extra_settings=self._extra_settings, samplerate=self._sampling_rate) - self._identifier = identifier + # self._identifier = identifier self._id = sd.query_devices(identifier)['name'] self.initialize() From 40676054cd19e21622ff023c9297c46ca6227167 Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Mon, 9 Oct 2023 19:43:14 +0200 Subject: [PATCH 26/57] Test Setter --- haiopy/buffers.py | 21 ++++++++----- tests/test_devices_physical.py | 56 ++++++++++++++++++---------------- 2 files changed, 43 insertions(+), 34 deletions(-) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index 8b150b0..c45ba2a 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -155,6 +155,7 @@ def __init__(self, block_size, signal) -> None: raise ValueError("signal must be a pyfar.Signal object.") if signal.time.ndim > 2: raise ValueError("Only one-dimensional arrays are allowed") + self._n_samples = signal.n_samples self._data = self._pad_data(signal) self._update_data() self._index = 0 @@ -172,9 +173,10 @@ def _pad_data(self, data): 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) + # n_samples = data.n_samples + if np.mod(self._n_samples, self._block_size) > 0: + pad_samples = self.block_size - np.mod(self._n_samples, + self.block_size) return pf.dsp.pad_zeros(data, pad_samples, mode='after') else: return data @@ -192,11 +194,10 @@ def sampling_rate(self): @sampling_rate.setter def sampling_rate(self, sampling_rate): """Set new sampling_rate and resample the input Signal""" - self.check_if_active() - self._data = pf.dsp.resample(self._data, sampling_rate) + self.data = pf.dsp.resample(self._data[:self._n_samples], + sampling_rate) warnings.warn("Resampling the input Signal to sampling_rate=" f"{sampling_rate} might generate artifacts.") - self._update_data() @property def n_blocks(self): @@ -218,12 +219,13 @@ def data(self): def data(self, data): """Set the underlying signal if the buffer is not active.""" self.check_if_active() + self._n_samples = data.n_samples self._data = self._pad_data(data) self._update_data() def _set_block_size(self, block_size): super()._set_block_size(block_size) - self._update_data() + self.data = self._data[:self._n_samples] def _update_data(self): """Update the data block strided of the underlying data. @@ -247,6 +249,11 @@ def next(self): return self._strided_data[..., current, :] self._stop("The buffer is empty.") + def reset_index(self): + self._is_active.clear() + self._is_finished.set() + self._index = 0 + def _reset(self): self._index = 0 super()._reset() diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index ce55ef2..14a15a0 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -68,13 +68,17 @@ def test_check_output_settings(empty_buffer_stub): block_size = 512 buffer = empty_buffer_stub[0] + sampling_rate = config['default_samplerate'] - out_device = devices.OutputAudioDevice( - identifier, 44100, block_size, channels=channels, dtype='float32', - output_buffer=buffer) + out_device = devices.OutputAudioDevice(identifier=identifier, + sampling_rate=sampling_rate, + block_size=block_size, + channels=channels, + dtype='float32', + output_buffer=buffer) # Check sampling rate - out_device.check_settings(sampling_rate=config['default_samplerate']) + out_device.check_settings(sampling_rate=sampling_rate) with pytest.raises(sd.PortAudioError, match="Invalid"): out_device.check_settings(sampling_rate=10) @@ -137,38 +141,36 @@ def test_check_init(empty_buffer_stub, sine_buffer_stub): out_device.output_buffer = buffer assert out_device._output_buffer == buffer assert out_device.output_buffer == buffer - """ - # set a buffer with non matching block size - buffer.block_size = 256 - with pytest.raises(ValueError, match='block size does not match'): - out_device.output_buffer = buffer - # Das hier wenn channel setter implementiert ist - buffer.n_channels = 8 - with pytest.raises(ValueError, match='channel number does not match'): - out_device.output_buffer = buffer - """ - - # change the block size of the buffer and check if buffers block size is - # set accordingly - """ - new_block_size = 256 + # test playback with new buffer + out_device.start() + out_device.wait() + + # set new block_size + new_block_size = 1024 out_device.block_size = new_block_size assert out_device._block_size == new_block_size assert out_device.output_buffer.block_size == new_block_size - """ - # set and get sampling rate - new_sampling_rate = 88200 + # test playback with new block_size + out_device.start() + out_device.wait() + + # set new sampling_rate + new_sampling_rate = 88200 if sampling_rate == 44100 else 44100 out_device.sampling_rate = new_sampling_rate assert out_device._sampling_rate == new_sampling_rate assert out_device.sampling_rate == new_sampling_rate assert out_device.output_buffer.sampling_rate == new_sampling_rate + # test playback with new sampling_rate + out_device.start() + out_device.wait() - # test if setters are blocked when the stream is in use + # set new channels + new_channels = [0, 1] + out_device.channels = new_channels + out_device._output_buffer.reset_index() + assert out_device.channels == new_channels + # test playback with new channels out_device.start() - """ - with pytest.raises(ValueError, match='currently in use'): - out_device.block_size = 512 - """ out_device.wait() # Close Output Stream for next Tests From 32c15b40f07641803d26f76805ef01eab0a78751 Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Tue, 10 Oct 2023 11:26:48 +0200 Subject: [PATCH 27/57] Revice sample_rate & block_size setter --- haiopy/buffers.py | 8 +++++--- tests/test_devices.py | 2 +- tests/test_devices_physical.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index c45ba2a..e28a9af 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -194,8 +194,9 @@ def sampling_rate(self): @sampling_rate.setter def sampling_rate(self, sampling_rate): """Set new sampling_rate and resample the input Signal""" - self.data = pf.dsp.resample(self._data[:self._n_samples], - sampling_rate) + signal = pf.Signal(self._data.time[..., :self._n_samples], + self.sampling_rate) + self.data = pf.dsp.resample(signal, sampling_rate) warnings.warn("Resampling the input Signal to sampling_rate=" f"{sampling_rate} might generate artifacts.") @@ -225,7 +226,8 @@ def data(self, data): def _set_block_size(self, block_size): super()._set_block_size(block_size) - self.data = self._data[:self._n_samples] + self._data.time = self._data.time[..., :self._n_samples] + self.data = self._data def _update_data(self): """Update the data block strided of the underlying data. diff --git a/tests/test_devices.py b/tests/test_devices.py index 0334e50..d060beb 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -138,4 +138,4 @@ def test_callback_errors(sine_buffer_stub): with pytest.raises(sd.CallbackAbort, match='Buffer underflow'): status = sd.CallbackFlags() status.output_underflow = True - out_device.output_callback(outdata, 512, None, status) \ No newline at end of file + out_device.output_callback(outdata, 512, None, status) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index 14a15a0..c0079d9 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -70,7 +70,7 @@ def test_check_output_settings(empty_buffer_stub): buffer = empty_buffer_stub[0] sampling_rate = config['default_samplerate'] - out_device = devices.OutputAudioDevice(identifier=identifier, + out_device = devices.OutputAudioDevice(identifier=identifier, sampling_rate=sampling_rate, block_size=block_size, channels=channels, From 365b0c65991de1a07db0146af143ebf0013b0fe3 Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Tue, 10 Oct 2023 11:53:56 +0200 Subject: [PATCH 28/57] revice sampling_rate setter --- haiopy/buffers.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/haiopy/buffers.py b/haiopy/buffers.py index e28a9af..81a1e3d 100644 --- a/haiopy/buffers.py +++ b/haiopy/buffers.py @@ -194,8 +194,13 @@ def sampling_rate(self): @sampling_rate.setter def sampling_rate(self, sampling_rate): """Set new sampling_rate and resample the input Signal""" - signal = pf.Signal(self._data.time[..., :self._n_samples], - self.sampling_rate) + signal = pf.Signal(data=self._data.time[..., :self._n_samples], + sampling_rate=self.sampling_rate, + n_samples=self._n_samples, + domain=self._data.domain, + fft_norm=self._data._fft_norm, + comment=self._data.comment + ) self.data = pf.dsp.resample(signal, sampling_rate) warnings.warn("Resampling the input Signal to sampling_rate=" f"{sampling_rate} might generate artifacts.") From 1803598244d40bd6f4ffbeb768154aceaf5f935f Mon Sep 17 00:00:00 2001 From: twennemann <84079382+twennemann@users.noreply.github.com> Date: Tue, 10 Oct 2023 14:59:42 +0200 Subject: [PATCH 29/57] documentation --- haiopy/devices.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 1f6a4a6..2714312 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -352,6 +352,7 @@ def output_buffer(self): @output_buffer.setter def output_buffer(self, buffer): + """Sets the output buffer""" if buffer.block_size != self.block_size: raise ValueError( "The buffer's block size does not match. ", @@ -382,7 +383,6 @@ def identifier(self, identifier): dtype=self._dtype, extra_settings=self._extra_settings, samplerate=self._sampling_rate) - # self._identifier = identifier self._id = sd.query_devices(identifier)['name'] self.initialize() @@ -392,15 +392,16 @@ def block_size(self): @block_size.setter def block_size(self, block_size): + """Sets the blocksize of the OutputDevice and the output buffer. + Therefore, the current stream is closed and a new stream with setted + blocksize and output buffer is initialized.""" if self.stream.active is True or self.output_buffer.is_active is True: raise ValueError( "The device is currently in use and needs to be closed first") self._close_stream() - # self.output_buffer._set_block_size(block_size) self._block_size = block_size self.output_buffer.block_size = block_size self.initialize() - # super(OutputAudioDevice, self.__class__).block_size.fset(self, value) @property def sampling_rate(self): @@ -408,6 +409,9 @@ def sampling_rate(self): @sampling_rate.setter def sampling_rate(self, sampling_rate): + """Sets the sampling rate of the OutputDevice and the output buffer. + Therefore, the current stream is closed and a new stream with setted + samplingrate and output buffer is initialized.""" if self.stream.active is True or self.output_buffer.is_active is True: raise ValueError( "The device is currently in use and needs to be closed first") @@ -423,6 +427,9 @@ def channels(self): @channels.setter def channels(self, channels): + """Sets the channels of the Output device. Therefore, the current + stream is closed and a new stream with setted channels is + initialized.""" if self.stream.active is True or self.output_buffer.is_active is True: raise ValueError( "The device is currently in use and needs to be closed first") @@ -439,6 +446,8 @@ def dtype(self): @dtype.setter def dtype(self, dtype): + """Sets the dtype of the output buffer. Therefore, the current stream + is closed and a new stream with setted dtype is initialized.""" if self.stream.active is True or self.output_buffer.is_active is True: raise ValueError( "The device is currently in use and needs to be closed first") From 069ca793b5032cf8da500607815ed22604587074 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 3 Apr 2025 22:29:35 +0200 Subject: [PATCH 30/57] add asio support for testing physical devices on windows --- tests/test_devices_physical.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index c0079d9..8040f4f 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -1,3 +1,6 @@ +# activate ASIO support +import os +os.environ["SD_ENABLE_ASIO"] = "1" from haiopy import devices import sounddevice as sd import pytest From 1cdb8a612f405af85cc187616e30c45bb00a78a9 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 3 Apr 2025 22:30:08 +0200 Subject: [PATCH 31/57] add ASIO devices to list of test devices --- tests/test_devices_physical.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index 8040f4f..1391ef7 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -16,7 +16,9 @@ def default_device_multiface_fireface(kind='both'): 'Fireface', 'Scarlett 2i4', 'MADIface', - 'Focusrite USB ASIO'] + 'Focusrite USB ASIO', + 'Steinberg USB ASIO', + ] for valid_device in valid_devices: for identifier, device in enumerate(device_list): @@ -34,13 +36,23 @@ def default_device_multiface_fireface(kind='both'): reason="CI does not have a soundcard") def test_default_device_helper(): identifier, device = default_device_multiface_fireface() + device_names = [ + 'Fireface', + 'Multiface', + 'Scarlett 2i4', + 'MADIface', + 'Focusrite USB ASIO', + 'Steinberg USB ASIO', + ] + assert any( + name in sd.query_devices(identifier)['name'] for name in device_names) + fireface = 'Fireface' in sd.query_devices(identifier)['name'] multiface = 'Multiface' in sd.query_devices(identifier)['name'] scarlett = 'Scarlett 2i4' in sd.query_devices(identifier)['name'] madiface = 'MADIface' in sd.query_devices(identifier)['name'] focusrite = 'Focusrite USB ASIO' in sd.query_devices(identifier)['name'] - - assert fireface or multiface or scarlett or madiface or focusrite + steinberg = 'Steinberg USB ASIO' in sd.query_devices(identifier)['name'] if fireface: assert device['max_input_channels'] == 18 @@ -58,6 +70,10 @@ def test_default_device_helper(): assert device['max_input_channels'] == 2 assert device['max_output_channels'] == 2 + if steinberg: + assert device['max_input_channels'] == 6 + assert device['max_output_channels'] == 6 + # ----------------------------------------------------------------------------- # Output Device Tests # ----------------------------------------------------------------------------- From b6b91ce45f6a0185e492d88e8520ca2d8e8e8447 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 3 Apr 2025 22:43:07 +0200 Subject: [PATCH 32/57] move sr, blocksize and dtype properties into abstract base class --- haiopy/devices.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 2714312..324f8e4 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -3,7 +3,7 @@ import numpy as np import sys import sounddevice as sd -from abc import abstractmethod +from abc import abstractmethod, ABCMeta from haiopy.buffers import SignalBuffer import pyfar as pf @@ -13,7 +13,7 @@ def list_devices(): pass -class _Device(object): +class _Device(metaclass=ABCMeta): def __init__( self, name, @@ -31,18 +31,38 @@ def name(self): return self._name @property - def id(self): - return self._id - def sampling_rate(self): + """Sampling rate of the device.""" return self._sampling_rate + @sampling_rate.setter + @abstractmethod + def sampling_rate(self, sampling_rate): + """Set the sampling rate of the device.""" + raise NotImplementedError('Needs to be implemented in child class.') + + @property def block_size(self): + """Block size used by the device.""" return self._block_size + @block_size.setter + @abstractmethod + def block_size(self, block_size): + """Set the block size of the device.""" + raise NotImplementedError('Needs to be implemented in child class.') + + @property def dtype(self): + """Data type of the devices audio buffer.""" return self._dtype + @dtype.setter + @abstractmethod + def dtype(self, dtype): + """Set the data type of the device.""" + raise NotImplementedError('Needs to be implemented in child class.') + class AudioDevice(_Device): def __init__( From 0754b3e32d117806c2fe712775a01abe10d92124 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 3 Apr 2025 22:45:42 +0200 Subject: [PATCH 33/57] add private checker function if the stream is active --- haiopy/devices.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/haiopy/devices.py b/haiopy/devices.py index 324f8e4..a1eeb9b 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -138,6 +138,9 @@ def stream(self): """ return self._stream + def _stream_active(self): + return self.stream.active if self.stream is not None else False + def finished_callback(self) -> None: """Custom callback after a audio stream has finished.""" print("I'm finished.") From 3f5a3cab180f448c7be8d08127dea772415a2361 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 3 Apr 2025 22:46:28 +0200 Subject: [PATCH 34/57] minor cleanup --- haiopy/devices.py | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index a1eeb9b..3da2b0e 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -82,7 +82,6 @@ def __init__( dtype=dtype ) self._id = identifier - # self._extra_settings = extra_settings self._callback = None self._stream = None @@ -106,33 +105,6 @@ def name(self): return self._name @property - def sampling_rate(self): - """The sampling rate of the audio device. - """ - return self._sampling_rate - """ - @sampling_rate.setter - def sampling_rate(self, value): - self.check_settings(None, value, None, None) - """ - - @property - def block_size(self): - """The block size of the audio buffer. - """ - return self._block_size - """ - @block_size.setter - def block_size(self, block_size): - self._block_size = block_size - """ - - @property - def dtype(self): - return self._dtype - - @property - @abstractmethod def stream(self): """The sounddevice audio stream. """ @@ -193,10 +165,6 @@ def _stop_buffer(self): def _close_stream(self): raise NotImplementedError() - @abstractmethod - def _reset_buffer(self): - raise NotImplementedError() - class OutputAudioDevice(AudioDevice): From 18235ccd3271141a8a256d754c54b8143f253c7e Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 10 Apr 2025 18:11:22 +0200 Subject: [PATCH 35/57] Implement class containing input and output channel mappings --- haiopy/devices.py | 178 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/haiopy/devices.py b/haiopy/devices.py index 3da2b0e..9017f56 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -4,6 +4,7 @@ import sys import sounddevice as sd from abc import abstractmethod, ABCMeta +import platform from haiopy.buffers import SignalBuffer import pyfar as pf @@ -166,6 +167,183 @@ def _close_stream(self): raise NotImplementedError() +class ChannelMapping(metaclass=ABCMeta): + """Class to handle the channel mapping of the device. + + Parameters + ---------- + channels : list + The channels to be used by the device. + """ + _valid_apis_windows = [ + 'asio', + 'windows directsound', 'directsound', + 'windows wdm-ks', 'wdm', + 'windows wasapi', 'wsapi'] + _valid_apis_linux = [ + 'alsa', + 'oss', + 'pulse', + 'jack'] + _valid_apis_darwin = [ + 'coreaudio'] + + _valid_apis = { + 'Windows': _valid_apis_windows, + 'Linux': _valid_apis_linux, + 'Darwin': _valid_apis_darwin + } + + _default_apis = { + 'Windows': 'asio', + 'Linux': 'alsa', + 'Darwin': 'coreaudio' + } + + def __init__(self, channels, n_channels_device, api): + + if api.lower() not in self._valid_apis[platform.system()]: + raise ValueError( + f"Invalid driver {api}. For your platform supported drivers" + f" are: f{self._valid_apis[platform.system()]}") + + self._api = api.lower() + self._n_channels_device = n_channels_device + self.channels = channels + + @property + def channels(self): + """The channels to be used by the device.""" + return self._channels + + @channels.setter + @abstractmethod + def channels(self, channels): + """Set the channels to be used by the device.""" + raise NotImplementedError() + + @property + def n_channels_used(self): + return len(self._channels) + + @property + def n_channels_device(self): + """The number of channels supported by the device.""" + return self._n_channels_device + + @n_channels_device.setter + def n_channels_device(self, n_channels_device): + """Set the number of channels supported by the device.""" + self._n_channels_device = n_channels_device + + @property + def n_channels_mapping(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. In case of + using only the first channel, portaudio plays back a mono signal, + which will be broadcast 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. + """ + if self.extra_settings is not None: + return self.n_channels_used + else: + return np.max((2, np.max(self._channels) + 1)) + + @property + def extra_settings(self): + """The extra settings for the device.""" + return self._extra_settings + + def __call__( + self, + data_buffer: np.ndarray[float]) -> np.ndarray[float]: + + if self._api in ['asio', 'coreaudio']: + # ASIO and CoreAudio handle the routing + return np.atleast_2d(data_buffer).T + + # Write a block to an array with all required output channels + # including zeros for unused channels. Required if the routing + # is not handled by ASIO or CoreAudio. Sounddevice alone does + # not support routing matrices + data = data_buffer + block_size = data_buffer.shape[-1] + + size_matches = block_size == self.n_channels_mapping + + if not self._stream_block_out or not size_matches: + self._stream_block_out = np.zeros( + (self.n_channels_mapping, block_size), + dtype=data.dtype) + self._stream_block_out[self.channels] = data + + return self._stream_block_out.T + + +class InputChannelMapping(ChannelMapping): + + def __init__(self, channels, n_channels_device, api): + super().__init__(channels, n_channels_device, api) + # self.channels = channels + + @ChannelMapping.channels.setter + def channels(self, channels): + """Set the channels to be used by the device.""" + if np.any(np.asarray(channels) > self.n_channels_device): + raise ValueError( + f"Invalid channels {channels}. The device only supports " + f"{self.n_channels_device} channels.") + + if 'asio' in self._api: + extra_settings = sd.AsioSettings( + channel_selectors=channels) + + elif 'coreaudio' in self._api: + extra_settings = sd.CoreAudioSettings( + channel_map=channels) + else: + extra_settings = None + self._stream_block_out = None + + self._channels = channels + self._extra_settings = extra_settings + + +class OutputChannelMapping(ChannelMapping): + + def __init__(self, channels, n_channels_device, api): + super().__init__(channels, n_channels_device, api) + + @ChannelMapping.channels.setter + def channels(self, channels): + """Set the channels to be used by the device.""" + if np.any(np.asarray(channels) > self.n_channels_device): + raise ValueError( + f"Invalid channels {channels}. The device only supports " + f"{self.n_channels_device} channels.") + + if 'asio' in self._api: + extra_settings = sd.AsioSettings( + channel_selectors=channels) + + elif 'coreaudio' in self._api: + channel_map = np.ones( + self.n_channels_device, dtype=int) * -1 + channel_map[channels] = channels + + extra_settings = sd.CoreAudioSettings( + channel_map=channel_map) + else: + extra_settings = None + self._stream_block_out = None + + self._channels = channels + self._extra_settings = extra_settings + + class OutputAudioDevice(AudioDevice): def __init__( From 147d68bee751e02113b908deb0ae9e6ca7e590d9 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Thu, 10 Apr 2025 18:54:40 +0200 Subject: [PATCH 36/57] init tests for mappings --- tests/test_mappings.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/test_mappings.py diff --git a/tests/test_mappings.py b/tests/test_mappings.py new file mode 100644 index 0000000..dc5a4ef --- /dev/null +++ b/tests/test_mappings.py @@ -0,0 +1,39 @@ +from unittest.mock import patch +import pytest + +from haiopy.devices import ( + OutputChannelMapping, InputChannelMapping) + + +@pytest.mark.parametrize( + 'valid_apis', [ + 'windows wasapi', + 'windows directsound', + 'asio', + 'windows wdm-ks']) +@patch('platform.system', new=lambda: 'Windows') +@patch('sounddevice.AsioSettings', new=lambda channel_selectors: None) +def test_init_checks_windows(valid_apis): + OutputChannelMapping([0], 1, valid_apis) + InputChannelMapping([0], 1, valid_apis) + + +@pytest.mark.parametrize( + 'valid_apis', [ + 'alsa', + 'oss', + 'pulse', + 'jack']) +@patch('platform.system', new=lambda: 'Linux') +def test_init_checks_linux(valid_apis): + OutputChannelMapping([0], 1, valid_apis) + InputChannelMapping([0], 1, valid_apis) + + +@pytest.mark.parametrize( + 'valid_apis', ['coreaudio']) +@patch('platform.system', new=lambda: 'Darwin') +@patch('sounddevice.CoreAudioSettings', new=lambda channel_map: None) +def test_init_checks_macos(valid_apis): + OutputChannelMapping([0], 1, valid_apis) + InputChannelMapping([0], 1, valid_apis) From db52305c3e87cbc677651f111867ab6189f37586 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 11 Apr 2025 11:00:08 +0200 Subject: [PATCH 37/57] make ChannelMapping private --- haiopy/devices.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 9017f56..9021af9 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -167,7 +167,7 @@ def _close_stream(self): raise NotImplementedError() -class ChannelMapping(metaclass=ABCMeta): +class _ChannelMapping(metaclass=ABCMeta): """Class to handle the channel mapping of the device. Parameters @@ -283,7 +283,7 @@ def __call__( return self._stream_block_out.T -class InputChannelMapping(ChannelMapping): +class InputChannelMapping(_ChannelMapping): def __init__(self, channels, n_channels_device, api): super().__init__(channels, n_channels_device, api) @@ -312,7 +312,7 @@ def channels(self, channels): self._extra_settings = extra_settings -class OutputChannelMapping(ChannelMapping): +class OutputChannelMapping(_ChannelMapping): def __init__(self, channels, n_channels_device, api): super().__init__(channels, n_channels_device, api) From 17eb186b55c04bd985f6fb4e5ca6835b5cc16082 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 11 Apr 2025 11:01:01 +0200 Subject: [PATCH 38/57] cleanup and type hinting for ChannelMapping --- haiopy/devices.py | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 9021af9..3d1696a 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -200,7 +200,11 @@ class _ChannelMapping(metaclass=ABCMeta): 'Darwin': 'coreaudio' } - def __init__(self, channels, n_channels_device, api): + def __init__( + self, + channels: list[int], + n_channels_device: int, + api: str): if api.lower() not in self._valid_apis[platform.system()]: raise ValueError( @@ -212,32 +216,33 @@ def __init__(self, channels, n_channels_device, api): self.channels = channels @property - def channels(self): + def channels(self) -> list[int]: """The channels to be used by the device.""" return self._channels @channels.setter @abstractmethod - def channels(self, channels): + def channels(self, channels: list[int]): """Set the channels to be used by the device.""" raise NotImplementedError() @property - def n_channels_used(self): + def n_channels_used(self) -> int: + """The number of channels containing data.""" return len(self._channels) @property - def n_channels_device(self): + def n_channels_device(self) -> int: """The number of channels supported by the device.""" return self._n_channels_device @n_channels_device.setter - def n_channels_device(self, n_channels_device): + def n_channels_device(self, n_channels_device: int): """Set the number of channels supported by the device.""" self._n_channels_device = n_channels_device @property - def n_channels_mapping(self): + def n_channels_mapping(self) -> int: """The number of output channels required for the stream. This includes a number of unused pre-pended channels which need to be @@ -253,7 +258,7 @@ def n_channels_mapping(self): return np.max((2, np.max(self._channels) + 1)) @property - def extra_settings(self): + def extra_settings(self) -> sd.AsioSettings | sd.CoreAudioSettings | None: """The extra settings for the device.""" return self._extra_settings @@ -285,12 +290,15 @@ def __call__( class InputChannelMapping(_ChannelMapping): - def __init__(self, channels, n_channels_device, api): + def __init__( + self, + channels: list[int], + n_channels_device: int, + api: str): super().__init__(channels, n_channels_device, api) - # self.channels = channels - @ChannelMapping.channels.setter - def channels(self, channels): + @_ChannelMapping.channels.setter + def channels(self, channels: list[int]): """Set the channels to be used by the device.""" if np.any(np.asarray(channels) > self.n_channels_device): raise ValueError( @@ -314,11 +322,15 @@ def channels(self, channels): class OutputChannelMapping(_ChannelMapping): - def __init__(self, channels, n_channels_device, api): + def __init__( + self, + channels: list[int], + n_channels_device: int, + api: str): super().__init__(channels, n_channels_device, api) - @ChannelMapping.channels.setter - def channels(self, channels): + @_ChannelMapping.channels.setter + def channels(self, channels: list[int]): """Set the channels to be used by the device.""" if np.any(np.asarray(channels) > self.n_channels_device): raise ValueError( From bec6a0adac199d2df9bcee32c3d804f2b700ea1e Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 11 Apr 2025 12:04:24 +0200 Subject: [PATCH 39/57] add class docstrings --- haiopy/devices.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/haiopy/devices.py b/haiopy/devices.py index 3d1696a..ecb59b8 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -289,7 +289,27 @@ def __call__( class InputChannelMapping(_ChannelMapping): + """Class to handle the input channel mapping of an audio device. + + Examples + -------- + + Create a mapping for a device with 8 channels and use only the second + channel. The input data is a 2D array with shape (1, 512). + After calling the mapping, the output data is a 2D array with shape + (512, 2), where the first channel is filled with zeros and the second + channel is filled with the input data. + + >>> import numpy as np + >>> from haiopy.devices import InputChannelMapping + >>> input_data = np.random.randn((1, 512), dtype='float32') + >>> device = InputChannelMapping( + ... channels=[2], + ... n_channels_device=8, + ... api='wasapi') + >>> device(input_data) + """ def __init__( self, channels: list[int], @@ -321,6 +341,27 @@ def channels(self, channels: list[int]): class OutputChannelMapping(_ChannelMapping): + """Class to handle the output channel mapping of an audio device. + + Examples + -------- + + Create a mapping for a device with 8 channels and use only the second + channel. The input data is a 2D array with shape (1, 512). + After calling the mapping, the output data is a 2D array with shape + (512, 2), where the first channel is filled with zeros and the second + channel is filled with the input data. + + >>> import numpy as np + >>> from haiopy.devices import InputChannelMapping + >>> input_data = np.random.randn((1, 512), dtype='float32') + >>> device = InputChannelMapping( + ... channels=[2], + ... n_channels_device=8, + ... api='wasapi') + >>> device(input_data) + + """ def __init__( self, From 6d3ad5b4ca0459d8f373b0d1b8c2cbeaa46cc9c1 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 11 Apr 2025 16:10:24 +0200 Subject: [PATCH 40/57] update docstrings --- haiopy/devices.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index ecb59b8..1e28eb6 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -259,12 +259,28 @@ def n_channels_mapping(self) -> int: @property def extra_settings(self) -> sd.AsioSettings | sd.CoreAudioSettings | None: - """The extra settings for the device.""" + """The sounddevice extra settings for the device. + + These are specific to python-sounddevice and are used when opening the + portaudio stream. + """ return self._extra_settings def __call__( self, data_buffer: np.ndarray[float]) -> np.ndarray[float]: + """Apply the mapping defined by the object to a data buffer. + + Parameters + ---------- + data_buffer : np.ndarray[float] + The input data buffer with shape (n_channels, block_size). + + Returns + ------- + np.ndarray[float] + The output data buffer with shape (n_channels_mapping, block_size). + """ if self._api in ['asio', 'coreaudio']: # ASIO and CoreAudio handle the routing From 44fbcbf7adbc42705b43c6855545303785b9281d Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 11 Apr 2025 16:41:57 +0200 Subject: [PATCH 41/57] adhere to ruff rules in ChannelMapping classes --- haiopy/devices.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 1e28eb6..18080da 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -175,6 +175,7 @@ class _ChannelMapping(metaclass=ABCMeta): channels : list The channels to be used by the device. """ + _valid_apis_windows = [ 'asio', 'windows directsound', 'directsound', @@ -191,13 +192,13 @@ class _ChannelMapping(metaclass=ABCMeta): _valid_apis = { 'Windows': _valid_apis_windows, 'Linux': _valid_apis_linux, - 'Darwin': _valid_apis_darwin + 'Darwin': _valid_apis_darwin, } _default_apis = { 'Windows': 'asio', 'Linux': 'alsa', - 'Darwin': 'coreaudio' + 'Darwin': 'coreaudio', } def __init__( @@ -309,7 +310,6 @@ class InputChannelMapping(_ChannelMapping): Examples -------- - Create a mapping for a device with 8 channels and use only the second channel. The input data is a 2D array with shape (1, 512). After calling the mapping, the output data is a 2D array with shape @@ -326,6 +326,7 @@ class InputChannelMapping(_ChannelMapping): >>> device(input_data) """ + def __init__( self, channels: list[int], @@ -361,7 +362,6 @@ class OutputChannelMapping(_ChannelMapping): Examples -------- - Create a mapping for a device with 8 channels and use only the second channel. The input data is a 2D array with shape (1, 512). After calling the mapping, the output data is a 2D array with shape From eacadb7f3ef764c65a30cf098d94282c9cb2241d Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 11:12:23 +0200 Subject: [PATCH 42/57] update imports --- haiopy/__init__.py | 11 +++++++++++ haiopy/devices.py | 5 ++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/haiopy/__init__.py b/haiopy/__init__.py index 4688356..714e221 100644 --- a/haiopy/__init__.py +++ b/haiopy/__init__.py @@ -5,3 +5,14 @@ __author__ = """The pyfar developers""" __email__ = 'info@pyfar.org' __version__ = '0.1.0' + + +from . import ( + buffers, + devices, +) + +__all__ = [ + 'buffers', + 'devices', +] diff --git a/haiopy/devices.py b/haiopy/devices.py index 18080da..687858d 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -1,4 +1,3 @@ - from multiprocessing import Event import numpy as np import sys @@ -6,8 +5,8 @@ from abc import abstractmethod, ABCMeta import platform -from haiopy.buffers import SignalBuffer -import pyfar as pf +from haiopy.buffers import EmptyBuffer +from haiopy.buffers import _Buffer def list_devices(): From 9d3e552cc08b8c375a8cd5a7b3523275acf54565 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 11:16:28 +0200 Subject: [PATCH 43/57] update AudioDevice ABC --- haiopy/devices.py | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 687858d..afadad0 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -65,6 +65,9 @@ def dtype(self, dtype): class AudioDevice(_Device): + """Abstract class implementing audio devices based on python-sounddevice. + """ + def __init__( self, identifier=0, @@ -79,9 +82,9 @@ def __init__( name=sd.query_devices(identifier)['name'], sampling_rate=sampling_rate, block_size=block_size, - dtype=dtype + dtype=dtype, ) - self._id = identifier + self._identifier = identifier self._callback = None self._stream = None @@ -91,17 +94,19 @@ def __init__( self._stream_finished = Event() @property - def id(self): - return self._id + def identifier(self): + """The identifier of the device.""" + return self._identifier @abstractmethod - def check_settings(**kwargs): + def check_settings(): + """Check if settings are compatible with the physical device. + """ raise NotImplementedError('Needs to be implemented in child class.') @property def name(self): - """The name of the device - """ + """The name of the device.""" return self._name @property @@ -111,20 +116,26 @@ def stream(self): return self._stream def _stream_active(self): + """Check if the stream is active.""" return self.stream.active if self.stream is not None else False def finished_callback(self) -> None: - """Custom callback after a audio stream has finished.""" - print("I'm finished.") + """Custom callback after a audio stream has finished. + Can be overwritten by users. + """ + pass def _finished_callback(self) -> None: - """Private portaudio callback after a audio stream has finished.""" + """Private portaudio callback after a audio stream has finished. + + Ensures that the buffer is stopped. + """ self._stream_finished.set() self.finished_callback() self.stream.stop() def start(self): - """Start the audio stream""" + """Start the audio stream and consume the buffer.""" if self.stream.closed: print("Stream is closed. Try re-initializing.", file=sys.stderr) return @@ -136,11 +147,11 @@ def start(self): print("Stream is already active.", file=sys.stderr) def wait(self): - """Wait for the audio stream to finish.""" + """Wait for the audio stream to finish the buffer.""" self._stream_finished.wait(timeout=None) def abort(self): - """Stop the audio steam without finishing remaining buffers.""" + """Stop the audio steam without finishing remaining callbacks.""" if self.stream.active is True: self.stream.abort() self._stop_buffer() @@ -152,7 +163,7 @@ def close(self): self._stop_buffer() def stop(self): - """Stop the audio stream after finishing the current buffer.""" + """Stop the audio stream after finishing all remaining callbacks.""" if self.stream.active is True: self.stream.stop() self._stop_buffer() From 8a96c1bd3106f782b5b98259c08a296786fd82a2 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 11:21:37 +0200 Subject: [PATCH 44/57] update docstrings --- haiopy/devices.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/haiopy/devices.py b/haiopy/devices.py index afadad0..4b13676 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -424,6 +424,11 @@ def channels(self, channels: list[int]): class OutputAudioDevice(AudioDevice): + """Class implementing an output audio device. + + The implementation is based on python-sounddevice and portaudio. + + """ def __init__( self, @@ -705,18 +710,28 @@ def dtype(self, dtype): self.initialize() def _stop_buffer(self): + """Stop the output buffer iteration. + This will raise a StopIteration exception in the buffer. + """ self._output_buffer._stop() def _close_stream(self): + """Close the steam and stop the output buffer. + This will release the soundcard lock. + """ if self.stream is not None: self.stream.close() self._output_buffer._stop(msg=None) def start(self): + """Start playback.""" + if self._stream is None: + self.initialize() self.output_buffer._start() self.output_buffer._is_active.wait() super().start() def wait(self): + """Wait for the device to finish playback.""" super().wait() self.output_buffer._is_finished.wait() From 2dc2b97f95b424352c45268109bd7e29b9202b11 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 11:27:46 +0200 Subject: [PATCH 45/57] Add method to check if buffer is active --- haiopy/devices.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 4b13676..36c915b 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -618,9 +618,9 @@ def output_buffer(self, buffer): self._output_buffer = buffer - @property - def identifier(self): - return self._identifier + def _buffer_active(self) -> bool: + """Check if the output buffer is active.""" + return False if self._output_buffer is None else self.output_buffer.is_active @identifier.setter def identifier(self, identifier): From d38e4080ae19ffec1393901c7bd39975219b5b84 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 11:32:02 +0200 Subject: [PATCH 46/57] update identifier property --- haiopy/devices.py | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 36c915b..9878e53 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -475,6 +475,17 @@ def __init__( self.output_buffer = output_buffer self.initialize() + @property + def name(self) -> str: + """The name of the device.""" + return sd.query_devices(self.identifier)['name'] + + @property + def host_api(self) -> str: + """The host API used by the device.""" + return sd.query_hostapis( + sd.query_devices(self.identifier)['hostapi'])['name'] + def check_settings( self, n_channels=None, @@ -501,7 +512,7 @@ def check_settings( raised. """ sd.check_output_settings( - device=self.id, + device=self.identifier, channels=n_channels, dtype=dtype, extra_settings=extra_settings, @@ -537,8 +548,9 @@ 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, 'output')['max_output_channels'] + """The number of output channels supported by the device.""" + return sd.query_devices( + self.identifier, 'output')['max_output_channels'] def output_callback(self, outdata, frames, time, status) -> None: """Portudio callback for output streams @@ -577,22 +589,22 @@ def output_callback(self, outdata, frames, time, status) -> None: except StopIteration as e: raise sd.CallbackStop("Buffer empty") from e - def initialize(self): - """Initialize the playback stream for a given number of channels.""" - # 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(self) -> None: + """Initialize and open the playback stream. + This will set the device to active, potentially blocking the soundcard for other + applications. Playback is not yet started. + """ ostream = sd.OutputStream( - self.sampling_rate, - self.block_size, - self.id, - self._n_channels_stream, - self._dtype, + samplerate=self.sampling_rate, + blocksize=self.block_size, + device=self.identifier, + channels=self.output_channel_mapping.n_channels_mapping, + dtype=self._dtype, callback=self.output_callback, - finished_callback=self._finished_callback) + finished_callback=self._finished_callback, + extra_settings=self.output_channel_mapping.extra_settings, + ) self._stream = ostream def initialize_buffer(self): From 3598897d0744a4782f8ce8476a8192c87cb13a25 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 11:34:15 +0200 Subject: [PATCH 47/57] update setter and getter --- haiopy/devices.py | 190 +++++++++++++++++++--------------------------- 1 file changed, 79 insertions(+), 111 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 9878e53..2abccf7 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -435,44 +435,39 @@ def __init__( identifier=sd.default.device['output'], sampling_rate=44100, block_size=512, - channels=[1], + channels=[0], 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): + ): # First check the settings before continuing - max_channel = np.max(channels) n_channels = len(channels) sd.check_output_settings( device=identifier, - channels=np.max([n_channels, max_channel+1]), + channels=np.max([n_channels, np.max(channels)+1]), dtype=dtype, - extra_settings=extra_settings, samplerate=sampling_rate) - self._extra_settings = extra_settings - self._identifier = identifier + # Init base class super().__init__( identifier=identifier, sampling_rate=sampling_rate, block_size=block_size, dtype=dtype) - self._output_channels = channels + # Set the output channel mapping which is specific for each host api + self._output_channel_mapping = OutputChannelMapping( + channels, self.max_channels_output, self.host_api) + # Set the output buffer which will be consumed in the callback + # function. If no buffer is given, an empty buffer is created. + self._output_buffer = None if output_buffer is None: - output_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')) + output_buffer = EmptyBuffer( + block_size, n_channels, sampling_rate) self.output_buffer = output_buffer + + # Initialize the device self.initialize() @property @@ -488,11 +483,11 @@ def host_api(self) -> str: def check_settings( self, - n_channels=None, - sampling_rate=None, - dtype=None, - extra_settings=None): - """Check if settings are compatible with the physical devices. + n_channels: list[int] = None, + sampling_rate: int = None, + dtype: np.int8 | np.int16 | np.int32 | np.float32 = None, + extra_settings: sd.CoreAudioSettings | sd.AsioSettings | None = None): + """Check if settings are compatible with the physical device. Parameters ---------- @@ -508,8 +503,7 @@ def check_settings( Raises ------ PortAudioError - If the settings are incompatible with the device an exception is - raised. + If the settings are incompatible with the device an exception is raised. """ sd.check_output_settings( device=self.identifier, @@ -519,8 +513,30 @@ def check_settings( samplerate=sampling_rate) @property - def output_channels(self): - return self._output_channels + def output_channel_mapping(self): + """The channel mapping of the device.""" + return self._output_channel_mapping + + @property + def output_channels(self) -> list[int]: + """The output channels of the device. + """ + return self.output_channel_mapping.channels + + @output_channels.setter + def output_channels(self, channels): + """Set the output channels of the device. + + Parameters + ---------- + channels : list + The output channels to be used by the device. + """ + + if self._stream_active() or self._buffer_active(): + raise ValueError("The device is currently in use and needs to be closed first") + self._close_stream() + self.output_channel_mapping.channels = channels @property def n_channels_output(self): @@ -531,20 +547,7 @@ def n_channels_output(self): 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. 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((2, np.max(self._output_channels) + 1)) + return self.output_channel_mapping.n_channels_used @property def max_channels_output(self): @@ -607,17 +610,28 @@ def initialize(self) -> None: ) self._stream = ostream - def initialize_buffer(self): + def initialize_buffer(self) -> None: + """Initialize the output buffer. + Starts the buffer and waits for it to be active. + """ self.output_buffer._start() self.output_buffer._is_active.wait() @property - def output_buffer(self): + def output_buffer(self) -> type[_Buffer] | None: + """The output buffer which is consumed by the device. + """ return self._output_buffer @output_buffer.setter - def output_buffer(self, buffer): - """Sets the output buffer""" + def output_buffer(self, buffer: type[_Buffer]): + """Set the output buffer which is consumed by the device. + The number of channels and the block size need to match the device settings. + """ + if self._stream_active() or self._buffer_active(): + raise ValueError( + "The device is currently in use and needs to be closed first") + if buffer.block_size != self.block_size: raise ValueError( "The buffer's block size does not match. ", @@ -626,7 +640,8 @@ def output_buffer(self, buffer): 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}") + f"mapping. A number of {self.n_channels_output} are currently " + f"used. These are {self.output_channels}") self._output_buffer = buffer @@ -634,33 +649,12 @@ def _buffer_active(self) -> bool: """Check if the output buffer is active.""" return False if self._output_buffer is None else self.output_buffer.is_active - @identifier.setter - def identifier(self, identifier): - if self.stream.active is True or self.output_buffer.is_active is True: - raise ValueError( - "The device is currently in use and needs to be closed first") - self._close_stream() - max_channel = np.max(self._output_channels) - n_channels = len(self._output_channels) - sd.check_output_settings( - device=sd.query_devices(identifier)['name'], - channels=np.max([n_channels, max_channel+1]), - dtype=self._dtype, - extra_settings=self._extra_settings, - samplerate=self._sampling_rate) - self._id = sd.query_devices(identifier)['name'] - self.initialize() - - @property - def block_size(self): - return self._block_size - - @block_size.setter - def block_size(self, block_size): - """Sets the blocksize of the OutputDevice and the output buffer. - Therefore, the current stream is closed and a new stream with setted - blocksize and output buffer is initialized.""" - if self.stream.active is True or self.output_buffer.is_active is True: + @_Device.block_size.setter + def block_size(self, block_size: int): + """Sets the block size of the device and the output buffer. + Any open stream needs to be closed an re-opened. The output buffer is reset. + """ + if self._stream_active() or self._buffer_active(): raise ValueError( "The device is currently in use and needs to be closed first") self._close_stream() @@ -668,16 +662,12 @@ def block_size(self, block_size): self.output_buffer.block_size = block_size self.initialize() - @property - def sampling_rate(self): - return self._sampling_rate - - @sampling_rate.setter - def sampling_rate(self, sampling_rate): - """Sets the sampling rate of the OutputDevice and the output buffer. - Therefore, the current stream is closed and a new stream with setted - samplingrate and output buffer is initialized.""" - if self.stream.active is True or self.output_buffer.is_active is True: + @_Device.sampling_rate.setter + def sampling_rate(self, sampling_rate: int): + """Sets the sampling rate of the device. + Any open stream needs to be closed an re-opened. + """ + if self._stream_active() or self._buffer_active(): raise ValueError( "The device is currently in use and needs to be closed first") self.check_settings(sampling_rate=sampling_rate) @@ -686,34 +676,12 @@ def sampling_rate(self, sampling_rate): self.output_buffer.sampling_rate = sampling_rate self.initialize() - @property - def channels(self): - return self._output_channels - - @channels.setter - def channels(self, channels): - """Sets the channels of the Output device. Therefore, the current - stream is closed and a new stream with setted channels is - initialized.""" - if self.stream.active is True or self.output_buffer.is_active is True: - raise ValueError( - "The device is currently in use and needs to be closed first") - self._close_stream() - max_channel = np.max(channels) - n_channels = len(channels) - self.check_settings(n_channels=np.max([n_channels, max_channel+1])) - self._output_channels = channels - self.initialize() - - @property - def dtype(self): - return self._dtype - - @dtype.setter + @_Device.dtype.setter def dtype(self, dtype): - """Sets the dtype of the output buffer. Therefore, the current stream - is closed and a new stream with setted dtype is initialized.""" - if self.stream.active is True or self.output_buffer.is_active is True: + """Sets the dtype of the device buffer (portaudio specific). + Any open stream needs to be closed an re-opened. + """ + if self._stream_active() or self._buffer_active(): raise ValueError( "The device is currently in use and needs to be closed first") self._close_stream() From 6b95004f49d18671b5edd7b5e34ecce60af2abbf Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 11:35:22 +0200 Subject: [PATCH 48/57] update calback --- haiopy/devices.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 2abccf7..7b2d64d 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -555,8 +555,13 @@ def max_channels_output(self): return sd.query_devices( self.identifier, 'output')['max_output_channels'] - def output_callback(self, outdata, frames, time, status) -> None: - """Portudio callback for output streams + def output_callback( + self, + outdata: np.ndarray[float], + frames: int, + timestamp, # noqa: ARG002 + status: sd.CallbackFlags) -> None: + """Portudio callback for output streams. Parameters ---------- @@ -564,8 +569,9 @@ def output_callback(self, outdata, frames, time, status) -> None: Output buffer view frames : int Length of the buffer - time : PaTimestamp - Timestamp of the callback event + timestamp : PaTimestamp + Timestamp of the callback event. This is a struct implemented in c and not directly + available. Only listed here since the number and order are required by portaudio. status : sounddevice.CallbackFlags Portaudio status flags @@ -583,12 +589,7 @@ def output_callback(self, outdata, frames, time, status) -> None: assert not status try: - # 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 + outdata[:] = self.output_channel_mapping(next(self.output_buffer)) except StopIteration as e: raise sd.CallbackStop("Buffer empty") from e From 1c81b26b820c6b4e7e5a86034e3f82072c8b848f Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 11:36:01 +0200 Subject: [PATCH 49/57] add destructor to avoid blocking soundcards after the device is deleted --- haiopy/devices.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/haiopy/devices.py b/haiopy/devices.py index 7b2d64d..884291d 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -176,6 +176,13 @@ def _stop_buffer(self): def _close_stream(self): raise NotImplementedError() + def __del__(self): + """Destructor for the AudioDevice. + Closes the sounddevice stream. + """ + if self._stream_active(): + self.stream.close() + class _ChannelMapping(metaclass=ABCMeta): """Class to handle the channel mapping of the device. @@ -716,3 +723,16 @@ def wait(self): """Wait for the device to finish playback.""" super().wait() self.output_buffer._is_finished.wait() + + def __del__(self): + """Destructor for the OutputDevice. + Closes the stream and stops the output buffer. + """ + # Close the stream if it is still active + super().__del__() + + # Stop the buffer if it is still active + if self._buffer_active(): + self._stop_buffer( + 'Output device has been closed. ', + 'Stopping buffer iteration.') From fefdcbbe48651bc812c63b0abd80f1911119d862 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 12:04:54 +0200 Subject: [PATCH 50/57] Do not axis alignment of data in the ChannelMapping classes --- haiopy/devices.py | 6 +++--- tests/test_mappings.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index 884291d..d44be86 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -297,7 +297,7 @@ def __call__( Returns ------- np.ndarray[float] - The output data buffer with shape (n_channels_mapping, block_size). + The output data buffer with shape (block_size, n_channels_mapping). """ if self._api in ['asio', 'coreaudio']: @@ -319,7 +319,7 @@ def __call__( dtype=data.dtype) self._stream_block_out[self.channels] = data - return self._stream_block_out.T + return self._stream_block_out class InputChannelMapping(_ChannelMapping): @@ -596,7 +596,7 @@ def output_callback( assert not status try: - outdata[:] = self.output_channel_mapping(next(self.output_buffer)) + outdata[:] = self.output_channel_mapping(next(self.output_buffer)).T except StopIteration as e: raise sd.CallbackStop("Buffer empty") from e diff --git a/tests/test_mappings.py b/tests/test_mappings.py index dc5a4ef..4c67a7e 100644 --- a/tests/test_mappings.py +++ b/tests/test_mappings.py @@ -37,3 +37,16 @@ def test_init_checks_linux(valid_apis): def test_init_checks_macos(valid_apis): OutputChannelMapping([0], 1, valid_apis) InputChannelMapping([0], 1, valid_apis) + + +@patch('platform.system', new=lambda: 'Linux') +def test_manual_mapping(): + output_mapping = OutputChannelMapping([2, 3], 4, 'alsa') + + data_block = np.ones((2, 512), dtype=float) + truth = np.vstack(( + np.zeros_like(data_block), + data_block, + )) + np.testing.assert_array_equal( + output_mapping(data_block), truth) From 0a8ce28c3088acfea4bb87f790d389a8b5bbb803 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 13:47:58 +0200 Subject: [PATCH 51/57] function to query and return a AudioDevice --- haiopy/devices.py | 85 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/haiopy/devices.py b/haiopy/devices.py index d44be86..0261a76 100644 --- a/haiopy/devices.py +++ b/haiopy/devices.py @@ -9,8 +9,89 @@ from haiopy.buffers import _Buffer -def list_devices(): - pass +_valid_apis_windows = { + 'mme': 0, + 'windows directsound': 1, + 'directsound': 1, + 'asio': 2, + 'windows wasapi': 3, + 'wasapi': 3, + 'windows wdm-ks': 4, + 'wdm': 4, +} +_valid_apis_linux = [ + 'alsa', + 'oss', + 'pulse', + 'jack'] +_valid_apis_darwin = [ + 'coreaudio'] + +_valid_apis = { + 'Windows': _valid_apis_windows, + 'Linux': _valid_apis_linux, + 'Darwin': _valid_apis_darwin, +} + +_default_apis = { + 'Windows': 2, + 'Linux': 1, + 'Darwin': 0, +} + + +def query_devices( + device: int | str = None, + kind: str | None = None, + host_api: str | None = None): + """Query the devices available on the system. + + Parameters + ---------- + device : int | str + The device to be queried. If None, all available devices are returned. + kind : str + The kind of device to be queried. Can be 'input' or 'output'. + host_api : str + The host API to be used. If None, the default API is used, which depends on the + operating system. For Windows, the default API is ASIO, for Linux it is ALSA, + and for macOS it is CoreAudio. + """ + + if device is None and host_api is None: + return sd.query_devices(device, kind) + + # if device is None: + if host_api is not None: + device_ids = sd.query_hostapis( + _valid_apis[platform.system()][host_api.lower()])['devices'] + else: + device_ids = sd.query_hostapis( + _default_apis[platform.system()])['devices'] + + device_list = [] + for dev_id in device_ids: + if device is None: + try: + device_list.append(sd.query_devices(dev_id, kind)) + except ValueError: + continue + elif device.lower() in sd.query_devices(dev_id)['name'].lower(): + device_list.append(sd.query_devices(dev_id, kind)) + + if len(device_list) > 1: + return sd.DeviceList(device_list) + + elif len(device_list) == 1: + if kind == 'output': + audio_device = OutputAudioDevice( + identifier=device_list[0]['index'], + sampling_rate=device_list[0]['default_samplerate'], + ) + else: + raise ValueError('Unsupported device type.') + + return audio_device class _Device(metaclass=ABCMeta): From 9af1b9930dffd2aced161706c70ee36f066238cd Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 13:55:47 +0200 Subject: [PATCH 52/57] update default devices for testing --- tests/test_devices_physical.py | 47 ++++++++++++++-------------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index 1391ef7..f3ec81b 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -11,14 +11,19 @@ def default_device_multiface_fireface(kind='both'): device_list = sd.query_devices() found = False - valid_devices = [ - 'Multiface', - 'Fireface', - 'Scarlett 2i4', - 'MADIface', - 'Focusrite USB ASIO', - 'Steinberg USB ASIO', - ] + import platform + + if platform.system() == 'Windows': + valid_devices = [ + 'ASIO Fireface USB', + ] + elif platform.system() == 'Darwin': + valid_devices = [ + 'Multiface', + 'Fireface', + 'Scarlett 2i4', + 'MADIface', + ] for valid_device in valid_devices: for identifier, device in enumerate(device_list): @@ -29,6 +34,10 @@ def default_device_multiface_fireface(kind='both'): raise ValueError( "Please connect Fireface or Multiface, or specify test device.") + sampling_rate = int(device['default_samplerate']) + name = device['name'] + print(f"\n\n Using: {name} with sampling rate = {sampling_rate}\n\n") + return identifier, device @@ -43,6 +52,7 @@ def test_default_device_helper(): 'MADIface', 'Focusrite USB ASIO', 'Steinberg USB ASIO', + 'ReaRoute ASIO', ] assert any( name in sd.query_devices(identifier)['name'] for name in device_names) @@ -53,26 +63,7 @@ def test_default_device_helper(): madiface = 'MADIface' in sd.query_devices(identifier)['name'] focusrite = 'Focusrite USB ASIO' in sd.query_devices(identifier)['name'] steinberg = 'Steinberg USB ASIO' in sd.query_devices(identifier)['name'] - - 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 - - if madiface: - assert device['max_input_channels'] == 196 - assert device['max_output_channels'] == 198 - - if focusrite: - assert device['max_input_channels'] == 2 - assert device['max_output_channels'] == 2 - - if steinberg: - assert device['max_input_channels'] == 6 - assert device['max_output_channels'] == 6 + rearoute = 'ReaRoute' in sd.query_devices(identifier)['name'] # ----------------------------------------------------------------------------- # Output Device Tests From d8a79abe3b7a48e40301bcf37cf77167aa438e60 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 13:56:04 +0200 Subject: [PATCH 53/57] temporarily deactivate testing channel setters --- tests/test_devices_physical.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index f3ec81b..03c26f9 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -175,13 +175,13 @@ def test_check_init(empty_buffer_stub, sine_buffer_stub): out_device.wait() # set new channels - new_channels = [0, 1] - out_device.channels = new_channels - out_device._output_buffer.reset_index() - assert out_device.channels == new_channels - # test playback with new channels - out_device.start() - out_device.wait() + # new_channels = [0, 1] + # out_device.channels = new_channels + # out_device._output_buffer.reset_index() + # assert out_device.channels == new_channels + # # test playback with new channels + # out_device.start() + # out_device.wait() # Close Output Stream for next Tests with pytest.raises(StopIteration, match="iteration stopped"): From 29a947f3b82a07a1dd68ebd0785e206c6d722f95 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 18:22:29 +0200 Subject: [PATCH 54/57] change the frequency of the sine stub to 440 Hz before the frequency changed depending on the sampling rate --- tests/conftest.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f04c8da..89214a2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,8 +42,9 @@ def sine_buffer_stub(): block_size = 512 n_blocks = 86 data = np.zeros((1, n_blocks*block_size), dtype='float32') - t = np.arange(0, block_size*n_blocks) - data = np.sin(2*np.pi*t*(block_size + 1)/sampling_rate)*10**(-6/20) + t = np.arange(0, block_size*n_blocks)/sampling_rate + freq = 440 + data = np.sin(2*np.pi*t*freq)*10**(-6/20) data = np.atleast_2d(data).astype('float32') buffer = SignalBuffer(block_size, pf.Signal(data, sampling_rate)) From 4d1553a87febe1e19243954ac8b3b905dec9ad07 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 18:23:51 +0200 Subject: [PATCH 55/57] add missing numpy import --- tests/test_mappings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_mappings.py b/tests/test_mappings.py index 4c67a7e..ef2aac9 100644 --- a/tests/test_mappings.py +++ b/tests/test_mappings.py @@ -1,4 +1,5 @@ from unittest.mock import patch +import numpy as np import pytest from haiopy.devices import ( From 6eeda6b3ef1e01fee80085fcdb0587ebdf9faa17 Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 18:28:07 +0200 Subject: [PATCH 56/57] cleanup import --- tests/test_devices_physical.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index 03c26f9..d6901e7 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -4,7 +4,6 @@ from haiopy import devices import sounddevice as sd import pytest -import os def default_device_multiface_fireface(kind='both'): From b00861420dee632270ca00bd2294da47f7c08c2b Mon Sep 17 00:00:00 2001 From: Marco Berzborn Date: Fri, 9 May 2025 18:29:19 +0200 Subject: [PATCH 57/57] cleanup test playback --- tests/test_devices_physical.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_devices_physical.py b/tests/test_devices_physical.py index d6901e7..91ee19b 100644 --- a/tests/test_devices_physical.py +++ b/tests/test_devices_physical.py @@ -113,12 +113,13 @@ def test_sine_playback(sine_buffer_stub): buffer = sine_buffer_stub[0] identifier, config = default_device_multiface_fireface() - sampling_rate = config['default_samplerate'] + sampling_rate = int(config['default_samplerate']) out_device = devices.OutputAudioDevice( identifier=identifier, output_buffer=buffer, channels=[1], + block_size=buffer.block_size, sampling_rate=sampling_rate) out_device.check_settings() @@ -138,7 +139,7 @@ def test_check_init(empty_buffer_stub, sine_buffer_stub): buffer = sine_buffer_stub[0] identifier, config = default_device_multiface_fireface() - sampling_rate = config['default_samplerate'] + sampling_rate = int(config['default_samplerate']) out_device = devices.OutputAudioDevice( identifier=identifier,