| 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 |