GCC Code Coverage Report


Directory: ./
File: boot/FPPINIT_Audio.cpp
Date: 2026-10-29 08:11:58
Exec Total Coverage
Lines: 1280 1458 87.8%
Functions: 49 50 98.0%
Branches: 2487 4942 50.3%

Line Branch Exec Source
1 /*
2 * This file is part of the Falcon Player (FPP) and is Copyright (C)
3 * 2013-2024 by the Falcon Player Developers.
4 *
5 * The Falcon Player (FPP) is free software, and is covered under
6 * multiple Open Source licenses. Please see the included 'LICENSES'
7 * file for descriptions of what files are covered by each license.
8 *
9 * This source file is covered under the LGPL v2.1 as described in the
10 * included LICENSE.LGPL file.
11 */
12
13 #include <algorithm>
14 #include "fpp-json.h"
15 #include <chrono>
16 #include <climits>
17 #include <cstdio>
18 #include <cstdlib>
19 #include <dirent.h>
20 #include <map>
21 #include <regex>
22 #include <set>
23 #include <sstream>
24 #include <sys/stat.h>
25 #include <thread>
26 #include <unistd.h>
27 #include <vector>
28
29 #include "FPPINIT.h"
30
31 // Resolve ALSA card number to stable card ID (e.g., 3 -> "S3", 0 -> "ICUSBAUDIO7D")
32 // Reads /proc/asound/cards: " 3 [S3 ]: USB-Audio - ..."
33 // Falls back to the card number as string if not found.
34 // Read the USB product name for an ALSA card from sysfs (e.g. "USB Sound Device").
35 // Falls back to the ALSA card type name if sysfs is unavailable.
36 30 static std::string getAlsaCardProductName(int cardNum, const std::string& fallback) {
37
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 std::string devicePath = "/sys/class/sound/card" + std::to_string(cardNum) + "/device";
38 char resolved[PATH_MAX];
39
2/2
✓ Branch 0 taken 27 times.
✓ Branch 1 taken 3 times.
30 if (realpath(devicePath.c_str(), resolved)) {
40 // The ALSA device sysfs node points to a USB interface.
41 // The USB product string is in the parent USB device node.
42
1/2
✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
27 std::string parentDir = resolved;
43 27 auto pos = parentDir.rfind('/');
44
1/2
✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
27 if (pos != std::string::npos) {
45
2/4
✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 27 times.
✗ Branch 3 not taken.
27 std::string productFile = parentDir.substr(0, pos) + "/product";
46
1/2
✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
27 std::string product = GetFileContents(productFile);
47
1/2
✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
27 TrimWhiteSpace(product);
48 // It is written into PipeWire's SPA-JSON config as a quoted
49 // string, and the device chose it: a '"' or '\\' would end the
50 // string early, and a config PipeWire can't parse brings audio
51 // down at every boot.
52
0/2
✗ Branch 0 not taken.
✗ Branch 1 not taken.
27 std::string safe;
53
2/2
✓ Branch 0 taken 84 times.
✓ Branch 1 taken 27 times.
111 for (unsigned char ch : product) {
54
2/4
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 84 times.
84 if (ch == '"' || ch == '\\') {
55 ✗ safe += '\'';
56
2/4
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 84 times.
✗ Branch 3 not taken.
84 } else if (ch >= 0x20 && ch != 0x7f) {
57
1/2
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
84 safe += (char)ch;
58 }
59 }
60
1/2
✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
27 TrimWhiteSpace(safe);
61
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 23 times.
27 if (!safe.empty()) {
62 4 return safe;
63 }
64
4/4
✓ Branch 0 taken 23 times.
✓ Branch 1 taken 4 times.
✓ Branch 2 taken 23 times.
✓ Branch 3 taken 4 times.
54 }
65
2/2
✓ Branch 0 taken 23 times.
✓ Branch 1 taken 4 times.
27 }
66
1/2
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
26 return fallback;
67 30 }
68
69 // Written at boot by setupAudio(); see the comment where it is written.
70 static const char* const EFFECTIVE_AUDIO_OUTPUT_PATH = "/run/fppd/audio-output.json";
71
72 364 static std::string getAlsaCardId(int cardNum) {
73
2/4
✓ Branch 0 taken 364 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 364 times.
✗ Branch 3 not taken.
364 std::string cardsContent = GetFileContents("/proc/asound/cards");
74
2/2
✓ Branch 0 taken 302 times.
✓ Branch 1 taken 62 times.
364 if (!cardsContent.empty()) {
75
1/2
✓ Branch 0 taken 302 times.
✗ Branch 1 not taken.
302 std::istringstream iss(cardsContent);
76 302 std::string line;
77
3/6
✓ Branch 0 taken 668 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 668 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 668 times.
✗ Branch 5 not taken.
668 while (std::getline(iss, line)) {
78 // Match: " 3 [S3 ]: ..."
79 668 auto bracket = line.find('[');
80 668 auto closeBracket = line.find(']');
81
4/6
✓ Branch 0 taken 479 times.
✓ Branch 1 taken 189 times.
✓ Branch 2 taken 479 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 479 times.
✗ Branch 5 not taken.
668 if (bracket != std::string::npos && closeBracket != std::string::npos && closeBracket > bracket) {
82
1/2
✓ Branch 0 taken 479 times.
✗ Branch 1 not taken.
479 std::string numStr = line.substr(0, bracket);
83
1/2
✓ Branch 0 taken 479 times.
✗ Branch 1 not taken.
479 TrimWhiteSpace(numStr);
84 try {
85
1/2
✓ Branch 0 taken 479 times.
✗ Branch 1 not taken.
479 int num = std::stoi(numStr);
86
2/2
✓ Branch 0 taken 302 times.
✓ Branch 1 taken 177 times.
479 if (num == cardNum) {
87
1/4
✓ Branch 0 taken 302 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
302 std::string cardId = line.substr(bracket + 1, closeBracket - bracket - 1);
88
1/2
✓ Branch 0 taken 302 times.
✗ Branch 1 not taken.
302 TrimWhiteSpace(cardId);
89
1/2
✓ Branch 0 taken 302 times.
✗ Branch 1 not taken.
302 if (!cardId.empty()) {
90 302 return cardId;
91 }
92 ✗ }
93 ✗ } catch (...) {}
94
2/2
✓ Branch 0 taken 177 times.
✓ Branch 1 taken 302 times.
479 }
95 }
96
2/4
✗ Branch 0 not taken.
✓ Branch 1 taken 302 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 302 times.
604 }
97 62 return std::to_string(cardNum);
98 364 }
99
100 // Inverse of getAlsaCardId: resolve a stable ALSA card ID (e.g. "S3",
101 // "bcm2835ALSA") to its current card number by scanning /proc/asound/cards.
102 // Returns -1 if no currently-present card matches that ID.
103 356 static int getAlsaCardNumForId(const std::string& cardId) {
104
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 356 times.
356 if (cardId.empty()) {
105 ✗ return -1;
106 }
107
3/6
✓ Branch 0 taken 356 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 356 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 356 times.
✗ Branch 5 not taken.
356 std::istringstream iss(GetFileContents("/proc/asound/cards"));
108 356 std::string line;
109
4/6
✓ Branch 0 taken 705 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 705 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 461 times.
✓ Branch 5 taken 244 times.
705 while (std::getline(iss, line)) {
110 461 auto bracket = line.find('[');
111 461 auto closeBracket = line.find(']');
112
4/6
✓ Branch 0 taken 175 times.
✓ Branch 1 taken 286 times.
✓ Branch 2 taken 175 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 175 times.
✗ Branch 5 not taken.
461 if (bracket != std::string::npos && closeBracket != std::string::npos && closeBracket > bracket) {
113
1/2
✓ Branch 0 taken 175 times.
✗ Branch 1 not taken.
175 std::string id = line.substr(bracket + 1, closeBracket - bracket - 1);
114
1/2
✓ Branch 0 taken 175 times.
✗ Branch 1 not taken.
175 TrimWhiteSpace(id);
115
2/2
✓ Branch 0 taken 112 times.
✓ Branch 1 taken 63 times.
175 if (id == cardId) {
116
1/2
✓ Branch 0 taken 112 times.
✗ Branch 1 not taken.
112 std::string numStr = line.substr(0, bracket);
117
1/2
✓ Branch 0 taken 112 times.
✗ Branch 1 not taken.
112 TrimWhiteSpace(numStr);
118 try {
119
1/2
✓ Branch 0 taken 112 times.
✗ Branch 1 not taken.
112 return std::stoi(numStr);
120 ✗ } catch (...) {}
121
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 112 times.
112 }
122
2/2
✓ Branch 0 taken 63 times.
✓ Branch 1 taken 112 times.
175 }
123 }
124 244 return -1;
125 356 }
126
127 // Number of registered cards that aren't the synthetic snd-dummy. Reads
128 // /proc/asound/cards, which is pure kernel state -- unlike "aplay -l", it needs
129 // no /dev/snd node and so is visible the instant the card registers.
130 137 static int countRealAlsaCards() {
131 137 int count = 0;
132
3/6
✓ Branch 0 taken 137 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 137 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 137 times.
✗ Branch 5 not taken.
137 std::istringstream iss(GetFileContents("/proc/asound/cards"));
133 137 std::string line;
134
4/6
✓ Branch 0 taken 245 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 245 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 108 times.
✓ Branch 5 taken 137 times.
245 while (std::getline(iss, line)) {
135 108 auto bracket = line.find('[');
136 108 auto closeBracket = line.find(']');
137
4/6
✓ Branch 0 taken 9 times.
✓ Branch 1 taken 99 times.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
108 if (bracket != std::string::npos && closeBracket != std::string::npos && closeBracket > bracket) {
138
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 std::string id = line.substr(bracket + 1, closeBracket - bracket - 1);
139
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 TrimWhiteSpace(id);
140
2/4
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
9 if (id != "Dummy") {
141 9 ++count;
142 }
143 9 }
144 }
145 137 return count;
146 137 }
147
148 // True if the device tree declares a sound card -- i.e. this board is *expected*
149 // to have audio, whether or not the driver has bound yet. Used to decide whether
150 // "no cards yet" means "still coming" or "there is genuinely no audio here", so
151 // only the ambiguous case ever waits. A board with no sound node (a BeagleBone
152 // with no audio cape) costs one failed opendir.
153 37 static bool deviceTreeDeclaresSoundCard() {
154 37 DIR* dp = opendir("/proc/device-tree");
155
2/2
✓ Branch 0 taken 31 times.
✓ Branch 1 taken 6 times.
37 if (!dp) {
156 31 return false;
157 }
158 6 bool found = false;
159 6 struct dirent* ep = nullptr;
160
6/6
✓ Branch 0 taken 20 times.
✓ Branch 1 taken 5 times.
✓ Branch 2 taken 19 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 19 times.
✓ Branch 5 taken 6 times.
25 while (!found && (ep = readdir(dp)) != nullptr) {
161
1/2
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
31 std::string name = ep->d_name;
162 // the node is "sound" or, when addressed, "sound@<unit>"
163
6/8
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 13 times.
✓ Branch 3 taken 6 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 13 times.
✓ Branch 6 taken 6 times.
✓ Branch 7 taken 13 times.
19 if (name == "sound" || name.starts_with("sound@")) {
164 // Some boards ship the node with status "disabled" (an audio cape
165 // overlay flips it to "okay"). A disabled node can never bind, so
166 // it must not count as "audio is expected". No status property
167 // means enabled per the DT spec.
168
3/6
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 6 times.
✗ Branch 5 not taken.
6 std::string status = GetFileContents("/proc/device-tree/" + name + "/status");
169
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 TrimWhiteSpace(status);
170
3/4
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✓ Branch 3 taken 1 times.
6 found = status.empty() || status.starts_with("ok");
171 6 }
172 19 }
173 6 closedir(dp);
174 6 return found;
175 }
176
177 // True if a USB audio device is plugged in, whether or not snd-usb-audio has
178 // bound to it yet. A USB DAC has no device-tree node, so it needs its own check:
179 // the device appears in sysfs at enumeration, then udev autoloads snd-usb-audio,
180 // then the card registers -- and setupAudio can land in that gap.
181 //
182 // Read from sysfs rather than lsusb: only "lsusb -v" reports interface class, and
183 // it costs ~200ms on a BeagleBone (it walks and wakes every device) against a
184 // handful of open() calls here.
185 //
186 // bInterfaceClass 01 is USB Audio. Subclass 03 is MIDIStreaming, which yields a
187 // card with no playback device, so it can never satisfy the probe below -- skip
188 // it and don't wait on a MIDI controller.
189 32 static bool usbAudioDevicePresent() {
190 32 DIR* dp = opendir("/sys/bus/usb/devices");
191
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 32 times.
32 if (!dp) {
192 ✗ return false;
193 }
194 32 bool found = false;
195 32 struct dirent* ep = nullptr;
196
5/6
✓ Branch 0 taken 96 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 64 times.
✓ Branch 3 taken 32 times.
✓ Branch 4 taken 64 times.
✓ Branch 5 taken 32 times.
96 while (!found && (ep = readdir(dp)) != nullptr) {
197
1/2
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
64 std::string dev = ep->d_name;
198
6/10
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 32 times.
✓ Branch 3 taken 32 times.
✓ Branch 4 taken 32 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 32 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 64 times.
✗ Branch 9 not taken.
64 if (dev == "." || dev == "..") {
199 64 continue;
200 }
201 ✗ std::string base = "/sys/bus/usb/devices/" + dev;
202 ✗ std::string cls = GetFileContents(base + "/bInterfaceClass");
203 ✗ TrimWhiteSpace(cls);
204 ✗ if (cls != "01") {
205 ✗ continue;
206 }
207 ✗ std::string sub = GetFileContents(base + "/bInterfaceSubClass");
208 ✗ TrimWhiteSpace(sub);
209 ✗ if (sub != "03") {
210 ✗ found = true;
211 }
212
1/6
✗ Branch 0 not taken.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 64 times.
64 }
213 32 closedir(dp);
214 32 return found;
215 }
216
217 // An ASoC card cannot bind until *every* component it references is present --
218 // the codec, the CPU DAI (e.g. McASP) and the machine driver (simple-audio-card).
219 // A cape EEPROM only names the codec, and the rest are device-tree platform
220 // devices whose drivers udev autoloads from their modalias. On a slow single-core
221 // board udev's coldplug can land tens of seconds into boot, long after we would
222 // have given up and taken snd-dummy -- measured ~35s after the codec on a
223 // BeagleBone, with the card arriving 10s behind snd-dummy.
224 //
225 // So do udev's job for the audio devices, early: modprobe the modalias of any
226 // still-unbound platform device whose compatible looks audio-related. Driving it
227 // off the kernel's own modalias means no module or SoC name is hardcoded, so this
228 // works for any cape/codec/CPU-DAI combination.
229 5 static void loadPendingSoundDrivers() {
230
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 const std::string base = "/sys/bus/platform/devices";
231
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 DIR* dp = opendir(base.c_str());
232
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 5 times.
5 if (!dp) {
233 ✗ return;
234 }
235 5 struct dirent* ep = nullptr;
236
3/4
✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 32 times.
✓ Branch 3 taken 5 times.
37 while ((ep = readdir(dp)) != nullptr) {
237
1/2
✓ Branch 0 taken 32 times.
✗ Branch 1 not taken.
52 std::string dev = ep->d_name;
238
8/10
✓ Branch 0 taken 32 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 27 times.
✓ Branch 3 taken 5 times.
✓ Branch 4 taken 27 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 5 times.
✓ Branch 7 taken 22 times.
✓ Branch 8 taken 10 times.
✓ Branch 9 taken 22 times.
32 if (dev == "." || dev == "..") {
239 10 continue;
240 }
241
2/4
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 22 times.
✗ Branch 3 not taken.
22 std::string path = base + "/" + dev;
242
4/6
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 22 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 9 times.
✓ Branch 5 taken 13 times.
22 if (FileExists(path + "/driver")) {
243 9 continue; // a driver is already bound
244 }
245
2/4
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 13 times.
✗ Branch 3 not taken.
13 std::string modalias = GetFileContents(path + "/modalias");
246
1/2
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
13 TrimWhiteSpace(modalias);
247 // the modalias embeds the DT compatible, so it is also what we match on.
248 // A quote would let the string escape the shell below; nothing the kernel
249 // emits contains one, but refuse rather than trust that.
250
3/6
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 13 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 13 times.
13 if (modalias.empty() || modalias.find('\'') != std::string::npos) {
251 ✗ continue;
252 }
253
1/2
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
13 std::string lower = modalias;
254 13 std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
255
7/10
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 8 times.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 5 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 5 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 5 times.
✓ Branch 9 taken 8 times.
13 if (!lower.contains("audio") && !lower.contains("sound") && !lower.contains("mcasp") && !lower.contains("i2s")) {
256 5 continue;
257 }
258
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 printf("FPP - Loading sound driver for %s (%s)\n", dev.c_str(), modalias.c_str());
259
3/6
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
8 exec("/sbin/modprobe '" + modalias + "' > /dev/null 2>&1");
260
8/8
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 5 times.
✓ Branch 2 taken 8 times.
✓ Branch 3 taken 5 times.
✓ Branch 4 taken 8 times.
✓ Branch 5 taken 14 times.
✓ Branch 6 taken 8 times.
✓ Branch 7 taken 24 times.
56 }
261
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 closedir(dp);
262
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 }
263
264 // Load a PipeWire audio-groups config (pipewire-audio-groups[-simple].json).
265 // The UI writes these, but fppinit reads them at boot with no one to restart
266 // it, so every field the readers below touch is checked for the type they read
267 // it as: a file that fails is reported unusable, which each caller already
268 // treats as a missing or stale config (regenerate it, or skip it), instead of
269 // throwing out of fppinit on a wrong-typed value.
270 218 static bool loadAudioGroupsConfig(const std::string& path, Json::Value& root) {
271
1/2
✓ Branch 0 taken 218 times.
✗ Branch 1 not taken.
218 root = Json::Value();
272
10/16
✓ Branch 0 taken 218 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 161 times.
✓ Branch 3 taken 57 times.
✓ Branch 4 taken 161 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 161 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 161 times.
✓ Branch 10 taken 161 times.
✓ Branch 11 taken 57 times.
✓ Branch 12 taken 57 times.
✓ Branch 13 taken 161 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
218 if (!FileExists(path) || !LoadJsonFromString(GetFileContents(path), root, JsonRoot::Object)) {
273 57 return false;
274 }
275 1093 auto fits = [](const Json::Value& o, const char* key, Json::ValueType t) {
276
3/4
✓ Branch 0 taken 930 times.
✓ Branch 1 taken 163 times.
✓ Branch 2 taken 930 times.
✗ Branch 3 not taken.
1093 return !o.isMember(key) || o[key].isConvertibleTo(t);
277 };
278
1/2
✓ Branch 0 taken 161 times.
✗ Branch 1 not taken.
161 const Json::Value& groups = root["groups"];
279
7/10
✓ Branch 0 taken 161 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 161 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 161 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 159 times.
✓ Branch 8 taken 2 times.
✓ Branch 9 taken 159 times.
161 if (!groups.isNull() && !groups.isArray()) {
280 2 return false;
281 }
282
7/12
✓ Branch 0 taken 159 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 159 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 159 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 155 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 314 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 159 times.
✓ Branch 11 taken 155 times.
314 for (const auto& grp : groups) {
283
5/10
✓ Branch 0 taken 159 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 157 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 157 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 157 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 157 times.
✗ Branch 9 not taken.
316 if (!grp.isObject() || !fits(grp, "enabled", Json::booleanValue) || !fits(grp, "id", Json::intValue) ||
284
8/12
✓ Branch 0 taken 157 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 157 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 157 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 157 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 157 times.
✓ Branch 10 taken 2 times.
✓ Branch 11 taken 157 times.
316 !fits(grp, "volume", Json::intValue) || !fits(grp, "name", Json::stringValue)) {
285 4 return false;
286 }
287
1/2
✓ Branch 0 taken 157 times.
✗ Branch 1 not taken.
157 const Json::Value& members = grp["members"];
288
7/10
✓ Branch 0 taken 157 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 157 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 157 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 155 times.
✓ Branch 8 taken 2 times.
✓ Branch 9 taken 155 times.
157 if (!members.isNull() && !members.isArray()) {
289 2 return false;
290 }
291
7/12
✓ Branch 0 taken 155 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 155 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 155 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 155 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 310 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 155 times.
✓ Branch 11 taken 155 times.
310 for (const auto& mbr : members) {
292
3/6
✓ Branch 0 taken 155 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 155 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 155 times.
✗ Branch 5 not taken.
310 if (!mbr.isObject() || !fits(mbr, "cardId", Json::stringValue) ||
293
6/12
✓ Branch 0 taken 155 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 155 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 155 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 155 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 155 times.
✗ Branch 10 not taken.
✓ Branch 11 taken 155 times.
310 !fits(mbr, "nodeTarget", Json::stringValue) || !fits(mbr, "volume", Json::intValue)) {
294 ✗ return false;
295 }
296 }
297 }
298 155 return true;
299 }
300
301 // Returns true if every ALSA card referenced by the PipeWire audio-groups JSON
302 // is currently present (by stable card ID, as listed in /proc/asound/cards).
303 // Used to decide whether the cached PipeWire group config is still valid for the
304 // current hardware without forking the (expensive) regeneration PHP. A removed
305 // or swapped card -> some referenced cardId is absent -> returns false so the
306 // regen runs and strips/re-resolves it. The regen resolves by stable card ID
307 // (not card number), so a card that's still present but renumbered keeps a valid
308 // config -- which is why presence-by-ID is a sufficient check.
309 48 static bool pipewireConfigCardsPresent(const std::string& jsonPath) {
310
1/2
✓ Branch 0 taken 48 times.
✗ Branch 1 not taken.
48 Json::Value root;
311
3/4
✓ Branch 0 taken 48 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✓ Branch 3 taken 45 times.
48 if (!loadAudioGroupsConfig(jsonPath, root)) {
312 3 return false;
313 }
314 45 std::vector<std::string> present;
315
3/6
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 45 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 45 times.
✗ Branch 5 not taken.
45 std::istringstream iss(GetFileContents("/proc/asound/cards"));
316 45 std::string line;
317
4/6
✓ Branch 0 taken 189 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 189 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 144 times.
✓ Branch 5 taken 45 times.
189 while (std::getline(iss, line)) {
318 144 auto b = line.find('[');
319 144 auto e = line.find(']');
320
4/6
✓ Branch 0 taken 76 times.
✓ Branch 1 taken 68 times.
✓ Branch 2 taken 76 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 76 times.
✗ Branch 5 not taken.
144 if (b != std::string::npos && e != std::string::npos && e > b) {
321
1/2
✓ Branch 0 taken 76 times.
✗ Branch 1 not taken.
76 std::string id = line.substr(b + 1, e - b - 1);
322
1/2
✓ Branch 0 taken 76 times.
✗ Branch 1 not taken.
76 TrimWhiteSpace(id);
323
1/2
✓ Branch 0 taken 76 times.
✗ Branch 1 not taken.
76 if (!id.empty()) {
324
1/2
✓ Branch 0 taken 76 times.
✗ Branch 1 not taken.
76 present.push_back(id);
325 }
326 76 }
327 }
328
8/14
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 45 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 45 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 45 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 45 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 90 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 45 times.
✓ Branch 13 taken 45 times.
90 for (const auto& grp : root["groups"]) {
329
8/14
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 45 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 45 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 45 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 45 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 90 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 45 times.
✓ Branch 13 taken 45 times.
90 for (const auto& mbr : grp["members"]) {
330
3/6
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 45 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 45 times.
✗ Branch 5 not taken.
45 std::string cid = mbr.get("cardId", "").asString();
331
4/8
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 45 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 45 times.
✗ Branch 6 not taken.
✓ Branch 7 taken 45 times.
45 if (!cid.empty() && std::find(present.begin(), present.end(), cid) == present.end()) {
332 ✗ return false;
333 }
334
1/2
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
45 }
335 }
336 45 return true;
337 48 }
338
339 // Normalise an ALSA card ID for use in a PipeWire node name: lowercase, with
340 // every non-alphanumeric (and non-underscore) character replaced by '_'. Must
341 // match the convention used by the 95-fpp-alsa-sink.conf generation above and
342 // by GeneratePipeWireGroupsConfig() in pipewire.php.
343 226 static std::string normalizeCardIdForNode(const std::string& cardId) {
344 226 std::string n = cardId;
345 226 std::transform(n.begin(), n.end(), n.begin(), ::tolower);
346
2/2
✓ Branch 0 taken 2525 times.
✓ Branch 1 taken 226 times.
2751 for (auto& ch : n) {
347
4/4
✓ Branch 0 taken 37 times.
✓ Branch 1 taken 2488 times.
✓ Branch 2 taken 29 times.
✓ Branch 3 taken 8 times.
2525 if (!std::isalnum(static_cast<unsigned char>(ch)) && ch != '_') ch = '_';
348 }
349 226 return n;
350 }
351
352 // Returns the first output-group member whose cached nodeTarget is a WirePlumber
353 // name for a card FPP owns (ownedCids: normalised IDs of the cards the boot conf
354 // covers), or "" if there is none.
355 //
356 // Every such card has its WirePlumber device disabled by
357 // 50-fpp-suppress-alsa-dupes.conf, so that node never exists: the group conf
358 // built from it links its filter-chain to nothing and plays silently, with no
359 // error anywhere. It gets there when the card was hot-plugged (WirePlumber's
360 // node was the only one when the group was applied) or the group predates the
361 // suppression rule. The card-presence check cannot see it -- the card IS
362 // present -- so without this the cached conf is re-validated every boot.
363 42 static std::string pipewireConfigSuppressedTarget(const std::string& jsonPath,
364 const std::set<std::string>& ownedCids) {
365
1/2
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
42 Json::Value root;
366
2/4
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 42 times.
42 if (!loadAudioGroupsConfig(jsonPath, root)) {
367 ✗ return "";
368 }
369
8/14
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 42 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 42 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 42 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 40 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 82 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 42 times.
✓ Branch 13 taken 40 times.
82 for (const auto& grp : root["groups"]) {
370
8/14
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 42 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 42 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 42 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 40 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 82 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 42 times.
✓ Branch 13 taken 40 times.
82 for (const auto& mbr : grp["members"]) {
371
3/6
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 42 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 42 times.
✗ Branch 5 not taken.
42 std::string cid = mbr.get("cardId", "").asString();
372
3/6
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 42 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 42 times.
✗ Branch 5 not taken.
42 std::string target = mbr.get("nodeTarget", "").asString();
373
11/18
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 38 times.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 2 times.
✓ Branch 9 taken 2 times.
✓ Branch 10 taken 4 times.
✓ Branch 11 taken 38 times.
✓ Branch 12 taken 2 times.
✓ Branch 13 taken 40 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
94 if (!cid.empty() && !target.empty() && !startsWith(target, "fpp_alsa_") &&
374
7/12
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 40 times.
✓ Branch 8 taken 4 times.
✓ Branch 9 taken 38 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
44 ownedCids.count(normalizeCardIdForNode(cid))) {
375 2 return target;
376 }
377
4/4
✓ Branch 0 taken 40 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 40 times.
✓ Branch 3 taken 2 times.
44 }
378 }
379
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
80 return "";
380 42 }
381
382 // Known multi-channel I2S cards whose drivers advertise a continuous channel
383 // range (e.g. "CHANNELS: [2 8]") that the non-USB range heuristic below would
384 // clamp to stereo, and whose live /proc hw_params may show a stale 2-channel
385 // negotiation left over from a previous config. Returns the minimum channel
386 // count the card must be configured with, or 0 when no quirk applies. Matched
387 // against the card's full `aplay -l` line (issue #2620).
388 // Mirrored in DetectAlsaCardMaxChannels() in www/api/controllers/pipewire.php.
389 49 static int quirkMinChannelsForCard(const std::string& aplayLine) {
390 static const std::pair<const char*, int> quirks[] = {
391 { "hifiberry_dac8x", 8 }, // HiFiBerry DAC8x / Raspiaudio 8xOUT: 4x stereo DACs on one I2S bus
392 };
393
1/2
✓ Branch 0 taken 49 times.
✗ Branch 1 not taken.
49 std::string lineLc = aplayLine;
394 49 std::transform(lineLc.begin(), lineLc.end(), lineLc.begin(), ::tolower);
395
2/2
✓ Branch 0 taken 49 times.
✓ Branch 1 taken 46 times.
95 for (const auto& [needle, ch] : quirks) {
396
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 46 times.
49 if (lineLc.find(needle) != std::string::npos) {
397 3 return ch;
398 }
399 }
400 46 return 0;
401 49 }
402
403 // Return the single-group member cardId recorded in a Simple-mode groups JSON,
404 // or "" if the file is absent/empty/unparseable or has no member. Simple mode
405 // always has exactly one group with one member (the selected output card).
406 14 static std::string simpleConfigCardId(const std::string& jsonPath) {
407
1/2
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
14 Json::Value root;
408
2/6
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 14 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
14 if (!loadAudioGroupsConfig(jsonPath, root)) return "";
409
1/2
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
14 const Json::Value& groups = root["groups"];
410
5/12
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 14 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 14 times.
✗ Branch 8 not taken.
✓ Branch 9 taken 14 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
14 if (!groups.isArray() || groups.empty()) return "";
411
2/4
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
14 const Json::Value& members = groups[0]["members"];
412
5/12
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 14 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 14 times.
✗ Branch 8 not taken.
✓ Branch 9 taken 14 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
14 if (!members.isArray() || members.empty()) return "";
413
4/8
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 14 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 14 times.
✗ Branch 7 not taken.
14 return members[0].get("cardId", "").asString();
414 14 }
415
416 // Open the device in one PCM format and report the sample rate the driver
417 // actually settled on -- 0 if the format is unusable or the probe could not run.
418 //
419 // This has to be an open. `aplay --dump-hw-params` reports the unrefined
420 // capability space and ignores both -f and -r, so a fixed-bit-clock I2S card
421 // (TI McASP driving a PCM5102A on a BeagleBone cape) lists "FORMAT: S16_LE
422 // S32_LE" and "RATE: [11025 44100]" while in fact only S16_LE reaches 44100:
423 // the bit clock is the constant, not the rate, so 32 bits/frame x 44100 ==
424 // 64 bits/frame x 22050. Nothing in the advertised space shows that coupling.
425 //
426 // Feeding 8KB of zeros opens the device, negotiates, and exits in ~250ms. The
427 // payload is silence, so nothing audible is emitted.
428 //
429 // Two different failures have to be told apart, and neither can be read off the
430 // banner: aplay prints "Playing raw data ..." from header() *before*
431 // set_params() negotiates anything, so it appears even for a format the card
432 // then rejects. An unusable format exits non-zero ("Sample format non
433 // available"); a rate the driver refines to something else only warns
434 // ("rate is not accurate (requested = X, got = Y)") and still exits 0. So:
435 // judge usability by exit status, and take the achieved rate from the warning.
436 // Keep in sync with PipeWireProbeFormatRate() in www/api/controllers/pipewire.php.
437 static constexpr char kProbeRcTag[] = "fpp-audio-probe-rc:";
438
439 26 static int achievedRateForFormat(const std::string& alsaPath, const std::string& pwFmt,
440 int rate, int channels) {
441 26 std::string alsaFmt;
442
3/4
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✓ Branch 3 taken 18 times.
26 if (pwFmt == "S32LE") {
443
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 alsaFmt = "S32_LE";
444
2/4
✓ Branch 0 taken 18 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 18 times.
18 } else if (pwFmt == "S24_32LE") {
445 ✗ alsaFmt = "S24_LE";
446
2/4
✓ Branch 0 taken 18 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 18 times.
18 } else if (pwFmt == "S24LE") {
447 ✗ alsaFmt = "S24_3LE";
448
2/4
✓ Branch 0 taken 18 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 18 times.
✗ Branch 3 not taken.
18 } else if (pwFmt == "S16LE") {
449
1/2
✓ Branch 0 taken 18 times.
✗ Branch 1 not taken.
18 alsaFmt = "S16_LE";
450 } else {
451 ✗ return 0;
452 }
453
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 26 times.
26 if (rate <= 0) rate = 44100;
454
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 26 times.
26 if (channels <= 0) channels = 2;
455 // $? after the pipeline is aplay's own status; echo it so the exit code
456 // survives execAndReturn(), which only hands back stdout.
457 26 std::string out = execAndReturn("head -c 8192 /dev/zero | timeout 2 /usr/bin/aplay -D " +
458
4/8
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 26 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 26 times.
✗ Branch 7 not taken.
72 alsaPath + " -t raw -f " + alsaFmt + " -r " +
459
3/6
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 26 times.
✗ Branch 5 not taken.
84 std::to_string(rate) + " -c " + std::to_string(channels) +
460
4/8
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 26 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 26 times.
✗ Branch 7 not taken.
26 " - 2>&1; echo \"" + kProbeRcTag + "$?\"");
461 // Unverifiable (device busy, probe timed out, aplay missing, shell never ran).
462
1/2
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
26 const size_t tagPos = out.rfind(kProbeRcTag);
463
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 26 times.
26 if (tagPos == std::string::npos) {
464 ✗ return 0;
465 }
466
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 26 times.
26 if (std::strtol(out.c_str() + tagPos + (sizeof(kProbeRcTag) - 1), nullptr, 10) != 0) {
467 ✗ return 0;
468 }
469 26 std::smatch m;
470
3/6
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 26 times.
26 if (std::regex_search(out, m, std::regex(R"(not accurate \(requested = \d+Hz, got = (\d+)Hz\))"))) {
471 try {
472 ✗ return std::stoi(m[1].str());
473 ✗ } catch (...) {
474 ✗ }
475 }
476 26 return rate; // negotiated exactly what was asked for
477 26 }
478
479 // Widest first, so the first match in a FORMAT line is the best one. ALSA spells
480 // these with an underscore before LE, PipeWire without.
481 static const std::pair<const char*, const char*> kPcmFormatNames[] = {
482 { "S32_LE", "S32LE" },
483 { "S24_LE", "S24_32LE" }, // 24-bit in a 32-bit container
484 { "S24_3LE", "S24LE" }, // packed 24-bit (3 bytes)
485 { "S16_LE", "S16LE" },
486 };
487
488 // ALSA's spelling of a single PCM format -> PipeWire's. Anything unrecognised
489 // (an exotic or big-endian format) uses the legacy S16LE fallback.
490 ✗ static std::string pipewireFormatName(const std::string& alsaFmt) {
491 ✗ for (const auto& [alsaName, pwName] : kPcmFormatNames) {
492 ✗ if (alsaFmt == alsaName) {
493 ✗ return pwName;
494 }
495 }
496 ✗ return "S16LE";
497 }
498
499 // Pick the widest PCM format the card advertises that costs no sample rate
500 // relative to S16LE when the card supports it.
501 //
502 // The question is NOT "does this format hold the rate we asked for". A card can
503 // be unable to deliver the requested rate in ANY format -- an AM62x PCM5102A cape
504 // clocks no lower than 88200, so a 44100 request is refined upward for S16_LE and
505 // S32_LE alike. Treating any refinement as a rejection there throws away S32 for
506 // a card that pays nothing to provide it. Meanwhile the AM335x cape refines
507 // S32_LE from 44100 down to 22050 while S16_LE holds 44100 exactly, and taking
508 // S32 there is what makes PipeWire fail adapter creation, abort context creation,
509 // exit, and get restarted by systemd every few seconds forever (issue #2811).
510 //
511 // Both are the same rule once the comparison is made against what the fallback
512 // actually achieves rather than against what we asked for: widen only when it is
513 // free. Measured on both capes; see the achievedRateForFormat() probe.
514 21 static std::string bestFormatForRate(const std::string& fmtLine, const std::string& alsaPath,
515 int rate, int channels) {
516 // Nothing wider is even advertised -- skip the baseline probe entirely.
517 21 bool anyWider = false;
518
2/2
✓ Branch 0 taken 63 times.
✓ Branch 1 taken 21 times.
168 for (size_t i = 0; i + 1 < std::size(kPcmFormatNames); ++i) {
519 63 anyWider |= fmtLine.find(kPcmFormatNames[i].first) != std::string::npos;
520 }
521
2/2
✓ Branch 0 taken 17 times.
✓ Branch 1 taken 4 times.
21 if (!anyWider) {
522
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
34 return "S16LE";
523 }
524 4 const bool hasS16 = fmtLine.find("S16_LE") != std::string::npos;
525 // The rate to beat when S16 is available. If that advertised baseline cannot
526 // be established (device busy, probe timed out), decline to widen: a
527 // needlessly narrow format costs only bit depth, a wrongly wide one costs all
528 // audio. Cards that do not advertise S16 must use one of their real formats.
529 const int baselineRate = hasS16
530
6/12
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 2 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
10 ? achievedRateForFormat(alsaPath, "S16LE", rate, channels)
531
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
4 : 0;
532
3/4
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 2 times.
4 if (hasS16 && baselineRate <= 0) {
533 ✗ return "S16LE";
534 }
535 4 std::string advertisedFallback;
536 // Stop before the last entry: S16LE is the fallback, already probed above.
537
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
8 for (size_t i = 0; i + 1 < std::size(kPcmFormatNames); ++i) {
538 4 const auto& [alsaName, pwName] = kPcmFormatNames[i];
539
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if (fmtLine.find(alsaName) == std::string::npos) {
540 ✗ continue;
541 }
542
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 advertisedFallback = pwName;
543
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
6 const int achievedRate = achievedRateForFormat(alsaPath, pwName, rate, channels);
544
4/6
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 2 times.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
4 if ((!hasS16 && achievedRate > 0) ||
545
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 (hasS16 && achievedRate >= baselineRate)) {
546
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
10 return pwName;
547 }
548 }
549 ✗ return hasS16 ? "S16LE" : advertisedFallback;
550 4 }
551
552 // Version of the rules the ALSA probe below applies when it derives an adapter
553 // from a card (format, rate, channels, headroom, quirks).
554 //
555 // 95-fpp-alsa-sink.conf lives in /etc and so survives every upgrade, and the
556 // "adapters already match present cards" fast path re-validates only which cards
557 // it names -- not how it described them. Without a version, an install keeps a
558 // conf that FPP's own code would no longer generate, forever: fixing a probe rule
559 // only ever helps boxes flashed after the fix, never the ones already broken by
560 // it. That is not hypothetical. Generation 1 (untagged) could pick S32LE for a
561 // fixed-bit-clock I2S cape that only reaches S16LE at the target rate; PipeWire
562 // then failed adapter creation, aborted context creation and exited, and systemd
563 // restarted it every few seconds indefinitely -- thousands of restarts and no
564 // audio on a box that had already installed the fix (issue #2811).
565 //
566 // Bump this whenever the probe can produce a different conf for unchanged
567 // hardware. Costs one extra probe on the first boot after the upgrade.
568 static constexpr int kAlsaSinkConfGeneration = 8;
569
570 160 static std::string alsaSinkConfGenerationTag() {
571
2/4
✓ Branch 0 taken 160 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 160 times.
✗ Branch 3 not taken.
160 return "# FPP ALSA sink adapters (generation " + std::to_string(kAlsaSinkConfGeneration) + ")";
572 }
573
574 // Marker recording the rate the AudioFormat setting asked for when the conf was
575 // written, so the boot-time cache check can tell that the setting has changed.
576 //
577 // It cannot read that back off default.clock.rate: since the graph clock started
578 // following the hardware, that field holds the rate the card *achieved*, which a
579 // fixed-bit-clock cape refines upward (an AM62x PCM5102A asks for 44100 and lands
580 // on 88200). Comparing it against the configured rate therefore mismatched on
581 // every boot for exactly the capes that refine, forcing a full re-probe each time
582 // -- several seconds of aplay --dump-hw-params -- only to regenerate a
583 // byte-identical file. Nor can audio.rate serve: the capture adapters emit a
584 // fixed 44100, so a plain search finds the wrong one on a card with a mic.
585 88 static std::string alsaSinkConfRateTag(int configuredRate) {
586
1/2
✓ Branch 0 taken 88 times.
✗ Branch 1 not taken.
88 return "# configured rate: " + std::to_string(configuredRate);
587 }
588
589 // Marker recording the AudioPeriodSize the conf was written with.
590 //
591 // Same job as alsaSinkConfRateTag(), for the same reason: perSize is consumed
592 // only where the adapters are written, so without a tag to compare, changing
593 // AudioPeriodSize left a conf that still declared the old api.alsa.period-size
594 // and every boot logged "already match present cards; skipping probe". The
595 // setting read as applied in the UI while the graph never saw it.
596 //
597 // It cannot be read back off api.alsa.period-size: the capture adapters do not
598 // carry one, and a plain search would find whichever sink adapter came first
599 // rather than the value this boot would write.
600 88 static std::string alsaSinkConfPeriodSizeTag(int perSize) {
601
1/2
✓ Branch 0 taken 88 times.
✗ Branch 1 not taken.
88 return "# period size: " + std::to_string(perSize);
602 }
603
604 // Marker recording the cards the probe *considered*, which is not the same as
605 // the cards it ended up writing an adapter for.
606 //
607 // The validity check used to compare the adapters in the conf against the
608 // present candidate cards, but the probe drops a candidate whenever the device
609 // turns out to be unusable -- an IEC958-only HDMI card, or one that will not
610 // open. Those cards are still present and still candidates on the next boot,
611 // so the two sets could never agree and the fast path was dead: a Pi with a
612 // connected display re-ran the whole ALSA probe, several seconds of aplay
613 // --dump-hw-params, on every single boot, and silently -- none of the three
614 // checks that print a reason was the one rejecting it.
615 //
616 // Comparing the candidate sets instead asks the question that actually matters:
617 // the hardware on offer is unchanged, so the probe would reach the same
618 // conclusions, skips included. A card that was merely busy at probe time gets
619 // its skip cached too, which the no-sinks recovery at the end of runAudioSetup()
620 // already exists to catch, and a probe-rule change is what the generation tag
621 // above is for.
622 160 static std::string alsaSinkConfCardsTag(const std::set<std::string>& cids) {
623
1/2
✓ Branch 0 taken 160 times.
✗ Branch 1 not taken.
160 std::string tag = "# cards:";
624
2/2
✓ Branch 0 taken 98 times.
✓ Branch 1 taken 160 times.
258 for (const auto& c : cids) {
625
2/4
✓ Branch 0 taken 98 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 98 times.
✗ Branch 3 not taken.
98 tag += " " + c;
626 }
627 160 return tag;
628 ✗ }
629
630 // api.alsa.headroom for a USB card, which is decided by whether the card can end
631 // up *following* someone else's clock.
632 //
633 // A USB card's oscillator is its own, unrelated to whatever drives the PipeWire
634 // graph, so when several sinks share one driver graph the followers need slack
635 // for the adaptive resampler to absorb the drift in -- without it the resampler
636 // hard-resyncs, which is audible as crackle and pitch wobble. That is what
637 // kUsbHeadroomSharedGroup buys.
638 //
639 // A card that is the only sink in its group has nobody to follow: PipeWire makes
640 // it the graph driver (pw-top shows it as the one node with a non-zero quantum),
641 // so the graph clock *is* that card's oscillator and there is no drift to absorb.
642 // Every frame of headroom is then pure added output latency.
643 //
644 // Measured through fppd/GStreamer on a Pi 5 with a full-speed USB dongle at
645 // 44.1 kHz, reading the card's own queue depth from
646 // /proc/asound/cardN/pcm0p/sub0/status (the same figure GStreamerOut.cpp uses to
647 // correct reported position): 156 ms of delay at 4096, 109 ms at 2048, 86 ms at
648 // 1024 -- with zero xruns on the sink node through sustained playback under a
649 // fully loaded CPU at each value. Below 1024 PipeWire's own minimum buffer fill
650 // dominates and further cuts stop paying: 512 saves only 13 ms more than 1024,
651 // and 256 only 3 ms beyond that, for a steadily thinner scheduling margin. 1024
652 // is that knee, and it is what a non-drifting card actually needs.
653 static constexpr int kUsbHeadroomSoleSink = 1024;
654 static constexpr int kUsbHeadroomSharedGroup = 4096;
655 static constexpr int kHeadroomNonUsb = 256;
656
657 // How many members the largest audio group containing each card has, keyed by
658 // stable ALSA card ID -- i.e. how many sinks that card may have to share a
659 // driver graph with. Cards absent from the result are in no group at all: they
660 // still get an adapter, but nothing links to it, so they can only ever drive
661 // their own (idle) graph and count as sole sinks.
662 //
663 // Read straight from the groups JSON rather than from the live graph because
664 // this runs while 95-fpp-alsa-sink.conf is being written, before any of those
665 // nodes exist. The file persists across reboots, so the answer is available at
666 // the point the decision has to be made.
667 86 static std::map<std::string, int> pipewireGroupSizeByCard(const std::string& jsonPath) {
668 86 std::map<std::string, int> sizes;
669
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 Json::Value root;
670
7/10
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✓ Branch 3 taken 60 times.
✓ Branch 4 taken 26 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 26 times.
✓ Branch 8 taken 60 times.
✓ Branch 9 taken 26 times.
86 if (!loadAudioGroupsConfig(jsonPath, root) || !root.isMember("groups")) {
671 60 return sizes;
672 }
673
8/14
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 26 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 26 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 26 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 52 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 26 times.
✓ Branch 13 taken 26 times.
52 for (const auto& grp : root["groups"]) {
674 // A disabled group is not built into the graph, so its members are not
675 // sharing anything and must not be charged the follower's headroom.
676
6/12
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 26 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 26 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 26 times.
✗ Branch 10 not taken.
✓ Branch 11 taken 26 times.
26 if (grp.isMember("enabled") && !grp["enabled"].asBool()) {
677 ✗ continue;
678 }
679
2/4
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
26 const int count = static_cast<int>(grp["members"].size());
680
8/14
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 26 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 26 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 26 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 52 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 26 times.
✓ Branch 13 taken 26 times.
52 for (const auto& mbr : grp["members"]) {
681
3/6
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 26 times.
✗ Branch 5 not taken.
26 const std::string cid = mbr.get("cardId", "").asString();
682
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 26 times.
26 if (cid.empty()) {
683 ✗ continue;
684 }
685
2/4
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
26 if (count > sizes[cid]) {
686
1/2
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
26 sizes[cid] = count;
687 }
688
1/2
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
26 }
689 }
690 26 return sizes;
691 86 }
692
693 // Marker recording which cards the conf charged the shared-group USB headroom.
694 //
695 // Group membership changes without the set of present cards changing -- adding a
696 // second card to a group in the UI is exactly that -- so the cards tag above
697 // cannot see it, and the fast path would keep serving a conf whose headroom no
698 // longer matches the graph. Recording the decision itself makes the check able
699 // to notice. Empty (the common single-card case) is a meaningful value, not a
700 // missing one, so the tag is always written.
701 160 static std::string alsaSinkConfUsbHeadroomTag(const std::set<std::string>& sharedCids) {
702
1/2
✓ Branch 0 taken 160 times.
✗ Branch 1 not taken.
160 std::string tag = "# usb shared-group cards:";
703
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 160 times.
160 for (const auto& c : sharedCids) {
704 ✗ tag += " " + c;
705 }
706 160 return tag;
707 ✗ }
708
709 // Every context.objects entry PipeWire creates is mandatory by default: if one
710 // fails, the daemon aborts context creation and exits (234). systemd then
711 // restarts it, and because fpp-pipewire.service sets StartLimitIntervalSec=0 it
712 // never gives up -- so one un-openable object crash-loops the whole media stack
713 // forever, taking audio, video routing, AES67, Opus RTP and RTSP with it.
714 //
715 // That is not hypothetical for the objects below. They are backed by real
716 // hardware, which can be absent for reasons entirely outside FPP: a USB adapter
717 // unplugged between boots, a card another process already holds, or -- the case
718 // this was found on -- an HDMI monitor that is switched off, whose vc4 audio
719 // device is still registered in /proc/asound/cards but returns ENOTSUPP on open
720 // because the powered-down display supplies no ELD. A show controller's HDMI
721 // monitor being off is the normal state, not a fault.
722 //
723 // nofail makes such a failure skip just that object: the node is not created,
724 // the reason is still logged, and everything else in the graph comes up. The
725 // missing node is then visible on the Troubleshooting page rather than being
726 // inferred from a daemon that will not start.
727 static constexpr const char* kNoFailFlag = "flags = [ nofail ]";
728
729 // Build the contents of 97-fpp-audio-groups.conf for the Simple-mode synthetic
730 // group: a single 2-channel "Default" group whose one member is the selected
731 // sound card. This reproduces, byte-for-byte for the simple case, what
732 // GeneratePipeWireGroupsConfig() in www/api/controllers/pipewire.php emits:
733 //
734 // * an optional context.objects ALSA adapter — only when the card has no
735 // boot-time fpp_alsa_* node from 95-fpp-alsa-sink.conf (the snd-dummy
736 // fallback, which is detected after that conf was written),
737 // * a per-member delay filter-chain (delay always present at 0s so the UI
738 // can adjust it live during calibration — see $hasDelay = true in the PHP),
739 // * a combine-stream group sink named fpp_group_default targeting the
740 // filter-chain's virtual sink.
741 //
742 // Generating this here avoids forking PHP (apply_pipewire_simple_config /
743 // regenerate_pipewire_groups) on first boot, which costs ~10-30s of
744 // interpreter + common.php/pipewire.php startup on a single-core SBC.
745 30 static std::string buildSimplePipeWireGroupsConf(int card, const std::string& cId,
746 bool cardHasBootAdapter, int perSize,
747 int pipewireSampleRate) {
748
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 const std::string cidNorm = normalizeCardIdForNode(cId);
749
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 const std::string nodeName = "fpp_alsa_" + cidNorm; // adapter (boot-time 95 conf or custom below)
750
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 const std::string fxNode = "fpp_fx_g1_" + cidNorm; // filter-chain virtual sink
751
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 const std::string fxOut = fxNode + "_out";
752
753
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 std::ostringstream c;
754
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 c << "# Auto-generated by FPP - PipeWire Audio Output Groups\n";
755
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 c << "# Do not edit manually - managed via FPP UI\n\n";
756
757 // No real sound card: the selection resolved to the synthetic snd-dummy.
758 // Building the normal graph here (hw:Dummy adapter -> filter-chain ->
759 // combine-stream) pins the dummy PCM open, and because the filter-chain's
760 // playback link is not passive the sink never reaches idle, so PipeWire
761 // cycles the graph at the quantum rate (~47 wakeups/s) forever -- measured
762 // at 4% of a core on a single-core K8-PB that can never produce sound.
763 //
764 // fppd still needs *a* sink: media playback drives sequence position, and
765 // with no sink at all pipewiresink fails with "no target node available"
766 // and returns instantly instead of playing in real time, which would break
767 // sequence timing on exactly these boards. (WirePlumber ships
768 // scripts/fallback-sink.lua for this, but it is not registered in any
769 // profile, so no auto_null is ever created.)
770 //
771 // A null-audio-sink satisfies both: it consumes in real time so timing is
772 // preserved, and it suspends when idle so the graph parks completely.
773 // It must be wrapped in the `adapter` factory (as WirePlumber's own
774 // fallback-sink does) -- created via spa-node-factory directly it gets no
775 // usable ports and linking fails the same way as having no sink at all.
776
3/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✓ Branch 3 taken 25 times.
30 if (cId == "Dummy") {
777
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << "# No sound card detected - a null sink keeps media playback (and so\n";
778
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << "# sequence timing) working while letting the graph park when idle.\n";
779
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << "context.objects = [\n";
780
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << " { factory = adapter\n";
781
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << " args = {\n";
782
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << " factory.name = support.null-audio-sink\n";
783
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << " node.name = \"fpp_group_default\"\n";
784
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << " node.description = \"FPP Audio (no sound card)\"\n";
785
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << " media.class = \"Audio/Sink\"\n";
786
3/6
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 5 times.
✗ Branch 5 not taken.
5 c << " audio.rate = " << pipewireSampleRate << "\n";
787
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << " audio.channels = 2\n";
788
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << " audio.position = [ FL FR ]\n";
789
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << " monitor.channel-volumes = true\n";
790
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << " }\n";
791
3/6
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 5 times.
✗ Branch 5 not taken.
5 c << " " << kNoFailFlag << "\n";
792
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << " }\n";
793
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 c << "]\n";
794
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 return c.str();
795 }
796
797
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 15 times.
25 if (!cardHasBootAdapter) {
798 // No boot-time adapter exists for this card (snd-dummy: the dummy is
799 // loaded after 95-fpp-alsa-sink.conf is generated, so it never gets a
800 // node there). Create the adapter inline so the filter-chain/combine
801 // playback has a real sink to target. Detect the best PCM format the
802 // device advertises, using S16LE only when no PCM format is available.
803 // Capture the advertised format list here, but defer choosing one until
804 // alsaPath is resolved below: the rate-holding probe has to open the
805 // device we will actually configure (hw: or sysdefault:), not a guess.
806
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 std::string fmt = "S16LE";
807 10 std::string fmtLine;
808
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
20 std::string hwParams = execAndReturn("timeout 2 /usr/bin/aplay -D hw:" + cId +
809
2/4
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
10 " --dump-hw-params /dev/zero 2>&1 | head -40");
810 10 std::smatch fmtMatch;
811
4/6
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 9 times.
10 if (std::regex_search(hwParams, fmtMatch, std::regex(R"(FORMAT[^:]*:\s+(.+))"))) {
812
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 fmtLine = fmtMatch[1].str();
813 }
814 // Some cards expose only IEC958_SUBFRAME_LE passthrough on raw hw: with
815 // no standard PCM format (e.g. the Pi Zero W2 / Pi 3 vc4-hdmi card under
816 // KMS). PipeWire's SPA ALSA plugin cannot open a passthrough-only hw:
817 // device, so no sink is created and the fpp_group_default combine-stream
818 // ends up with no target, making GStreamer fail with "Failed to connect".
819 // sysdefault: routes through ALSA's dmix/plug layer (which downconverts
820 // IEC958 to normal PCM), so use it whenever hw: lacks a PCM format but
821 // sysdefault: provides one.
822
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 std::string alsaPath = "hw:" + cId;
823
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
20 bool hasPcmFmt = hwParams.find("S16_LE") != std::string::npos ||
824
2/4
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 10 times.
20 hwParams.find("S24_LE") != std::string::npos ||
825 10 hwParams.find("S32_LE") != std::string::npos;
826
5/6
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 9 times.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 9 times.
10 if (!hasPcmFmt && hwParams.find("IEC958_SUBFRAME_LE") != std::string::npos) {
827
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
2 std::string sysParams = execAndReturn("timeout 2 /usr/bin/aplay -D sysdefault:" + cId +
828
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 " --dump-hw-params /dev/zero 2>&1 | head -40");
829
0/2
✗ Branch 0 not taken.
✗ Branch 1 not taken.
1 if (sysParams.find("S16_LE") != std::string::npos ||
830
2/6
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 sysParams.find("S24_LE") != std::string::npos ||
831 ✗ sysParams.find("S32_LE") != std::string::npos) {
832
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 alsaPath = "sysdefault:" + cId;
833 }
834 1 }
835 // Now that the target device is known, pick the widest advertised format
836 // that can actually hold the configured rate on it.
837
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 9 times.
10 if (!fmtLine.empty()) {
838
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 fmt = bestFormatForRate(fmtLine, alsaPath, pipewireSampleRate, 2);
839 }
840
4/8
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 10 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 10 times.
✗ Branch 7 not taken.
10 const std::string desc = getAlsaCardProductName(card, cId) + " (" + cId + ")";
841
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 c << "# Custom FPP ALSA adapter nodes\n";
842
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 c << "# These provide sinks for cards with no WirePlumber node or needing extra channels\n";
843
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 c << "context.objects = [\n";
844
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 c << " { factory = adapter\n";
845
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 c << " args = {\n";
846
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 c << " factory.name = api.alsa.pcm.sink\n";
847
3/6
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 10 times.
✗ Branch 5 not taken.
10 c << " node.name = \"" << nodeName << "\"\n";
848
3/6
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 10 times.
✗ Branch 5 not taken.
10 c << " node.description = \"" << desc << "\"\n";
849
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 c << " media.class = \"Audio/Sink\"\n";
850
3/6
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 10 times.
✗ Branch 5 not taken.
10 c << " api.alsa.path = \"" << alsaPath << "\"\n";
851
3/6
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 10 times.
✗ Branch 5 not taken.
10 c << " api.alsa.period-size = " << perSize << "\n";
852
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 c << " api.alsa.headroom = 256\n";
853
3/6
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 10 times.
✗ Branch 5 not taken.
10 c << " audio.format = \"" << fmt << "\"\n";
854
3/6
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 10 times.
✗ Branch 5 not taken.
10 c << " audio.rate = " << pipewireSampleRate << "\n";
855
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 c << " audio.channels = 2\n";
856
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 c << " audio.position = [ FL FR ]\n";
857
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 c << " }\n";
858
3/6
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 10 times.
✗ Branch 5 not taken.
10 c << " " << kNoFailFlag << "\n";
859
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 c << " }\n";
860
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 c << "]\n\n";
861 10 }
862
863 // Passive links let the graph reach idle so PipeWire can suspend the card.
864 // A non-passive playback link keeps the ALSA sink running forever, so the
865 // whole chain (combine -> filter-chain -> sink, plus any resampling) is
866 // recomputed every quantum on a box that is playing nothing -- measured at
867 // 4-5% of a core on single-core boards. Marking these links passive means
868 // only a real stream starts the graph. Settable so it can be turned off if
869 // a card misbehaves coming out of suspend.
870
2/4
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25 times.
✗ Branch 3 not taken.
25 const bool passiveSinks = getRawSettingInt("PipeWirePassiveSinks", 1) != 0;
871
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << "context.modules = [\n";
872 // Delay filter-chain (0s) — always emitted, matching the PHP, so the
873 // running graph has the fpp_fx_g1_* nodes the UI adjusts during calibration.
874
3/6
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 25 times.
✗ Branch 5 not taken.
25 c << " # Filter chain (Delay) for: " << cId << " (Group 1)\n";
875
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " { name = libpipewire-module-filter-chain\n";
876
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " args = {\n";
877
3/6
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 25 times.
✗ Branch 5 not taken.
25 c << " node.description = \"Delay: " << cId << "\"\n";
878
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " filter.graph = {\n";
879
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " nodes = [\n";
880
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " { type = builtin label = delay name = delay_l config = { \"max-delay\" = 5 } control = { \"Delay (s)\" = 0 } }\n";
881
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " { type = builtin label = delay name = delay_r config = { \"max-delay\" = 5 } control = { \"Delay (s)\" = 0 } }\n";
882
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " ]\n";
883
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " links = [\n";
884
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " ]\n";
885
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " inputs = [ \"delay_l:In\" \"delay_r:In\" ]\n";
886
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " outputs = [ \"delay_l:Out\" \"delay_r:Out\" ]\n";
887
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " }\n";
888
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " capture.props = {\n";
889
3/6
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 25 times.
✗ Branch 5 not taken.
25 c << " node.name = \"" << fxNode << "\"\n";
890
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " media.class = Audio/Sink\n";
891
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " audio.channels = 2\n";
892
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " audio.position = [ FL FR ]\n";
893
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " }\n";
894
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " playback.props = {\n";
895
3/6
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 25 times.
✗ Branch 5 not taken.
25 c << " node.name = \"" << fxOut << "\"\n";
896
3/6
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 25 times.
✗ Branch 5 not taken.
25 c << " node.target = \"" << nodeName << "\"\n";
897
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 if (passiveSinks) {
898
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " node.passive = true\n";
899 }
900
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " stream.dont-remix = true\n";
901
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " audio.channels = 2\n";
902
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " audio.position = [ FL FR ]\n";
903
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " }\n";
904
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " }\n";
905
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " }\n";
906 // combine-stream group sink (the node fppd targets via PipeWireSinkName).
907
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " { name = libpipewire-module-combine-stream\n";
908
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " args = {\n";
909
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " combine.mode = sink\n";
910
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " node.name = \"fpp_group_default\"\n";
911
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " node.description = \"Default\"\n";
912
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " combine.latency-compensate = false\n";
913
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " combine.props = {\n";
914
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " audio.position = [ FL FR ]\n";
915
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " }\n";
916
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " stream.props = {\n";
917
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 if (passiveSinks) {
918
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " node.passive = true\n";
919 }
920
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " stream.dont-remix = true\n";
921
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " }\n";
922
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " stream.rules = [\n";
923
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " { matches = [\n";
924
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " { media.class = \"Audio/Sink\"\n";
925
3/6
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 25 times.
✗ Branch 5 not taken.
25 c << " node.name = \"" << fxNode << "\"\n";
926
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " }\n";
927
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " ]\n";
928
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " actions = {\n";
929
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " create-stream = {\n";
930
3/6
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 25 times.
✗ Branch 5 not taken.
25 c << " node.target = \"" << fxNode << "\"\n";
931
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " combine.audio.position = [ FL FR ]\n";
932
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " audio.position = [ FL FR ]\n";
933
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " }\n";
934
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " }\n";
935
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " }\n";
936
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " ]\n";
937
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " }\n";
938
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << " }\n";
939
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 c << "]\n";
940
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 return c.str();
941 30 }
942
943 // Ensure the WirePlumber "block combine fallback" hook is installed. It is a
944 // static, hardware-independent script shipped in the repo under /opt/fpp/etc;
945 // copy it into WirePlumber's search paths if absent. Without it, combine-stream
946 // output nodes can get rogue links to the default sink (doubled audio). A fresh
947 // image does not yet have it in /etc + /usr/share, so the Simple-mode C++ path
948 // installs it here rather than forking the PHP just to bootstrap it.
949 // Returns true if the hook is present (or was just installed from the shipped
950 // copy), false only if the shipped source is missing (a broken checkout — the
951 // files are committed to the repo, so this normally can't happen) so the caller
952 // can fall back to the PHP apply path for the rest of the work.
953 34 static bool ensureWirePlumberLinkingHook() {
954
1/2
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
68 const std::string luaSrc = "/opt/fpp/etc/wireplumber/scripts/linking/fpp-block-combine-fallback.lua";
955
1/2
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
68 const std::string luaDst = "/usr/share/wireplumber/scripts/linking/fpp-block-combine-fallback.lua";
956
1/2
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
68 const std::string confSrc = "/opt/fpp/etc/wireplumber/wireplumber.conf.d/60-fpp-block-combine-fallback.conf";
957
1/2
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
34 const std::string confDst = "/etc/wireplumber/wireplumber.conf.d/60-fpp-block-combine-fallback.conf";
958
5/10
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 34 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 34 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 34 times.
✗ Branch 8 not taken.
✓ Branch 9 taken 34 times.
34 if (!FileExists(luaSrc) || !FileExists(confSrc)) {
959 ✗ return false; // shipped copies not present (pre-upgrade install) — let PHP handle it
960 }
961
3/4
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✓ Branch 3 taken 23 times.
34 if (!FileExists(luaDst)) {
962
2/4
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
11 exec("/bin/mkdir -p /usr/share/wireplumber/scripts/linking");
963
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 CopyFileContents(luaSrc, luaDst);
964 11 chmod(luaDst.c_str(), 0644); // world-readable to match the PHP installer (WirePlumber may run non-root)
965 }
966
3/4
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✓ Branch 3 taken 23 times.
34 if (!FileExists(confDst)) {
967
2/4
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
11 exec("/bin/mkdir -p /etc/wireplumber/wireplumber.conf.d");
968
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 CopyFileContents(confSrc, confDst);
969 11 chmod(confDst.c_str(), 0644);
970 }
971 34 return true;
972 34 }
973
974 // Bounded wait for FPP's sinks to exist in the graph. pactl against a graph that
975 // is up but has not finished loading its modules silently sets nothing, so the
976 // volume restore needs the nodes to be there first. Replaces a flat sleep on the
977 // paths that skip the regeneration: normally returns on the first poll, and it
978 // cannot outlast maxSeconds if the graph never comes up.
979 //
980 // Returns false on timeout, which is also the audio stack's failure signal: see
981 // the recovery block at the end of runAudioSetup().
982 104 static bool waitForPipeWireSinks(int maxSeconds) {
983 const std::string cmd = "PIPEWIRE_RUNTIME_DIR=/run/pipewire-fpp XDG_RUNTIME_DIR=/run/pipewire-fpp "
984 "PULSE_RUNTIME_PATH=/run/pipewire-fpp/pulse "
985
1/2
✓ Branch 0 taken 104 times.
✗ Branch 1 not taken.
104 "/usr/bin/pactl list short sinks 2>/dev/null";
986
2/2
✓ Branch 0 taken 1290 times.
✓ Branch 1 taken 14 times.
1304 for (int i = 0; i < maxSeconds * 4; i++) {
987
3/4
✓ Branch 0 taken 1290 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 90 times.
✓ Branch 3 taken 1200 times.
1290 if (execAndReturn(cmd).find("fpp_") != std::string::npos) {
988 90 return true;
989 }
990
1/2
✓ Branch 0 taken 1200 times.
✗ Branch 1 not taken.
1200 std::this_thread::sleep_for(std::chrono::milliseconds(250));
991 }
992 14 return false;
993 104 }
994
995 13 static void setPipeWireSinkMute(const std::string& node, bool mute) {
996
3/6
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 13 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 13 times.
13 if (node.empty() || node.find('\'') != std::string::npos) {
997 ✗ return;
998 }
999
1/2
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
13 exec("PIPEWIRE_RUNTIME_DIR=/run/pipewire-fpp XDG_RUNTIME_DIR=/run/pipewire-fpp "
1000 "PULSE_RUNTIME_PATH=/run/pipewire-fpp/pulse "
1001 13 "/usr/bin/pactl set-sink-mute '" +
1002
7/10
✓ Branch 0 taken 12 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 13 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✓ Branch 5 taken 10 times.
✓ Branch 6 taken 13 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 13 times.
✗ Branch 9 not taken.
39 node + "' " + (mute ? "1" : "0") + " 2>/dev/null");
1003 }
1004
1005 49 static void setPipeWireSinkVolume(const std::string& node, int volPct) {
1006 // Node names are generated by normalizeCardIdForNode() (alphanumerics and
1007 // underscore only), but nodeTarget comes straight out of the user's JSON --
1008 // refuse anything that could escape the single quotes rather than handing it
1009 // to a shell.
1010
3/6
✓ Branch 0 taken 49 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 49 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 49 times.
49 if (node.empty() || node.find('\'') != std::string::npos) {
1011 ✗ return;
1012 }
1013
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 49 times.
49 if (volPct < 0) volPct = 0;
1014
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 49 times.
49 if (volPct > 200) volPct = 200;
1015
1/2
✓ Branch 0 taken 49 times.
✗ Branch 1 not taken.
49 exec("PIPEWIRE_RUNTIME_DIR=/run/pipewire-fpp XDG_RUNTIME_DIR=/run/pipewire-fpp "
1016 "PULSE_RUNTIME_PATH=/run/pipewire-fpp/pulse "
1017 49 "/usr/bin/pactl set-sink-volume '" +
1018
4/8
✓ Branch 0 taken 49 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 49 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 49 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 49 times.
✗ Branch 7 not taken.
147 node + "' " + std::to_string(volPct) + "% 2>/dev/null");
1019 }
1020
1021 // Make the sink fppd plays to (PipeWireSinkName) the graph's default sink, so a
1022 // client that names no target -- a plugin's player, anything speaking plain
1023 // PulseAudio -- takes the same route as show audio (issue #3063). The UI's
1024 // applies already do this, but a boot never did: what WirePlumber remembered
1025 // from the last apply stood, and with nothing remembered (a first boot, whose
1026 // Simple config is generated here in C++) every FPP sink ties on priority and
1027 // the first adapter wins -- card 0, whichever card AudioOutput selected.
1028 //
1029 // pactl rather than the metadata directly: pipewire-pulse refuses a sink that
1030 // is not in the graph, so a name that never came up leaves the old default
1031 // rather than pointing everything at nothing.
1032 //
1033 // KEEP IN SYNC with the pactl set-default-sink calls in
1034 // www/api/controllers/pipewire.php, which choose the same sink.
1035 40 static void syncPipeWireDefaultSink() {
1036 40 std::string target;
1037
2/4
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 40 times.
✗ Branch 3 not taken.
40 getRawSetting("PipeWireSinkName", target);
1038
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
40 TrimWhiteSpace(target);
1039 // Node names FPP generates are slugged to [a-z0-9_]; refuse anything that
1040 // could escape the shell's quotes.
1041
5/6
✓ Branch 0 taken 30 times.
✓ Branch 1 taken 10 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 30 times.
✓ Branch 4 taken 10 times.
✓ Branch 5 taken 30 times.
40 if (target.empty() || target.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-") != std::string::npos) {
1042 10 return;
1043 }
1044 const std::string env = "PIPEWIRE_RUNTIME_DIR=/run/pipewire-fpp XDG_RUNTIME_DIR=/run/pipewire-fpp "
1045
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 "PULSE_RUNTIME_PATH=/run/pipewire-fpp/pulse ";
1046
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 std::string current = execAndReturn(env + "/usr/bin/pactl get-default-sink 2>/dev/null");
1047
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 TrimWhiteSpace(current);
1048
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 30 times.
30 if (current == target) {
1049 ✗ return;
1050 }
1051 // "<id>\t<name>\t..." -- match the whole name, not a prefix of a longer one.
1052
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 const std::string sinks = execAndReturn(env + "/usr/bin/pactl list short sinks 2>/dev/null");
1053
4/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 25 times.
✓ Branch 5 taken 5 times.
30 if (sinks.find("\t" + target + "\t") == std::string::npos) {
1054
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 printf("FPP - PipeWire default sink left as '%s': %s is not in the graph\n", current.c_str(), target.c_str());
1055 25 return;
1056 }
1057
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 printf("FPP - PipeWire default sink: %s (was '%s')\n", target.c_str(), current.c_str());
1058
4/8
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 5 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 5 times.
✗ Branch 7 not taken.
5 exec(env + "/usr/bin/pactl set-default-sink '" + target + "' 2>/dev/null");
1059
8/8
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 25 times.
✓ Branch 2 taken 5 times.
✓ Branch 3 taken 25 times.
✓ Branch 4 taken 5 times.
✓ Branch 5 taken 25 times.
✓ Branch 6 taken 5 times.
✓ Branch 7 taken 35 times.
115 }
1060
1061 // A playback stream's level, as a linear gain set on the running node with
1062 // pw-cli -- the stage pactl and the UI's faders drive (channelVolumes). The
1063 // generated config's channelmix.volume is not applied by PipeWire, and a
1064 // stream node is no sink for pactl set-sink-volume.
1065 // KEEP IN SYNC with pw_set_node_volume_linear() in pipewire.php.
1066 8 static void setPipeWireNodeVolumeLinear(int nodeId, int volPct, int channels) {
1067 8 volPct = std::max(0, std::min(100, volPct));
1068 8 std::string vals;
1069
2/2
✓ Branch 0 taken 20 times.
✓ Branch 1 taken 8 times.
28 for (int i = 0; i < std::max(1, channels); i++) {
1070 char v[16];
1071
2/2
✓ Branch 0 taken 12 times.
✓ Branch 1 taken 8 times.
20 snprintf(v, sizeof(v), "%s%.4g", i ? ", " : "", volPct / 100.0);
1072
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 vals += v;
1073 }
1074
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 exec("PIPEWIRE_RUNTIME_DIR=/run/pipewire-fpp XDG_RUNTIME_DIR=/run/pipewire-fpp "
1075 8 "/usr/bin/pw-cli set-param " +
1076
4/8
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 8 times.
✗ Branch 7 not taken.
24 std::to_string(nodeId) + " Props '{ channelVolumes: [ " + vals + " ] }' >/dev/null 2>&1");
1077 8 }
1078
1079 // A node in the running graph, as the input group levels below look for it.
1080 struct PipeWireStreamNode {
1081 int id = 0;
1082 int channels = 2;
1083 std::string name;
1084 std::string target;
1085 };
1086
1087 // Every node in the running graph, from one pw-dump: its name, what it was
1088 // told to connect to, and how many channels its volume has (from the
1089 // channelVolumes it reports; stereo if it reports none).
1090 // KEEP IN SYNC with pw_node_channel_count() in pipewire.php.
1091 11 static std::vector<PipeWireStreamNode> pipeWireStreamNodes() {
1092 11 std::vector<PipeWireStreamNode> nodes;
1093
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
22 const std::string out = execAndReturn("PIPEWIRE_RUNTIME_DIR=/run/pipewire-fpp XDG_RUNTIME_DIR=/run/pipewire-fpp "
1094
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 "/usr/bin/pw-dump 2>/dev/null");
1095
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 Json::Value dump;
1096
6/8
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 9 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 2 times.
✓ Branch 6 taken 9 times.
✓ Branch 7 taken 2 times.
11 if (out.find('[') == std::string::npos || !LoadJsonFromString(out, dump, JsonRoot::Array)) {
1097 9 return nodes;
1098 }
1099 46 auto str = [](const Json::Value& o, const char* key) {
1100
3/4
✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 42 times.
✓ Branch 3 taken 4 times.
46 return o.isObject() && o[key].isString() ? o[key].asString() : std::string();
1101 };
1102
7/12
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 16 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 16 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 18 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 16 times.
✓ Branch 11 taken 2 times.
18 for (const auto& obj : dump) {
1103
13/24
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 16 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 14 times.
✓ Branch 5 taken 2 times.
✓ Branch 6 taken 14 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 14 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 14 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 14 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 14 times.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✓ Branch 17 taken 14 times.
✓ Branch 18 taken 16 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 2 times.
✓ Branch 21 taken 14 times.
✗ Branch 22 not taken.
✗ Branch 23 not taken.
16 if (str(obj, "type") != "PipeWire:Interface:Node" || !obj["id"].isIntegral() || !obj["info"].isObject()) {
1104 2 continue;
1105 }
1106
1/2
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
14 const Json::Value& info = obj["info"];
1107 14 PipeWireStreamNode n;
1108
2/4
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
14 n.id = obj["id"].asInt();
1109
2/4
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
14 n.name = str(info["props"], "node.name");
1110
2/4
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
14 n.target = str(info["props"], "node.target");
1111
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 12 times.
14 if (n.target.empty()) {
1112
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 n.target = str(info["props"], "target.object");
1113 }
1114
1/2
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
14 const Json::Value& params = info["params"];
1115
6/12
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 14 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 14 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 14 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 14 times.
✗ Branch 11 not taken.
14 if (params.isObject() && params["Props"].isArray()) {
1116
6/14
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 14 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 14 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✓ Branch 10 taken 14 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 14 times.
✗ Branch 13 not taken.
14 for (const auto& p : params["Props"]) {
1117
9/18
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 14 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 14 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 14 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 14 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 14 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 14 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 14 times.
✗ Branch 17 not taken.
14 if (p.isObject() && p["channelVolumes"].isArray() && !p["channelVolumes"].empty()) {
1118
2/4
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
14 n.channels = p["channelVolumes"].size();
1119 14 break;
1120 }
1121 }
1122 }
1123
1/2
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
14 nodes.push_back(n);
1124 14 }
1125 2 return nodes;
1126 11 }
1127
1128 // The levels inside the input groups: each member's send into its bus (the
1129 // playback half, output.<name>, of its loopback) and each routing path from a
1130 // bus to an output group (the bus's combine-stream output to that group).
1131 // They are rebuilt at full level on every start like the buses. Every saved
1132 // level is set, 100% included: WirePlumber restores a stream's last live level
1133 // when it comes back, which need not be the saved one. A muted member or path
1134 // has no node -- the generated config leaves it out.
1135 //
1136 // KEEP IN SYNC with RestoreInputGroupSendAndPathLevels() in
1137 // www/api/controllers/pipewire.php, and the node names with
1138 // GeneratePipeWireInputGroupsConfig().
1139 11 static void restoreInputGroupSendAndPathLevels(const Json::Value& inputGroups) {
1140 11 std::map<int, std::string> outputNodes;
1141
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 Json::Value og;
1142
3/6
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 11 times.
✗ Branch 5 not taken.
11 if (loadAudioGroupsConfig(FPP_MEDIA_DIR + "/config/pipewire-audio-groups.json", og)) {
1143
8/14
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 11 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 11 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 11 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 22 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 11 times.
✓ Branch 13 taken 11 times.
22 for (const auto& grp : og["groups"]) {
1144
2/4
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
11 if (grp.isMember("id")) {
1145
8/16
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 11 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 11 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 11 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 11 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 11 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 11 times.
✗ Branch 15 not taken.
11 outputNodes[grp["id"].asInt()] = "fpp_group_" + normalizeCardIdForNode(grp.get("name", "Group").asString());
1146 }
1147 }
1148 }
1149 // A level as the PHP's intval() reads it; anything that is not a number is 100.
1150 8 auto level = [](const Json::Value& o) {
1151 8 const Json::Value& v = o["volume"];
1152
3/4
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 4 times.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
8 return v.isNumeric() ? (int)std::max(0.0, std::min(100.0, v.asDouble())) : 100;
1153 };
1154 8 auto muted = [](const Json::Value& o) {
1155
3/4
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 6 times.
8 return o["mute"].isConvertibleTo(Json::booleanValue) && o["mute"].asBool();
1156 };
1157
1158 11 std::vector<PipeWireStreamNode> nodes;
1159 11 bool dumped = false;
1160
7/12
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 13 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 15 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 13 times.
✓ Branch 11 taken 2 times.
15 for (const auto& ig : inputGroups) {
1161
9/18
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 13 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 13 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 13 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 13 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 13 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 13 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 13 times.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✓ Branch 17 taken 13 times.
26 if (!ig.isObject() || !ig["enabled"].isConvertibleTo(Json::booleanValue) || !ig["enabled"].asBool() ||
1162
3/6
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 13 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 13 times.
13 !ig["id"].isIntegral()) {
1163 ✗ continue;
1164 }
1165
2/2
✓ Branch 0 taken 11 times.
✓ Branch 1 taken 2 times.
13 if (!dumped) {
1166
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 nodes = pipeWireStreamNodes();
1167 11 dumped = true;
1168
2/2
✓ Branch 0 taken 9 times.
✓ Branch 1 taken 2 times.
11 if (nodes.empty()) {
1169 9 return;
1170 }
1171 }
1172
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 const int groupId = ig["id"].asInt();
1173
1174
3/6
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
4 if (ig["members"].isArray()) {
1175
4/6
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 6 times.
✓ Branch 5 taken 4 times.
10 for (Json::ArrayIndex mi = 0; mi < ig["members"].size(); mi++) {
1176
2/4
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
6 const Json::Value& mbr = ig["members"][mi];
1177
7/10
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 6 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 4 times.
✓ Branch 8 taken 2 times.
✓ Branch 9 taken 4 times.
6 if (!mbr.isObject() || muted(mbr)) {
1178 2 continue;
1179 }
1180
5/12
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
12 std::string slug = mbr["type"].isString() && mbr["type"].asString() == "fppd_stream"
1181
6/14
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 4 times.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
12 ? (mbr["sourceId"].isString() ? mbr["sourceId"].asString() : "fppd_stream_1")
1182
4/24
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
✗ Branch 18 not taken.
✓ Branch 19 taken 4 times.
✗ Branch 20 not taken.
✓ Branch 21 taken 4 times.
✗ Branch 22 not taken.
✗ Branch 23 not taken.
12 : (mbr["name"].isString() ? mbr["name"].asString() : "Member " + std::to_string(mi));
1183
4/8
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
4 const std::string loopback = "fpp_loopback_ig" + std::to_string(groupId) + "_" + normalizeCardIdForNode(slug);
1184 4 std::vector<const PipeWireStreamNode*> found, fallback;
1185
2/2
✓ Branch 0 taken 28 times.
✓ Branch 1 taken 4 times.
32 for (const auto& n : nodes) {
1186
3/4
✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 24 times.
28 if (n.name == "output." + loopback) {
1187
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 found.push_back(&n);
1188
7/12
✓ Branch 0 taken 24 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 24 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 22 times.
✓ Branch 6 taken 24 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 2 times.
✓ Branch 9 taken 22 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
24 } else if (n.name == loopback || n.name == "input." + loopback) {
1189
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 fallback.push_back(&n);
1190 }
1191 }
1192
3/4
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 4 times.
8 for (const auto* n : found.empty() ? fallback : found) {
1193
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 setPipeWireNodeVolumeLinear(n->id, level(mbr), n->channels);
1194 }
1195 4 }
1196 }
1197
1198 // Every path the bus has (its outputs), at its saved level or the
1199 // generator's default of 100% when the routing matrix has none.
1200
3/6
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
4 if (ig["outputs"].isArray()) {
1201
4/12
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 4 times.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✓ Branch 10 taken 4 times.
✗ Branch 11 not taken.
4 const Json::Value& eq = ig["effects"].isObject() ? ig["effects"]["eq"] : Json::Value::nullSingleton();
1202
1/8
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
4 const bool hasEffects = eq.isObject() && eq["enabled"].isConvertibleTo(Json::booleanValue) &&
1203
1/20
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
✗ Branch 18 not taken.
✗ Branch 19 not taken.
4 eq["enabled"].asBool() && eq["bands"].isArray() && !eq["bands"].empty();
1204 ✗ const std::string route = hasEffects ? "fpp_route_ig_" + std::to_string(groupId)
1205
12/34
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 4 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 4 times.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✓ Branch 16 taken 4 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 4 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 4 times.
✗ Branch 21 not taken.
✓ Branch 22 taken 4 times.
✗ Branch 23 not taken.
✗ Branch 24 not taken.
✓ Branch 25 taken 4 times.
✗ Branch 26 not taken.
✓ Branch 27 taken 4 times.
✗ Branch 28 not taken.
✗ Branch 29 not taken.
✗ Branch 30 not taken.
✗ Branch 31 not taken.
✗ Branch 32 not taken.
✗ Branch 33 not taken.
4 : "fpp_input_" + normalizeCardIdForNode(ig["name"].isString() ? ig["name"].asString() : "");
1206
8/14
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 8 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 4 times.
✓ Branch 13 taken 4 times.
8 for (const auto& out : ig["outputs"]) {
1207
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 4 times.
4 if (!out.isIntegral()) {
1208 ✗ continue;
1209 }
1210
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 const std::string key = std::to_string(out.asInt());
1211
6/12
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 2 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 2 times.
✗ Branch 11 not taken.
6 const Json::Value& path = ig["routing"].isObject() && ig["routing"][key].isObject()
1212
4/6
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
6 ? ig["routing"][key]
1213
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 : Json::Value::nullSingleton();
1214
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 auto target = outputNodes.find(out.asInt());
1215
7/12
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 2 times.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 4 times.
✗ Branch 10 not taken.
✓ Branch 11 taken 4 times.
4 if ((path.isObject() && muted(path)) || target == outputNodes.end()) {
1216 ✗ continue;
1217 }
1218
2/2
✓ Branch 0 taken 28 times.
✓ Branch 1 taken 4 times.
32 for (const auto& n : nodes) {
1219
5/10
✓ Branch 0 taken 26 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 26 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 26 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
80 const bool ours = n.name == route || startsWith(n.name, route + ".") ||
1220
11/18
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 26 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✓ Branch 7 taken 22 times.
✓ Branch 8 taken 26 times.
✓ Branch 9 taken 2 times.
✓ Branch 10 taken 26 times.
✓ Branch 11 taken 2 times.
✓ Branch 12 taken 26 times.
✓ Branch 13 taken 2 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
54 startsWith(n.name, "output." + route + "_");
1221
6/6
✓ Branch 0 taken 6 times.
✓ Branch 1 taken 22 times.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 2 times.
✓ Branch 4 taken 4 times.
✓ Branch 5 taken 24 times.
28 if (ours && n.target == target->second) {
1222
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 setPipeWireNodeVolumeLinear(n.id, level(path), n.channels);
1223 }
1224 }
1225
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 }
1226 4 }
1227 }
1228
6/6
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 9 times.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 9 times.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 9 times.
29 }
1229
1230 // Reapply the saved mix bus levels. A mix bus is an input group's own
1231 // combine-stream sink (fpp_input_<name>) -- the output of the mix, a stage of
1232 // its own between the sources feeding it and the routing paths leaving it.
1233 // PipeWire rebuilds those sinks at full volume on every start, so without this
1234 // a bus fader lasts only until the next reboot. The members' and routing
1235 // paths' levels follow (restoreInputGroupSendAndPathLevels()).
1236 //
1237 // Advanced mode only: Simple mode has no input groups, and no file.
1238 //
1239 // KEEP IN SYNC with RestorePipeWireInputGroupVolumes() in
1240 // www/api/controllers/pipewire.php.
1241 17 static void restorePipeWireInputGroupVolumes() {
1242
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 const std::string inputGroupsJsonPath = FPP_MEDIA_DIR + "/config/pipewire-input-groups.json";
1243
3/4
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✓ Branch 3 taken 11 times.
17 if (!FileExists(inputGroupsJsonPath)) {
1244 6 return;
1245 }
1246
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 Json::Value root;
1247
7/16
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 11 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 11 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 11 times.
✓ Branch 10 taken 11 times.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✓ Branch 13 taken 11 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
11 if (!LoadJsonFromString(GetFileContents(inputGroupsJsonPath), root) || !root.isMember("inputGroups")) {
1248 ✗ return;
1249 }
1250
8/14
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 11 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 13 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 13 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 24 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 13 times.
✓ Branch 13 taken 11 times.
24 for (const auto& ig : root["inputGroups"]) {
1251 // Read at boot: an entry with a field of the wrong JSON type is skipped,
1252 // not thrown on (see loadAudioGroupsConfig()).
1253 52 auto fits = [&ig](const char* key, Json::ValueType t) {
1254
3/4
✓ Branch 0 taken 41 times.
✓ Branch 1 taken 11 times.
✓ Branch 2 taken 41 times.
✗ Branch 3 not taken.
52 return !ig.isMember(key) || ig[key].isConvertibleTo(t);
1255 13 };
1256
5/10
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 13 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 13 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 13 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 13 times.
✗ Branch 9 not taken.
26 if (!ig.isObject() || !fits("enabled", Json::booleanValue) || !fits("name", Json::stringValue) ||
1257
6/12
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 13 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 13 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 13 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 13 times.
✗ Branch 10 not taken.
✓ Branch 11 taken 13 times.
26 !fits("volume", Json::intValue) || !fits("mute", Json::booleanValue)) {
1258 ✗ continue;
1259 }
1260
4/8
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 13 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 13 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 13 times.
13 if (!ig.get("enabled", false).asBool()) {
1261 ✗ continue;
1262 }
1263
5/10
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 13 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 13 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 13 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 13 times.
✗ Branch 9 not taken.
13 std::string busNode = "fpp_input_" + normalizeCardIdForNode(ig.get("name", "Input Group").asString());
1264
4/8
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 13 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 13 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 13 times.
✗ Branch 7 not taken.
13 setPipeWireSinkVolume(busNode, ig.get("volume", 100).asInt());
1265
4/8
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 13 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 13 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 13 times.
✗ Branch 7 not taken.
13 setPipeWireSinkMute(busNode, ig.get("mute", false).asBool());
1266 13 }
1267
2/4
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
11 restoreInputGroupSendAndPathLevels(root["inputGroups"]);
1268
3/4
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✓ Branch 3 taken 6 times.
17 }
1269
1270 // Reapply the user's configured group/member volumes after the PipeWire stack has
1271 // been (re)started -- WirePlumber restores its own persisted state on every start,
1272 // which is not necessarily what the FPP UI has configured.
1273 //
1274 // This is a direct C++ port of RestorePipeWireGroupVolumes() in
1275 // www/api/controllers/pipewire.php, which remains the implementation used by the
1276 // UI's interactive applies (where the interpreter is already warm). Forking it at
1277 // boot cost php + common.php + the API controller plus sudo'd pactl calls --
1278 // measured 6s uncontended and 12s under boot load on a single-core BBB -- to emit
1279 // a handful of pactl calls, and all of it landed on the critical path to fppd, and
1280 // therefore to the moment the web UI is actually usable. fppinit already runs as
1281 // root, so the sudo hops go away too.
1282 //
1283 // KEEP IN SYNC with the PHP. The node-naming convention is shared with
1284 // GeneratePipeWireGroupsConfig(); normalizeCardIdForNode() already mirrors its
1285 // slugging rule, and this reuses it rather than re-deriving it.
1286 17 static void restorePipeWireVolumes() {
1287 // Read the config that describes the graph actually running. Simple mode's
1288 // groups live in pipewire-audio-groups-simple.json and its single group is
1289 // named "Default" (node fpp_group_default, what buildSimplePipeWireGroupsConf
1290 // emits); the advanced file is a different set of groups under different
1291 // names. Reading the advanced one unconditionally -- which is what this did,
1292 // and what the PHP still did -- meant a Simple-mode box with a leftover
1293 // advanced config pactl'd node names that do not exist, so every restore was
1294 // a silent no-op and any volume stored for the running group was ignored.
1295
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
34 std::string mediaBackend = "pipewire-simple";
1296
2/4
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
17 getRawSetting("MediaBackend", mediaBackend);
1297 17 std::transform(mediaBackend.begin(), mediaBackend.end(), mediaBackend.begin(), [](unsigned char c) {
1298 185 return std::tolower(c);
1299 });
1300
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
34 const std::string groupsJsonPath = FPP_MEDIA_DIR + "/config/" +
1301
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 (mediaBackend == "pipewire-simple"
1302 ? "pipewire-audio-groups-simple.json"
1303
3/4
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 10 times.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
18 : "pipewire-audio-groups.json");
1304
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 Json::Value root;
1305
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 const bool haveGroups = loadAudioGroupsConfig(groupsJsonPath, root)
1306
3/6
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
17 && root.isMember("groups");
1307
1308 // The filter-chain/combine-stream nodes appear a moment after the daemon
1309 // itself, and pactl against a half-built graph silently sets nothing.
1310
2/4
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 17 times.
17 if (!waitForPipeWireSinks(30)) {
1311 ✗ printf("FPP - Timed out waiting for PipeWire sinks; restoring volumes anyway\n");
1312 }
1313
1314 // A missing or unreadable groups file is not a reason to skip the mix
1315 // buses: they live in their own file and their sinks come up just as loud.
1316
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 17 times.
17 if (!haveGroups) {
1317 ✗ restorePipeWireInputGroupVolumes();
1318 ✗ return;
1319 }
1320
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 printf("FPP - Restoring PipeWire audio group volumes...\n");
1321
8/14
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 17 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 17 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 34 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 17 times.
✓ Branch 13 taken 17 times.
34 for (const auto& grp : root["groups"]) {
1322
12/28
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 17 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 17 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 17 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 17 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 17 times.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✓ Branch 17 taken 17 times.
✓ Branch 18 taken 17 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 17 times.
✗ Branch 21 not taken.
✗ Branch 22 not taken.
✓ Branch 23 taken 17 times.
✗ Branch 24 not taken.
✗ Branch 25 not taken.
✗ Branch 26 not taken.
✗ Branch 27 not taken.
17 if (!grp.get("enabled", false).asBool() || !grp.isMember("members") || grp["members"].empty()) {
1323 ✗ continue;
1324 }
1325
3/6
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
17 int groupId = grp.get("id", 0).asInt();
1326
7/14
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 17 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 17 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 17 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 17 times.
✗ Branch 13 not taken.
49 setPipeWireSinkVolume("fpp_group_" + normalizeCardIdForNode(grp.get("name", "Group").asString()),
1327
2/4
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
34 grp.get("volume", 100).asInt());
1328
8/14
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 17 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 17 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 34 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 17 times.
✓ Branch 13 taken 17 times.
34 for (const auto& mbr : grp["members"]) {
1329
3/6
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
17 std::string cardId = mbr.get("cardId", "").asString();
1330
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 17 times.
17 if (cardId.empty()) {
1331 ✗ continue;
1332 }
1333
6/12
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 17 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 17 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 17 times.
✗ Branch 11 not taken.
49 setPipeWireSinkVolume("fpp_fx_g" + std::to_string(groupId) + "_" + normalizeCardIdForNode(cardId),
1334
2/4
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
34 mbr.get("volume", 100).asInt());
1335 // A member targeting a WirePlumber-managed node (e.g. an HDMI output)
1336 // must have that node pinned to 100%: WirePlumber initialises them at
1337 // ~40%, which would silently attenuate everything the filter chain
1338 // delivers. FPP-owned nodes are already created at full volume.
1339
3/6
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
17 std::string nodeTarget = mbr.get("nodeTarget", "").asString();
1340
19/32
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 13 times.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 2 times.
✓ Branch 8 taken 2 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 2 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 2 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 2 times.
✓ Branch 15 taken 15 times.
✓ Branch 16 taken 2 times.
✓ Branch 17 taken 15 times.
✓ Branch 18 taken 4 times.
✓ Branch 19 taken 13 times.
✓ Branch 20 taken 4 times.
✓ Branch 21 taken 13 times.
✓ Branch 22 taken 2 times.
✓ Branch 23 taken 15 times.
✗ Branch 24 not taken.
✗ Branch 25 not taken.
✗ Branch 26 not taken.
✗ Branch 27 not taken.
✗ Branch 28 not taken.
✗ Branch 29 not taken.
✗ Branch 30 not taken.
✗ Branch 31 not taken.
29 if (!nodeTarget.empty() && !startsWith(nodeTarget, "fpp_") && !startsWith(nodeTarget, "aes67_")) {
1341
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 setPipeWireSinkVolume(nodeTarget, 100);
1342 }
1343
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 }
1344 }
1345
1346
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 restorePipeWireInputGroupVolumes();
1347
3/6
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
17 }
1348
1349 // WirePlumber's ALSA monitor enumerates every card independently of the static
1350 // fpp_alsa_* adapters PipeWire loads from 95-fpp-alsa-sink.conf, so a card FPP
1351 // already owns ALSO gets a monitor-created node (alsa_output.<sysfs>.<profile>).
1352 // Both want the same PCM, and the monitor's node -- backed by a real device
1353 // object, unlike a bare context.objects adapter -- wins the default-sink
1354 // election. Every untargeted stream then lands on a node whose snd_pcm_open()
1355 // returns EBUSY: the node goes to error and the stream neither starts nor ends,
1356 // so the client blocks in poll() forever. That is not a hypothetical -- it is
1357 // how the flite IP announcement wedged a Type=oneshot boot service and held
1358 // multi-user.target unreachable indefinitely.
1359 //
1360 // FPP declares both sink and source adapters for every present card, so the
1361 // monitor has nothing left to contribute for these: disable its device outright.
1362 // Keyed on the ALSA card short-name because api.alsa.card.name is populated by
1363 // the time the monitor evaluates monitor.alsa.rules, whereas alsa.id /
1364 // alsa.card_name are attached to the device only afterwards -- a rule keyed on
1365 // those silently never matches (verified on hardware).
1366 //
1367 // Returns true if the file changed. WirePlumber reads rules only at startup,
1368 // so the caller must restart it for a change to take effect.
1369 86 static bool ensureWirePlumberAlsaDupeSuppression(const std::set<std::string>& cardNames) {
1370
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 const std::string confPath = "/etc/wireplumber/wireplumber.conf.d/50-fpp-suppress-alsa-dupes.conf";
1371
2/2
✓ Branch 0 taken 43 times.
✓ Branch 1 taken 43 times.
86 if (cardNames.empty()) {
1372 // Nothing FPP owns statically (no usable card, or the PipeWire backend is
1373 // off): drop any stale rule rather than leaving cards suppressed with no
1374 // adapter to replace them.
1375
2/4
✓ Branch 0 taken 43 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 43 times.
✗ Branch 3 not taken.
43 if (!FileExists(confPath)) {
1376 43 return false;
1377 }
1378 ✗ unlink(confPath.c_str());
1379 ✗ return true;
1380 }
1381
1/2
✓ Branch 0 taken 43 times.
✗ Branch 1 not taken.
43 std::ostringstream out;
1382 out << "# Auto-generated by FPP - do not edit manually\n"
1383 << "# Suppresses WirePlumber's duplicate ALSA device for every card that already\n"
1384 << "# has a static fpp_alsa_* adapter in 95-fpp-alsa-sink.conf. Two nodes on one\n"
1385 << "# PCM means the loser fails to open with EBUSY and hangs its clients forever.\n"
1386
5/10
✓ Branch 0 taken 43 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 43 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 43 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 43 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 43 times.
✗ Branch 9 not taken.
43 << "monitor.alsa.rules = [\n";
1387
2/2
✓ Branch 0 taken 58 times.
✓ Branch 1 taken 43 times.
101 for (const auto& name : cardNames) {
1388 out << " {\n"
1389 << " matches = [ { api.alsa.card.name = \"" << name << "\" } ]\n"
1390 << " actions = { update-props = { device.disabled = true } }\n"
1391
6/12
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 58 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 58 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 58 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 58 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 58 times.
✗ Branch 11 not taken.
58 << " }\n";
1392 }
1393
1/2
✓ Branch 0 taken 43 times.
✗ Branch 1 not taken.
43 out << "]\n";
1394
1/2
✓ Branch 0 taken 43 times.
✗ Branch 1 not taken.
43 const std::string conf = out.str();
1395
10/14
✓ Branch 0 taken 43 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✓ Branch 3 taken 23 times.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 18 times.
✓ Branch 7 taken 2 times.
✓ Branch 8 taken 20 times.
✓ Branch 9 taken 23 times.
✓ Branch 10 taken 18 times.
✓ Branch 11 taken 25 times.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
43 if (FileExists(confPath) && GetFileContents(confPath) == conf) {
1396 18 return false;
1397 }
1398
2/4
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25 times.
✗ Branch 3 not taken.
25 exec("/bin/mkdir -p /etc/wireplumber/wireplumber.conf.d");
1399
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 PutFileContents(confPath, conf);
1400 25 chmod(confPath.c_str(), 0644);
1401 25 return true;
1402 86 }
1403
1404 // First-boot fast path for Simple PipeWire mode: synthesise the same audio
1405 // config that apply_pipewire_simple_config (PHP) would, but entirely in C++ so
1406 // no PHP interpreter is forked. Writes the simple groups JSON, the
1407 // 97-fpp-audio-groups.conf (both /etc dest and the media-dir cache so it
1408 // survives reboot), removes the input-groups conf (simple mode has none), and
1409 // sets PipeWireSinkName. The caller restarts PipeWire once afterwards.
1410 30 static void generateSimplePipeWireAudioConfig(int card, const std::string& cId,
1411 bool cardHasBootAdapter, int perSize,
1412 int pipewireSampleRate) {
1413
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 const std::string simpleGroupsJsonPath = FPP_MEDIA_DIR + "/config/pipewire-audio-groups-simple.json";
1414
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 const std::string videoSimpleJsonPath = FPP_MEDIA_DIR + "/config/pipewire-video-groups-simple.json";
1415
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 const std::string groupsConfCache = FPP_MEDIA_DIR + "/config/pipewire-audio-groups.conf";
1416
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 const std::string groupsConfDest = "/etc/pipewire/pipewire.conf.d/97-fpp-audio-groups.conf";
1417
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 const std::string igConfCache = FPP_MEDIA_DIR + "/config/pipewire-input-groups.conf";
1418
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 const std::string igConfDest = "/etc/pipewire/pipewire.conf.d/96-fpp-input-groups.conf";
1419
1420
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 printf("FPP - pipewire-simple: generating audio config in C++ (card %s)\n", cId.c_str());
1421
1422 // Simple groups JSON — mirrors BuildSimpleAudioGroupsData() in pipewire.php.
1423
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
30 Json::Value root, group, member;
1424
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 group["id"] = 1;
1425
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 group["name"] = "Default";
1426
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 group["enabled"] = true;
1427
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 group["channels"] = 2;
1428
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 group["volume"] = 100;
1429
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 group["activeGroup"] = true;
1430
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 member["cardId"] = cId;
1431
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 member["channels"] = 2;
1432
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 member["delayMs"] = 0;
1433
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 member["volume"] = 100;
1434
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 group["members"] = Json::Value(Json::arrayValue);
1435
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 group["members"].append(member);
1436
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 root["groups"] = Json::Value(Json::arrayValue);
1437
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 root["groups"].append(group);
1438
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 PutFileContents(simpleGroupsJsonPath, SaveJsonToString(root));
1439
1440 // 97-fpp-audio-groups.conf — write the /etc dest and the media-dir cache
1441 // identically so the boot-time skip-regen fast path (cache == dest) holds.
1442
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 std::string conf = buildSimplePipeWireGroupsConf(card, cId, cardHasBootAdapter, perSize, pipewireSampleRate);
1443
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 exec("/bin/mkdir -p /etc/pipewire/pipewire.conf.d");
1444
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 PutFileContents(groupsConfDest, conf);
1445
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 PutFileContents(groupsConfCache, conf);
1446
1447 // Simple mode has no input groups — remove any leftover 96 conf + cache.
1448
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 30 times.
30 if (FileExists(igConfDest)) unlink(igConfDest.c_str());
1449
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 30 times.
30 if (FileExists(igConfCache)) unlink(igConfCache.c_str());
1450
1451 // Route fppd at the group sink; clear stale advanced-mode per-slot names.
1452
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
90 setRawSetting("PipeWireSinkName", "fpp_group_default");
1453
2/2
✓ Branch 0 taken 120 times.
✓ Branch 1 taken 30 times.
150 for (int s = 2; s <= 5; s++) {
1454
3/6
✓ Branch 0 taken 120 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 120 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 120 times.
✗ Branch 5 not taken.
332 setRawSetting("PipeWireSinkName_" + std::to_string(s), "");
1455 }
1456
1457 // Record the (empty) simple video config so the UI/video path matches what
1458 // ApplyPipeWireVideoGroups would leave for an unconfigured VideoOutput.
1459
3/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✓ Branch 3 taken 10 times.
30 if (!FileExists(videoSimpleJsonPath)) {
1460
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
40 PutFileContents(videoSimpleJsonPath, "{\n \"videoOutputGroups\": []\n}");
1461 }
1462 30 }
1463
1464 // `recoveryPass` is set only by the self-heal at the very end of this function,
1465 // which discards the cached audio config and re-runs the whole setup once when
1466 // the stack it just started produced no sink at all. It exists solely to bound
1467 // that retry to one attempt.
1468 86 static void runAudioSetup(bool recoveryPass) {
1469
3/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 47 times.
✓ Branch 3 taken 39 times.
86 if (!FileExists("/root/.libao")) {
1470
3/6
✓ Branch 0 taken 47 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 47 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 47 times.
✗ Branch 5 not taken.
188 PutFileContents("/root/.libao", "dev=default");
1471 }
1472
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
172 std::string mediaBackend = "pipewire-simple";
1473
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
86 getRawSetting("MediaBackend", mediaBackend);
1474
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 std::string mediaBackendLower = mediaBackend;
1475 86 std::transform(mediaBackendLower.begin(), mediaBackendLower.end(), mediaBackendLower.begin(), [](unsigned char c) {
1476 1206 return std::tolower(c);
1477 });
1478 // The ALSA ("Hardware Direct") backend has been retired -- FPP10 always uses
1479 // a PipeWire backend. Migrate any device still set to "alsa" to the default
1480 // pipewire-simple and persist it, so we never take the (being-removed) ALSA
1481 // path and every other MediaBackend reader sees the new value. Done here, the
1482 // earliest audio entry point, so the rest of setupAudio runs as PipeWire.
1483
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 86 times.
86 if (mediaBackendLower == "alsa") {
1484 ✗ printf("FPP - MediaBackend 'alsa' is retired; migrating to 'pipewire-simple'\n");
1485 ✗ setRawSetting("MediaBackend", "pipewire-simple");
1486 ✗ mediaBackend = "pipewire-simple";
1487 ✗ mediaBackendLower = "pipewire-simple";
1488 }
1489
5/8
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 74 times.
✓ Branch 3 taken 12 times.
✓ Branch 4 taken 74 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 74 times.
✗ Branch 7 not taken.
86 bool usePipeWireBackend = (mediaBackendLower == "pipewire" || mediaBackendLower == "pipewire-simple");
1490
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 bool runningInDocker = FileExists("/.dockerenv");
1491
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 const std::string audioEnvPath = "/run/fppd/fpp-audio.env";
1492
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
86 printf("FPP - Audio backend: %s\n", usePipeWireBackend ? "PipeWire" : "ALSA");
1493
1494 // AudioOutput is persisted as a stable ALSA card ID string (e.g. "S3",
1495 // "bcm2835ALSA") rather than a card index, so a probe-order change (USB
1496 // add/remove, slow-probing cape, kernel update) doesn't repoint us at the
1497 // wrong device.
1498 // - empty/unset -> first present card (index 0), as before
1499 // - all-digits -> legacy index; used as-is and migrated to an ID below
1500 // - otherwise -> card ID; matched against /proc/asound/cards
1501 86 std::string audioOutputId;
1502
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
86 getRawSetting("AudioOutput", audioOutputId);
1503
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 TrimWhiteSpace(audioOutputId);
1504
3/4
✓ Branch 0 taken 50 times.
✓ Branch 1 taken 36 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 50 times.
86 bool legacyNumeric = !audioOutputId.empty() && audioOutputId.find_first_not_of("0123456789") == std::string::npos;
1505
1506 // "Dummy" selects the synthetic snd-dummy card (a box that wants no real
1507 // audio). It only ever registers because we modprobe it, so waiting for it
1508 // below could never succeed -- load it now and every wait in this function
1509 // is already satisfied.
1510
13/20
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✓ Branch 3 taken 76 times.
✓ Branch 4 taken 10 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 10 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✓ Branch 9 taken 6 times.
✓ Branch 10 taken 10 times.
✓ Branch 11 taken 76 times.
✓ Branch 12 taken 10 times.
✓ Branch 13 taken 76 times.
✓ Branch 14 taken 4 times.
✓ Branch 15 taken 82 times.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
✗ Branch 18 not taken.
✗ Branch 19 not taken.
106 if (audioOutputId == "Dummy" && getAlsaCardNumForId("Dummy") < 0) {
1511
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 printf("FPP - Audio device 'Dummy' selected; loading snd-dummy\n");
1512
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 modprobe("snd-dummy");
1513 }
1514
1515 // A card selected by ID may not have registered yet: cape modules are
1516 // modprobed back at cape detect, but an ASoC card (e.g. the K32Max's
1517 // CapeAudio-pcm5102a) binds asynchronously and can land after we get here.
1518 // Wait for it before probing, otherwise the aplay snapshot below misses it,
1519 // snd-dummy gets loaded in its place, and the selection resolves to Dummy.
1520 // Only a selected-but-absent card waits, so a board with no selection (or
1521 // one whose card is already up) pays nothing.
1522
8/10
✓ Branch 0 taken 50 times.
✓ Branch 1 taken 36 times.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 48 times.
✓ Branch 8 taken 2 times.
✓ Branch 9 taken 84 times.
86 if (!audioOutputId.empty() && !legacyNumeric && getAlsaCardNumForId(audioOutputId) < 0) {
1523
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 printf("FPP - Waiting for audio device '%s' to register...\n", audioOutputId.c_str());
1524 2 int waited = 0;
1525
6/8
✓ Branch 0 taken 200 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 200 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 200 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 200 times.
✓ Branch 7 taken 2 times.
202 while (waited < 100 && getAlsaCardNumForId(audioOutputId) < 0) {
1526
1/2
✓ Branch 0 taken 200 times.
✗ Branch 1 not taken.
200 std::this_thread::sleep_for(std::chrono::milliseconds(100));
1527 200 ++waited;
1528 }
1529
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 if (getAlsaCardNumForId(audioOutputId) < 0) {
1530
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 printf("FPP - Audio device '%s' did not appear after %d seconds\n", audioOutputId.c_str(), waited / 10);
1531 } else {
1532 ✗ printf("FPP - Audio device '%s' registered after %d.%d seconds\n", audioOutputId.c_str(), waited / 10, waited % 10);
1533 }
1534 }
1535
1536 86 std::string aplay;
1537 86 std::vector<std::string> lines;
1538 // Keyed "card N", in card-number order: as plain strings "card 10" sorts
1539 // before "card 2", and the fallback below takes the first usable card.
1540 struct CardKeyLess {
1541 1661 bool operator()(const std::string& a, const std::string& b) const {
1542 1661 int na = atoi(a.c_str() + std::min<size_t>(5, a.size()));
1543 1661 int nb = atoi(b.c_str() + std::min<size_t>(5, b.size()));
1544
2/2
✓ Branch 0 taken 541 times.
✓ Branch 1 taken 1120 times.
1661 return na != nb ? na < nb : a < b;
1545 }
1546 };
1547 86 std::map<std::string, std::string, CardKeyLess> cards;
1548 86 std::map<std::string, std::string, CardKeyLess> cardLines; // full aplay line per "card N"
1549 // Normalised card IDs that got an fpp_alsa_* sink node in 95-fpp-alsa-sink.conf
1550 // below; consulted by the Simple-mode C++ config generator to know whether a
1551 // card already has a boot-time adapter or needs a custom one inline.
1552 86 std::set<std::string> bootAdapterCids;
1553 // True once the Simple-mode config has been generated in C++ this run, so the
1554 // post-restart PHP regenerate/volume-restore dance can be skipped (the config
1555 // is already complete and self-contained).
1556 86 bool cppGeneratedSimpleConfig = false;
1557 86 std::map<std::string, bool> hdmiStatus;
1558 86 bool hasNonHDMI = false;
1559 236 auto lineHasHDMI = [](const std::string& l) {
1560
1/2
✓ Branch 0 taken 236 times.
✗ Branch 1 not taken.
236 std::string lc = l;
1561 236 std::transform(lc.begin(), lc.end(), lc.begin(), ::tolower);
1562 472 return lc.find("hdmi") != std::string::npos;
1563 236 };
1564 // Snapshot the cards "aplay -l" can see. Wrapped so it can be re-run after
1565 // waiting for a late-binding card below.
1566 90 auto probeCards = [&]() {
1567
2/4
✓ Branch 0 taken 90 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 90 times.
✗ Branch 3 not taken.
90 aplay = execAndReturn("/usr/bin/aplay -l 2>&1");
1568
1/2
✓ Branch 0 taken 90 times.
✗ Branch 1 not taken.
90 lines = split(aplay, '\n');
1569 90 cards.clear();
1570 90 cardLines.clear();
1571 90 hasNonHDMI = false;
1572
2/2
✓ Branch 0 taken 303 times.
✓ Branch 1 taken 90 times.
393 for (auto& l : lines) {
1573 // "card 10: S3 [USB Audio Device], device 0: ..." -- the number is
1574 // as wide as it is. Fixed offsets made card 10 "card 1" with no
1575 // id, overwriting card 1.
1576 303 size_t colon = l.find(':');
1577
6/8
✓ Branch 0 taken 74 times.
✓ Branch 1 taken 229 times.
✓ Branch 2 taken 74 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 74 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 74 times.
✓ Branch 7 taken 229 times.
303 if (l.starts_with("card ") && colon != std::string::npos && colon > 5) {
1578
1/2
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
74 std::string k = l.substr(0, colon);
1579
3/10
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 74 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 74 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
74 std::string v = colon + 2 <= l.size() ? l.substr(colon + 2) : "";
1580
1/2
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
74 v = v.substr(0, v.find(' '));
1581
2/4
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 74 times.
✗ Branch 3 not taken.
74 cards[k] = v;
1582
2/4
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 74 times.
✗ Branch 3 not taken.
74 cardLines[k] = l;
1583 // The synthetic snd-dummy is not real hardware; with an explicit
1584 // "Dummy" selection it is loaded before this probe runs and must
1585 // not defeat the no-real-soundcard fast paths below.
1586
5/8
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 42 times.
✓ Branch 3 taken 32 times.
✓ Branch 4 taken 42 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 42 times.
✗ Branch 7 not taken.
74 hasNonHDMI |= !lineHasHDMI(l) && v != "Dummy";
1587 74 }
1588 }
1589 90 };
1590
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 probeCards();
1591 86 int hdmiIdx = 0;
1592 86 bool anyHDMIConnected = false;
1593
2/2
✓ Branch 0 taken 344 times.
✓ Branch 1 taken 86 times.
430 for (int x = 0; x < 4; x++) {
1594
2/4
✓ Branch 0 taken 344 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 344 times.
✗ Branch 3 not taken.
344 std::string cstr = "/sys/class/drm/card" + std::to_string(x) + "-HDMI-A-1/status";
1595
3/4
✓ Branch 0 taken 344 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 16 times.
✓ Branch 3 taken 328 times.
344 if (FileExists(cstr)) {
1596
2/2
✓ Branch 0 taken 64 times.
✓ Branch 1 taken 16 times.
80 for (int p = 1; p < 5; p++) {
1597
4/8
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 64 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 64 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 64 times.
✗ Branch 7 not taken.
64 std::string cstr = "/sys/class/drm/card" + std::to_string(x) + "-HDMI-A-" + std::to_string(p) + "/status";
1598
1/2
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
64 std::string c = GetFileContents(cstr);
1599
1/2
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
64 std::string k = "vc4hdmi" + std::to_string(hdmiIdx);
1600
4/4
✓ Branch 0 taken 32 times.
✓ Branch 1 taken 32 times.
✓ Branch 2 taken 17 times.
✓ Branch 3 taken 15 times.
64 bool conn = c.contains("connected") && !c.contains("disconnected");
1601
1/2
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
64 hdmiStatus[k] = conn;
1602 64 anyHDMIConnected |= conn;
1603 64 hdmiIdx++;
1604 64 }
1605 }
1606 344 }
1607 // True when the only audio device is the synthetic snd-dummy (no real,
1608 // non-HDMI sound card present) -- the common case on a BeagleBone with no
1609 // audio cape. Used later to skip the expensive PipeWire device-enumeration
1610 // and group-regeneration dance, which only matters for real/hot-pluggable
1611 // hardware.
1612
9/16
✓ Branch 0 taken 38 times.
✓ Branch 1 taken 48 times.
✓ Branch 2 taken 38 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 38 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 38 times.
✓ Branch 8 taken 38 times.
✓ Branch 9 taken 48 times.
✓ Branch 10 taken 38 times.
✓ Branch 11 taken 48 times.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
124 bool noRealSoundcard = (!hasNonHDMI || contains(aplay, "no soundcards"));
1613 // Loading snd-dummy is a one-way door: it takes the next free card number, so
1614 // a real card that binds a moment later lands behind it and the default
1615 // selection resolves to Dummy. Before committing, give hardware that is
1616 // physically here -- declared in the device tree, or a plugged-in USB audio
1617 // device -- a bounded chance to finish binding. Cape modules are modprobed
1618 // back in fppinit's cape detect (an earlier systemd unit) and an ASoC card
1619 // binds synchronously as its last component registers, so this is normally
1620 // already satisfied -- it only pays out when the driver is still being
1621 // autoloaded. Gated so a board with neither (a BeagleBone with no audio cape,
1622 // the common case) waits zero.
1623 // An explicit "Dummy" selection also skips this: the user asked for no real
1624 // audio, so there is nothing worth waiting on even if hardware is present.
1625
15/20
✓ Branch 0 taken 48 times.
✓ Branch 1 taken 38 times.
✓ Branch 2 taken 48 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 38 times.
✓ Branch 5 taken 10 times.
✓ Branch 6 taken 38 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 37 times.
✓ Branch 9 taken 1 times.
✓ Branch 10 taken 37 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 32 times.
✓ Branch 13 taken 5 times.
✓ Branch 14 taken 32 times.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✓ Branch 17 taken 32 times.
✓ Branch 18 taken 5 times.
✓ Branch 19 taken 81 times.
86 if (noRealSoundcard && audioOutputId != "Dummy" && countRealAlsaCards() == 0 && (deviceTreeDeclaresSoundCard() || usbAudioDevicePresent())) {
1626
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 printf("FPP - No soundcard yet, but audio hardware is present; waiting...\n");
1627 // Don't just wait on udev -- it may not have autoloaded the CPU DAI or
1628 // machine driver yet, and without them the card can never bind no matter
1629 // how long we sit here.
1630
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 loadPendingSoundDrivers();
1631 5 int waited = 0;
1632
7/8
✓ Branch 0 taken 94 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 94 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 90 times.
✓ Branch 5 taken 4 times.
✓ Branch 6 taken 90 times.
✓ Branch 7 taken 5 times.
95 while (waited < 50 && countRealAlsaCards() == 0) {
1633
1/2
✓ Branch 0 taken 90 times.
✗ Branch 1 not taken.
90 std::this_thread::sleep_for(std::chrono::milliseconds(100));
1634 90 ++waited;
1635 }
1636
3/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 1 times.
5 if (countRealAlsaCards() > 0) {
1637 // The card is registered in the kernel, but "aplay -l" reads
1638 // /dev/snd/controlC*, which udev creates a beat later (~115ms measured
1639 // on a BeagleBone). This is the one part udev genuinely owns, so settle
1640 // for it rather than re-probing into the gap.
1641
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 exec("/usr/bin/udevadm settle --timeout=5 > /dev/null 2>&1");
1642
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 printf("FPP - Soundcard registered after %d.%ds\n", waited / 10, waited % 10);
1643
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 probeCards();
1644
6/16
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 4 times.
✓ Branch 8 taken 4 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 4 times.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
12 noRealSoundcard = (!hasNonHDMI || contains(aplay, "no soundcards"));
1645 } else {
1646
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 printf("FPP - No soundcard appeared after %d seconds\n", waited / 10);
1647 }
1648 }
1649
12/18
✓ Branch 0 taken 44 times.
✓ Branch 1 taken 42 times.
✓ Branch 2 taken 44 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 44 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 34 times.
✓ Branch 7 taken 10 times.
✓ Branch 8 taken 44 times.
✓ Branch 9 taken 42 times.
✓ Branch 10 taken 44 times.
✓ Branch 11 taken 42 times.
✓ Branch 12 taken 34 times.
✓ Branch 13 taken 52 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
174 if (noRealSoundcard && getAlsaCardNumForId("Dummy") < 0) {
1650
1/2
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
34 printf("FPP - No Soundcard Detected, loading snd-dummy\n");
1651
1/2
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
34 modprobe("snd-dummy");
1652 }
1653 // Resolve the selected card ID (read above) to the current card number;
1654 // everything below continues to work with the numeric index.
1655 // True when the selected card ID names a device that isn't present, so we
1656 // are running this boot on a fallback card. The selection is the user's and
1657 // the absence may be transient (a card still probing, a USB device
1658 // unplugged for the evening), so a fallback must not be written back over
1659 // it -- doing so silently loses the selection across a reboot.
1660 86 bool selectedCardMissing = false;
1661 // True when this boot plays on some other card than the selection --
1662 // the selected card is missing, is HDMI with no display, or never
1663 // answered. The fallback is used for this boot only and recorded in
1664 // EFFECTIVE_AUDIO_OUTPUT_PATH; AudioOutput keeps the user's choice.
1665 // Writing the fallback into AudioOutput lost that choice for good: a show
1666 // controller whose TV was off at boot came back on the headphone jack
1667 // every boot after, display or not.
1668 86 bool onFallback = false;
1669 86 int card = 0;
1670
2/2
✓ Branch 0 taken 36 times.
✓ Branch 1 taken 50 times.
86 if (audioOutputId.empty()) {
1671 36 card = 0;
1672
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 50 times.
50 } else if (legacyNumeric) {
1673 ✗ card = std::stoi(audioOutputId);
1674 } else {
1675
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 card = getAlsaCardNumForId(audioOutputId);
1676
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 48 times.
50 if (card < 0) {
1677
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 printf("FPP - Audio device '%s' not currently present; falling back to card 0 for this boot\n", audioOutputId.c_str());
1678 2 card = 0;
1679 2 selectedCardMissing = true;
1680 2 onFallback = true;
1681 }
1682 }
1683
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 std::string cstr = "card " + std::to_string(card);
1684 86 bool found = false;
1685 86 int count = 0;
1686
1687 // Treat the selected card as unusable if it's any HDMI card (vc4hdmi OR
1688 // legacy bcm2835 HDMI) with no display connected. Playing to an
1689 // unconnected HDMI audio device panics the kernel on some Pis.
1690 162 auto cardIsDeadHDMI = [&](const std::string& k) {
1691
2/2
✓ Branch 0 taken 127 times.
✓ Branch 1 taken 35 times.
162 if (!lineHasHDMI(cardLines[k])) return false;
1692
1/2
✓ Branch 0 taken 35 times.
✗ Branch 1 not taken.
35 if (cards[k].starts_with("vc4hdmi")) {
1693 // hdmiStatus is keyed by a synthesized connector index ("vc4hdmi0",
1694 // "vc4hdmi1" on dual-HDMI boards). Single-HDMI boards (e.g. Pi Zero
1695 // 2 W, Pi 3) register the card as plain "vc4hdmi", which never
1696 // matches an "NN" index key; the std::map read then yields false and
1697 // the card is wrongly declared "no HDMI connected" even when a
1698 // display is attached, causing setupAudio to permanently revert
1699 // AudioOutput back to card 0. When there is no per-port status for
1700 // this card, fall back to the any-HDMI-connected signal (same
1701 // semantics as the legacy bcm2835 HDMI branch below).
1702
2/4
✓ Branch 0 taken 35 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 35 times.
✗ Branch 3 not taken.
35 auto it = hdmiStatus.find(cards[k]);
1703
2/2
✓ Branch 0 taken 30 times.
✓ Branch 1 taken 5 times.
35 if (it != hdmiStatus.end()) {
1704 30 return !it->second;
1705 }
1706 5 return !anyHDMIConnected;
1707 }
1708 // legacy bcm2835 HDMI shares the physical port; if any HDMI is
1709 // connected, assume this device may work, otherwise treat as dead.
1710 ✗ return !anyHDMIConnected;
1711 86 };
1712
1713
3/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 85 times.
86 if (cardIsDeadHDMI(cstr)) {
1714 // Walk cards in index order and pick the first non-HDMI, or HDMI with
1715 // display connected. Falls through to the snd-dummy case otherwise.
1716 1 int fallback = -1;
1717
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 for (const auto& [k, _] : cards) {
1718
3/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 1 times.
2 if (!cardIsDeadHDMI(k)) {
1719
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 fallback = std::stoi(k.substr(5));
1720 1 break;
1721 }
1722 }
1723
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 if (fallback >= 0 && fallback != card) {
1724
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 printf("FPP - Audio device %d has no HDMI connected, using card %d (%s) for this boot\n",
1725
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
2 card, fallback, cards["card " + std::to_string(fallback)].c_str());
1726 1 card = fallback;
1727 } else {
1728 ✗ card = cards.size();
1729 ✗ found = true;
1730 }
1731 1 onFallback = true;
1732 }
1733
3/4
✓ Branch 0 taken 86 times.
✓ Branch 1 taken 86 times.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
172 while (!found && count < 50) {
1734 // A read: "cset numid=3" was the old probe, which wrote whatever
1735 // control 3 is on any card but the Pi's own (output routing there).
1736
3/6
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 86 times.
✗ Branch 5 not taken.
86 std::string amixer = execAndReturn("/usr/bin/amixer -c " + std::to_string(card) + " info 2>&1");
1737
3/6
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 86 times.
172 if (contains(amixer, "Invalid ")) {
1738 ✗ ++count;
1739 ✗ std::this_thread::sleep_for(std::chrono::milliseconds(200));
1740 } else {
1741 86 found = true;
1742 }
1743 86 }
1744
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 86 times.
86 if (!found) {
1745 // The probe below is an amixer control write, which a working card
1746 // can refuse; don't record card 0 over the selection on its word.
1747 ✗ printf("FPP - Could not find audio device %d, defaulting to device 0.\n", card);
1748 ✗ CopyFileContents("/opt/fpp/etc/asoundrc.plain", "/root/.asoundrc");
1749 } else {
1750
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 printf("FPP - Waited for %d seconds for audio device\n", (count / 5));
1751 // Persist the selection as the stable ALSA card ID. This migrates a
1752 // legacy numeric value and canonicalizes the stored ID so the next
1753 // boot matches by name regardless of probe order. Skipped when we're on
1754 // a fallback card: that would overwrite the user's choice with whatever
1755 // happened to be present (typically the synthetic Dummy).
1756
6/10
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 83 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 83 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✓ Branch 7 taken 83 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
89 std::string canonicalId = onFallback ? "" : getAlsaCardId(card);
1757
6/6
✓ Branch 0 taken 83 times.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 36 times.
✓ Branch 3 taken 47 times.
✓ Branch 4 taken 36 times.
✓ Branch 5 taken 50 times.
86 if (!canonicalId.empty() && canonicalId != audioOutputId) {
1758
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 printf("FPP - Persisting selected audio device as ID '%s'\n", canonicalId.c_str());
1759
2/4
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 36 times.
✗ Branch 3 not taken.
72 setRawSetting("AudioOutput", canonicalId);
1760 }
1761 // Point ALSA's default at the chosen card so root-context playback
1762 // (e.g. flite from fppinit) doesn't land on a dead HDMI device.
1763
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
86 std::string arc = GetFileContents("/opt/fpp/etc/asoundrc.plain");
1764
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 std::string cardLine = "card " + std::to_string(card);
1765 86 size_t pos = 0;
1766
2/2
✓ Branch 0 taken 172 times.
✓ Branch 1 taken 86 times.
258 while ((pos = arc.find("card 0", pos)) != std::string::npos) {
1767
1/2
✓ Branch 0 taken 172 times.
✗ Branch 1 not taken.
172 arc.replace(pos, 6, cardLine);
1768 172 pos += cardLine.size();
1769 }
1770
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
86 PutFileContents("/root/.asoundrc", arc);
1771 86 }
1772 // What this boot plays on, for whatever reads AudioOutput later (Simple
1773 // mode's PHP apply, fppd's volume control). They use "effective" while
1774 // AudioOutput still equals "selected", and AudioOutput once the user has
1775 // changed it.
1776
2/2
✓ Branch 0 taken 50 times.
✓ Branch 1 taken 36 times.
86 if (!runningInDocker) {
1777 50 mkdir("/run/fppd", 0755);
1778
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 Json::Value eff;
1779
2/4
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
50 eff["selected"] = audioOutputId;
1780
3/6
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
50 eff["effective"] = getAlsaCardId(card);
1781
2/4
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
50 eff["fallback"] = onFallback;
1782
3/6
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
72 PutFileContents(EFFECTIVE_AUDIO_OUTPUT_PATH, SaveJsonToString(eff));
1783 50 }
1784
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
86 int v = getRawSettingInt("volume", -1);
1785
2/2
✓ Branch 0 taken 60 times.
✓ Branch 1 taken 26 times.
86 if (v == -1) {
1786
3/6
✓ Branch 0 taken 60 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 60 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 60 times.
✗ Branch 5 not taken.
180 setRawSetting("volume", "70");
1787 60 v = 70;
1788 }
1789 86 std::string origAudio0CardType;
1790
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
172 getRawSetting("AudioCard0Type", origAudio0CardType);
1791
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 std::string audioCardType = "unknown";
1792
3/6
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 86 times.
✗ Branch 5 not taken.
86 std::string aplayl = execAndReturn("/usr/bin/aplay -l | grep 'card " + std::to_string(card) + "'");
1793 #ifdef PLATFORM_PI
1794 // Pi headphone jack needs a volume adjustment, in reality a lot of sound cards do, but we
1795 // don't want to put in a lot of special cases here so only handle the Pi
1796
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 15 times.
16 if (card == 0) {
1797
8/30
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 1 times.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✓ Branch 13 taken 1 times.
✗ Branch 14 not taken.
✓ Branch 15 taken 1 times.
✓ Branch 16 taken 1 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 1 times.
✗ Branch 19 not taken.
✗ Branch 20 not taken.
✓ Branch 21 taken 1 times.
✗ Branch 22 not taken.
✗ Branch 23 not taken.
✗ Branch 24 not taken.
✗ Branch 25 not taken.
✗ Branch 26 not taken.
✗ Branch 27 not taken.
✗ Branch 28 not taken.
✗ Branch 29 not taken.
3 if (contains(aplayl, "[bcm2") && !contains(aplayl, "-i2s")) {
1798 ✗ v = (v / 2) + 50;
1799 ✗ audioCardType = "bcm2";
1800 }
1801 }
1802 #endif
1803
2/2
✓ Branch 0 taken 60 times.
✓ Branch 1 taken 26 times.
86 if (audioCardType != origAudio0CardType) {
1804
2/4
✓ Branch 0 taken 60 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 60 times.
✗ Branch 3 not taken.
120 setRawSetting("AudioCard0Type", audioCardType);
1805 }
1806
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 std::string cardType = aplayl.substr(aplayl.find("[") + 1);
1807
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 cardType = cardType.substr(0, cardType.find("]"));
1808
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 printf("FPP - Found sound card of type %s\n", cardType.c_str());
1809 86 std::string asoundrc;
1810
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 if (usePipeWireBackend) {
1811
7/10
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 47 times.
✓ Branch 3 taken 39 times.
✓ Branch 4 taken 47 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 47 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 47 times.
✓ Branch 9 taken 39 times.
86 if (!FileExists("/etc/pipewire/client.conf") && FileExists("/usr/share/pipewire/client.conf")) {
1812
3/6
✓ Branch 0 taken 47 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 47 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 47 times.
✗ Branch 5 not taken.
188 CopyFileContents("/usr/share/pipewire/client.conf", "/etc/pipewire/client.conf");
1813 }
1814
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
172 asoundrc = GetFileContents("/opt/fpp/etc/asoundrc.pipewire");
1815 } else {
1816 ✗ if (FileExists(FPP_MEDIA_DIR + "/tmp/asoundrc")) {
1817 ✗ if (contains(cardType, "pcm510")) {
1818 ✗ printf("FPP - Using Cape Provided asoundrc\n");
1819 ✗ asoundrc = GetFileContents(FPP_MEDIA_DIR + "/tmp/asoundrc");
1820 } else {
1821 ✗ asoundrc = GetFileContents("/opt/fpp/etc/asoundrc.dmix");
1822 }
1823 }
1824 ✗ if (asoundrc.empty()) {
1825 ✗ if (contains(cardType, "vc4-hdmi")) {
1826 ✗ replaceAll(cardType, "-", "");
1827 ✗ asoundrc = GetFileContents("/opt/fpp/etc/asoundrc.hdmi");
1828 ✗ } else if (contains(cardType, "bcm2")) {
1829 ✗ asoundrc = GetFileContents("/opt/fpp/etc/asoundrc.plain");
1830 } else {
1831 ✗ asoundrc = GetFileContents("/opt/fpp/etc/asoundrc.dmix");
1832 }
1833 }
1834 }
1835 // The legacy ALSA templates carry a BUFFERSIZE token; the PipeWire one does
1836 // not, and the ALSA backend is retired (see the migration above), so nothing
1837 // reachable consumes this any more. The old AudioBufferSize setting was
1838 // removed rather than left in the UI doing nothing; substitute the value it
1839 // defaulted to so the templates stay valid if one is ever used again.
1840 86 constexpr int LEGACY_ALSA_BUFFER_SIZE = 3072;
1841
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
172 int perSize = getRawSettingInt("AudioPeriodSize", 1024);
1842
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
125 replaceAll(asoundrc, "CARDTYPE", cardType);
1843
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
219 replaceAll(asoundrc, "BUFFERSIZE", std::to_string(LEGACY_ALSA_BUFFER_SIZE));
1844
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
180 replaceAll(asoundrc, "PERIODSIZE", std::to_string(perSize));
1845
2/2
✓ Branch 0 taken 860 times.
✓ Branch 1 taken 86 times.
946 for (int x = 0; x < 10; x++) {
1846
2/2
✓ Branch 0 taken 774 times.
✓ Branch 1 taken 86 times.
860 if (x != card) {
1847
3/6
✓ Branch 0 taken 774 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 774 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 774 times.
✗ Branch 5 not taken.
774 replaceAll(asoundrc, "card " + std::to_string(x), "card " + std::to_string(card));
1848 }
1849 }
1850 // AudioFormat used to encode both a rate and a sample format (44100/S16,
1851 // 44100/S32, ...), but only the rate has been used since the ALSA backend
1852 // was retired -- PipeWire probes the card for the format. The setting now
1853 // offers just the rates (0 default, 1 = 44100, 4 = 48000, 7 = 96000), so
1854 // fold any stored bit-depth variant onto its rate. Without this the box
1855 // keeps a value the dropdown no longer lists, which renders as no selection
1856 // and silently drops to 44100 the first time the page is saved.
1857
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
86 int rate = getRawSettingInt("AudioFormat", 0);
1858 86 int normalizedRate = rate;
1859
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 85 times.
86 if (rate >= 7) {
1860 1 normalizedRate = 7;
1861
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 83 times.
85 } else if (rate >= 4) {
1862 2 normalizedRate = 4;
1863
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 82 times.
83 } else if (rate >= 1) {
1864 1 normalizedRate = 1;
1865 }
1866
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 83 times.
86 if (normalizedRate != rate) {
1867
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 printf("FPP - AudioFormat %d no longer selects a sample format; storing %d\n", rate, normalizedRate);
1868
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
9 setRawSetting("AudioFormat", std::to_string(normalizedRate));
1869 3 rate = normalizedRate;
1870 }
1871 86 int pipewireSampleRate = 44100;
1872
4/4
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 82 times.
86 switch (rate) {
1873 1 case 1:
1874 // replaceAll(asoundrc, "rate 44100", "rate 44100");
1875 1 break;
1876 2 case 4:
1877
3/6
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
6 replaceAll(asoundrc, "rate 44100", "rate 48000");
1878 2 pipewireSampleRate = 48000;
1879 2 break;
1880 1 case 7:
1881
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
3 replaceAll(asoundrc, "rate 44100", "rate 96000");
1882 1 pipewireSampleRate = 96000;
1883 1 break;
1884 82 default:
1885 82 break;
1886 }
1887
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
172 PutFileContents("/root/.asoundrc", asoundrc);
1888
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 const std::string pipewireSinkConfPath = "/etc/pipewire/pipewire.conf.d/95-fpp-alsa-sink.conf";
1889 // Snapshot what PipeWire loaded at boot so we can tell whether the freshly
1890 // generated sink config actually differs (and thus needs a restart below).
1891
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 const std::string existingSinkConf = GetFileContents(pipewireSinkConfPath);
1892 86 bool sinkConfigChanged = false;
1893 // Cheap pre-check: is the persisted 95-fpp-alsa-sink.conf still valid for the
1894 // cards present right now? It is when its fpp_alsa_* sink adapters cover
1895 // exactly the present, usable (non-dummy, non-dead-HDMI) cards. When so, skip
1896 // the whole per-card ALSA probe below -- aplay/amixer/arecord plus a 2s
1897 // hw-params dump each, all via popen, which is the slowest part of audio
1898 // setup -- since it only changes anything when a card was added or removed.
1899 // The conf persists in /etc across reboots, so the common boot hits this.
1900 //
1901 // Which cards share an audio group with another sink, which is what decides
1902 // the USB headroom below. Derived here, before the probe, so the validity
1903 // check can compare it: it needs nothing the probe produces, only the groups
1904 // JSON and the aplay lines already in hand.
1905 const std::map<std::string, int> groupSizeByCard = pipewireGroupSizeByCard(
1906
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
172 FPP_MEDIA_DIR + "/config/" +
1907
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 (mediaBackendLower == "pipewire-simple" ? "pipewire-audio-groups-simple.json"
1908
4/6
✓ Branch 0 taken 74 times.
✓ Branch 1 taken 12 times.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 86 times.
✗ Branch 5 not taken.
133 : "pipewire-audio-groups.json"));
1909 1 auto cardSharesGroup = [&groupSizeByCard](const std::string& cId) {
1910
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 auto it = groupSizeByCard.find(cId);
1911
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
1 return it != groupSizeByCard.end() && it->second > 1;
1912 86 };
1913 58 auto lineIsUsbCard = [](const std::string& l) {
1914 58 return l.find("USB Audio") != std::string::npos;
1915 };
1916 86 std::set<std::string> sharedGroupUsbCids; // USB cards that must keep the follower headroom
1917 86 std::set<std::string> adapterCandidateCids; // present cards that would get an adapter
1918 86 std::set<std::string> adapterCardNames; // ...and their ALSA short-names, for the WirePlumber rule
1919
2/2
✓ Branch 0 taken 74 times.
✓ Branch 1 taken 86 times.
160 for (const auto& [key, cardId] : cards) {
1920
3/6
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 74 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 74 times.
✗ Branch 5 not taken.
74 std::string cId = getAlsaCardId(std::stoi(key.substr(5)));
1921
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 74 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
74 if (cId.empty()) cId = cardId;
1922
7/10
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 74 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 74 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 16 times.
✓ Branch 7 taken 58 times.
✓ Branch 8 taken 16 times.
✓ Branch 9 taken 58 times.
74 if (cId == "Dummy" || cardIsDeadHDMI(key)) continue;
1923
2/4
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 58 times.
✗ Branch 3 not taken.
58 adapterCandidateCids.insert(normalizeCardIdForNode(cId));
1924
8/14
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 58 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 58 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✓ Branch 7 taken 57 times.
✓ Branch 8 taken 1 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✓ Branch 11 taken 1 times.
✗ Branch 12 not taken.
✓ Branch 13 taken 58 times.
58 if (cardLines.count(key) && lineIsUsbCard(cardLines[key]) && cardSharesGroup(cId)) {
1925 ✗ sharedGroupUsbCids.insert(normalizeCardIdForNode(cId));
1926 }
1927 // "card N: <id> [<short name>], device M: ..." -- that first bracketed
1928 // field is exactly what the ALSA monitor exposes as api.alsa.card.name,
1929 // which is what ensureWirePlumberAlsaDupeSuppression() matches on.
1930
2/4
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 58 times.
✗ Branch 3 not taken.
58 if (cardLines.count(key)) {
1931
1/2
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
58 const std::string& l = cardLines[key];
1932 58 auto b = l.find('[');
1933 58 auto e = l.find(']');
1934
3/6
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 58 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 58 times.
✗ Branch 5 not taken.
58 if (b != std::string::npos && e != std::string::npos && e > b) {
1935
1/2
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
58 std::string nm = l.substr(b + 1, e - b - 1);
1936
1/2
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
58 TrimWhiteSpace(nm);
1937 // A quote would break out of the generated SPA-JSON string; such a
1938 // card name has never been seen, but a malformed rules file would
1939 // take down the whole monitor, so skip rather than risk it.
1940
3/6
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 58 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 58 times.
✗ Branch 5 not taken.
58 if (!nm.empty() && nm.find('"') == std::string::npos) {
1941
1/2
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
58 adapterCardNames.insert(nm);
1942 }
1943 58 }
1944 }
1945
2/2
✓ Branch 0 taken 58 times.
✓ Branch 1 taken 16 times.
74 }
1946 86 std::set<std::string> existingAdapterCids; // fpp_alsa_* sink adapters already in the conf
1947
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 std::regex alsaAdapterRe(R"RE(node\.name = "fpp_alsa_([a-z0-9_]+)")RE");
1948
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 for (auto it = std::sregex_iterator(existingSinkConf.begin(), existingSinkConf.end(), alsaAdapterRe);
1949
3/4
✓ Branch 0 taken 103 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✓ Branch 3 taken 86 times.
103 it != std::sregex_iterator(); ++it) {
1950
4/8
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 17 times.
✗ Branch 7 not taken.
17 existingAdapterCids.insert((*it)[1].str());
1951 86 }
1952 // A conf written by an older FPP describes the same cards by rules that have
1953 // since been corrected, and the card-set comparison below cannot see that.
1954 // Treat a missing or older generation tag as reason enough to re-probe.
1955
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
86 const bool sinkConfGenerationCurrent = startsWith(existingSinkConf, alsaSinkConfGenerationTag());
1956
5/8
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✓ Branch 3 taken 67 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 19 times.
✗ Branch 6 not taken.
✓ Branch 7 taken 86 times.
86 if (usePipeWireBackend && !existingSinkConf.empty() && !sinkConfGenerationCurrent) {
1957 ✗ printf("FPP - PipeWire: ALSA sink config predates the current probe rules; re-probing\n");
1958 }
1959 // Compared against the recorded candidate list, not against the adapters the
1960 // conf declares: the probe legitimately writes fewer adapters than there are
1961 // candidates. See alsaSinkConfCardsTag().
1962 const bool sinkConfCardsMatch =
1963
3/6
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 86 times.
✗ Branch 5 not taken.
86 contains(existingSinkConf, alsaSinkConfCardsTag(adapterCandidateCids) + "\n");
1964 // Group membership moves independently of the card set, so this has to be
1965 // reported separately -- blaming a headroom change on the hardware would send
1966 // anyone reading the boot log looking for a card that did not actually move.
1967 const bool sinkConfHeadroomMatch =
1968
3/6
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 86 times.
✗ Branch 5 not taken.
86 contains(existingSinkConf, alsaSinkConfUsbHeadroomTag(sharedGroupUsbCids) + "\n");
1969
3/4
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✓ Branch 3 taken 4 times.
105 bool sinkConfStillValid = !existingSinkConf.empty() && sinkConfGenerationCurrent &&
1970
3/4
✓ Branch 0 taken 19 times.
✓ Branch 1 taken 67 times.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
105 sinkConfCardsMatch && sinkConfHeadroomMatch;
1971
8/10
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✓ Branch 3 taken 67 times.
✓ Branch 4 taken 19 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✓ Branch 7 taken 15 times.
✓ Branch 8 taken 4 times.
✓ Branch 9 taken 82 times.
86 if (usePipeWireBackend && !existingSinkConf.empty() && sinkConfGenerationCurrent && !sinkConfStillValid) {
1972
2/4
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 printf("FPP - PipeWire: %s; re-probing\n",
1973 !sinkConfCardsMatch ? "set of present sound cards has changed"
1974 : "USB audio group membership has changed");
1975 }
1976 // A conf written before a card's channel-count quirk existed (or while the
1977 // card was held open at a stale stereo negotiation) can cover all cards yet
1978 // still pin a known multi-channel card to too few channels. Verify each
1979 // quirk card's adapter declares at least the quirk count; otherwise reprobe.
1980
3/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✓ Branch 3 taken 71 times.
86 if (usePipeWireBackend && sinkConfStillValid) {
1981
2/2
✓ Branch 0 taken 29 times.
✓ Branch 1 taken 14 times.
43 for (const auto& [key, cardName] : cards) {
1982
4/8
✓ Branch 0 taken 29 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 29 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 29 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 29 times.
✗ Branch 7 not taken.
29 int quirkCh = cardLines.count(key) ? quirkMinChannelsForCard(cardLines[key]) : 0;
1983
2/2
✓ Branch 0 taken 28 times.
✓ Branch 1 taken 1 times.
29 if (quirkCh <= 0) continue;
1984
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 std::string cId = getAlsaCardId(std::stoi(key.substr(5)));
1985
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
1 if (cId.empty()) cId = cardName;
1986
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
2 std::regex chRe("node\\.name = \"fpp_alsa_" + normalizeCardIdForNode(cId) +
1987
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 "\"[\\s\\S]*?audio\\.channels = (\\d+)");
1988 1 std::smatch chM;
1989
8/18
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 1 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 1 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 1 times.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
1 if (std::regex_search(existingSinkConf, chM, chRe) && std::stoi(chM[1].str()) < quirkCh) {
1990
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 printf("FPP - PipeWire: %s adapter has %s channels but card needs %d; regenerating\n",
1991
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
2 cId.c_str(), chM[1].str().c_str(), quirkCh);
1992 1 sinkConfStillValid = false;
1993 1 break;
1994 }
1995
3/6
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 1 times.
3 }
1996 }
1997 // The persisted conf pins each adapter's audio.rate (and, via the probe, the
1998 // graph clock) to whatever AudioFormat was set when it was written. Changing
1999 // that setting has to regenerate, which the card-set comparison above cannot
2000 // see -- the same cards are still present, just at a different rate.
2001 //
2002 // Compared against the recorded request, NOT against default.clock.rate: that
2003 // field holds the rate the card achieved, so on a cape that refines the
2004 // request upward it never equals pipewireSampleRate and every boot re-probed.
2005
3/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✓ Branch 3 taken 72 times.
100 if (usePipeWireBackend && sinkConfStillValid &&
2006
9/18
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 14 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 14 times.
✓ Branch 8 taken 14 times.
✓ Branch 9 taken 72 times.
✓ Branch 10 taken 14 times.
✓ Branch 11 taken 72 times.
✗ Branch 12 not taken.
✓ Branch 13 taken 86 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
100 !contains(existingSinkConf, alsaSinkConfRateTag(pipewireSampleRate) + "\n")) {
2007 ✗ printf("FPP - PipeWire: configured sample rate is now %d; regenerating\n", pipewireSampleRate);
2008 ✗ sinkConfStillValid = false;
2009 }
2010 // Period size moves independently of everything above -- the card set, the
2011 // rate and the group membership can all be unchanged while AudioPeriodSize
2012 // has been retyped in the UI. A conf written before the tag existed has no
2013 // such line and re-probes once, which is correct: its period-size is
2014 // whatever the old default was, not necessarily what the setting now says.
2015
3/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✓ Branch 3 taken 72 times.
100 if (usePipeWireBackend && sinkConfStillValid &&
2016
11/18
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 14 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 12 times.
✓ Branch 8 taken 14 times.
✓ Branch 9 taken 72 times.
✓ Branch 10 taken 14 times.
✓ Branch 11 taken 72 times.
✓ Branch 12 taken 2 times.
✓ Branch 13 taken 84 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
100 !contains(existingSinkConf, alsaSinkConfPeriodSizeTag(perSize) + "\n")) {
2017
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 printf("FPP - PipeWire: configured period size is now %d; regenerating\n", perSize);
2018 2 sinkConfStillValid = false;
2019 }
2020
3/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✓ Branch 3 taken 74 times.
86 if (usePipeWireBackend && sinkConfStillValid) {
2021
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 printf("FPP - PipeWire: ALSA sink adapters already match present cards; skipping probe\n");
2022 // bootAdapterCids must still reflect the conf's adapters for the
2023 // Simple-mode group generator below; take them from the existing conf.
2024
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 bootAdapterCids = existingAdapterCids;
2025
1/2
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
74 } else if (usePipeWireBackend) {
2026
2/4
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 74 times.
✗ Branch 3 not taken.
148 exec("/bin/mkdir -p /etc/pipewire/pipewire.conf.d");
2027 // Create FPP ALSA adapter nodes for ALL playback-capable cards present
2028 // at boot. This ensures consistent naming (fpp_alsa_{cardIdNorm}) and
2029 // prevents WirePlumber from creating confusingly-named duplicates.
2030 // USB devices plugged in after boot get adapters created at Apply time
2031 // by GeneratePipeWireGroupsConfig() in pipewire.php.
2032
2/4
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 74 times.
✗ Branch 3 not taken.
74 std::string arecordAll = execAndReturn("/usr/bin/arecord -l 2>/dev/null");
2033
1/2
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
74 std::ostringstream pipewireSink;
2034 // The rate the graph clock has to run at: what the *selected* card's PCM
2035 // actually ends up clocked at, which is not always what was asked for.
2036 // A fixed-bit-clock cape can refine the request upward -- an AM62x
2037 // PCM5102A cape clocks no lower than 88200, so a 44100 request lands on
2038 // 88200 -- and a graph pinned to the requested rate then resamples for
2039 // the life of the process. Filled in from the device below.
2040 74 int graphClockRate = pipewireSampleRate;
2041
1/2
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
74 pipewireSink << "context.objects = [\n";
2042
2/2
✓ Branch 0 taken 50 times.
✓ Branch 1 taken 74 times.
124 for (const auto& [key, cardName] : cards) {
2043
2/4
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
50 int cardNum = std::stoi(key.substr(5)); // "card N" -> N
2044
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 std::string cId = getAlsaCardId(cardNum);
2045
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 50 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
50 if (cId.empty()) cId = cardName;
2046 // Never create a boot-time adapter for the synthetic snd-dummy card.
2047 // It is only present here when snd-dummy happened to be loaded before
2048 // the aplay probe (e.g. a re-run of setupAudio within one boot); on a
2049 // clean boot it is loaded *after* this probe and so isn't in `cards`.
2050 // Letting it slip in would make the 95-conf non-deterministic and
2051 // leave the cached 97 group config targeting an fpp_alsa_dummy that
2052 // vanishes on the next clean reboot. The dummy is always handled
2053 // inline by the Simple-mode group config (buildSimplePipeWireGroupsConf).
2054
2/4
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 50 times.
50 if (cId == "Dummy") continue;
2055 // Normalise card ID for node name: lowercase, non-alnum → underscore
2056
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 std::string cidNorm = cId;
2057 50 std::transform(cidNorm.begin(), cidNorm.end(), cidNorm.begin(), ::tolower);
2058
2/2
✓ Branch 0 taken 534 times.
✓ Branch 1 taken 50 times.
584 for (auto& ch : cidNorm) {
2059
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 534 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
534 if (!std::isalnum(static_cast<unsigned char>(ch)) && ch != '_') ch = '_';
2060 }
2061
2062 // Probe ALSA HW params with an exclusive open. This can fail for two
2063 // very different reasons that must NOT be conflated:
2064 // 1. The device is genuinely dead — e.g. HDMI with nothing
2065 // connected (error 524/ENOMEDIUM). Skip it.
2066 // 2. The device is merely busy because PipeWire/WirePlumber already
2067 // grabbed it. This code runs in postNetwork, AFTER
2068 // fpp-pipewire.service has started and opened every card it knows
2069 // about, so the *selected* card is routinely busy here. Skipping
2070 // it would drop the adapter for the very device that's playing
2071 // and (once PipeWire is restarted for any reason) silence it.
2072 // A busy device reports EBUSY and, because it is open, exposes its
2073 // live negotiated params in /proc; a dead device reports ENOMEDIUM and
2074 // its /proc hw_params reads "closed".
2075
3/6
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
50 std::string hwParams = execAndReturn("timeout 2 /usr/bin/aplay -D hw:" + cId + " --dump-hw-params /dev/zero 2>&1 | head -40");
2076 // Set when hwParams below is synthesised from /proc rather than read
2077 // from the device, which changes how much the format line is worth.
2078 50 bool paramsFromLiveDevice = false;
2079 // Rate read straight off a device that is already open, if it is.
2080 50 int liveRate = 0;
2081
4/6
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 29 times.
✓ Branch 5 taken 21 times.
100 if (!contains(hwParams, "HW Params")) {
2082
3/6
✓ Branch 0 taken 29 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 29 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 29 times.
✗ Branch 5 not taken.
29 std::string procHw = GetFileContents("/proc/asound/card" + std::to_string(cardNum) + "/pcm0p/sub0/hw_params");
2083
10/28
✓ Branch 0 taken 29 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 29 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 29 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 29 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 29 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✓ Branch 11 taken 29 times.
✓ Branch 12 taken 29 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 29 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 29 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 29 times.
✗ Branch 19 not taken.
✗ Branch 20 not taken.
✗ Branch 21 not taken.
✗ Branch 22 not taken.
✗ Branch 23 not taken.
✗ Branch 24 not taken.
✗ Branch 25 not taken.
✗ Branch 26 not taken.
✗ Branch 27 not taken.
116 bool busy = contains(hwParams, "resource busy") || contains(hwParams, "Resource busy");
2084
9/16
✓ Branch 0 taken 18 times.
✓ Branch 1 taken 11 times.
✓ Branch 2 taken 18 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 18 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 18 times.
✓ Branch 8 taken 18 times.
✓ Branch 9 taken 11 times.
✓ Branch 10 taken 18 times.
✓ Branch 11 taken 11 times.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
47 bool procOpen = !procHw.empty() && !contains(procHw, "closed");
2085
2/4
✓ Branch 0 taken 29 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 29 times.
✗ Branch 3 not taken.
29 if (!busy && !procOpen) {
2086
1/2
✓ Branch 0 taken 29 times.
✗ Branch 1 not taken.
29 printf("FPP - PipeWire: skipping card %d (%s) — device cannot be opened\n",
2087 cardNum, cId.c_str());
2088 29 continue;
2089 }
2090 // Present but busy: synthesise an aplay-style "HW Params" block
2091 // from the live /proc values so the channel/format detection below
2092 // works unchanged. /proc reports the format PipeWire actually
2093 // negotiated (always a real PCM, never IEC958-only). Fall back to
2094 // safe stereo S16_LE if /proc lacks the fields.
2095 ✗ std::string synthFmt = "S16_LE";
2096 ✗ std::string synthCh = "2";
2097 ✗ std::smatch pm;
2098 // /proc reports the rate the driver actually settled on, which is
2099 // exactly what the graph clock needs and costs no probe.
2100 ✗ if (std::regex_search(procHw, pm, std::regex(R"(rate:\s*(\d+))"))) {
2101 ✗ liveRate = std::stoi(pm[1].str());
2102 }
2103 ✗ if (std::regex_search(procHw, pm, std::regex(R"(format:\s*(\S+))"))) {
2104 ✗ synthFmt = pm[1].str();
2105 }
2106 ✗ if (std::regex_search(procHw, pm, std::regex(R"(channels:\s*(\d+))"))) {
2107 ✗ synthCh = pm[1].str();
2108 }
2109 hwParams = "HW Params of device (busy — synthesised from /proc)\n"
2110 ✗ "FORMAT: " + synthFmt + "\n"
2111 ✗ "CHANNELS: " + synthCh + "\n";
2112 ✗ paramsFromLiveDevice = true;
2113 ✗ printf("FPP - PipeWire: card %d (%s) busy (held by PipeWire); using live params FORMAT=%s CHANNELS=%s\n",
2114 cardNum, cId.c_str(), synthFmt.c_str(), synthCh.c_str());
2115
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 29 times.
29 }
2116 // Verify card supports at least one standard PCM format
2117
8/22
✓ Branch 0 taken 21 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 3 times.
✓ Branch 9 taken 18 times.
✓ Branch 10 taken 21 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 21 times.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
✗ Branch 18 not taken.
✗ Branch 19 not taken.
✗ Branch 20 not taken.
✗ Branch 21 not taken.
48 bool hasPcmFormat = contains(hwParams, "S16_LE") || contains(hwParams, "S24_LE")
2118
20/34
✓ Branch 0 taken 21 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✓ Branch 3 taken 18 times.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 3 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 3 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 3 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 2 times.
✓ Branch 15 taken 1 times.
✓ Branch 16 taken 3 times.
✓ Branch 17 taken 18 times.
✓ Branch 18 taken 3 times.
✓ Branch 19 taken 18 times.
✓ Branch 20 taken 3 times.
✓ Branch 21 taken 18 times.
✓ Branch 22 taken 3 times.
✓ Branch 23 taken 18 times.
✓ Branch 24 taken 3 times.
✓ Branch 25 taken 18 times.
✗ Branch 26 not taken.
✗ Branch 27 not taken.
✗ Branch 28 not taken.
✗ Branch 29 not taken.
✗ Branch 30 not taken.
✗ Branch 31 not taken.
✗ Branch 32 not taken.
✗ Branch 33 not taken.
54 || contains(hwParams, "S24_3LE") || contains(hwParams, "S32_LE");
2119
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 20 times.
21 if (!hasPcmFormat) {
2120
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 printf("FPP - PipeWire: skipping card %d (%s) — no standard PCM format (IEC958 only?)\n",
2121 cardNum, cId.c_str());
2122 1 continue;
2123 }
2124
2125
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 std::string productName = getAlsaCardProductName(cardNum, cardName);
2126
3/6
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
20 std::string desc = productName + " (" + cId + ")";
2127
2128 // USB audio cards need extra headroom and use different channel-detection logic.
2129
4/8
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 20 times.
20 bool isUsbCard = cardLines.count(key) && cardLines[key].find("USB Audio") != std::string::npos;
2130
2131 // Detect playback channels from ALSA HW params.
2132 // Formats: "CHANNELS: 8" (fixed), "CHANNELS[2]: 2 8" (discrete
2133 // list), "CHANNELS: [1 8]" (continuous range).
2134 //
2135 // A continuous range like "[1 8]" means the driver accepts any
2136 // count in that range — it does NOT imply that many physical
2137 // outputs. The Pi onboard analog (bcm2835) reports "[1 8]"
2138 // because it has 8 hardware mixer subdevices, but it is a
2139 // 2-channel stereo jack. Opening it as 8ch feeds interleaved
2140 // 8-channel data to a stereo DAC → garbled, high-pitched noise.
2141 // So for non-USB cards with a range we default to stereo;
2142 // only fixed/discrete declarations use the largest value.
2143 20 int maxChannels = 2;
2144 20 std::smatch chMatch;
2145
3/6
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
20 if (std::regex_search(hwParams, chMatch, std::regex(R"(CHANNELS\[?\d*\]?:\s+(.+))"))) {
2146
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 std::string chLine = chMatch[1].str();
2147 20 bool isRange = chLine.find('[') != std::string::npos;
2148
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 std::regex numRe(R"(\d+)");
2149
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 std::sregex_iterator it(chLine.begin(), chLine.end(), numRe);
2150
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 std::sregex_iterator end;
2151 20 std::vector<int> nums;
2152
2/2
✓ Branch 0 taken 22 times.
✓ Branch 1 taken 20 times.
42 for (; it != end; ++it) {
2153
5/10
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 22 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 22 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 22 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 22 times.
✗ Branch 9 not taken.
22 nums.push_back(std::stoi((*it)[0].str()));
2154 }
2155
5/6
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 18 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 18 times.
20 if (isRange && nums.size() >= 2) {
2156 2 int lo = nums.front();
2157 2 int hi = nums.back();
2158
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 if (isUsbCard) {
2159 // USB audio: a range [lo hi] covers actual multi-channel
2160 // altsets — use the maximum.
2161 ✗ maxChannels = hi;
2162 } else {
2163 // Non-USB (e.g. bcm2835 analog): range is misleading,
2164 // default to stereo clamped into the range.
2165 2 maxChannels = std::min(std::max(2, lo), hi);
2166 }
2167 } else {
2168 // Fixed or discrete list: take the largest declared count.
2169
2/2
✓ Branch 0 taken 18 times.
✓ Branch 1 taken 18 times.
36 for (int ch : nums) {
2170
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 18 times.
18 if (ch > maxChannels) maxChannels = ch;
2171 }
2172 }
2173 20 }
2174 // For USB audio also read /proc/asound/cardN/stream0 which lists
2175 // every playback altset with its exact channel count. This catches
2176 // cases where aplay reports a range or probes only one altset.
2177
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 20 times.
20 if (isUsbCard) {
2178 ✗ std::string stream0 = GetFileContents("/proc/asound/card" + std::to_string(cardNum) + "/stream0");
2179 ✗ if (!stream0.empty()) {
2180 // Only look in the Playback section (stop before Capture:)
2181 ✗ size_t capturePos = stream0.find("\nCapture:");
2182 ✗ std::string playSection = (capturePos != std::string::npos) ? stream0.substr(0, capturePos) : stream0;
2183 ✗ std::regex chRe(R"(\bChannels:\s+(\d+))");
2184 ✗ std::sregex_iterator it(playSection.begin(), playSection.end(), chRe);
2185 ✗ std::sregex_iterator end;
2186 ✗ for (; it != end; ++it) {
2187 ✗ int ch = std::stoi((*it)[1].str());
2188 ✗ if (ch > maxChannels) maxChannels = ch;
2189 }
2190 ✗ }
2191 ✗ }
2192 // Quirk override: known multi-channel I2S cards report a continuous
2193 // range and would be clamped to stereo above; a busy card can also
2194 // synthesise a stale 2ch value from /proc. Either way the quirk
2195 // count wins (issue #2620: HiFiBerry DAC8x stuck at 2 channels).
2196
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 if (cardLines.count(key)) {
2197
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 int quirkCh = quirkMinChannelsForCard(cardLines[key]);
2198
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 18 times.
20 if (quirkCh > maxChannels) {
2199
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 printf("FPP - PipeWire: card %d (%s) quirk raises channels %d -> %d\n",
2200 cardNum, cId.c_str(), maxChannels, quirkCh);
2201 2 maxChannels = quirkCh;
2202 }
2203 }
2204
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 20 times.
20 if (maxChannels > 8) maxChannels = 8; // cap at 7.1
2205
2206 // Detect best audio format from ALSA HW params
2207 // FORMAT line examples: "FORMAT: S16_LE S24_3LE", "FORMAT: S16_LE S24_LE S32_LE"
2208 // Priority: S32 > S24 > S16. ALSA uses _ (S24_3LE), PipeWire drops it (S24LE).
2209 // A wider format only counts if the card can still reach the target
2210 // rate in it — fixed-bit-clock I2S cards advertise S32_LE but reach
2211 // it only at half the rate (see bestFormatForRate).
2212
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 std::string audioFormat = "S16LE"; // legacy fallback when no PCM format is found
2213 20 std::smatch fmtMatch;
2214
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 20 times.
20 if (paramsFromLiveDevice) {
2215 // The single format in /proc is not an advertisement, it is what
2216 // the hardware is running right now -- the very thing the probe
2217 // exists to establish, already established. Probing it again is
2218 // worse than redundant: formatHoldsRate() has to open the device,
2219 // gets the same EBUSY that forced this synthesis in the first
2220 // place, and (correctly, by its own bias-to-safe rule) reports
2221 // "unverifiable" -- silently rewriting a working S32 card as
2222 // S16LE. PipeWire holds the card on every postNetwork run, so
2223 // that is not an edge case, it is the normal path for the
2224 // selected output; measured on an AM62x PCM5102A cape running
2225 // S32_LE, which the probe demoted on every regeneration.
2226 ✗ if (std::regex_search(hwParams, fmtMatch, std::regex(R"(FORMAT[^:]*:\s+(\S+))"))) {
2227 ✗ audioFormat = pipewireFormatName(fmtMatch[1].str());
2228 }
2229
3/6
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
20 } else if (std::regex_search(hwParams, fmtMatch, std::regex(R"(FORMAT[^:]*:\s+(.+))"))) {
2230
4/8
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 20 times.
✗ Branch 7 not taken.
40 audioFormat = bestFormatForRate(fmtMatch[1].str(), "hw:" + cId,
2231 20 pipewireSampleRate, maxChannels);
2232 }
2233
2234 // Channel position arrays matching PipeWire convention
2235 static const char* positionArrays[] = {
2236 nullptr,
2237 "[ MONO ]",
2238 "[ FL FR ]",
2239 "[ FL FR FC ]",
2240 "[ FL FR RL RR ]",
2241 "[ FL FR FC RL RR ]",
2242 "[ FL FR FC LFE RL RR ]",
2243 "[ FL FR FC LFE RL RR RC ]",
2244 "[ FL FR FC LFE RL RR SL SR ]"
2245 };
2246
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 const char* posStr = (maxChannels >= 1 && maxChannels <= 8) ? positionArrays[maxChannels] : "[ FL FR ]";
2247
2248 // The graph clock follows the card FPP actually outputs on. Only that
2249 // one matters: there is a single graph rate, and any other card is
2250 // resampled to it regardless. Prefer the live rate off an already-open
2251 // device (free, and it is the negotiated truth); otherwise ask the
2252 // device what it would settle on for the format just chosen, which is
2253 // the only way to see an upward refinement -- --dump-hw-params reports
2254 // the unrefined capability space and ignores -r entirely.
2255
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 if (cardNum == card) {
2256 int achieved = liveRate > 0
2257
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 ? liveRate
2258
3/8
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
20 : achievedRateForFormat("hw:" + cId, audioFormat, pipewireSampleRate, maxChannels);
2259
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 20 times.
20 if (achieved > 0 && achieved != graphClockRate) {
2260 ✗ printf("FPP - PipeWire: card %d (%s) clocks at %d, not the configured %d; "
2261 "running the graph at %d to avoid resampling\n",
2262 cardNum, cId.c_str(), achieved, pipewireSampleRate, achieved);
2263 }
2264
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 if (achieved > 0) {
2265 20 graphClockRate = achieved;
2266 }
2267 }
2268
2269 // See kUsbHeadroomSoleSink: a USB card only pays the drift headroom
2270 // when it shares a group with another sink and so may have to follow
2271 // that sink's clock. Alone in its group it is the graph driver, and
2272 // the extra frames are latency bought for nothing.
2273
1/6
✗ Branch 0 not taken.
✓ Branch 1 taken 20 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
20 const bool usbSharesGroup = isUsbCard && sharedGroupUsbCids.count(cidNorm) > 0;
2274 20 int headroom = kHeadroomNonUsb;
2275
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 20 times.
20 if (isUsbCard) {
2276 ✗ headroom = usbSharesGroup ? kUsbHeadroomSharedGroup : kUsbHeadroomSoleSink;
2277 }
2278
2279 20 std::string usbNote;
2280
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 20 times.
20 if (isUsbCard) {
2281 ✗ usbNote = std::string(" (USB, ") +
2282 ✗ (usbSharesGroup ? "shares a group" : "sole sink in its group") +
2283 ✗ ", headroom=" + std::to_string(headroom) + ")";
2284 }
2285
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 printf("FPP - PipeWire: creating adapter fpp_alsa_%s for card %d (%s) [%dch %s]%s\n",
2286 cidNorm.c_str(), cardNum, cId.c_str(), maxChannels, audioFormat.c_str(),
2287 usbNote.c_str());
2288 pipewireSink << " { factory = adapter\n"
2289 << " args = {\n"
2290 << " factory.name = api.alsa.pcm.sink\n"
2291 << " node.name = \"fpp_alsa_" << cidNorm << "\"\n"
2292 << " node.description = \"" << desc << "\"\n"
2293 << " media.class = \"Audio/Sink\"\n"
2294 << " api.alsa.path = \"hw:" << cId << "\"\n"
2295
15/30
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 20 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 20 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 20 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 20 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 20 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 20 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 20 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 20 times.
✗ Branch 21 not taken.
✓ Branch 22 taken 20 times.
✗ Branch 23 not taken.
✓ Branch 24 taken 20 times.
✗ Branch 25 not taken.
✓ Branch 26 taken 20 times.
✗ Branch 27 not taken.
✓ Branch 28 taken 20 times.
✗ Branch 29 not taken.
20 << " api.alsa.period-size = " << perSize << "\n"
2296
3/6
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
20 << " api.alsa.headroom = " << headroom << "\n"
2297 << " audio.format = \"" << audioFormat << "\"\n"
2298
6/12
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 20 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 20 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 20 times.
✗ Branch 11 not taken.
20 << " audio.rate = " << pipewireSampleRate << "\n"
2299
3/6
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
20 << " audio.channels = " << maxChannels << "\n"
2300 << " audio.position = " << posStr << "\n"
2301 << " }\n"
2302 << " " << kNoFailFlag << "\n"
2303
9/18
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 20 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 20 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 20 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 20 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 20 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 20 times.
✗ Branch 17 not taken.
20 << " }\n";
2304
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 bootAdapterCids.insert(cidNorm);
2305 // If this card has capture capability, also create an Audio/Source node.
2306
3/6
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 20 times.
20 if (arecordAll.find("card " + std::to_string(cardNum) + ":") != std::string::npos) {
2307 // Detect capture channel count from ALSA HW params
2308 ✗ int capChannels = 2; // safe default — most USB cards need at least 2
2309 ✗ std::string capParams = execAndReturn("timeout 2 /usr/bin/arecord -D hw:" + cId + " --dump-hw-params /dev/zero 2>&1 | head -40");
2310 ✗ if (contains(capParams, "HW Params")) {
2311 ✗ std::smatch capChMatch;
2312 ✗ if (std::regex_search(capParams, capChMatch, std::regex(R"(CHANNELS\[?\d*\]?:\s+(.+))"))) {
2313 ✗ std::string capChLine = capChMatch[1].str();
2314 // Find the smallest number (minimum channels)
2315 ✗ std::regex capNumRe(R"(\d+)");
2316 ✗ std::sregex_iterator capIt(capChLine.begin(), capChLine.end(), capNumRe);
2317 ✗ std::sregex_iterator capEnd;
2318 ✗ int minCap = 99;
2319 ✗ for (; capIt != capEnd; ++capIt) {
2320 ✗ int c = std::stoi((*capIt)[0].str());
2321 ✗ if (c > 0 && c < minCap) minCap = c;
2322 }
2323 ✗ if (minCap > 0 && minCap < 99) capChannels = minCap;
2324 ✗ }
2325 ✗ }
2326 ✗ printf("FPP - PipeWire: card %d (%s) has capture [%dch], creating source node\n",
2327 cardNum, cId.c_str(), capChannels);
2328 pipewireSink << " { factory = adapter\n"
2329 << " args = {\n"
2330 << " factory.name = api.alsa.pcm.source\n"
2331 << " node.name = \"fpp_alsain_" << cidNorm << "\"\n"
2332 << " node.description = \"" << desc << "\"\n"
2333 << " node.nick = \"" << cId << "\"\n"
2334 << " media.class = \"Audio/Source\"\n"
2335 << " api.alsa.path = \"hw:" << cId << "\"\n"
2336 << " audio.format = \"" << audioFormat << "\"\n"
2337 << " audio.rate = 44100\n"
2338 ✗ << " audio.channels = " << capChannels << "\n"
2339 << " }\n"
2340 << " " << kNoFailFlag << "\n"
2341 ✗ << " }\n";
2342 ✗ }
2343
6/6
✓ Branch 0 taken 20 times.
✓ Branch 1 taken 30 times.
✓ Branch 2 taken 20 times.
✓ Branch 3 taken 30 times.
✓ Branch 4 taken 20 times.
✓ Branch 5 taken 30 times.
110 }
2344
1/2
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
74 pipewireSink << "]\n";
2345 // Assembled here rather than streamed from the top: the clock rate is only
2346 // known once the selected card has been probed. Section order does not
2347 // matter to SPA-JSON, but the generation tag must stay the first line --
2348 // the cache check above matches it as a prefix.
2349 //
2350 // Whenever the graph rate differs from the device rate the ALSA sink
2351 // adapter resamples on every cycle, for as long as the graph runs, not
2352 // just while media is playing. The daemon default in 90-fpp.conf is
2353 // 48000; this file sorts after it, so the value set here wins. Measured
2354 // per graph cycle in the sink node: 478us -> 171us on a 44100 I2S cape,
2355 // 538us -> 107us on a 44100 USB card, 779us -> 86us on an 88200 cape.
2356 // allowed-rates is deliberately left to 90-fpp.conf -- a clock rate
2357 // outside that list is honoured anyway (verified with 88200 against the
2358 // stock [44100 48000 96000]), and narrowing it here would only forbid
2359 // switches that already do not happen.
2360
1/2
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
74 std::ostringstream sinkConf;
2361
1/2
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
74 sinkConf << alsaSinkConfGenerationTag() << "\n"
2362 // What AudioFormat asked for, which default.clock.rate below no
2363 // longer records once a cape refines it. See alsaSinkConfRateTag().
2364 74 << alsaSinkConfRateTag(pipewireSampleRate) << "\n"
2365 // What AudioPeriodSize asked for, which nothing else in the
2366 // file records. See alsaSinkConfPeriodSizeTag().
2367 74 << alsaSinkConfPeriodSizeTag(perSize) << "\n"
2368 // The cards considered, including any the probe then skipped as
2369 // unusable. See alsaSinkConfCardsTag().
2370 74 << alsaSinkConfCardsTag(adapterCandidateCids) << "\n"
2371 // Which USB cards were charged the follower headroom, which
2372 // group membership can change without the card set changing.
2373 // See alsaSinkConfUsbHeadroomTag().
2374 74 << alsaSinkConfUsbHeadroomTag(sharedGroupUsbCids) << "\n"
2375 << "context.properties = {\n"
2376
17/34
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 74 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 74 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 74 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 74 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 74 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 74 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 74 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 74 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 74 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 74 times.
✗ Branch 21 not taken.
✓ Branch 22 taken 74 times.
✗ Branch 23 not taken.
✓ Branch 24 taken 74 times.
✗ Branch 25 not taken.
✓ Branch 26 taken 74 times.
✗ Branch 27 not taken.
✓ Branch 28 taken 74 times.
✗ Branch 29 not taken.
✓ Branch 30 taken 74 times.
✗ Branch 31 not taken.
✓ Branch 32 taken 74 times.
✗ Branch 33 not taken.
74 << " default.clock.rate = " << graphClockRate << "\n"
2377 << "}\n"
2378
4/8
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 74 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 74 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 74 times.
✗ Branch 7 not taken.
74 << pipewireSink.str();
2379
2/4
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 74 times.
✗ Branch 3 not taken.
74 if (sinkConf.str() != existingSinkConf) {
2380
2/4
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 74 times.
✗ Branch 3 not taken.
74 PutFileContents(pipewireSinkConfPath, sinkConf.str());
2381 // PipeWire only reads this file at startup; a card add/remove (or a
2382 // capability change) rewrites it, so flag a restart below to load it.
2383 74 sinkConfigChanged = true;
2384 }
2385
0/4
✗ Branch 0 not taken.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
74 } else if (FileExists(pipewireSinkConfPath)) {
2386 ✗ unlink(pipewireSinkConfPath.c_str());
2387 ✗ sinkConfigChanged = true;
2388 }
2389 // Stop WirePlumber from double-opening the cards we just declared adapters
2390 // for. Runs on the skip-probe path too: the rules file is what makes the
2391 // static adapters the sole owner of each PCM, and an existing install has
2392 // never had it written. Rules are read only at WirePlumber start, so fold a
2393 // change into sinkConfigChanged to pick up the restart below.
2394
6/8
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 25 times.
✓ Branch 5 taken 61 times.
✓ Branch 6 taken 25 times.
✓ Branch 7 taken 61 times.
86 if (usePipeWireBackend && ensureWirePlumberAlsaDupeSuppression(adapterCardNames)) {
2395
1/2
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
25 printf("FPP - PipeWire: suppressing WirePlumber duplicate ALSA devices for %d card(s)\n",
2396 25 (int)adapterCardNames.size());
2397 25 sinkConfigChanged = true;
2398 }
2399
3/6
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 86 times.
✗ Branch 5 not taken.
86 std::string mixers = execAndReturn("/usr/bin/amixer -c " + std::to_string(card) + " scontrols | cut -f2 -d\"'\"");
2400
2/2
✓ Branch 0 taken 50 times.
✓ Branch 1 taken 36 times.
86 if (mixers.empty()) {
2401 // for some sound cards, the mixer devices won't show up
2402 // until something is played. Play one second of silence
2403
2/4
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
50 exec("/usr/bin/aplay -d 1 /opt/fpp/media/silence_5sec.wav >> /dev/null 2>&1 &");
2404
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 std::this_thread::sleep_for(std::chrono::milliseconds(200));
2405
3/6
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
50 mixers = execAndReturn("/usr/bin/amixer -c " + std::to_string(card) + " scontrols | cut -f2 -d\"'\"");
2406 }
2407
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 TrimWhiteSpace(mixers);
2408 86 std::string mixer;
2409
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
86 getRawSetting("AudioMixerDevice", mixer);
2410
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 std::string origMixer = mixer;
2411
6/8
✓ Branch 0 taken 15 times.
✓ Branch 1 taken 71 times.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 15 times.
✓ Branch 6 taken 71 times.
✓ Branch 7 taken 15 times.
86 if (mixer.empty() || !contains(mixers, mixer)) {
2412
1/2
✓ Branch 0 taken 71 times.
✗ Branch 1 not taken.
71 mixer = mixers;
2413
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 71 times.
71 if (mixer.find("\n") != std::string::npos) {
2414 ✗ mixer = mixer.substr(0, mixer.find("\n"));
2415 }
2416 }
2417
2/2
✓ Branch 0 taken 21 times.
✓ Branch 1 taken 65 times.
86 if (mixer != origMixer) {
2418
1/2
✓ Branch 0 taken 21 times.
✗ Branch 1 not taken.
21 printf("FPP - Setting mixer device to %s\n", mixer.c_str());
2419
2/4
✓ Branch 0 taken 21 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 21 times.
✗ Branch 3 not taken.
42 setRawSetting("AudioMixerDevice", mixer);
2420 }
2421 // Quoted for the shell: control names have spaces ("PCM Playback"), and
2422 // AudioMixerDevice is a setting.
2423
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 std::string quotedMixer = "'";
2424
2/2
✓ Branch 0 taken 140 times.
✓ Branch 1 taken 86 times.
226 for (char ch : mixer) {
2425
5/16
✗ Branch 0 not taken.
✓ Branch 1 taken 140 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✓ Branch 4 taken 140 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 140 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 140 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✓ Branch 11 taken 140 times.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
420 quotedMixer += (ch == '\'') ? std::string("'\\''") : std::string(1, ch);
2426 }
2427
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 quotedMixer += "'";
2428
7/14
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 86 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 86 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 86 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 86 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 86 times.
✗ Branch 13 not taken.
86 exec("/usr/bin/amixer -c " + std::to_string(card) + " set " + quotedMixer + " " + std::to_string(v) + "% > /dev/null 2>&1");
2429
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
86 setRawSetting("AudioCardType", cardType);
2430
2431
2/2
✓ Branch 0 taken 50 times.
✓ Branch 1 taken 36 times.
86 if (!runningInDocker) {
2432 50 mkdir("/run/fppd", 0755);
2433
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 std::ostringstream audioEnv;
2434
4/8
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 50 times.
✗ Branch 7 not taken.
50 audioEnv << "SDL_AUDIODRIVER=" << (usePipeWireBackend ? "pulse" : "alsa") << "\n";
2435 // Keep OpenAL on the same backend as the rest of FPP audio.
2436 // In hardware (ALSA) mode this prevents OpenAL from defaulting to
2437 // PulseAudio and aborting during teardown if Pulse is unavailable.
2438
4/8
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 50 times.
✗ Branch 7 not taken.
50 audioEnv << "ALSOFT_DRIVERS=" << (usePipeWireBackend ? "pulse" : "alsa") << "\n";
2439
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 if (usePipeWireBackend) {
2440 audioEnv << "PIPEWIRE_RUNTIME_DIR=/run/pipewire-fpp\n"
2441 << "XDG_RUNTIME_DIR=/run/pipewire-fpp\n"
2442 << "PIPEWIRE_CONFIG_DIR=/etc/pipewire\n"
2443
4/8
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 50 times.
✗ Branch 7 not taken.
50 << "PULSE_RUNTIME_PATH=/run/pipewire-fpp/pulse\n";
2444 }
2445
2/4
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
50 PutFileContents(audioEnvPath, audioEnv.str());
2446
2/4
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 36 times.
86 } else if (FileExists(audioEnvPath)) {
2447 ✗ unlink(audioEnvPath.c_str());
2448 }
2449
2450 // --- Audio Output Groups Configuration ---
2451 // On first PipeWire setup, create default output and input group configs
2452 // that mirror the legacy ALSA configuration: one output group with the
2453 // configured sound card, and one input group with fppd_stream_1 routed
2454 // to it. This ensures audio works immediately when switching to PipeWire.
2455
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 const std::string groupsJsonPath = FPP_MEDIA_DIR + "/config/pipewire-audio-groups.json";
2456
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 const std::string simpleGroupsJsonPath = FPP_MEDIA_DIR + "/config/pipewire-audio-groups-simple.json";
2457
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 const std::string igJsonPath = FPP_MEDIA_DIR + "/config/pipewire-input-groups.json";
2458
2459 // Simple PipeWire mode: the active config lives in pipewire-audio-groups-simple.json
2460 // and PipeWireSinkName is written by ApplyPipeWireSimpleConfig(). If that file is
2461 // missing (e.g. OS upgrade, fresh flash, or settings migrated without an explicit
2462 // UI save) synthesise it now from the current AudioOutput/VideoOutput settings so
2463 // PipeWireSinkName is populated before fppd starts.
2464 // Regenerate the simple groups config when it's missing OR when it
2465 // references a sound card that's no longer present. The latter is the
2466 // card-changed case (e.g. a USB card added to a previously dummy-only board,
2467 // or removed): the stale config would otherwise leave an unresolved device,
2468 // producing a "# WARNING:" conf that (a) is wrong and (b) defeats the
2469 // skip-regen fast path, forcing the full regen+restart dance on every boot.
2470 // Regenerating here re-points it at the current AudioOutput card.
2471 // The selected output card as a stable ALSA card ID (derived from the
2472 // AudioOutput setting above). The Simple config must point at this card.
2473
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 std::string selCid = getAlsaCardId(card);
2474
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 86 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
86 if (selCid.empty()) selCid = "Dummy"; // an empty ID only happens with no real card
2475 // Regenerate the simple config when it is missing, references an absent card,
2476 // OR points at a different card than the currently-selected output. The last
2477 // case is the post-upgrade / settings-changed scenario: e.g. AudioOutput is a
2478 // USB card (card 1) but the cached config still names the onboard card 0,
2479 // which is present so the cards-present check alone would wrongly skip it.
2480 172 bool simpleConfigStale = !FileExists(simpleGroupsJsonPath)
2481
3/4
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✓ Branch 3 taken 3 times.
17 || !pipewireConfigCardsPresent(simpleGroupsJsonPath)
2482
7/12
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✓ Branch 3 taken 69 times.
✓ Branch 4 taken 14 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 14 times.
✓ Branch 8 taken 14 times.
✓ Branch 9 taken 72 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
103 || simpleConfigCardId(simpleGroupsJsonPath) != selCid;
2483 // Upgrade case for boards with no sound card: an install configured by an
2484 // older FPP has a cached config holding the full hw:Dummy adapter ->
2485 // filter-chain -> combine-stream graph, which never reaches idle and so
2486 // cycles PipeWire at the quantum rate forever (~47 wakeups/s, ~4% of a core
2487 // on a single-core PocketBeagle) for audio that can never be heard.
2488 // None of the tests above catch it: the cached card ID is still "Dummy" so
2489 // it matches selCid, and snd-dummy was modprobed earlier this boot so
2490 // /proc/asound/cards lists [Dummy] and the cards-present test passes too.
2491 // The config is therefore never regenerated and the board keeps the old
2492 // graph on every boot. Force exactly one regeneration when the active conf
2493 // predates the null-sink form. Self-limiting: the regenerated conf contains
2494 // support.null-audio-sink, so this cannot drive a regen/restart loop.
2495
7/8
✓ Branch 0 taken 14 times.
✓ Branch 1 taken 72 times.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✓ Branch 5 taken 11 times.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 1 times.
89 if (!simpleConfigStale && selCid == "Dummy" &&
2496
10/18
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✓ Branch 5 taken 83 times.
✓ Branch 6 taken 3 times.
✓ Branch 7 taken 83 times.
✓ Branch 8 taken 3 times.
✓ Branch 9 taken 83 times.
✓ Branch 10 taken 2 times.
✓ Branch 11 taken 84 times.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
92 GetFileContents("/etc/pipewire/pipewire.conf.d/97-fpp-audio-groups.conf")
2497 3 .find("support.null-audio-sink") == std::string::npos) {
2498
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 printf("FPP - No sound card: audio config predates the null sink, regenerating\n");
2499 2 simpleConfigStale = true;
2500 }
2501 // 97-fpp-audio-groups.conf and 95-fpp-alsa-sink.conf can end up declaring
2502 // the same fpp_alsa_* node for the same card, and none of the tests above
2503 // notice: they all ask about the *card*, which has not changed.
2504 //
2505 // buildSimplePipeWireGroupsConf() emits its own adapter only when the card
2506 // had no boot adapter at the time (cardHasBootAdapter, from bootAdapterCids
2507 // below). That answer goes stale as soon as 95 is regenerated with the card
2508 // present -- a USB card that enumerates after the first pass, or the
2509 // recovery pass re-running this whole function -- and nothing re-runs the
2510 // simple generator afterwards to hand ownership back.
2511 //
2512 // The result is two adapters on one PCM under one node.name. That is the
2513 // failure ensureWirePlumberAlsaDupeSuppression() exists to prevent from the
2514 // WirePlumber side: the loser's snd_pcm_open() returns EBUSY, its node goes
2515 // to error, and a stream that landed on it blocks in poll() forever. The
2516 // rule there cannot see this one, because both declarations are static.
2517 //
2518 // Self-limiting: the regenerated conf omits the adapter (the card now has a
2519 // boot adapter), so this cannot hold on the following pass.
2520
3/4
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✓ Branch 3 taken 1 times.
12 if (!simpleConfigStale && selCid != "Dummy" &&
2521
7/12
✓ Branch 0 taken 12 times.
✓ Branch 1 taken 74 times.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 11 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 11 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 2 times.
✓ Branch 9 taken 84 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
196 bootAdapterCids.count(normalizeCardIdForNode(selCid)) > 0 &&
2522
11/20
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 11 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 9 times.
✓ Branch 8 taken 11 times.
✓ Branch 9 taken 75 times.
✓ Branch 10 taken 11 times.
✓ Branch 11 taken 75 times.
✓ Branch 12 taken 11 times.
✓ Branch 13 taken 75 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
✗ Branch 18 not taken.
✗ Branch 19 not taken.
117 contains(GetFileContents("/etc/pipewire/pipewire.conf.d/97-fpp-audio-groups.conf"),
2523
11/20
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 11 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 11 times.
✓ Branch 7 taken 75 times.
✓ Branch 8 taken 11 times.
✓ Branch 9 taken 75 times.
✓ Branch 10 taken 11 times.
✓ Branch 11 taken 75 times.
✓ Branch 12 taken 11 times.
✓ Branch 13 taken 75 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
✗ Branch 18 not taken.
✗ Branch 19 not taken.
97 "node.name = \"fpp_alsa_" + normalizeCardIdForNode(selCid) + "\"")) {
2524
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 printf("FPP - Simple audio config duplicates the boot ALSA adapter for %s; regenerating\n",
2525 selCid.c_str());
2526 2 simpleConfigStale = true;
2527 }
2528 // The selected card's filter-chain has to play into fpp_alsa_<card> once FPP
2529 // owns the card, and nothing above checks where it actually points.
2530 //
2531 // A card selected while WirePlumber still served it (hot-plugged after boot,
2532 // so not yet in 95) is resolved by the PHP apply to WirePlumber's
2533 // alsa_output.* node -- correct at that moment. The next run here adds the
2534 // card to 95 and disables its WirePlumber device, so that node never exists
2535 // again and the filter-chain output stays unlinked: silent, no error. The
2536 // run that takes ownership regenerates (ownershipChanged below), but a conf
2537 // already in this state is not caught by anything else: the card is present
2538 // and selected, and the Simple JSON records no nodeTarget for
2539 // pipewireConfigSuppressedTarget() to inspect. So read the conf itself.
2540 //
2541 // Self-limiting: both generators target fpp_alsa_<card> for a card FPP owns,
2542 // so the regenerated conf passes this on the following run.
2543
3/6
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 10 times.
✗ Branch 5 not taken.
10 if (!simpleConfigStale && usePipeWireBackend && mediaBackendLower == "pipewire-simple" &&
2544
8/14
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 9 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 9 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 2 times.
✓ Branch 11 taken 84 times.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
96 selCid != "Dummy" && adapterCandidateCids.count(normalizeCardIdForNode(selCid)) > 0 &&
2545
5/6
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 76 times.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 8 times.
✓ Branch 5 taken 1 times.
104 FileExists("/etc/pipewire/pipewire.conf.d/97-fpp-audio-groups.conf") &&
2546
11/20
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 6 times.
✓ Branch 8 taken 8 times.
✓ Branch 9 taken 78 times.
✓ Branch 10 taken 8 times.
✓ Branch 11 taken 78 times.
✓ Branch 12 taken 8 times.
✓ Branch 13 taken 78 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
✗ Branch 18 not taken.
✗ Branch 19 not taken.
110 !contains(GetFileContents("/etc/pipewire/pipewire.conf.d/97-fpp-audio-groups.conf"),
2547
11/20
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 8 times.
✓ Branch 7 taken 78 times.
✓ Branch 8 taken 8 times.
✓ Branch 9 taken 78 times.
✓ Branch 10 taken 8 times.
✓ Branch 11 taken 78 times.
✓ Branch 12 taken 9 times.
✓ Branch 13 taken 77 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
✗ Branch 18 not taken.
✗ Branch 19 not taken.
94 "node.target = \"fpp_alsa_" + normalizeCardIdForNode(selCid) + "\"")) {
2548
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 printf("FPP - Simple audio config does not target FPP's adapter for %s; regenerating\n",
2549 selCid.c_str());
2550 2 simpleConfigStale = true;
2551 }
2552
5/6
✓ Branch 0 taken 50 times.
✓ Branch 1 taken 36 times.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 38 times.
✓ Branch 5 taken 12 times.
86 if (usePipeWireBackend && !runningInDocker && mediaBackendLower == "pipewire-simple"
2553
5/6
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✓ Branch 3 taken 8 times.
✓ Branch 4 taken 30 times.
✓ Branch 5 taken 56 times.
172 && simpleConfigStale) {
2554 // The simple config is missing or points at an absent card (fresh flash,
2555 // OS upgrade, or a USB card added/removed). It's always the auto-generated
2556 // single-group/single-card shape, so synthesise it directly in C++ rather
2557 // than forking the PHP (apply_pipewire_simple_config), which costs ~10-30s
2558 // of interpreter startup on a single-core SBC.
2559 //
2560 // Two cases keep the PHP for correctness rather than speed:
2561 // * VideoOutput is configured — apply_pipewire_simple_config also builds
2562 // the video output groups, which the C++ path doesn't cover.
2563 // * The shipped WirePlumber combine-fallback hook source isn't present
2564 // to copy (a broken checkout — normally can't happen) — defer to the
2565 // PHP apply path rather than generating a config without the hook.
2566 30 std::string videoOutput;
2567
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 getRawSetting("VideoOutput", videoOutput);
2568
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 TrimWhiteSpace(videoOutput);
2569
1/10
✗ Branch 0 not taken.
✓ Branch 1 taken 30 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
30 bool videoConfigured = !(videoOutput.empty() || videoOutput == "Disabled" || videoOutput == "--Default--");
2570
4/8
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 30 times.
✗ Branch 7 not taken.
30 if (!videoConfigured && ensureWirePlumberLinkingHook()) {
2571
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
30 bool cardHasBootAdapter = bootAdapterCids.count(normalizeCardIdForNode(selCid)) > 0;
2572
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 generateSimplePipeWireAudioConfig(card, selCid, cardHasBootAdapter, perSize, pipewireSampleRate);
2573 30 cppGeneratedSimpleConfig = true;
2574 } else {
2575 ✗ printf("FPP - pipewire-simple: simple groups config missing or references an absent card; regenerating from AudioOutput setting...\n");
2576 ✗ system("/usr/bin/php /opt/fpp/scripts/apply_pipewire_simple_config");
2577 }
2578 30 }
2579
2580
8/10
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✓ Branch 3 taken 36 times.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 20 times.
✓ Branch 7 taken 30 times.
✓ Branch 8 taken 20 times.
✓ Branch 9 taken 66 times.
86 if (usePipeWireBackend && !runningInDocker && !FileExists(groupsJsonPath)) {
2581
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 std::string defCardId = getAlsaCardId(card);
2582
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 printf("FPP - First PipeWire setup: creating default output group (card %s) and input group\n", defCardId.c_str());
2583
2584 // Default output group: one group with the legacy audio device
2585
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 Json::Value ogRoot;
2586
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 Json::Value group;
2587
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 group["id"] = 1;
2588
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 group["name"] = "Main Output";
2589
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 group["enabled"] = true;
2590
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 Json::Value member;
2591
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 member["cardId"] = defCardId;
2592
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 member["channels"] = 2;
2593
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 member["delayMs"] = 0.0;
2594
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 group["members"] = Json::Value(Json::arrayValue);
2595
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 group["members"].append(member);
2596
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 ogRoot["groups"] = Json::Value(Json::arrayValue);
2597
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 ogRoot["groups"].append(group);
2598
2599
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 PutFileContents(groupsJsonPath, SaveJsonToString(ogRoot));
2600
2601 // Default input group: fppd_stream_1 routed to output group 1
2602
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 Json::Value igRoot;
2603
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 Json::Value ig;
2604
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 ig["id"] = 1;
2605
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 ig["name"] = "Mix Bus 1";
2606
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 ig["enabled"] = true;
2607
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 ig["channels"] = 2;
2608
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 ig["volume"] = 100;
2609
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 Json::Value igMember;
2610
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 igMember["type"] = "fppd_stream";
2611
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 igMember["sourceId"] = "fppd_stream_1";
2612
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 igMember["name"] = "Media Playback";
2613
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 igMember["volume"] = 100;
2614
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 igMember["mute"] = false;
2615
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 ig["members"] = Json::Value(Json::arrayValue);
2616
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 ig["members"].append(igMember);
2617
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 ig["outputs"] = Json::Value(Json::arrayValue);
2618
3/6
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
20 ig["outputs"].append(1);
2619
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 igRoot["inputGroups"] = Json::Value(Json::arrayValue);
2620
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 igRoot["inputGroups"].append(ig);
2621
2622
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 PutFileContents(igJsonPath, SaveJsonToString(igRoot));
2623 20 }
2624
2625 // Restore cached combine-stream and input group configs so group sinks
2626 // survive reboot. These are initially copied as-is so PipeWire starts
2627 // with the right module layout. After PipeWire is up and WirePlumber
2628 // has enumerated devices, a regeneration script re-resolves card→sink
2629 // mappings to handle card-number changes.
2630
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 const std::string groupsConfCache = FPP_MEDIA_DIR + "/config/pipewire-audio-groups.conf";
2631
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 const std::string groupsConfDest = "/etc/pipewire/pipewire.conf.d/97-fpp-audio-groups.conf";
2632
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 const std::string igConfCache = FPP_MEDIA_DIR + "/config/pipewire-input-groups.conf";
2633
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 const std::string igConfDest = "/etc/pipewire/pipewire.conf.d/96-fpp-input-groups.conf";
2634
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 const std::string vbanConfCache = FPP_MEDIA_DIR + "/config/pipewire-vban.conf";
2635
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 const std::string vbanConfDest = "/etc/pipewire/pipewire.conf.d/94-fpp-vban.conf";
2636 86 bool hasGroupsConfig = false;
2637 // Track whether the effective PipeWire config actually changes from what it
2638 // already loaded at boot. PipeWire (fpp-pipewire.service) starts during boot
2639 // with the persisted /etc/pipewire configs, so a restart in postNetwork is
2640 // only needed when something actually differs -- otherwise the costly
2641 // pipewire-pulse/WirePlumber/session-bus restart cascade buys nothing. On a
2642 // no-soundcard board the config is identical every boot, so this skips it.
2643 // A changed ALSA-sink adapter config (95-fpp-alsa-sink.conf, handled above)
2644 // needs the same restart as a changed groups config: PipeWire reads both only
2645 // at start. Seed the flag from it so a card add/remove isn't silently ignored.
2646 // A C++-generated Simple config (just written above) always needs the one
2647 // restart below to take effect — PipeWire started at boot without it.
2648
4/4
✓ Branch 0 taken 12 times.
✓ Branch 1 taken 74 times.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 8 times.
86 bool audioConfigChanged = sinkConfigChanged || cppGeneratedSimpleConfig;
2649
8/10
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✓ Branch 3 taken 36 times.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 42 times.
✓ Branch 7 taken 8 times.
✓ Branch 8 taken 42 times.
✓ Branch 9 taken 44 times.
86 if (usePipeWireBackend && !runningInDocker && FileExists(groupsConfCache)) {
2650
12/20
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 41 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 41 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 41 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 41 times.
✓ Branch 10 taken 41 times.
✓ Branch 11 taken 1 times.
✓ Branch 12 taken 41 times.
✓ Branch 13 taken 1 times.
✓ Branch 14 taken 1 times.
✓ Branch 15 taken 41 times.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
✗ Branch 18 not taken.
✗ Branch 19 not taken.
42 if (!FileExists(groupsConfDest) || GetFileContents(groupsConfCache) != GetFileContents(groupsConfDest)) {
2651
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 printf("FPP - Restoring PipeWire audio output groups config\n");
2652
4/8
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✗ Branch 7 not taken.
1 exec("/bin/cp " + groupsConfCache + " " + groupsConfDest);
2653 1 audioConfigChanged = true;
2654 }
2655 42 hasGroupsConfig = true;
2656
7/10
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✓ Branch 3 taken 36 times.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 8 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 8 times.
✓ Branch 9 taken 36 times.
44 } else if (usePipeWireBackend && !runningInDocker && FileExists(groupsJsonPath)) {
2657 // JSON exists but no cached .conf yet (e.g. first-time default creation).
2658 // The regeneration script will generate the .conf from the JSON.
2659 8 hasGroupsConfig = true;
2660 8 audioConfigChanged = true;
2661
3/10
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 36 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 36 times.
36 } else if (usePipeWireBackend && !runningInDocker && FileExists(groupsConfDest)) {
2662 // JSON config was deleted but stale conf remains — clean up
2663 ✗ if (!FileExists(groupsJsonPath)) {
2664 ✗ unlink(groupsConfDest.c_str());
2665 ✗ audioConfigChanged = true;
2666 }
2667 }
2668
6/10
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✓ Branch 3 taken 36 times.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 50 times.
✗ Branch 8 not taken.
✓ Branch 9 taken 86 times.
86 if (usePipeWireBackend && !runningInDocker && FileExists(igConfCache)) {
2669 ✗ if (!FileExists(igConfDest) || GetFileContents(igConfCache) != GetFileContents(igConfDest)) {
2670 ✗ printf("FPP - Restoring PipeWire input groups config\n");
2671 ✗ exec("/bin/cp " + igConfCache + " " + igConfDest);
2672 ✗ audioConfigChanged = true;
2673 }
2674 }
2675
2676 // VBAN streams are PipeWire modules declared in 94-fpp-vban.conf, numbered
2677 // below the group confs so a received source and a send sink both exist
2678 // before 96/97 try to link them. Restore it the same way, and drop a stale
2679 // dest when the cache is gone so deleting every VBAN stream does not leave
2680 // the modules loading forever.
2681
3/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✓ Branch 3 taken 36 times.
86 if (usePipeWireBackend && !runningInDocker) {
2682
2/4
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 50 times.
50 if (FileExists(vbanConfCache)) {
2683 ✗ if (!FileExists(vbanConfDest) || GetFileContents(vbanConfCache) != GetFileContents(vbanConfDest)) {
2684 ✗ printf("FPP - Restoring PipeWire VBAN config\n");
2685 ✗ exec("/bin/cp " + vbanConfCache + " " + vbanConfDest);
2686 ✗ audioConfigChanged = true;
2687 }
2688
2/4
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 50 times.
50 } else if (FileExists(vbanConfDest)) {
2689 ✗ unlink(vbanConfDest.c_str());
2690 ✗ audioConfigChanged = true;
2691 }
2692 }
2693
2694 // --- AES67 cleanup ---
2695 // AES67 is now managed by AES67Manager in fppd (GStreamer-based).
2696 // Remove any leftover PipeWire RTP module configs from the legacy Python approach.
2697 // Note the unlinks are all evaluated -- no short-circuiting -- so every stale
2698 // file goes even if an earlier one was already absent.
2699 86 bool hadLegacyAES67 = (unlink("/etc/pipewire/pipewire.conf.d/96-fpp-aes67-rtp.conf") == 0);
2700 86 hadLegacyAES67 |= (unlink("/etc/pipewire/pipewire.conf.d/96-fpp-aes67-sap.conf") == 0);
2701 86 hadLegacyAES67 |= (unlink("/etc/ptp4l-fpp.conf") == 0);
2702 // Kill any leftover legacy daemons -- but only when this boot actually found
2703 // legacy config to remove. The daemons were only ever launched from those
2704 // files, so once they are gone there is nothing to kill, and two `pkill -f`
2705 // scans (each a /bin/sh plus a walk of every /proc/*/cmdline) were costing
2706 // real time on a single-core board on every boot, forever, on the critical
2707 // path to fppd -- to hunt for processes belonging to a removed feature.
2708
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 86 times.
86 if (hadLegacyAES67) {
2709 ✗ exec("pkill -f fpp_aes67_sap 2>/dev/null || true");
2710 ✗ exec("pkill -f 'ptp4l.*ptp4l-fpp' 2>/dev/null || true");
2711 }
2712
2713 // PipeWire is already running (started at boot with the persisted configs),
2714 // so only restart it when the config actually changed. A restart is
2715 // synchronous and triggers an expensive pipewire-pulse/WirePlumber/
2716 // session-bus cascade, so skipping it on an unchanged boot (always the case
2717 // on a no-soundcard board) saves ~10-15s on a single-core SBC.
2718
3/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✓ Branch 3 taken 36 times.
86 if (usePipeWireBackend && !runningInDocker) {
2719 // Keep the FPP PipeWire systemd units current. install_pipewire.sh
2720 // copies them once at install time and nothing ever refreshed them
2721 // afterwards, so unit fixes (e.g. the RestartSec/StartLimitIntervalSec
2722 // backoff that stops PipeWire from permanently giving up when a USB
2723 // sound card enumerates a few seconds late) never reached
2724 // already-installed systems. Only refresh units that are already
2725 // installed -- a missing unit means install_pipewire.sh hasn't run yet.
2726 50 bool unitsChanged = false;
2727
2/2
✓ Branch 0 taken 150 times.
✓ Branch 1 taken 50 times.
200 for (const char* svc : { "fpp-pipewire.service", "fpp-wireplumber.service", "fpp-pipewire-pulse.service" }) {
2728
2/4
✓ Branch 0 taken 150 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 150 times.
✗ Branch 3 not taken.
300 std::string unitSrc = std::string("/opt/fpp/etc/systemd/") + svc;
2729
2/4
✓ Branch 0 taken 150 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 150 times.
✗ Branch 3 not taken.
150 std::string unitDst = std::string("/lib/systemd/system/") + svc;
2730
13/24
✓ Branch 0 taken 150 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 150 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 150 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 117 times.
✓ Branch 7 taken 33 times.
✓ Branch 8 taken 117 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 117 times.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✓ Branch 13 taken 117 times.
✓ Branch 14 taken 117 times.
✓ Branch 15 taken 33 times.
✓ Branch 16 taken 117 times.
✓ Branch 17 taken 33 times.
✗ Branch 18 not taken.
✓ Branch 19 taken 150 times.
✗ Branch 20 not taken.
✗ Branch 21 not taken.
✗ Branch 22 not taken.
✗ Branch 23 not taken.
150 if (FileExists(unitSrc) && FileExists(unitDst) && GetFileContents(unitSrc) != GetFileContents(unitDst)) {
2731 ✗ printf("FPP - Updating systemd unit %s\n", svc);
2732 ✗ CopyFileContents(unitSrc, unitDst);
2733 ✗ unitsChanged = true;
2734 }
2735 150 }
2736
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 50 times.
50 if (unitsChanged) {
2737 ✗ exec("/usr/bin/systemctl daemon-reload");
2738 }
2739
2740 // The WirePlumber linking hook (blocks rogue default-target fallback
2741 // links for fpp_* nodes) is needed in ALL PipeWire modes, but was only
2742 // installed by the Simple-mode C++ path or a groups Apply from the UI.
2743 // A groups-mode box that never re-applied since the hook shipped runs
2744 // without it: a member stream whose target is missing (e.g. an fx
2745 // chain for a disconnected HDMI output) gets fallback-linked to the
2746 // default sink, double-opening that device and stalling the whole
2747 // graph mid-playback. Install it here and restart the stack when it
2748 // was newly installed (WirePlumber only reads hooks at startup).
2749
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 bool linkingHookPresent = FileExists("/usr/share/wireplumber/scripts/linking/fpp-block-combine-fallback.lua")
2750
4/6
✓ Branch 0 taken 46 times.
✓ Branch 1 taken 4 times.
✓ Branch 2 taken 46 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 46 times.
✗ Branch 5 not taken.
50 && FileExists("/etc/wireplumber/wireplumber.conf.d/60-fpp-block-combine-fallback.conf");
2751
6/8
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 46 times.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✓ Branch 7 taken 46 times.
50 if (!linkingHookPresent && ensureWirePlumberLinkingHook()) {
2752
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 printf("FPP - Installed WirePlumber FPP linking hook\n");
2753 4 audioConfigChanged = true;
2754 }
2755
2756 // Validate/regenerate the audio group config from the JSON, and use its
2757 // exit code (2 == it changed/created the .conf) to decide whether a
2758 // restart is needed. Run it whenever there's a groups config -- NOT gated
2759 // on the dest already existing (chicken-and-egg: the regen is what creates
2760 // it).
2761 //
2762 // No real sound card: the synthetic snd-dummy can't go missing or shift
2763 // cards, so the cached .conf is permanently valid. Run WITHOUT --force so
2764 // the regen fast-exits (no pw-dump, no rewrite) when the cache is already
2765 // clean -- avoiding a full regenerate + PipeWire restart on every boot.
2766 // (--force is non-deterministic and reports "changed" every run, so it
2767 // would restart PipeWire every boot.) With a real card we keep --force so
2768 // a removed/shifted device is stripped before PipeWire opens it (PipeWire
2769 // crashes fatally on a missing ALSA device).
2770 // Set when the cheap C++ check below proves the cached group conf is
2771 // complete, already loaded, and references only present cards. Survives
2772 // that block so the post-start path can skip re-deriving the same answer
2773 // with a forked, forced PHP regeneration.
2774 50 bool cachedGroupsConfValidated = false;
2775
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 if (hasGroupsConfig) {
2776 // Decide whether the cached PipeWire group config is still valid in
2777 // C++ (cheap file reads) so we only fork the regeneration PHP -- which
2778 // costs ~3s of php/common.php startup, ~6s under boot contention --
2779 // when something actually changed. This covers BOTH no-soundcard and
2780 // real-card boards (e.g. BBB capes with onboard PCM5012A): skip the
2781 // PHP when the cached conf is complete (no unresolved-device warning),
2782 // matches what PipeWire loaded at boot (dest), and every sound card it
2783 // references is still present. The regen only runs on a genuine change
2784 // (first boot, settings edit, or a card added/removed), which is rare.
2785 50 bool needRegen = true;
2786
7/10
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 42 times.
✓ Branch 3 taken 8 times.
✓ Branch 4 taken 42 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 42 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 42 times.
✓ Branch 9 taken 8 times.
50 if (FileExists(groupsConfCache) && FileExists(groupsConfDest)) {
2787
1/2
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
42 std::string cached = GetFileContents(groupsConfCache);
2788 const std::string& activeJson =
2789
3/4
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 38 times.
✓ Branch 3 taken 4 times.
42 (mediaBackendLower == "pipewire-simple") ? simpleGroupsJsonPath : groupsJsonPath;
2790 // The cached conf bakes in the passive-link choice, and neither
2791 // the card-presence check nor the dest comparison can see a
2792 // change to PipeWirePassiveSinks -- both sides stay consistent
2793 // with each other while disagreeing with the setting. Only this
2794 // file carries the marker; the input groups live in 96-*.conf.
2795
2/4
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 42 times.
✗ Branch 3 not taken.
42 const bool wantPassive = getRawSettingInt("PipeWirePassiveSinks", 1) != 0;
2796 42 const bool cachedPassive = cached.find("node.passive = true") != std::string::npos;
2797 // A new 95 adapter or suppression rule changes which node names
2798 // can exist at all, so a conf resolved against the old set may
2799 // now target a node that is never coming back. The C++ Simple
2800 // generator just wrote its conf against the new set and needs no
2801 // second opinion.
2802
4/4
✓ Branch 0 taken 30 times.
✓ Branch 1 taken 12 times.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 26 times.
42 const bool ownershipChanged = sinkConfigChanged && !cppGeneratedSimpleConfig;
2803
1/2
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
42 const std::string suppressedTarget = pipewireConfigSuppressedTarget(activeJson, adapterCandidateCids);
2804
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 40 times.
42 if (!suppressedTarget.empty()) {
2805
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 printf("FPP - PipeWire audio group targets %s, which FPP's own adapter replaces; regenerating\n",
2806 suppressedTarget.c_str());
2807 }
2808 84 if (cached.find("# WARNING:") == std::string::npos
2809
2/2
✓ Branch 0 taken 36 times.
✓ Branch 1 taken 6 times.
42 && wantPassive == cachedPassive
2810
4/8
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 36 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 31 times.
✓ Branch 5 taken 11 times.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
78 && cached == GetFileContents(groupsConfDest)
2811
2/2
✓ Branch 0 taken 33 times.
✓ Branch 1 taken 3 times.
36 && !ownershipChanged
2812
2/2
✓ Branch 0 taken 31 times.
✓ Branch 1 taken 2 times.
33 && suppressedTarget.empty()
2813
5/8
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 31 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 31 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 36 times.
✓ Branch 7 taken 6 times.
84 && pipewireConfigCardsPresent(activeJson)) {
2814 31 needRegen = false;
2815 // Remember this for the post-start block further down, which
2816 // otherwise re-runs the same resolution as a forced PHP regen.
2817 31 cachedGroupsConfValidated = true;
2818
1/2
✓ Branch 0 taken 31 times.
✗ Branch 1 not taken.
31 printf("FPP - PipeWire audio config valid, loaded, and all referenced cards present; skipping regeneration\n");
2819 }
2820 42 }
2821
2/2
✓ Branch 0 taken 19 times.
✓ Branch 1 taken 31 times.
50 if (needRegen) {
2822
1/2
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
19 std::string regenCmd = "/usr/bin/php /opt/fpp/scripts/regenerate_pipewire_groups";
2823
2/2
✓ Branch 0 taken 11 times.
✓ Branch 1 taken 8 times.
19 if (!noRealSoundcard) {
2824
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 regenCmd += " --force";
2825 }
2826
1/2
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
19 printf("FPP - Validating PipeWire audio group config against current hardware...\n");
2827
1/2
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
19 int rc = system(regenCmd.c_str());
2828
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 19 times.
19 if (WEXITSTATUS(rc) == 2) {
2829 ✗ audioConfigChanged = true;
2830 }
2831 19 }
2832 }
2833
2834 // The skip-restart optimisation below assumes PipeWire is already
2835 // running (started at boot with the persisted configs). Verify that
2836 // assumption: with a pre-backoff unit file, a USB sound card that
2837 // enumerates a few seconds late makes PipeWire exhaust its start-limit
2838 // burst and give up early in boot (adapter creation is fatal on a
2839 // missing ALSA device), leaving the whole stack down. Skipping the
2840 // restart then silences all audio even though every config matches.
2841
2/4
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
50 std::string pwState = execAndReturn("/usr/bin/systemctl is-active fpp-pipewire.service");
2842
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 TrimWhiteSpace(pwState);
2843
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 bool pipewireRunning = (pwState == "active");
2844 // "inactive" here is a COLD START, not the crashed-stack case above: the FPP
2845 // PipeWire units ship disabled (nothing in the boot transaction pulls them
2846 // in -- postNetwork is what starts them), so on a stock box pwState is
2847 // "inactive" every single boot. Conflating that with failure made the
2848 // skip-restart fast path below dead code on exactly the boards it was
2849 // written for: every boot took the full restart + 3s settle + forced PHP
2850 // regenerate + PHP volume restore tail (~11s on a single-core BBB) purely to
2851 // re-derive a config the cheap C++ validation above had already proven
2852 // byte-identical and card-complete a few seconds earlier.
2853 // Starting the daemons now is in fact a *stronger* guarantee than the
2854 // already-running case: they read the configs that are on disk right now.
2855 // "failed"/"activating"/"deactivating" remain genuine recovery and still
2856 // take the slow path.
2857
4/6
✓ Branch 0 taken 44 times.
✓ Branch 1 taken 6 times.
✓ Branch 2 taken 44 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 44 times.
✗ Branch 5 not taken.
50 bool pipewireColdStart = !pipewireRunning && (pwState == "inactive");
2858 50 bool pipewireRestarted = false;
2859
3/4
✓ Branch 0 taken 44 times.
✓ Branch 1 taken 6 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 44 times.
50 if (!pipewireRunning && !pipewireColdStart) {
2860 ✗ printf("FPP - PipeWire service is '%s'; recovering audio stack\n", pwState.c_str());
2861 // Clear any start-limit lockout so the restart below can succeed.
2862 ✗ exec("/usr/bin/systemctl reset-failed fpp-pipewire.service fpp-wireplumber.service fpp-pipewire-pulse.service 2>/dev/null");
2863 }
2864 50 bool pipewireColdStarted = false;
2865
3/4
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 42 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 8 times.
50 if (!audioConfigChanged && pipewireRunning) {
2866 // Config matches what PipeWire loaded at boot -- restarting would
2867 // change nothing (and the volume-restore below only exists to undo a
2868 // restart's reset of WirePlumber state, so it's unneeded too).
2869 ✗ printf("FPP - PipeWire audio config unchanged since boot; skipping restart\n");
2870
3/4
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 42 times.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
50 } else if (!audioConfigChanged && pipewireColdStart) {
2871 // Cold start with a config nothing has touched: bring the stack up and
2872 // stop there. A daemon that has not started yet cannot be holding a
2873 // stale config, so a restart-to-reload would be pure ceremony.
2874
2/4
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
8 exec("/usr/bin/systemctl start fpp-pipewire.service fpp-wireplumber.service fpp-pipewire-pulse.service");
2875 8 pipewireColdStarted = true;
2876
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 printf("FPP - PipeWire started with the unchanged on-disk config\n");
2877
3/4
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✓ Branch 3 taken 33 times.
42 } else if (hasGroupsConfig && noRealSoundcard) {
2878 // Config changed but there's no real sound card: a restart is needed
2879 // to load it, but there are no USB cards to enumerate and no
2880 // card-number shifts to resolve, so skip the enumerate/regen dance.
2881
2/4
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
9 exec("/usr/bin/systemctl restart fpp-pipewire.service fpp-wireplumber.service fpp-pipewire-pulse.service");
2882 9 pipewireRestarted = true;
2883
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 printf("FPP - No real sound card present; skipping PipeWire device enumeration/regeneration\n");
2884 } else {
2885
2/4
✓ Branch 0 taken 33 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 33 times.
✗ Branch 3 not taken.
33 exec("/usr/bin/systemctl restart fpp-pipewire.service fpp-wireplumber.service fpp-pipewire-pulse.service");
2886 33 pipewireRestarted = true;
2887 }
2888
2889 // Wait for WirePlumber to enumerate ALSA devices before regenerating
2890 // configs. WirePlumber needs a moment to discover USB sound cards
2891 // and create their PipeWire sink nodes.
2892 //
2893 // Skipped when we just generated the Simple config in C++: it's
2894 // self-contained (combine-stream/filter-chain target the static
2895 // fpp_alsa_* adapter loaded from 95/97 conf), so there are no card→sink
2896 // mappings to re-resolve via pw-dump and no need to fork the PHP
2897 // regenerate/restore-volume scripts. The single restart above is enough.
2898 //
2899 // Also skipped when the cheap C++ validation already proved the cached
2900 // conf byte-identical to what is being loaded and every card it names
2901 // present: the forced regen re-resolves precisely the card→node mapping
2902 // that check verified, so it can only ever conclude "unchanged" -- at a
2903 // cost of 3s of settle plus ~4.5s of PHP on a single-core BBB, on every
2904 // boot. That was the bulk of a 24s fpp_postnetwork.
2905
4/6
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 42 times.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
50 bool audioStackTouched = audioConfigChanged || pipewireRestarted || pipewireColdStarted;
2906
8/10
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 41 times.
✓ Branch 5 taken 9 times.
✓ Branch 6 taken 17 times.
✓ Branch 7 taken 24 times.
✓ Branch 8 taken 11 times.
✓ Branch 9 taken 6 times.
50 if (audioStackTouched && hasGroupsConfig && !noRealSoundcard && !cppGeneratedSimpleConfig && !cachedGroupsConfValidated) {
2907
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 printf("FPP - Waiting for WirePlumber to enumerate devices...\n");
2908
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 std::this_thread::sleep_for(std::chrono::seconds(3));
2909
2910 // Regenerate output/input group configs using current device state.
2911 // This fixes card-number shifts after reboot and resolves any
2912 // previously unresolved members. Exit code 2 = config changed.
2913
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 printf("FPP - Regenerating PipeWire audio group configs...\n");
2914
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 int regenResult = system("/usr/bin/php /opt/fpp/scripts/regenerate_pipewire_groups --force");
2915 11 regenResult = WEXITSTATUS(regenResult);
2916
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 11 times.
11 if (regenResult == 2) {
2917 // Config was changed — restart PipeWire to pick up the new config
2918 ✗ printf("FPP - PipeWire group config changed, restarting PipeWire...\n");
2919 ✗ exec("/usr/bin/systemctl restart fpp-pipewire.service fpp-wireplumber.service fpp-pipewire-pulse.service");
2920 // Counts as a restart for the fppd reconnect below: a live fppd's
2921 // cached daemon connection dies here just as surely as it does in
2922 // the restart paths above.
2923 ✗ pipewireRestarted = true;
2924 // Wait for WirePlumber & combine-stream/filter-chain nodes to come up
2925 ✗ std::this_thread::sleep_for(std::chrono::seconds(3));
2926 }
2927 }
2928
2929 // A board with a real sound card that reaches this point with no sink in
2930 // the graph is not merely slow: PipeWire aborts context creation and
2931 // exits when it cannot build a node the config declares, and systemd then
2932 // restarts it every few seconds forever. Nothing else notices -- every
2933 // check above was satisfied by a config that is internally consistent and
2934 // names only present cards; it is simply one the hardware rejects. Until
2935 // now the only trace at this level was a "timed out waiting for sinks"
2936 // line we then ignored, so the box booted clean and played no audio, on
2937 // every boot, until someone deleted the file by hand.
2938 //
2939 // The graph is the ground truth the config checks cannot reach, so use it:
2940 // discard the derived config and re-run the setup, which re-probes the
2941 // hardware from scratch. Bounded to a single retry by recoveryPass, and
2942 // cheap where it matters -- a healthy box gets one extra pactl on the
2943 // first poll, and only a box that is already silent pays the timeout.
2944 //
2945 // Only derived files go. pipewire-audio-groups.json is the user's own
2946 // configuration and is what the regeneration reads to rebuild the rest.
2947
8/12
✓ Branch 0 taken 41 times.
✓ Branch 1 taken 9 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 41 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✓ Branch 6 taken 41 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 8 times.
✓ Branch 9 taken 33 times.
✓ Branch 10 taken 8 times.
✓ Branch 11 taken 42 times.
50 if (!noRealSoundcard && (audioStackTouched || pipewireRunning) && !waitForPipeWireSinks(30)) {
2948
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 4 times.
8 if (!recoveryPass) {
2949
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 printf("FPP - PipeWire produced no sinks; discarding the derived audio config and re-probing\n");
2950 4 unlink(pipewireSinkConfPath.c_str());
2951
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 unlink((FPP_MEDIA_DIR + "/config/pipewire-audio-groups-simple.json").c_str());
2952 4 unlink(groupsConfCache.c_str());
2953
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 runAudioSetup(true);
2954 4 return;
2955 }
2956
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 printf("FPP - PipeWire still has no sinks after re-probing the audio hardware; audio will not work.\n");
2957
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 printf("FPP - Check 'journalctl -u fpp-pipewire' for the reason the daemon is failing to start.\n");
2958 }
2959
2960 // Per-group/per-member volume restore is needed whenever the stack came up
2961 // or changed under us. Deliberately NOT gated on the regeneration above:
2962 // this is orthogonal to whether the config resolved correctly. WirePlumber
2963 // reapplies its own persisted volume state every time it starts, so a cold
2964 // start needs the user's configured levels pushed back just as much as a
2965 // restart does -- gating it on the regen would silently leave a box booting
2966 // at whatever level WirePlumber last remembered.
2967 //
2968
6/8
✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 46 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 37 times.
✓ Branch 5 taken 9 times.
✓ Branch 6 taken 17 times.
✓ Branch 7 taken 20 times.
46 if (audioStackTouched && hasGroupsConfig && !noRealSoundcard && !cppGeneratedSimpleConfig) {
2969
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 restorePipeWireVolumes();
2970 }
2971
2972 // Every boot, not only when the stack was touched: what WirePlumber
2973 // remembers is exactly what goes stale. On a healthy box the sinks are
2974 // already up and this costs two pactl calls.
2975
6/10
✗ Branch 0 not taken.
✓ Branch 1 taken 46 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✓ Branch 4 taken 46 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 40 times.
✓ Branch 7 taken 6 times.
✓ Branch 8 taken 40 times.
✓ Branch 9 taken 6 times.
46 if ((audioStackTouched || pipewireRunning) && waitForPipeWireSinks(10)) {
2976
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
40 syncPipeWireDefaultSink();
2977 }
2978
2979 // fppd cannot survive the daemon being restarted underneath it. The
2980 // GStreamer PipeWire plugin caches its connection to the daemon process
2981 // wide, so once that connection dies every later pipewiresink reuses the
2982 // dead one: the pipeline reports PLAYING, no error is ever posted on the
2983 // bus, and playback is silent until the process restarts. Verified by
2984 // wedging a box this way and then playing the same pipeline from a
2985 // separate process, which worked -- the daemon and graph were healthy
2986 // and only fppd's own connection was dead.
2987 //
2988 // So whenever this run brought the stack up or down, restart fppd too.
2989 // Gated on fppd actually running, which at boot it is not: setupAudio runs
2990 // in postNetwork, before fppd starts, so a normal boot never takes this.
2991 // Only a later re-run -- an audio settings apply -- does.
2992 //
2993 // pipewireColdStarted counts as well: a stack that was down while fppd was
2994 // up leaves fppd's cached connection just as dead as a restart does, and
2995 // starting the daemons underneath it does not revive it.
2996 //
2997 // `fpp -r` rather than `systemctl restart fppd`: it asks fppd to re-exec
2998 // itself, which is an equally fresh process image -- that is what clears
2999 // the cached connection -- and it resumes a playlist that was running,
3000 // which systemctl cannot. It also keeps the PID, so repeated applies do
3001 // not burn the unit's start limit. Matches RestartPipeWireStack() in
3002 // pipewire.php, which is the same decision on the PHP side.
3003
5/6
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 38 times.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 45 times.
92 if ((pipewireRestarted || pipewireColdStarted) &&
3004
3/4
✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 45 times.
46 system("/usr/bin/systemctl is-active --quiet fppd") == 0) {
3005
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 printf("FPP - PipeWire was restarted under a running fppd; restarting fppd so it reconnects\n");
3006
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
2 exec("/opt/fpp/src/fpp -r");
3007 }
3008
3/4
✓ Branch 0 taken 46 times.
✓ Branch 1 taken 4 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 36 times.
132 } else if (!runningInDocker) {
3009 ✗ exec("/usr/bin/systemctl stop fpp-pipewire-pulse.service fpp-wireplumber.service fpp-pipewire.service");
3010 }
3011
3012 // AES67 is now initialized by AES67Manager in fppd after PipeWire is running.
3013 // No external daemons to start here.
3014
32/32
✓ Branch 0 taken 82 times.
✓ Branch 1 taken 4 times.
✓ Branch 2 taken 82 times.
✓ Branch 3 taken 4 times.
✓ Branch 4 taken 82 times.
✓ Branch 5 taken 4 times.
✓ Branch 6 taken 82 times.
✓ Branch 7 taken 4 times.
✓ Branch 8 taken 82 times.
✓ Branch 9 taken 4 times.
✓ Branch 10 taken 82 times.
✓ Branch 11 taken 4 times.
✓ Branch 12 taken 82 times.
✓ Branch 13 taken 4 times.
✓ Branch 14 taken 82 times.
✓ Branch 15 taken 4 times.
✓ Branch 16 taken 82 times.
✓ Branch 17 taken 4 times.
✓ Branch 18 taken 82 times.
✓ Branch 19 taken 4 times.
✓ Branch 20 taken 82 times.
✓ Branch 21 taken 4 times.
✓ Branch 22 taken 82 times.
✓ Branch 23 taken 4 times.
✓ Branch 24 taken 82 times.
✓ Branch 25 taken 4 times.
✓ Branch 26 taken 82 times.
✓ Branch 27 taken 4 times.
✓ Branch 28 taken 82 times.
✓ Branch 29 taken 4 times.
✓ Branch 30 taken 82 times.
✓ Branch 31 taken 4 times.
146 }
3015
3016 82 void setupAudio() {
3017 82 runAudioSetup(false);
3018 82 }
3019