/
/
1"""
2PulseAudio remap-sink topology computation for multi-channel cards.
3
4For any output sink with more than 2 channels, computes the set of
5module-remap-sink.c sinks local_audio should create:
6 - one stereo "zone" sink per recognized channel pair present in the
7 master's channel map (front/rear/side stereo, center+sub), always
8 exposed to clients as front-left,front-right regardless of which
9 physical channels they pull from, and
10 - one full-passthrough sink (same channel count and channel map as the
11 master) for sinks with 6+ channels, giving "play to all outputs"
12 behavior with independent hardware volume control.
13
14This mirrors the channel-pair definitions and naming convention previously
15implemented by an external PulseAudio stereo-pairs addon, so that existing
16sink names/layouts are preserved when local_audio takes over creating them.
17"""
18
19from __future__ import annotations
20
21import re
22from dataclasses import dataclass
23from typing import Final
24
25# Recognized stereo zone pairs: suffix -> (master channel 1, master channel 2).
26# A zone is created only if BOTH channels are present in the master's
27# channel map. The zone sink always exposes front-left,front-right to
28# clients regardless of which physical channels it pulls from.
29STEREO_PAIRS: Final[dict[str, tuple[str, str]]] = {
30 "front_stereo": ("front-left", "front-right"),
31 "rear_stereo": ("rear-left", "rear-right"),
32 "side_stereo": ("side-left", "side-right"),
33 "center_sub": ("front-center", "lfe"),
34}
35
36# Minimum master channel count for a full-passthrough "multichannel stereo"
37# sink (named after the AVR "Multi Channel Stereo" / "All Channel Stereo"
38# mode â plays the same content to all output pairs).
39SURROUND_PASSTHROUGH_MIN_CHANNELS: Final = 6
40
41ZONE_CHANNEL_MAP: Final[tuple[str, str]] = ("front-left", "front-right")
42
43
44@dataclass(frozen=True, slots=True)
45class RemapSinkSpec:
46 """A single module-remap-sink.c sink to create."""
47
48 sink_name: str
49 master_channel_map: tuple[str, ...]
50 channel_map: tuple[str, ...]
51 channels: int
52
53
54def normalize_card_name(alsa_card_name: str) -> str:
55 """
56 Normalize an alsa.card_name property into a sink-name prefix.
57
58 Mirrors `tr ' -' '_' | tr -cd '[:alnum:]_'`, e.g. "Creative X-Fi" ->
59 "Creative_X_Fi", "HD-Audio Generic" -> "HD_Audio_Generic".
60 """
61 name = re.sub(r"[ -]", "_", alsa_card_name)
62 return re.sub(r"[^A-Za-z0-9_]", "", name)
63
64
65def compute_remap_topology(
66 card_name: str, channel_map: list[str], max_output_channels: int
67) -> list[RemapSinkSpec]:
68 """
69 Compute the remap-sink topology for one multi-channel master sink.
70
71 :param card_name: Normalized card name prefix (see normalize_card_name),
72 used as f"{card_name}_{suffix}" for each created sink.
73 :param channel_map: The master sink's PA channel map (channel position
74 names, e.g. ["front-left", "front-right", "rear-left", ...]).
75 :param max_output_channels: The master sink's channel count.
76 :returns: List of RemapSinkSpec to create. Empty if the master is
77 already stereo or smaller (channels <= 2) â nothing to remap.
78 """
79 if max_output_channels <= 2:
80 return []
81
82 channel_set = set(channel_map)
83 specs: list[RemapSinkSpec] = []
84
85 for suffix, (ch_a, ch_b) in STEREO_PAIRS.items():
86 if ch_a in channel_set and ch_b in channel_set:
87 specs.append(
88 RemapSinkSpec(
89 sink_name=f"{card_name}_{suffix}",
90 master_channel_map=(ch_a, ch_b),
91 channel_map=ZONE_CHANNEL_MAP,
92 channels=2,
93 )
94 )
95
96 if max_output_channels >= SURROUND_PASSTHROUGH_MIN_CHANNELS:
97 specs.append(
98 RemapSinkSpec(
99 sink_name=f"{card_name}_multichannel_stereo",
100 master_channel_map=tuple(channel_map),
101 channel_map=tuple(channel_map),
102 channels=max_output_channels,
103 )
104 )
105
106 return specs
107
108
109def build_remap_sink_argument(spec: RemapSinkSpec, master_sink_name: str) -> str:
110 """Build the module-remap-sink.c argument string for a RemapSinkSpec."""
111 return (
112 f"sink_name={spec.sink_name} "
113 f"master={master_sink_name} "
114 f"sink_properties=device.description={spec.sink_name} "
115 f"channels={spec.channels} "
116 f"master_channel_map={','.join(spec.master_channel_map)} "
117 f"channel_map={','.join(spec.channel_map)} "
118 f"remix=no"
119 )
120