From 2f6c690b1ddd1cac6f2d359ebd3a1a666c39ebc8 Mon Sep 17 00:00:00 2001 From: szlop Date: Sun, 8 Nov 2020 19:23:44 +0100 Subject: [PATCH 1/9] Fix: Prevent soundcard from crashing, if more than 6 channels are selected. Replaced the call of pa_channel_map_init_auto for the generation of the channel map by pa_channel_map_init_extend. pa_channel_map_init_auto returns NULL, if more than 6 channels are used, which causes an assertion error in SoundCard. pa_channel_map_init_extend returns a valid channel map for any number of channels. --- soundcard/pulseaudio.py | 2 +- soundcard/pulseaudio.py.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/soundcard/pulseaudio.py b/soundcard/pulseaudio.py index 53a27ff..fc6f023 100644 --- a/soundcard/pulseaudio.py +++ b/soundcard/pulseaudio.py @@ -651,7 +651,7 @@ def __enter__(self): # pam and channelmap refer to the same object, but need different # names to avoid garbage collection trouble on the Python/C boundary pam = _ffi.new("pa_channel_map*") - channelmap = _pa.pa_channel_map_init_auto(pam, samplespec.channels, _pa.PA_CHANNEL_MAP_DEFAULT) + channelmap = _pa.pa_channel_map_init_extend(pam, samplespec.channels, _pa.PA_CHANNEL_MAP_DEFAULT) if isinstance(self.channels, collections.abc.Iterable): for idx, ch in enumerate(self.channels): channelmap.map[idx] = ch+1 diff --git a/soundcard/pulseaudio.py.h b/soundcard/pulseaudio.py.h index 7b1facc..2411c14 100644 --- a/soundcard/pulseaudio.py.h +++ b/soundcard/pulseaudio.py.h @@ -109,7 +109,7 @@ typedef enum pa_channel_map_def { PA_CHANNEL_MAP_DEFAULT = PA_CHANNEL_MAP_AIFF } pa_channel_map_def_t; -pa_channel_map* pa_channel_map_init_auto(pa_channel_map *m, unsigned channels, pa_channel_map_def_t def); +pa_channel_map* pa_channel_map_init_extend(pa_channel_map *m, unsigned channels, pa_channel_map_def_t def); int pa_channel_map_valid(const pa_channel_map *map); typedef struct pa_buffer_attr { From 72860c76b329472f614857ae7138d3b96d73b7ad Mon Sep 17 00:00:00 2001 From: szlop Date: Wed, 1 Mar 2023 19:38:59 +0100 Subject: [PATCH 2/9] This commit changes the behavior of the channel map handling in the Pulseaudio backend. It breaks backward compatiblity for costum channel maps in the Pulseaudio backend! - It adds the possibility to set a list of Pulseaudio channel position name strings to the channels parameter of the speaker and recorder functions. - There are new helper functions to support the use of channel position strings: channel_position_to_string(channel) and channel_string_to_position(channel_string) to convert indices to strings and vice versa and get_channel_positions(), which returns a dicts containing all possible channel position strings with the according indices. - The increment of the channel map indices prior to passing them to the Pulseaudio channel map is removed. This way the indices match the Pulseaudio pa_channel_position type. This will break existing code wich makes use of custom channel maps. --- soundcard/pulseaudio.py | 43 ++++++++++++++++++++++++++++++++++++++- soundcard/pulseaudio.py.h | 2 ++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/soundcard/pulseaudio.py b/soundcard/pulseaudio.py index fc6f023..21d4da4 100644 --- a/soundcard/pulseaudio.py +++ b/soundcard/pulseaudio.py @@ -46,6 +46,43 @@ def func_with_lock(*args, **kwargs): self._pa_operation_unref(operation) return func_with_lock + +_channel_positions = { + 'left': _pa.PA_CHANNEL_POSITION_LEFT, + 'right': _pa.PA_CHANNEL_POSITION_RIGHT, + 'center': _pa.PA_CHANNEL_POSITION_CENTER, + 'subwoofer': _pa.PA_CHANNEL_POSITION_SUBWOOFER} | \ + { + _ffi.string(_pa.pa_channel_position_to_string(idx)).decode('utf-8'): idx for idx in + range(_pa.PA_CHANNEL_POSITION_MAX) + } + + +def get_channel_positions(): + """ + Return a dict containing the Pulseaudio channel position enum type index + for every channel position name string. + """ + return _channel_positions + + +def channel_position_to_string(channel): + """ + Return the Pulseaudio channel position name string for enum type index channel. + """ + return _ffi.string(_pa.pa_channel_position_to_string(channel)).decode('utf-8') + + +def channel_string_to_position(channel_string): + """ + Return the Pulseaudio channel position enum type index for position name channel_string. + """ + channel = _pa.pa_channel_position_from_string(_ffi.new("char[]", channel_string.encode())) + if channel == -1: + raise KeyError(channel_string + " is not a valid channel position name.") + return channel + + class _PulseAudio: """Proxy for communcation with Pulseaudio. @@ -654,7 +691,11 @@ def __enter__(self): channelmap = _pa.pa_channel_map_init_extend(pam, samplespec.channels, _pa.PA_CHANNEL_MAP_DEFAULT) if isinstance(self.channels, collections.abc.Iterable): for idx, ch in enumerate(self.channels): - channelmap.map[idx] = ch+1 + if isinstance(ch, int): + channelmap.map[idx] = ch + else: + channelmap.map[idx] = channel_string_to_position(ch) + if not _pa.pa_channel_map_valid(channelmap): raise RuntimeError('invalid channel map') diff --git a/soundcard/pulseaudio.py.h b/soundcard/pulseaudio.py.h index 2411c14..dbe106b 100644 --- a/soundcard/pulseaudio.py.h +++ b/soundcard/pulseaudio.py.h @@ -111,6 +111,8 @@ typedef enum pa_channel_map_def { pa_channel_map* pa_channel_map_init_extend(pa_channel_map *m, unsigned channels, pa_channel_map_def_t def); int pa_channel_map_valid(const pa_channel_map *map); +const char* pa_channel_position_to_string(pa_channel_position_t pos); +pa_channel_position_t pa_channel_position_from_string(const char *s); typedef struct pa_buffer_attr { uint32_t maxlength; From cd31490c4f6d83691a97dd7844b67eb872d9ebc9 Mon Sep 17 00:00:00 2001 From: szlop Date: Tue, 7 Mar 2023 09:53:19 +0100 Subject: [PATCH 3/9] - Renamed get_channel_positions() to channel_name_map(). The dictionary mapping channel names to indices is now constructed, when channel_name_map() is called and not a private variable of the module anymore. - Removed channel_position_to_string() and channel_string_to_position() from pulseaudio.py, since their use cases can be covered by using the dict returned by channel_name_map(). Also removed the definition of pa_channel_position_from_string, it is not used anymore. --- soundcard/pulseaudio.py | 46 ++++++++++++--------------------------- soundcard/pulseaudio.py.h | 1 - 2 files changed, 14 insertions(+), 33 deletions(-) diff --git a/soundcard/pulseaudio.py b/soundcard/pulseaudio.py index 21d4da4..c84f697 100644 --- a/soundcard/pulseaudio.py +++ b/soundcard/pulseaudio.py @@ -47,40 +47,21 @@ def func_with_lock(*args, **kwargs): return func_with_lock -_channel_positions = { - 'left': _pa.PA_CHANNEL_POSITION_LEFT, - 'right': _pa.PA_CHANNEL_POSITION_RIGHT, - 'center': _pa.PA_CHANNEL_POSITION_CENTER, - 'subwoofer': _pa.PA_CHANNEL_POSITION_SUBWOOFER} | \ - { - _ffi.string(_pa.pa_channel_position_to_string(idx)).decode('utf-8'): idx for idx in - range(_pa.PA_CHANNEL_POSITION_MAX) - } - - -def get_channel_positions(): +def channel_name_map(): """ - Return a dict containing the Pulseaudio channel position enum type index - for every channel position name string. + Return a dict containing the channel position index for every channel position name string. """ - return _channel_positions - -def channel_position_to_string(channel): - """ - Return the Pulseaudio channel position name string for enum type index channel. - """ - return _ffi.string(_pa.pa_channel_position_to_string(channel)).decode('utf-8') - - -def channel_string_to_position(channel_string): - """ - Return the Pulseaudio channel position enum type index for position name channel_string. - """ - channel = _pa.pa_channel_position_from_string(_ffi.new("char[]", channel_string.encode())) - if channel == -1: - raise KeyError(channel_string + " is not a valid channel position name.") - return channel + channel_indices = { + 'left': _pa.PA_CHANNEL_POSITION_LEFT, + 'right': _pa.PA_CHANNEL_POSITION_RIGHT, + 'center': _pa.PA_CHANNEL_POSITION_CENTER, + 'subwoofer': _pa.PA_CHANNEL_POSITION_SUBWOOFER} | \ + { + _ffi.string(_pa.pa_channel_position_to_string(idx)).decode('utf-8'): idx for idx in + range(_pa.PA_CHANNEL_POSITION_MAX) + } + return channel_indices class _PulseAudio: @@ -694,7 +675,8 @@ def __enter__(self): if isinstance(ch, int): channelmap.map[idx] = ch else: - channelmap.map[idx] = channel_string_to_position(ch) + channel_name_to_index = channel_name_map() + channelmap.map[idx] = channel_name_to_index[ch] if not _pa.pa_channel_map_valid(channelmap): raise RuntimeError('invalid channel map') diff --git a/soundcard/pulseaudio.py.h b/soundcard/pulseaudio.py.h index dbe106b..2d82ee9 100644 --- a/soundcard/pulseaudio.py.h +++ b/soundcard/pulseaudio.py.h @@ -112,7 +112,6 @@ typedef enum pa_channel_map_def { pa_channel_map* pa_channel_map_init_extend(pa_channel_map *m, unsigned channels, pa_channel_map_def_t def); int pa_channel_map_valid(const pa_channel_map *map); const char* pa_channel_position_to_string(pa_channel_position_t pos); -pa_channel_position_t pa_channel_position_from_string(const char *s); typedef struct pa_buffer_attr { uint32_t maxlength; From 8f543902cd1b2e6e4e677614760999ab8bb01580 Mon Sep 17 00:00:00 2001 From: szlop Date: Tue, 7 Mar 2023 11:35:49 +0100 Subject: [PATCH 4/9] Reverted channel indices back to decreased by 1 compared to the Pulseaudio enum type. This is to align the channel indices for left and right channel position to the use in the other backends. Now channel index 0 refers to the left position and 1 to the right position once again. --- soundcard/pulseaudio.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/soundcard/pulseaudio.py b/soundcard/pulseaudio.py index c84f697..438c94f 100644 --- a/soundcard/pulseaudio.py +++ b/soundcard/pulseaudio.py @@ -61,6 +61,11 @@ def channel_name_map(): _ffi.string(_pa.pa_channel_position_to_string(idx)).decode('utf-8'): idx for idx in range(_pa.PA_CHANNEL_POSITION_MAX) } + + # The above values returned from Pulseaudio contain 1 for 'left', 2 for 'right' and so on. + # SoundCard's channel indices for 'left' start at 0. Therefore, we have to decrement all values. + channel_indices = {key: value - 1 for (key, value) in channel_indices.items()} + return channel_indices @@ -673,10 +678,10 @@ def __enter__(self): if isinstance(self.channels, collections.abc.Iterable): for idx, ch in enumerate(self.channels): if isinstance(ch, int): - channelmap.map[idx] = ch + channelmap.map[idx] = ch + 1 else: channel_name_to_index = channel_name_map() - channelmap.map[idx] = channel_name_to_index[ch] + channelmap.map[idx] = channel_name_to_index[ch] + 1 if not _pa.pa_channel_map_valid(channelmap): raise RuntimeError('invalid channel map') From a4ad25ee20be4dc791540cf3e06e7af603a33939 Mon Sep 17 00:00:00 2001 From: szlop Date: Wed, 8 Mar 2023 19:47:44 +0100 Subject: [PATCH 5/9] Fixed: For multi channel playback, the number of bytes written to the PulseAudio stream object would exceed the stream writable size, because the number of audio channels written was not factored in. This could result in a problem, where the beginning of a data chunk was discarded and not played at all. --- soundcard/pulseaudio.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/soundcard/pulseaudio.py b/soundcard/pulseaudio.py index 438c94f..3042510 100644 --- a/soundcard/pulseaudio.py +++ b/soundcard/pulseaudio.py @@ -776,7 +776,8 @@ def play(self, data): if data.shape[1] != self.channels: raise TypeError('second dimension of data must be equal to the number of channels, not {}'.format(data.shape[1])) while data.nbytes > 0: - nwrite = _pulse._pa_stream_writable_size(self.stream) // 4 + nwrite = _pulse._pa_stream_writable_size(self.stream) // (4 * self.channels) # 4 bytes per sample + if nwrite == 0: time.sleep(0.001) continue From 41ba3caa9ae3cd190b392472830fa2ef5d24a948 Mon Sep 17 00:00:00 2001 From: szlop Date: Wed, 8 Mar 2023 20:21:56 +0100 Subject: [PATCH 6/9] Added explanation and example code to the Channel Maps section of the README to adapt the documentation to the string based channel map definition in the PulseAudio backend. --- README.rst | 68 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index e6c990e..1dd00d6 100644 --- a/README.rst +++ b/README.rst @@ -145,14 +145,66 @@ Channel Maps Some professional sound cards have large numbers of channels. If you want to record or play only a subset of those channels, you can specify a channel map. -For playback, a channel map of ``[0, 3, 4]`` will play three-channel audio data -on the physical channels one, four, and five. For recording, a channel map of -``[0, 3, 4]`` will return three-channel audio data recorded from the physical -channels one, four, and five. - -In addition, pulseaudio/Linux defines channel ``-1`` as the mono mix of all -channels for both playback and recording. CoreAudio/macOS defines channel ``-1`` -as silence for both playback and recording. +A channel map consists of a list of channel specifiers, which refer to the +channels of the audio backend in use. The index of each of those specifiers +in the the channel map list indicates the channel index in the numpy data array +used in SoundCard: + +.. code:: python + # record one second of audio from backend channels 0 to 3: + data = default_mic.record(samplerate=48000, channels=[0, 1, 2, 3], numframes=48000) + + # play back the recorded audio in reverse channel order: + default_speaker.play(data=data, channels=[3, 2, 1, 0], samplerate=48000) + +The meaning of the channel specifiers depend on the backend in use. For WASAPI +(Windows) and CoreAudio (macOS) the indices refer to the physical output +channels of the sound device in use. For the PulseAudio backend (Linux) the +specifiers refer to logical channel positions instead of physical hardware +channels. + +The channel position identifiers in the PulseAudio backend are based on: +https://freedesktop.org/software/pulseaudio/doxygen/channelmap_8h.html +Since the mapping of position indices to audio channels is not obvious, a +dictionary containing all possible positions and channel indices can be +retrieved by calling ``channel_name_map()``. The positions for the indices up to 10 are :: + 'mono': -1, + 'left': 0, + 'right': 1, + 'center': 2, + 'rear-center': 3, + 'rear-left': 4, + 'rear-right': 5, + 'lfe': 6, + 'front-left-of-center': 7, + 'front-right-of-center': 8, + 'side-left': 9, + 'side-right': 10 + +The identifier ``mono`` or the index ``-1`` can be used for mono mix of all +channels for both playback and recording. (CoreAudio/macOS defines channel ``-1`` +as silence for both playback and recording.) In addition to the indices, the PulseAudio +backend allows the use of the name strings to define a channel map :: + +.. code:: python + # This example plays one second of noise on each channel defined in the channel map consecutively. + # The channel definition scheme using strings only works with the PulseAudio backend! + + # This defines a channel map for a 7.1 audio sink device + channel_map = ['left', 'right', 'center', 'lfe', 'rear-left', 'rear-right', 'side-left', 'side-right'] + + num_channels = len(channel_map) + samplerate = 48000 + + # Create the multi channel noise array. + noise_samples = 48000 + noise = numpy.random.uniform(-0.1, 0.1, noise_samples) + data = numpy.zeros((num_channels * noise_samples, num_channels), dtype=numpy.float32) + for channel in range(num_channels): + data[channel * noise_samples:(channel + 1) * noise_samples, channel] = noise + + # Playback using the 7.1 channel map. + default_speaker.play(data=data, channels=channel_map, samplerate=samplerate) FAQ --- From 7909518dc3ee4659156ed9bf6cc1047af78dfc98 Mon Sep 17 00:00:00 2001 From: szlop <65676717+szlop@users.noreply.github.com> Date: Wed, 8 Mar 2023 20:36:12 +0100 Subject: [PATCH 7/9] Update README.rst Fixed: Code examples in the README would not show due to syntax errors. --- README.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 1dd00d6..49c2592 100644 --- a/README.rst +++ b/README.rst @@ -151,12 +151,14 @@ in the the channel map list indicates the channel index in the numpy data array used in SoundCard: .. code:: python + # record one second of audio from backend channels 0 to 3: data = default_mic.record(samplerate=48000, channels=[0, 1, 2, 3], numframes=48000) # play back the recorded audio in reverse channel order: default_speaker.play(data=data, channels=[3, 2, 1, 0], samplerate=48000) + The meaning of the channel specifiers depend on the backend in use. For WASAPI (Windows) and CoreAudio (macOS) the indices refer to the physical output channels of the sound device in use. For the PulseAudio backend (Linux) the @@ -167,7 +169,7 @@ The channel position identifiers in the PulseAudio backend are based on: https://freedesktop.org/software/pulseaudio/doxygen/channelmap_8h.html Since the mapping of position indices to audio channels is not obvious, a dictionary containing all possible positions and channel indices can be -retrieved by calling ``channel_name_map()``. The positions for the indices up to 10 are :: +retrieved by calling ``channel_name_map()``. The positions for the indices up to 10 are: :: 'mono': -1, 'left': 0, 'right': 1, @@ -184,9 +186,10 @@ retrieved by calling ``channel_name_map()``. The positions for the indices up to The identifier ``mono`` or the index ``-1`` can be used for mono mix of all channels for both playback and recording. (CoreAudio/macOS defines channel ``-1`` as silence for both playback and recording.) In addition to the indices, the PulseAudio -backend allows the use of the name strings to define a channel map :: +backend allows the use of the name strings to define a channel map: .. code:: python + # This example plays one second of noise on each channel defined in the channel map consecutively. # The channel definition scheme using strings only works with the PulseAudio backend! From 62d84b3def2f88dc18350333a67252d26372293f Mon Sep 17 00:00:00 2001 From: szlop <65676717+szlop@users.noreply.github.com> Date: Tue, 14 Mar 2023 14:30:12 +0100 Subject: [PATCH 8/9] Update README.rst Explained how to list the available channels of all PulseAudio sinks and sources using pactl. --- README.rst | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.rst b/README.rst index 49c2592..2372ed6 100644 --- a/README.rst +++ b/README.rst @@ -209,6 +209,33 @@ backend allows the use of the name strings to define a channel map: # Playback using the 7.1 channel map. default_speaker.play(data=data, channels=channel_map, samplerate=samplerate) +The available channels of each PulseAudio source or sink can be listed by :: + + > pactl list sinks + > pactl list sources + + +The ``Channel Map`` property lists the channel identifier of the source/sink. :: + + > pactl list sinks | grep "Channel Map" -B 6 + + Sink #486 + State: SUSPENDED + Name: alsa_output.usb-C-Media_Electronics_Inc._USB_Advanced_Audio_Device-00.analog-stereo + Description: USB Advanced Audio Device Analog Stereo + Driver: PipeWire + Sample Specification: s24le 2ch 48000Hz + Channel Map: front-left,front-right + -- + Sink #488 + State: RUNNING + Name: alsa_output.pci-0000_2f_00.4.analog-surround-71 + Description: Starship/Matisse HD Audio Controller Analog Surround 7.1 + Driver: PipeWire + Sample Specification: s32le 8ch 48000Hz + Channel Map: front-left,front-right,rear-left,rear-right,front-center,lfe,side-left,side-right + + FAQ --- Q: How to make it work on a headless Raspberry Pi? From 2f48ee9453037d67be74dcb7fab5b1f35f45e0d0 Mon Sep 17 00:00:00 2001 From: szlop Date: Mon, 20 Mar 2023 22:10:04 +0100 Subject: [PATCH 9/9] Replaced the merge operator for dictionaries (|) with dict.update() for backwards compatibility and added a comment. --- soundcard/pulseaudio.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/soundcard/pulseaudio.py b/soundcard/pulseaudio.py index 3042510..3458d2f 100644 --- a/soundcard/pulseaudio.py +++ b/soundcard/pulseaudio.py @@ -53,16 +53,18 @@ def channel_name_map(): """ channel_indices = { - 'left': _pa.PA_CHANNEL_POSITION_LEFT, - 'right': _pa.PA_CHANNEL_POSITION_RIGHT, - 'center': _pa.PA_CHANNEL_POSITION_CENTER, - 'subwoofer': _pa.PA_CHANNEL_POSITION_SUBWOOFER} | \ - { - _ffi.string(_pa.pa_channel_position_to_string(idx)).decode('utf-8'): idx for idx in - range(_pa.PA_CHANNEL_POSITION_MAX) - } - - # The above values returned from Pulseaudio contain 1 for 'left', 2 for 'right' and so on. + _ffi.string(_pa.pa_channel_position_to_string(idx)).decode('utf-8'): idx for idx in + range(_pa.PA_CHANNEL_POSITION_MAX) + } + + # Append alternative names for front-left, front-right, front-center and lfe according to + # the PulseAudio definitions. + channel_indices.update({'left': _pa.PA_CHANNEL_POSITION_LEFT, + 'right': _pa.PA_CHANNEL_POSITION_RIGHT, + 'center': _pa.PA_CHANNEL_POSITION_CENTER, + 'subwoofer': _pa.PA_CHANNEL_POSITION_SUBWOOFER}) + + # The values returned from Pulseaudio contain 1 for 'left', 2 for 'right' and so on. # SoundCard's channel indices for 'left' start at 0. Therefore, we have to decrement all values. channel_indices = {key: value - 1 for (key, value) in channel_indices.items()}