Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pa channelmap #169

Merged
merged 9 commits into from
Mar 21, 2023
98 changes: 90 additions & 8 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
35 changes: 32 additions & 3 deletions soundcard/pulseaudio.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,29 @@ 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 = {
'left': _pa.PA_CHANNEL_POSITION_LEFT,
'right': _pa.PA_CHANNEL_POSITION_RIGHT,
'center': _pa.PA_CHANNEL_POSITION_CENTER,
'subwoofer': _pa.PA_CHANNEL_POSITION_SUBWOOFER} | \
bastibe marked this conversation as resolved.
Show resolved Hide resolved
{
_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


class _PulseAudio:
"""Proxy for communcation with Pulseaudio.

Expand Down Expand Up @@ -651,10 +674,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
bastibe marked this conversation as resolved.
Show resolved Hide resolved
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')

Expand Down Expand Up @@ -748,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
Expand Down
3 changes: 2 additions & 1 deletion soundcard/pulseaudio.py.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down