diff --git a/README.rst b/README.rst index e6c990e..2372ed6 100644 --- a/README.rst +++ b/README.rst @@ -145,14 +145,96 @@ 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) + +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 --- diff --git a/soundcard/pulseaudio.py b/soundcard/pulseaudio.py index 53a27ff..3458d2f 100644 --- a/soundcard/pulseaudio.py +++ b/soundcard/pulseaudio.py @@ -46,6 +46,31 @@ def func_with_lock(*args, **kwargs): self._pa_operation_unref(operation) return func_with_lock + +def channel_name_map(): + """ + Return a dict containing the channel position index for every channel position name string. + """ + + channel_indices = { + _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()} + + return channel_indices + + class _PulseAudio: """Proxy for communcation with Pulseaudio. @@ -651,10 +676,15 @@ 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 + if isinstance(ch, int): + channelmap.map[idx] = ch + 1 + else: + channel_name_to_index = channel_name_map() + channelmap.map[idx] = channel_name_to_index[ch] + 1 + if not _pa.pa_channel_map_valid(channelmap): raise RuntimeError('invalid channel map') @@ -748,7 +778,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 diff --git a/soundcard/pulseaudio.py.h b/soundcard/pulseaudio.py.h index 7b1facc..2d82ee9 100644 --- a/soundcard/pulseaudio.py.h +++ b/soundcard/pulseaudio.py.h @@ -109,8 +109,9 @@ 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); +const char* pa_channel_position_to_string(pa_channel_position_t pos); typedef struct pa_buffer_attr { uint32_t maxlength;