GCC Code Coverage Report


Directory: ./
File: mediaoutput/AES67Manager.cpp
Date: 2026-10-29 08:11:58
Exec Total Coverage
Lines: 2301 2812 81.8%
Functions: 116 132 87.9%
Branches: 2360 4950 47.7%

Line Branch Exec Source
1 /*
2 * This file is part of the Falcon Player (FPP) and is Copyright (C)
3 * 2013-2025 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 "fpp-pch.h"
14
15 #include "fpp-json.h"
16 #include "fpphttp.h" // drogon/HTTP helpers used here; no longer pulled transitively (see fpphttp_types.h)
17
18 #include "Warnings.h" // WarningHolder -- needed directly for NOPCH builds
19
20 #include "AES67Manager.h"
21 #include "GStreamerOut.h"
22 #include "PipeWireGraphConfig.h"
23
24 #ifdef HAS_AES67_GSTREAMER
25
26 #include <gst/gst.h>
27
28 #if __has_include(<samplerate.h>)
29 #include <samplerate.h>
30 #define FPP_HAVE_SAMPLERATE 1
31 #endif
32
33 #include <arpa/inet.h>
34 #include <fcntl.h>
35 #include <ifaddrs.h>
36 // Only for PhcIndexForInterface(): finding a NIC's hardware PTP clock is a
37 // Linux ethtool query. Without it (macOS) every interface reports no PHC and the
38 // clock falls back to CLOCK_REALTIME, as it does on Linux for software-only NICs.
39 #if __has_include(<linux/ethtool.h>)
40 #define FPP_AES67_HAS_ETHTOOL
41 #include <linux/ethtool.h>
42 #include <linux/sockios.h>
43 #endif
44 #include <net/if.h>
45 #include <netinet/in.h>
46 #include <signal.h>
47 #include <sys/ioctl.h>
48 #include <sys/socket.h>
49 #include <sys/wait.h>
50 #include <unistd.h>
51 #if defined(__linux__)
52 #include <poll.h>
53 #include <sys/syscall.h>
54 #endif
55
56 #include <algorithm>
57 #include <cctype>
58 #include <chrono>
59 #include <cmath>
60 #include <cstdio>
61 #include <cstring>
62 #include <deque>
63 #include <fstream>
64 #include <memory>
65 #include <sstream>
66
67 #include "common.h"
68 #include "log.h"
69 #include "mediaoutput.h"
70 #include "settings.h"
71 #include "../SysSocket.h"
72
73 // ──────────────────────────────────────────────────────────────────────────────
74 // AES67 namespace helpers
75 // ──────────────────────────────────────────────────────────────────────────────
76 namespace AES67 {
77
78 ✗ const char* GetSDPChannelNames(int channels) {
79 ✗ switch (channels) {
80 ✗ case 1: return "M";
81 ✗ case 2: return "FL, FR";
82 ✗ case 4: return "FL, FR, RL, RR";
83 ✗ case 6: return "FL, FR, FC, LFE, RL, RR";
84 ✗ case 8: return "FL, FR, FC, LFE, RL, RR, SL, SR";
85 ✗ default: return "";
86 }
87 }
88
89 } // namespace AES67
90
91 // ──────────────────────────────────────────────────────────────────────────────
92 // Singleton instance
93 // ──────────────────────────────────────────────────────────────────────────────
94 static AES67Manager s_aes67Manager;
95 AES67Manager& AES67Manager::INSTANCE = s_aes67Manager;
96
97 // Set a send pipeline's master volume element (see SetMasterVolume()).
98 21 static void SetPipelineMasterVolume(GstElement* pipeline, int volume) {
99 21 GstElement* vol = gst_bin_get_by_name(GST_BIN(pipeline), "mastervol");
100
1/2
✓ Branch 0 taken 21 times.
✗ Branch 1 not taken.
21 if (vol) {
101 21 g_object_set(vol, "volume", masterVolumeGain(volume), nullptr);
102 21 gst_object_unref(vol);
103 }
104 21 }
105
106
1/2
✓ Branch 0 taken 2346 times.
✗ Branch 1 not taken.
2346 AES67Manager::AES67Manager() {
107
2/4
✓ Branch 0 taken 2346 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2346 times.
✗ Branch 3 not taken.
2346 m_configPath = getFPPMediaDir("/config/pipewire-aes67-instances.json");
108 2346 }
109
110 916 AES67Manager::~AES67Manager() {
111 916 Shutdown();
112 916 }
113
114 // ──────────────────────────────────────────────────────────────────────────────
115 // Lifecycle
116 // ──────────────────────────────────────────────────────────────────────────────
117 3716 bool AES67Manager::Init() {
118
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3716 times.
3716 if (m_initialized.load()) {
119 ✗ return true;
120 }
121
122 // AES67 uses pipewiresrc/pipewiresink which require the full PipeWire graph.
123 // Only proceed when MediaBackend is "pipewire" (the advanced PipeWire mode).
124 // - ALSA / no backend: PipeWire daemon is not running; gst_parse_launch with
125 // pipewiresrc crashes in gst_value_deserialize (NULL deref at addr 0x4).
126 // - pipewire-simple: the graph lacks the node connections needed for audio
127 // format negotiation, causing the state change to block indefinitely.
128
2/4
✓ Branch 0 taken 3716 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3716 times.
✗ Branch 3 not taken.
3716 std::string mediaBackend = toLowerCopy(getSetting("MediaBackend"));
129
8/10
✓ Branch 0 taken 3716 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3704 times.
✓ Branch 3 taken 12 times.
✓ Branch 4 taken 3704 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3610 times.
✓ Branch 7 taken 94 times.
✓ Branch 8 taken 3622 times.
✓ Branch 9 taken 94 times.
3716 if (!isPipeWireBackend() || mediaBackend != "pipewire") {
130
1/2
✓ Branch 0 taken 3622 times.
✗ Branch 1 not taken.
3622 LogDebug(VB_MEDIAOUT, "AES67Manager: MediaBackend='%s' (need 'pipewire'), skipping AES67 init\n",
131 mediaBackend.c_str());
132 3622 return true;
133 }
134
135 // Check if the config file exists
136
3/4
✓ Branch 0 taken 94 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 36 times.
✓ Branch 3 taken 58 times.
94 if (!FileExists(m_configPath)) {
137
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 LogDebug(VB_MEDIAOUT, "AES67Manager: No config file at %s, skipping init\n",
138 m_configPath.c_str());
139 36 return true; // not an error — just no AES67 configured
140 }
141
142 // Shared with the playback path and the video managers so gst_init()
143 // happens exactly once, under one lock, whichever subsystem gets here
144 // first.
145
1/2
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
58 GStreamerOutput::EnsureGStreamerInit();
146
147 // Set PipeWire env vars so pipewiresrc/pipewiresink can find the FPP PipeWire runtime
148 58 setenv("PIPEWIRE_RUNTIME_DIR", "/run/pipewire-fpp", 0);
149 58 setenv("XDG_RUNTIME_DIR", "/run/pipewire-fpp", 0);
150 58 setenv("PULSE_RUNTIME_PATH", "/run/pipewire-fpp/pulse", 0);
151
152 58 m_initialized.store(true);
153
1/2
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
58 LogInfo(VB_MEDIAOUT, "AES67Manager: Initialized\n");
154 58 return true;
155 3716 }
156
157 2325 void AES67Manager::Shutdown() {
158
2/2
✓ Branch 0 taken 2267 times.
✓ Branch 1 taken 58 times.
2325 if (!m_initialized.load()) {
159 2267 return;
160 }
161
162
1/2
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
58 LogInfo(VB_MEDIAOUT, "AES67Manager: Shutting down\n");
163
164 // Nothing may apply from here on, including a watchdog rebuild that is
165 // already scheduled: ApplyConfig() would re-Init() and bring back the
166 // pipelines this is about to tear down.
167 58 m_stopping.store(true);
168 58 m_applyGeneration++;
169 58 m_initialized.store(false);
170
171 // Join any in-flight watchdog rebuild thread FIRST, before touching
172 // m_sapAnnounceThread/m_sapRecvThread. The rebuild thread internally
173 // calls ApplyConfig(), which itself joins those two threads -- joining
174 // them here concurrently with the rebuild thread would be a double
175 // join on the same std::thread object (undefined behavior). Taken out
176 // under m_rebuildMutex: the watchdog thread assigns it.
177 58 std::thread rebuild;
178 {
179
1/2
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
58 std::lock_guard<std::mutex> rl(m_rebuildMutex);
180 58 rebuild = std::move(m_rebuildThread);
181 58 }
182
2/2
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 53 times.
58 if (rebuild.joinable()) {
183
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 rebuild.join();
184 }
185
186 // Only now take the apply lock -- m_rebuildThread calls ApplyConfig(),
187 // so holding this across the join above would deadlock. Taking it here
188 // still blocks until any in-flight ApplyConfig() (e.g. from a command
189 // thread) has finished, so we never tear down threads it is mid-rebuild.
190
1/2
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
58 std::lock_guard<std::mutex> applyLock(m_applyMutex);
191
192 // Stop SAP threads
193
1/2
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
58 StopWorkerThreads();
194
195
1/2
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
58 StopAllPipelines();
196
1/2
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
58 ShutdownPTP();
197
1/2
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
58 ReleaseMediaClock();
198
199 58 m_active.store(false);
200
1/2
✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
58 LogInfo(VB_MEDIAOUT, "AES67Manager: Shutdown complete\n");
201 58 }
202
203 // ──────────────────────────────────────────────────────────────────────────────
204 // Config loading — reads pipewire-aes67-instances.json
205 // ──────────────────────────────────────────────────────────────────────────────
206 // Accessors -- see m_configMutex. Each copies out under the lock so callers
207 // never hold a reference into a vector or string LoadConfig() may reallocate.
208 1383 AES67Config AES67Manager::GetConfigSnapshot() {
209
1/2
✓ Branch 0 taken 1383 times.
✗ Branch 1 not taken.
1383 std::lock_guard<std::mutex> lock(m_configMutex);
210
1/2
✓ Branch 0 taken 1383 times.
✗ Branch 1 not taken.
2766 return m_config;
211 1383 }
212
213 1696 bool AES67Manager::IsPtpEnabled() {
214
1/2
✓ Branch 0 taken 1696 times.
✗ Branch 1 not taken.
1696 std::lock_guard<std::mutex> lock(m_configMutex);
215 1696 return m_config.ptpEnabled;
216 1696 }
217
218 74 int AES67Manager::GetPtpDomain() {
219
1/2
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
74 std::lock_guard<std::mutex> lock(m_configMutex);
220 74 return m_config.ptpDomain;
221 74 }
222
223 1633 std::string AES67Manager::GetPtpInterface() {
224
1/2
✓ Branch 0 taken 1633 times.
✗ Branch 1 not taken.
1633 std::lock_guard<std::mutex> lock(m_configMutex);
225
1/2
✓ Branch 0 taken 1633 times.
✗ Branch 1 not taken.
3266 return m_config.ptpInterface;
226 1633 }
227
228 // cfg80211 drivers expose phy80211, older wext drivers expose wireless/
229 1 static bool IsWirelessInterface(const std::string& iface) {
230
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::string base = "/sys/class/net/" + iface;
231
3/8
✓ 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.
3 return access((base + "/wireless").c_str(), F_OK) == 0 ||
232
3/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 not taken.
✗ Branch 7 not taken.
3 access((base + "/phy80211").c_str(), F_OK) == 0;
233 1 }
234
235 // A blank ptpInterface used to be offered by the web page as "(Default)", but
236 // nothing downstream can act on it: ptp4l is exec'd with -i "" and exits
237 // immediately, and GetInterfaceIP("") returns whichever address getifaddrs
238 // hands back first -- on a Wi-Fi equipped Pi often wlan0, which cannot hold a
239 // PTP lock. The page no longer offers the blank, but configs written by
240 // older builds still carry one, so resolve it to a wired interface that has
241 // an IPv4 address, preferring eth0.
242 1 static std::string FirstWiredInterface() {
243 1 struct ifaddrs* addrs = nullptr;
244
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (getifaddrs(&addrs) != 0) {
245 ✗ return "eth0";
246 }
247
248 1 std::string first;
249
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 for (struct ifaddrs* ifa = addrs; ifa; ifa = ifa->ifa_next) {
250
3/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 2 times.
5 if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) continue;
251
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 std::string name = ifa->ifa_name;
252
7/10
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 1 times.
✓ Branch 8 taken 1 times.
✓ Branch 9 taken 1 times.
2 if (name == "lo" || IsWirelessInterface(name)) continue;
253
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 if (name == "eth0") {
254
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 first = name;
255 1 break;
256 }
257 ✗ if (first.empty()) first = name;
258
2/3
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 1 times.
2 }
259 1 freeifaddrs(addrs);
260
3/10
✗ 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.
✗ Branch 6 not taken.
✓ Branch 7 taken 1 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
1 return first.empty() ? "eth0" : first;
261 1 }
262
263
264 // Values that go into a gst_parse_launch() description unquoted. Anything
265 // outside these characters is refused at load: a space or '!' in a
266 // destination address re-parses as extra pipeline elements (a udpsrc feeding
267 // a filesink is a root file write), and no real address or interface name
268 // needs them.
269 185 static bool IsPipelineSafeHost(const std::string& host) {
270
3/6
✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 185 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 185 times.
185 if (host.empty() || host.size() > 253) {
271 ✗ return false;
272 }
273
2/2
✓ Branch 0 taken 1665 times.
✓ Branch 1 taken 185 times.
1850 for (unsigned char c : host) {
274
3/8
✓ Branch 0 taken 555 times.
✓ Branch 1 taken 1110 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 555 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
1665 if (!std::isalnum(c) && c != '.' && c != '-' && c != ':') {
275 ✗ return false;
276 }
277 }
278 185 return true;
279 }
280 185 static bool IsPipelineSafeInterface(const std::string& iface) {
281
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 185 times.
185 if (iface.size() > 15) { // IFNAMSIZ - 1
282 ✗ return false;
283 }
284
2/2
✓ Branch 0 taken 21 times.
✓ Branch 1 taken 185 times.
206 for (unsigned char c : iface) {
285
1/10
✗ Branch 0 not taken.
✓ Branch 1 taken 21 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.
21 if (!std::isalnum(c) && c != '.' && c != '-' && c != '_' && c != '@') {
286 ✗ return false;
287 }
288 }
289 185 return true;
290 }
291
292 // A top-level setting of the wrong JSON type ("ptpEnabled": "yes") threw from
293 // LoadConfig() to std::terminate, at startup and again on every restart. Such
294 // a setting is reported and left at its default.
295 template <typename T>
296 7056 static T ConfigField(const Json::Value& root, const char* key, const T& def, T (Json::Value::*as)() const) {
297 try {
298
4/6
✓ Branch 0 taken 3528 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3528 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3526 times.
✓ Branch 5 taken 2 times.
7064 return (root.get(key, Json::Value(def)).*as)();
299
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
4 } catch (const std::exception& e) {
300
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
4 WarningHolder::InvalidConfigEntry("pipewire-aes67-instances.json",
301
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.
12 std::string(key) + ": " + e.what());
302
0/2
✗ Branch 0 not taken.
✗ Branch 1 not taken.
4 return def;
303 }
304 }
305
306 148 bool AES67Manager::LoadConfig() {
307
1/2
✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
148 Json::Value root;
308
3/4
✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 147 times.
148 if (!LoadJsonFromFile(m_configPath, root, JsonRoot::Object)) {
309
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT, "AES67Manager: Failed to load config from %s\n",
310 m_configPath.c_str());
311 1 return false;
312 }
313
314 // Parse into a local config and publish it in one locked swap at the end.
315 // Filling m_config in place would let a status query on another thread
316 // observe a half-built instance list, or iterate the vector while
317 // push_back() reallocates it.
318 // Every fallback below is the member's own default rather than a literal.
319 // Spelling them out twice does not stay in step: nativeSourceRate and
320 // sourceBufferCopy were changed to false in the struct and kept a literal
321 // true here, so this function -- which always runs -- quietly put them
322 // back. The shipped behaviour was then the opposite of both the struct
323 // and the commit message that changed it.
324
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 AES67Config cfg;
325
3/4
✓ Branch 0 taken 57 times.
✓ Branch 1 taken 90 times.
✓ Branch 2 taken 57 times.
✗ Branch 3 not taken.
147 static const AES67Config kDefault;
326
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.ptpEnabled = ConfigField(root, "ptpEnabled", kDefault.ptpEnabled, &Json::Value::asBool);
327
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.ptpInterface = ConfigField(root, "ptpInterface", kDefault.ptpInterface, &Json::Value::asString);
328
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 146 times.
147 if (cfg.ptpInterface.empty()) {
329
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 cfg.ptpInterface = FirstWiredInterface();
330
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "AES67Manager: no PTP interface configured, using %s\n",
331 cfg.ptpInterface.c_str());
332 }
333
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.ptpDomain = ConfigField(root, "ptpDomain", kDefault.ptpDomain, &Json::Value::asInt);
334
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.ptpRole = ConfigField(root, "ptpRole", kDefault.ptpRole, &Json::Value::asString);
335
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.ptpLockWaitMs = ConfigField(root, "ptpLockWaitMs", kDefault.ptpLockWaitMs, &Json::Value::asInt);
336 // The wait runs inside ApplyConfig(), which holds the apply lock and, at
337 // boot, fppd's startup; Shutdown() has to wait it out too. Anything past
338 // half a minute is not a wait for lock any more.
339 static constexpr int MAX_PTP_LOCK_WAIT_MS = 30000;
340
2/4
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 147 times.
147 if (cfg.ptpLockWaitMs < 0 || cfg.ptpLockWaitMs > MAX_PTP_LOCK_WAIT_MS) {
341 ✗ int clamped = cfg.ptpLockWaitMs < 0 ? 0 : MAX_PTP_LOCK_WAIT_MS;
342 ✗ LogWarn(VB_MEDIAOUT, "AES67Manager: ptpLockWaitMs %d out of range, using %d\n",
343 cfg.ptpLockWaitMs, clamped);
344 ✗ cfg.ptpLockWaitMs = clamped;
345 }
346
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.ptpMediaClock = ConfigField(root, "ptpMediaClock", kDefault.ptpMediaClock, &Json::Value::asBool);
347
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.sourcePacing = ConfigField(root, "sourcePacing", kDefault.sourcePacing, &Json::Value::asBool);
348
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.sinkPacing = ConfigField(root, "sinkPacing", kDefault.sinkPacing, &Json::Value::asBool);
349
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.sinkPacingMs = ConfigField(root, "sinkPacingMs", kDefault.sinkPacingMs, &Json::Value::asInt);
350
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.driftResample = ConfigField(root, "driftResample", kDefault.driftResample, &Json::Value::asBool);
351
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.pipelineStats = ConfigField(root, "pipelineStats", kDefault.pipelineStats, &Json::Value::asBool);
352 147 cfg.nativeSourceRate =
353
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 ConfigField(root, "nativeSourceRate", kDefault.nativeSourceRate, &Json::Value::asBool);
354 147 cfg.sourceBufferCopy =
355
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 ConfigField(root, "sourceBufferCopy", kDefault.sourceBufferCopy, &Json::Value::asBool);
356 147 cfg.sourceMinBuffers =
357
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 ConfigField(root, "sourceMinBuffers", kDefault.sourceMinBuffers, &Json::Value::asInt);
358 147 cfg.splitClockDomains =
359
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 ConfigField(root, "splitClockDomains", kDefault.splitClockDomains, &Json::Value::asBool);
360
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.targetLeadMs = ConfigField(root, "targetLeadMs", kDefault.targetLeadMs, &Json::Value::asInt);
361 147 cfg.sourceSilenceFloor =
362
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 ConfigField(root, "sourceSilenceFloor", kDefault.sourceSilenceFloor, &Json::Value::asBool);
363 147 cfg.sourcePtpGroup =
364
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 ConfigField(root, "sourcePtpGroup", kDefault.sourcePtpGroup, &Json::Value::asBool);
365 cfg.sourcePtpGroupName =
366
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 ConfigField(root, "sourcePtpGroupName", kDefault.sourcePtpGroupName, &Json::Value::asString);
367
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.sourceAsync = ConfigField(root, "sourceAsync", kDefault.sourceAsync, &Json::Value::asBool);
368
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.rateMatch = ConfigField(root, "rateMatch", kDefault.rateMatch, &Json::Value::asBool);
369 147 cfg.rateMatchToleranceNs =
370 294 (guint64)ConfigField(root, "rateMatchToleranceNs",
371
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 (Json::UInt64)kDefault.rateMatchToleranceNs, &Json::Value::asUInt64);
372
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 cfg.adaptiveResample = ConfigField(root, "adaptiveResample", false, &Json::Value::asBool);
373 147 cfg.requireGroupSource =
374
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 ConfigField(root, "requireGroupSource", kDefault.requireGroupSource, &Json::Value::asBool);
375
376 // A domain outside 0-127 is not representable in the PTP header; an
377 // unknown role would silently fall through to the "auto" branch below,
378 // so normalise both here where we can tell the user about it.
379
3/4
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 146 times.
147 if (cfg.ptpDomain < 0 || cfg.ptpDomain > 127) {
380
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT, "AES67Manager: Invalid PTP domain %d, using %d\n",
381 cfg.ptpDomain, AES67::DEFAULT_PTP_DOMAIN);
382 1 cfg.ptpDomain = AES67::DEFAULT_PTP_DOMAIN;
383 }
384
8/10
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 143 times.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 2 times.
✓ Branch 8 taken 1 times.
✓ Branch 9 taken 146 times.
149 if (cfg.ptpRole != "auto" && cfg.ptpRole != "follower" &&
385
3/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 1 times.
2 cfg.ptpRole != "master") {
386
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT, "AES67Manager: Unknown PTP role '%s', using 'auto'\n",
387 cfg.ptpRole.c_str());
388
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 cfg.ptpRole = "auto";
389 }
390
391
6/12
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 147 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 147 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 147 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 147 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 147 times.
✗ Branch 11 not taken.
147 if (root.isMember("instances") && root["instances"].isArray()) {
392
8/14
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 147 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 147 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 186 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 186 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 333 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 186 times.
✓ Branch 13 taken 147 times.
333 for (const auto& instJson : root["instances"]) {
393
1/2
✓ Branch 0 taken 186 times.
✗ Branch 1 not taken.
186 AES67Instance inst;
394 // A value of the wrong JSON type threw from here to
395 // std::terminate, at startup; skip such an instance instead.
396 try {
397
4/6
✓ Branch 0 taken 186 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 186 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✓ Branch 5 taken 1 times.
188 inst.id = instJson.get("id", 0).asInt();
398
3/6
✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
185 inst.name = instJson.get("name", "AES67").asString();
399
3/6
✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
185 inst.enabled = instJson.get("enabled", true).asBool();
400
3/6
✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
185 inst.mode = instJson.get("mode", "send").asString();
401
3/6
✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
185 inst.multicastIP = instJson.get("multicastIP", AES67::DEFAULT_MULTICAST_IP).asString();
402
3/6
✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
185 inst.port = instJson.get("port", AES67::DEFAULT_PORT).asInt();
403
3/6
✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
185 inst.channels = instJson.get("channels", AES67::DEFAULT_CHANNELS).asInt();
404
3/6
✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
185 inst.interface = instJson.get("interface", "").asString();
405
3/6
✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
185 inst.sessionName = instJson.get("sessionName", inst.name).asString();
406
3/6
✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
185 inst.latency = instJson.get("latency", AES67::DEFAULT_LATENCY_MS).asInt();
407
3/6
✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
185 inst.sapEnabled = instJson.get("sapEnabled", true).asBool();
408
3/6
✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
185 inst.ptime = instJson.get("ptime", AES67::DEFAULT_PTIME_MS).asInt();
409
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 } catch (const std::exception& e) {
410
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 WarningHolder::InvalidConfigEntry("pipewire-aes67-instances.json", e.what());
411 1 continue;
412 1 }
413
414 // Validate ptime
415
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 185 times.
185 if (!AES67::IsValidPtime(inst.ptime)) {
416 ✗ inst.ptime = AES67::DEFAULT_PTIME_MS;
417 }
418
419
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 182 times.
185 if (inst.channels > AES67::MAX_SUPPORTED_CHANNELS) {
420
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 LogWarn(VB_MEDIAOUT,
421 "AES67Manager: instance '%s' asks for %d channels, but the "
422 "audio graph only carries %d -- the stream would not start "
423 "at all. Using %d.\n",
424 inst.name.c_str(), inst.channels,
425 AES67::MAX_SUPPORTED_CHANNELS, AES67::MAX_SUPPORTED_CHANNELS);
426 3 inst.channels = AES67::MAX_SUPPORTED_CHANNELS;
427 }
428
429 // ...then against the channel count. These are independent
430 // dropdowns in the UI with no interlock, and most of their
431 // combinations describe a packet that cannot be sent: 4ms of L24
432 // is 2304 bytes at 4 channels and 4608 at 8, against a 1440-byte
433 // limit. Left alone the payloader quietly splits each one, so
434 // the receiver gets several short packets per ptime instead of
435 // the single one a=ptime promises. Clamping is better than
436 // failing -- the stream stays up and stays legal -- but it is a
437 // config error, so say so at a level the user will see.
438 185 int maxPtime = AES67::MaxPtimeForChannels(inst.channels);
439
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 182 times.
185 if (inst.ptime > maxPtime) {
440
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 LogWarn(VB_MEDIAOUT,
441 "AES67Manager: instance '%s' asks for %dms ptime at %d channels, "
442 "which is %d bytes of L24 -- over the %d-byte packet limit. "
443 "Using %dms instead.\n",
444 inst.name.c_str(), inst.ptime, inst.channels,
445 AES67::L24PayloadBytes(inst.ptime, inst.channels),
446 AES67::MAX_RTP_PACKET_BYTES, maxPtime);
447 3 inst.ptime = maxPtime;
448 }
449
450
3/6
✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 185 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 185 times.
185 if (!IsPipelineSafeHost(inst.multicastIP) || !IsPipelineSafeInterface(inst.interface)) {
451 ✗ LogErr(VB_MEDIAOUT, "AES67Manager: instance '%s' has an invalid address or interface, skipping it\n",
452 inst.name.c_str());
453 ✗ WarningHolder::AddWarningTimeout(300, AES67::WARNING_ID_PIPELINE, "AES67: stream '" + inst.name +
454 ✗ "' has an invalid address or interface and was not started");
455 ✗ continue;
456 }
457
458
1/2
✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
185 cfg.instances.push_back(inst);
459
2/2
✓ Branch 0 taken 185 times.
✓ Branch 1 taken 1 times.
186 }
460 }
461
462 // These flags are not independent, and a partial set fails quietly in ways
463 // that look like a different bug entirely. A tester enabling only
464 // splitClockDomains and sourceSilenceFloor reported a stream bursting a
465 // whole PipeWire quantum at a time (23 packets back-to-back every 23.22ms)
466 // -- the payload, sequence and timestamps were all perfect, so nothing
467 // pointed at the config. Say so at load rather than let the measurement
468 // be blamed.
469
3/4
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 146 times.
147 if (cfg.sourceSilenceFloor && !cfg.driftResample) {
470
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT,
471 "AES67Manager: sourceSilenceFloor is set but driftResample is not, "
472 "so it does nothing -- the gap fill runs inside the drift probe. "
473 "Set driftResample too.\n");
474 }
475
3/4
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 146 times.
147 if (cfg.splitClockDomains && !cfg.ptpMediaClock) {
476
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT,
477 "AES67Manager: splitClockDomains is set but ptpMediaClock is not. "
478 "Without a PTP media clock there is nothing to split, so the whole "
479 "block is skipped -- no PTP anchor and no lead servo. "
480 "ptpMediaClock defaults on, so something has turned it off.\n");
481 }
482
3/4
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 81 times.
✓ Branch 3 taken 66 times.
147 if (cfg.splitClockDomains && !cfg.ptpEnabled) {
483
1/2
✓ Branch 0 taken 81 times.
✗ Branch 1 not taken.
81 LogWarn(VB_MEDIAOUT,
484 "AES67Manager: splitClockDomains is set but PTP is disabled, so the "
485 "media clock falls back to an undisciplined system clock and the RTP "
486 "timestamps mean nothing to a receiver.\n");
487 }
488
3/4
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 146 times.
147 if (cfg.splitClockDomains && !cfg.sinkPacing) {
489
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT,
490 "AES67Manager: splitClockDomains is set but sinkPacing is not. "
491 "The sink will transmit a whole quantum at once instead of pacing "
492 "it, so receivers see bursts rather than one packet per ptime. "
493 "Set sinkPacing too.\n");
494 }
495
496
3/4
✓ Branch 0 taken 66 times.
✓ Branch 1 taken 81 times.
✓ Branch 2 taken 147 times.
✗ Branch 3 not taken.
147 LogInfo(VB_MEDIAOUT, "AES67Manager: Loaded config with %d instances, PTP=%s interface=%s domain=%d role=%s\n",
497 (int)cfg.instances.size(),
498 cfg.ptpEnabled ? "enabled" : "disabled",
499 cfg.ptpInterface.c_str(),
500 cfg.ptpDomain,
501 cfg.ptpRole.c_str());
502
503 {
504
1/2
✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
147 std::lock_guard<std::mutex> lock(m_configMutex);
505 147 m_config = std::move(cfg);
506 147 }
507 147 return true;
508 148 }
509
510 // ──────────────────────────────────────────────────────────────────────────────
511 // ApplyConfig — called from PHP API and boot sequence
512 // ──────────────────────────────────────────────────────────────────────────────
513 // Retract the pipeline warnings for the kinds of stream that are no longer
514 // failing. CreateSendPipeline()/CreateRecvPipeline() raise these, and nothing
515 // used to take them back: a warning with no timeout stays in the banner until
516 // fppd restarts, so a user who fixed the cause (usually routing a group to the
517 // sender) and re-applied successfully was still told the stream had failed.
518 // Called from every path that ends with no failing pipeline of that kind --
519 // including the ones that end with no pipelines at all.
520 149 static void ClearAES67PipelineWarnings(bool clearSend, bool clearRecv) {
521
2/2
✓ Branch 0 taken 35 times.
✓ Branch 1 taken 114 times.
149 if (clearSend) {
522
3/6
✓ Branch 0 taken 35 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 35 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 35 times.
✗ Branch 5 not taken.
140 WarningHolder::RemoveWarning(AES67::WARNING_ID_PIPELINE, AES67::WARNING_SEND_FAILED);
523 }
524
2/2
✓ Branch 0 taken 143 times.
✓ Branch 1 taken 6 times.
149 if (clearRecv) {
525
3/6
✓ Branch 0 taken 143 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 143 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 143 times.
✗ Branch 5 not taken.
572 WarningHolder::RemoveWarning(AES67::WARNING_ID_PIPELINE, AES67::WARNING_RECV_FAILED);
526 }
527 149 }
528
529 // Retract the "PTP could not start" warnings. Mirrors
530 // ClearAES67PipelineWarnings() above, and exists for the same reason: these
531 // have no timeout either, so the only thing that ever takes one back is a call
532 // like this one. Without it a user who installed linuxptp, freed the
533 // interface or fixed the config path was still told PTP could not start, for
534 // as long as fppd stayed up.
535 //
536 // Called once ptp4l is confirmed running, and again on the way down -- a
537 // daemon that is not running cannot be failing to start, and InitPTP() raises
538 // whichever of these still applies on the next attempt.
539 238 static void ClearAES67PtpStartWarnings() {
540
3/6
✓ Branch 0 taken 238 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 238 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 238 times.
✗ Branch 5 not taken.
952 WarningHolder::RemoveWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_NOT_INSTALLED);
541
3/6
✓ Branch 0 taken 238 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 238 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 238 times.
✗ Branch 5 not taken.
952 WarningHolder::RemoveWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_CONF_WRITE);
542
3/6
✓ Branch 0 taken 238 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 238 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 238 times.
✗ Branch 5 not taken.
952 WarningHolder::RemoveWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_SPAWN_FAILED);
543
3/6
✓ Branch 0 taken 238 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 238 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 238 times.
✗ Branch 5 not taken.
714 WarningHolder::RemoveWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_START_FAILED);
544 238 }
545
546 1976 bool AES67Manager::ApplyConfig() {
547 // Serialize against concurrent ApplyConfig()/Shutdown()/Cleanup() calls -
548 // see m_applyMutex. Without this, two callers can both get past the SAP
549 // thread joins below and both reach the std::thread assignments at the
550 // end, where assigning over a joinable thread calls std::terminate().
551
1/2
✓ Branch 0 taken 1976 times.
✗ Branch 1 not taken.
1976 std::lock_guard<std::mutex> applyLock(m_applyMutex);
552
1/2
✓ Branch 0 taken 1976 times.
✗ Branch 1 not taken.
3952 return ApplyConfigLocked();
553 1976 }
554
555 1978 bool AES67Manager::ApplyConfigLocked() {
556
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1978 times.
1978 if (m_stopping.load()) {
557 ✗ return false;
558 }
559 // Any rebuild scheduled before this apply is now redundant.
560 1978 m_applyGeneration++;
561
562
2/2
✓ Branch 0 taken 1829 times.
✓ Branch 1 taken 149 times.
1978 if (!m_initialized.load()) {
563
2/4
✓ Branch 0 taken 1829 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1829 times.
1829 if (!Init()) {
564 ✗ return false;
565 }
566 // Init() may return true but skip initialization (e.g. wrong backend).
567 // If m_initialized is still false, there's nothing to do.
568
1/2
✓ Branch 0 taken 1829 times.
✗ Branch 1 not taken.
1829 if (!m_initialized.load()) {
569 1829 return true;
570 }
571 }
572
573 // Stop existing pipelines and SAP threads. ptp4l is left for now: it is
574 // only restarted below if the new config changes what it runs with.
575
1/2
✓ Branch 0 taken 149 times.
✗ Branch 1 not taken.
149 StopWorkerThreads();
576
1/2
✓ Branch 0 taken 149 times.
✗ Branch 1 not taken.
149 StopAllPipelines();
577 // Re-resolved below against the new config: the PTP interface (and so the
578 // PHC backing the media clock) may have changed.
579
1/2
✓ Branch 0 taken 149 times.
✗ Branch 1 not taken.
149 ReleaseMediaClock();
580
581
3/4
✓ Branch 0 taken 149 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 148 times.
149 if (!FileExists(m_configPath)) {
582
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "AES67Manager: No config file, nothing to apply\n");
583
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 ShutdownPTP();
584
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 ClearAES67PipelineWarnings(true, true);
585 1 m_active.store(false);
586 1 return true;
587 }
588
589
3/4
✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 147 times.
148 if (!LoadConfig()) {
590
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 ShutdownPTP();
591 1 return false;
592 }
593
594 // Count enabled instances
595 147 int enabledCount = 0;
596
2/2
✓ Branch 0 taken 185 times.
✓ Branch 1 taken 147 times.
332 for (const auto& inst : m_config.instances) {
597
2/2
✓ Branch 0 taken 179 times.
✓ Branch 1 taken 6 times.
185 if (inst.enabled) enabledCount++;
598 }
599
600
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 143 times.
147 if (enabledCount == 0) {
601
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 LogInfo(VB_MEDIAOUT, "AES67Manager: No enabled instances\n");
602
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 ShutdownPTP();
603
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 ClearAES67PipelineWarnings(true, true);
604 4 m_active.store(false);
605 4 return true;
606 }
607
608 // Initialize PTP if enabled. A rebuild -- one stream's error, a watchdog
609 // pass, an Apply that changed only the streams -- keeps a running ptp4l
610 // whose settings are unchanged: restarting it takes every receiver in the
611 // domain off the clock, all of them when this box is the grandmaster.
612
2/2
✓ Branch 0 taken 63 times.
✓ Branch 1 taken 80 times.
143 if (m_config.ptpEnabled) {
613
2/6
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
63 PtpParams wanted{ m_config.ptpInterface, m_config.ptpDomain, m_config.ptpRole };
614
8/10
✓ Branch 0 taken 11 times.
✓ Branch 1 taken 52 times.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 11 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 7 times.
✓ Branch 7 taken 4 times.
✓ Branch 8 taken 7 times.
✓ Branch 9 taken 56 times.
63 if (m_ptpInitialized && IsPtp4lRunning() && wanted == m_ptpStartedWith) {
615
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 LogInfo(VB_MEDIAOUT, "AES67Manager: PTP settings unchanged, keeping ptp4l (PID %d)\n",
616 (int)m_ptp4lPid);
617
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 AnchorToPtpLock();
618 } else {
619
1/2
✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
56 ShutdownPTP();
620
3/4
✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 22 times.
✓ Branch 3 taken 34 times.
56 if (!InitPTP()) {
621
1/2
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
22 LogWarn(VB_MEDIAOUT, "AES67Manager: PTP init failed, continuing without PTP clock\n");
622 }
623 }
624 63 } else {
625
1/2
✓ Branch 0 taken 80 times.
✗ Branch 1 not taken.
80 ShutdownPTP();
626 }
627
628 // Create pipelines for each enabled instance
629 143 bool anySend = false;
630 143 bool anyRecv = false;
631 143 bool anySAP = false;
632 143 bool sendFailed = false;
633 143 bool recvFailed = false;
634 143 int failedStarts = 0;
635 // Senders held idle because nothing feeds them -- published to
636 // m_deferredSenders below, once the whole pass has run.
637 143 std::map<int, std::string> deferred;
638
639
2/2
✓ Branch 0 taken 182 times.
✓ Branch 1 taken 143 times.
325 for (const auto& inst : m_config.instances) {
640
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 179 times.
182 if (!inst.enabled) continue;
641
642
5/8
✓ Branch 0 taken 179 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✓ Branch 3 taken 170 times.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 9 times.
179 bool wantSend = (inst.mode == "send" || inst.mode == "both");
643
5/8
✓ Branch 0 taken 179 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 170 times.
✓ Branch 3 taken 9 times.
✓ Branch 4 taken 170 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 170 times.
179 bool wantRecv = (inst.mode == "receive" || inst.mode == "both");
644
645
2/2
✓ Branch 0 taken 170 times.
✓ Branch 1 taken 9 times.
179 if (wantSend) {
646 // Nothing feeds this sender, so starting it would cost 30 seconds
647 // of blocked apply and end in FAILURE. Hold it instead -- see
648 // AES67Config::requireGroupSource.
649
2/4
✓ Branch 0 taken 170 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 170 times.
✗ Branch 3 not taken.
170 const std::string nodeName = SafeNodeName(inst.name) + "_send";
650
7/8
✓ Branch 0 taken 44 times.
✓ Branch 1 taken 126 times.
✓ Branch 2 taken 44 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 43 times.
✓ Branch 6 taken 1 times.
✓ Branch 7 taken 169 times.
170 if (m_config.requireGroupSource && !PipeWireGraphFeedsNode(nodeName)) {
651
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT,
652 "AES67 send [%d] '%s': nothing in the audio graph feeds %s, "
653 "holding the stream idle. Add it to an Audio Output Group "
654 "and apply that config to start it.\n",
655 inst.id, inst.name.c_str(), nodeName.c_str());
656
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 deferred[inst.id] =
657
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 "Waiting for audio — not a member of any enabled Audio Output Group";
658
3/4
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✓ Branch 3 taken 149 times.
169 } else if (CreateSendPipeline(inst)) {
659 20 anySend = true;
660 } else {
661 149 sendFailed = true;
662 149 failedStarts++;
663 }
664 170 }
665
666
2/2
✓ Branch 0 taken 9 times.
✓ Branch 1 taken 170 times.
179 if (wantRecv) {
667
3/4
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✓ Branch 3 taken 6 times.
9 if (CreateRecvPipeline(inst)) {
668 3 anyRecv = true;
669 } else {
670 6 recvFailed = true;
671 6 failedStarts++;
672 }
673 }
674
675
2/2
✓ Branch 0 taken 45 times.
✓ Branch 1 taken 134 times.
179 if (inst.sapEnabled) {
676 45 anySAP = true;
677 }
678 }
679
680 {
681
1/2
✓ Branch 0 taken 143 times.
✗ Branch 1 not taken.
143 std::lock_guard<std::mutex> lock(m_pipelineMutex);
682 143 m_deferredSenders = std::move(deferred);
683 143 m_failedStarts = failedStarts;
684 143 }
685
686 // Every enabled pipeline of that kind started, so whatever raised the
687 // warning last time has been dealt with. Note this runs after the create
688 // calls that raise it, so a still-failing kind keeps its warning. A held
689 // sender does not count as a failure -- nothing was attempted, and there
690 // is nothing wrong with the stream itself.
691
1/2
✓ Branch 0 taken 143 times.
✗ Branch 1 not taken.
143 ClearAES67PipelineWarnings(!sendFailed, !recvFailed);
692
693 // Start the drift control loop if anything is sending on the PTP clock
694
4/6
✓ Branch 0 taken 20 times.
✓ Branch 1 taken 123 times.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 20 times.
143 if (anySend && m_config.ptpMediaClock && m_config.adaptiveResample) {
695 ✗ m_driftRunning.store(true);
696 ✗ m_driftThread = std::thread(&AES67Manager::DriftControlLoop, this);
697 }
698
699 // Start SAP announcer if any send instances have SAP enabled
700
4/4
✓ Branch 0 taken 45 times.
✓ Branch 1 taken 98 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 44 times.
143 if (anySAP && anySend) {
701 1 m_sapAnnounceRunning.store(true);
702
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 m_sapAnnounceThread = std::thread(&AES67Manager::SAPAnnounceLoop, this);
703
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "AES67Manager: SAP announcer started\n");
704 }
705
706 // Start SAP receiver if any receive instances or SAP enabled
707
2/2
✓ Branch 0 taken 45 times.
✓ Branch 1 taken 98 times.
143 if (anySAP) {
708 45 m_sapRecvRunning.store(true);
709
1/2
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
45 m_sapRecvThread = std::thread(&AES67Manager::SAPReceiveLoop, this);
710
1/2
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
45 LogInfo(VB_MEDIAOUT, "AES67Manager: SAP receiver started\n");
711 }
712
713 // The pipelines are anchored now, so this is where the step detector's
714 // reference belongs -- taking it here rather than at its first check means
715 // a clock that steps in the next 30 seconds is caught on that check
716 // instead of the one after it.
717
1/2
✓ Branch 0 taken 143 times.
✗ Branch 1 not taken.
143 ResetPtpStepReference();
718
719 143 m_watchdogRunning.store(true);
720
1/2
✓ Branch 0 taken 143 times.
✗ Branch 1 not taken.
143 m_watchdogThread = std::thread(&AES67Manager::WatchdogLoop, this);
721
722 143 m_active.store(true);
723
1/2
✓ Branch 0 taken 143 times.
✗ Branch 1 not taken.
143 LogInfo(VB_MEDIAOUT, "AES67Manager: Applied config — %d send, %d receive pipelines\n",
724 (int)m_sendPipelines.size(), (int)m_recvPipelines.size());
725 143 return true;
726 143 }
727
728 // Stops and joins the drift, SAP and watchdog threads. Caller holds
729 // m_applyMutex. Never call it from one of those threads.
730 208 void AES67Manager::StopWorkerThreads() {
731 208 m_sapAnnounceRunning.store(false);
732 208 m_sapRecvRunning.store(false);
733 208 m_driftRunning.store(false);
734 208 m_watchdogRunning.store(false);
735
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 208 times.
208 if (m_driftThread.joinable()) {
736 ✗ m_driftThread.join();
737 }
738
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 207 times.
208 if (m_sapAnnounceThread.joinable()) {
739 1 m_sapAnnounceThread.join();
740 }
741
2/2
✓ Branch 0 taken 45 times.
✓ Branch 1 taken 163 times.
208 if (m_sapRecvThread.joinable()) {
742 45 m_sapRecvThread.join();
743 }
744
2/2
✓ Branch 0 taken 143 times.
✓ Branch 1 taken 65 times.
208 if (m_watchdogThread.joinable()) {
745 143 m_watchdogThread.join();
746 }
747 208 }
748
749 // Pipeline recovery, ptp4l supervision, the PTP step detector and the
750 // no-lock warning review, every SAP_ANNOUNCE_INTERVAL_S. This used to run
751 // inside the SAP announcer, which only exists when something sends with SAP
752 // on: a receive-only box, or one with SAP turned off, got none of it -- a
753 // dead pipeline or ptp4l stayed dead and the no-lock warning never cleared.
754 143 void AES67Manager::WatchdogLoop() {
755
1/2
✓ Branch 0 taken 146 times.
✗ Branch 1 not taken.
146 while (m_watchdogRunning.load()) {
756
6/6
✓ Branch 0 taken 627 times.
✓ Branch 1 taken 9 times.
✓ Branch 2 taken 490 times.
✓ Branch 3 taken 137 times.
✓ Branch 4 taken 490 times.
✓ Branch 5 taken 146 times.
636 for (int i = 0; i < AES67::SAP_ANNOUNCE_INTERVAL_S && m_watchdogRunning.load(); i++) {
757
1/2
✓ Branch 0 taken 490 times.
✗ Branch 1 not taken.
490 std::this_thread::sleep_for(std::chrono::seconds(1));
758 }
759
2/2
✓ Branch 0 taken 137 times.
✓ Branch 1 taken 9 times.
146 if (!m_watchdogRunning.load()) {
760 137 break;
761 }
762 // Evaluate both before deciding: PollPipelinesWatchdog() also drains
763 // each pipeline's bus, so short-circuiting past it would leave errors
764 // unread until the next pass.
765 9 const bool ptpStepped = CheckPtpWatchdog();
766
5/6
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 6 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
✓ Branch 4 taken 6 times.
✓ Branch 5 taken 3 times.
9 if (PollPipelinesWatchdog() || ptpStepped) {
767 // ApplyConfig() joins this thread, so the rebuild runs on its own
768 // thread once this one has stopped.
769 6 LogWarn(VB_MEDIAOUT, "AES67 watchdog: rebuild needed, scheduling ApplyConfig\n");
770 6 m_watchdogRunning.store(false);
771 6 ScheduleRebuild();
772 6 break;
773 }
774 }
775 143 }
776
777 6 void AES67Manager::ScheduleRebuild() {
778
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 std::lock_guard<std::mutex> rl(m_rebuildMutex);
779
5/6
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 5 times.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 5 times.
6 if (m_stopping.load() || m_rebuildRunning.load()) {
780 // Shutting down, or a rebuild is already on its way. Never join a
781 // running one here: its ApplyConfig() may be waiting to join the
782 // watchdog thread that is calling this.
783 1 return;
784 }
785
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 5 times.
5 if (m_rebuildThread.joinable()) {
786 ✗ m_rebuildThread.join(); // finished: returns at once
787 }
788 5 uint64_t generation = m_applyGeneration.load();
789 5 m_rebuildRunning.store(true);
790 5 m_rebuildThread = std::thread([this, generation]() {
791 // Brief delay to let the SAP announce thread finish exiting and
792 // become joinable.
793
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 std::this_thread::sleep_for(std::chrono::milliseconds(500));
794 {
795
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 std::lock_guard<std::mutex> applyLock(m_applyMutex);
796 // Nothing to do if anything applied, cleaned up or shut down in
797 // the meantime -- it either did this rebuild's work or undid it
798 // on purpose.
799
6/6
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 3 times.
8 if (!m_stopping.load() && generation == m_applyGeneration.load()) {
800
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 ApplyConfigLocked();
801 }
802 5 }
803 5 m_rebuildRunning.store(false);
804
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
10 });
805
2/2
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 1 times.
6 }
806
807 1 void AES67Manager::Cleanup() {
808
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "AES67Manager: Cleanup\n");
809
810
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::lock_guard<std::mutex> applyLock(m_applyMutex);
811 // A watchdog rebuild already scheduled must not bring back what this
812 // tears down.
813 1 m_applyGeneration++;
814
815
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 StopWorkerThreads();
816
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 StopAllPipelines();
817
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 ShutdownPTP();
818
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 ReleaseMediaClock();
819
820 // Nothing is running any more, so a "failed to start" warning describes a
821 // stream that no longer exists.
822
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 ClearAES67PipelineWarnings(true, true);
823
824 1 m_active.store(false);
825 1 }
826
827 // ──────────────────────────────────────────────────────────────────────────────
828 // PTP clock management — uses ptp4l (linuxptp) as an IEEE 1588 PTP daemon
829 //
830 // GStreamer's gst-ptp-helper is a PTP *client only* — it listens for PTP
831 // Announce/Sync messages but never originates them. For AES67, FPP must
832 // participate in PTP as either grandmaster or follower (via BMCA).
833 //
834 // We launch ptp4l as a managed subprocess with an AES67-appropriate config:
835 // - Configurable domain (default 0), two-step, announce every 2s, 8 syncs/sec
836 // - Hardware timestamping when available (/dev/ptp0)
837 // - BMCA priority driven by the "ptpRole" setting -- see AES67Config
838 // - DSCP EF (46) on PTP event/general messages -- see AES67::PTP_DSCP
839 //
840 // phc2sys is deliberately not launched -- see the note in InitPTP(). The
841 // pipeline reads PTP time directly rather than via the system clock.
842 // ──────────────────────────────────────────────────────────────────────────────
843
844 // Absolute path so we do not depend on whatever PATH systemd handed fppd --
845 // the ptp4l presence check below is absolute for the same reason.
846 static const char* PMC_BINARY = "/usr/sbin/pmc";
847 static const char* PTP4L_BINARY = "/usr/sbin/ptp4l";
848
849 // Does this ptp4l understand the given config key?
850 //
851 // linuxptp 4.0 renamed slaveOnly to clientOnly (and masterOnly to serverOnly)
852 // and kept the old spellings as deprecated aliases, which 4.2 announces on
853 // every start: "option slaveOnly is deprecated, please use clientOnly
854 // instead". Neither spelling is safe to hard-code. An unknown key is a hard
855 // config-parse failure -- ptp4l exits before it does anything at all -- so
856 // writing clientOnly takes PTP down entirely on a pre-4.0 install, while
857 // writing slaveOnly is fine today but relies on an alias that has already
858 // been announced as going away.
859 //
860 // Asking is better than either. ptp4l parses its config file before it looks
861 // at anything else, so a config carrying nothing but the key under test, run
862 // with no -i, answers the question without starting PTP, touching the network
863 // or opening a clock. Measured on linuxptp 4.2:
864 //
865 // clientOnly -> "no interface specified" (255)
866 // notARealOption -> "unknown option notARealOption at line 2 ..." (254)
867 //
868 // The exit codes differ too, but the message is the part that is actually
869 // documented behaviour, so that is what this reads. Anything unexpected --
870 // no output at all, ptp4l missing, popen failing -- is treated as "not
871 // supported", which falls back to the spelling that works on every release
872 // shipped so far rather than the one that only works on new ones.
873 28 static bool Ptp4lSupportsOption(const std::string& option) {
874 struct PipeCloser {
875
1/2
✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
28 void operator()(FILE* f) const { if (f) pclose(f); }
876 };
877
1/2
✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
28 const std::string probePath = "/tmp/fpp-ptp4l-probe.conf";
878 {
879
1/2
✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
28 std::ofstream probe(probePath);
880
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 28 times.
28 if (!probe.is_open()) {
881 ✗ return false;
882 }
883
3/6
✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 28 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 28 times.
✗ Branch 5 not taken.
28 probe << "[global]\n" << option << "\t\t1\n";
884
1/2
✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
28 }
885
4/8
✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 28 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 28 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 28 times.
✗ Branch 7 not taken.
28 std::string cmd = std::string(PTP4L_BINARY) + " -f " + probePath + " 2>&1";
886 28 std::string output;
887 {
888
1/2
✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
28 std::unique_ptr<FILE, PipeCloser> pipe(popen(cmd.c_str(), "r"));
889
1/2
✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
28 if (pipe) {
890 char buffer[256];
891
3/4
✓ Branch 0 taken 55 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 27 times.
✓ Branch 3 taken 28 times.
55 while (fgets(buffer, sizeof(buffer), pipe.get()) != nullptr) {
892
1/2
✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
27 output += buffer;
893 }
894 }
895 28 }
896 28 unlink(probePath.c_str());
897
898
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 27 times.
28 if (output.empty()) {
899 1 return false;
900 }
901 27 return output.find("unknown option") == std::string::npos;
902 28 }
903
904 // The spelling of the follower-role key this ptp4l accepts. Probed once: the
905 // binary does not change under a running fppd, and InitPTP() writes the config
906 // up to three times per start.
907 39 static const char* Ptp4lClientOnlyKey() {
908 static const char* key =
909
7/12
✓ Branch 0 taken 28 times.
✓ Branch 1 taken 11 times.
✓ Branch 2 taken 28 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 28 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 28 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 26 times.
✓ Branch 9 taken 2 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
95 Ptp4lSupportsOption("clientOnly") ? "clientOnly" : "slaveOnly";
910 39 return key;
911 }
912
913 39 bool AES67Manager::WritePtpConf(const std::string& path, bool hwTimestamping, bool includeDscp) {
914
1/2
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
39 std::ofstream conf(path);
915
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 39 times.
39 if (!conf.is_open()) {
916 ✗ LogErr(VB_MEDIAOUT, "AES67Manager: Cannot write PTP config to %s\n", path.c_str());
917 ✗ WarningHolder::AddWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_CONF_WRITE);
918 ✗ return false;
919 }
920
921 // BMCA behaviour. "auto" deliberately runs at a worse priority1 than the
922 // 128 that professional gear ships with: a tie on priority is broken by
923 // clock identity (lowest MAC wins), which is how an FPP box ends up
924 // grandmastering a Q-SYS or Yamaha domain it should have been following.
925 39 int priority1 = AES67::PTP_PRIORITY_AUTO;
926 39 bool slaveOnly = false;
927
3/4
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 38 times.
39 if (m_config.ptpRole == "master") {
928 1 priority1 = AES67::PTP_PRIORITY_PREFER_MASTER;
929
3/4
✓ Branch 0 taken 38 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 36 times.
38 } else if (m_config.ptpRole == "follower") {
930 2 slaveOnly = true;
931 }
932
933 // AES67 media profile: announce every 2s, sync 8/sec, delay req 1/sec.
934 conf << "[global]\n"
935
3/6
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 39 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 39 times.
✗ Branch 5 not taken.
39 << "domainNumber\t\t" << m_config.ptpDomain << "\n"
936 << "twoStepFlag\t\t1\n"
937
8/14
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 39 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 39 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 39 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 39 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 2 times.
✓ Branch 11 taken 37 times.
✓ Branch 12 taken 39 times.
✗ Branch 13 not taken.
39 << Ptp4lClientOnlyKey() << "\t\t" << (slaveOnly ? 1 : 0) << "\n"
938
3/6
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 39 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 39 times.
✗ Branch 5 not taken.
39 << "priority1\t\t" << priority1 << "\n"
939 << "priority2\t\t128\n"
940 << "clockClass\t\t248\n"
941 << "clockAccuracy\t\t0xFE\n"
942 << "offsetScaledLogVariance\t0xFFFF\n"
943 << "logAnnounceInterval\t1\n" // 1 announce/2s — matches announceReceiptTimeout cadence
944 << "logSyncInterval\t\t-3\n" // 8/sec (AES67 recommends -3)
945 << "logMinDelayReqInterval\t0\n"
946 << "announceReceiptTimeout\t3\n"
947 << "syncReceiptTimeout\t0\n"
948 // Step rather than slew a large offset. linuxptp defaults
949 // step_threshold to 0, meaning it only ever steps on the very first
950 // correction (first_step_threshold, 20us) and slews everything after.
951 // Initial lock is therefore fine -- a follower does converge on real
952 // hardware -- but a grandmaster changeover or a GM time jump after
953 // that leaves a large offset to be slewed out at the servo's frequency
954 // limit, which takes hours for anything past a second. The published
955 // AES67 profile sets this for the same reason ("converge faster when
956 // time jumps"). Harmless as grandmaster, where there is nothing to
957 // step towards.
958 << "step_threshold\t\t1\n"
959 << "transportSpecific\t0x0\n"
960 << "network_transport\tUDPv4\n"
961 << "delay_mechanism\t\tE2E\n"
962
19/36
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 39 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 39 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 39 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 39 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 39 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 39 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 39 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 39 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 39 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 39 times.
✗ Branch 21 not taken.
✓ Branch 22 taken 39 times.
✗ Branch 23 not taken.
✓ Branch 24 taken 39 times.
✗ Branch 25 not taken.
✓ Branch 26 taken 39 times.
✗ Branch 27 not taken.
✓ Branch 28 taken 39 times.
✗ Branch 29 not taken.
✓ Branch 30 taken 37 times.
✓ Branch 31 taken 2 times.
✓ Branch 32 taken 39 times.
✗ Branch 33 not taken.
✓ Branch 34 taken 39 times.
✗ Branch 35 not taken.
39 << "time_stamping\t\t" << (hwTimestamping ? "hardware" : "software") << "\n";
963
964
2/2
✓ Branch 0 taken 38 times.
✓ Branch 1 taken 1 times.
39 if (includeDscp) {
965
2/4
✓ Branch 0 taken 38 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 38 times.
✗ Branch 3 not taken.
38 conf << "dscp_event\t\t" << AES67::PTP_DSCP << "\n" // EF (46) -- Sync/Delay_Req
966
4/8
✓ Branch 0 taken 38 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 38 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 38 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 38 times.
✗ Branch 7 not taken.
38 << "dscp_general\t\t" << AES67::PTP_DSCP << "\n"; // EF (46) -- Announce/Follow_Up/etc.
967 }
968
1/2
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
39 conf << "# AES67 uses L2 multicast on 224.0.1.129/224.0.0.107\n";
969
1/2
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
39 conf.close();
970 39 return true;
971 39 }
972
973 // A pidfd for our own child, or -1 with errno set (ESRCH: already gone;
974 // ENOSYS: kernel or platform without pidfds, where callers fall back to
975 // the PID).
976 39 static int OpenPidfd(pid_t pid) {
977 #if defined(__linux__) && defined(SYS_pidfd_open)
978 39 return (int)syscall(SYS_pidfd_open, pid, 0); // close-on-exec by default
979 #else
980 (void)pid;
981 errno = ENOSYS;
982 return -1;
983 #endif
984 }
985
986 // Whether the child is alive. A pidfd becomes readable when its process
987 // exits. Without one, the PID has to belong to a process with the
988 // expected name: a dead child's PID may already be someone else's.
989 479 static bool ChildAlive(pid_t pid, int pidfd, const char* name) {
990 #if defined(__linux__)
991
1/2
✓ Branch 0 taken 479 times.
✗ Branch 1 not taken.
479 if (pidfd >= 0) {
992 479 struct pollfd pfd = { pidfd, POLLIN, 0 };
993
1/2
✓ Branch 0 taken 479 times.
✗ Branch 1 not taken.
479 return poll(&pfd, 1, 0) == 0;
994 }
995 #endif
996 ✗ if (pid <= 0 || kill(pid, 0) != 0) {
997 ✗ return false;
998 }
999 ✗ std::ifstream f("/proc/" + std::to_string((int)pid) + "/comm");
1000 ✗ if (!f.is_open()) {
1001 ✗ return true; // no procfs (macOS): the signal probe is all there is
1002 }
1003 ✗ std::string comm;
1004 ✗ std::getline(f, comm);
1005 ✗ return comm == name;
1006 ✗ }
1007
1008 34 static void SignalChild(pid_t pid, int pidfd, int sig, const char* name) {
1009 #if defined(__linux__) && defined(SYS_pidfd_send_signal)
1010
1/2
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
34 if (pidfd >= 0) {
1011 34 syscall(SYS_pidfd_send_signal, pidfd, sig, nullptr, 0);
1012 34 return;
1013 }
1014 #endif
1015 ✗ if (ChildAlive(pid, -1, name)) {
1016 ✗ kill(pid, sig);
1017 }
1018 }
1019
1020 4 void AES67Manager::ForgetPtp4l() {
1021
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 std::lock_guard<std::mutex> lock(m_ptp4lProcMutex);
1022
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 1 times.
4 if (m_ptp4lPidfd >= 0) {
1023
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 close(m_ptp4lPidfd);
1024 3 m_ptp4lPidfd = -1;
1025 }
1026 4 m_ptp4lPid = -1;
1027 4 }
1028
1029 39 bool AES67Manager::StartPtp4l(bool hwTimestamping, bool includeDscp) {
1030
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 39 times.
39 if (!WritePtpConf(m_ptpConfPath, hwTimestamping, includeDscp)) {
1031 ✗ return false;
1032 }
1033
1034 39 pid_t pid = fork();
1035
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 78 times.
78 if (pid < 0) {
1036 ✗ LogErr(VB_MEDIAOUT, "AES67Manager: fork() failed for ptp4l: %s\n", FPPstrerror(errno));
1037 ✗ WarningHolder::AddWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_SPAWN_FAILED);
1038 ✗ return false;
1039 }
1040
2/2
✓ Branch 0 taken 39 times.
✓ Branch 1 taken 39 times.
78 if (pid == 0) {
1041 // Child process — exec ptp4l (-m: log to stderr)
1042 39 execlp("ptp4l", "ptp4l",
1043 "-i", m_config.ptpInterface.c_str(),
1044 "-f", m_ptpConfPath.c_str(),
1045 "-m",
1046 nullptr);
1047 // If exec fails
1048 39 _exit(127);
1049 }
1050 39 int pidfd = OpenPidfd(pid);
1051
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 39 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
39 bool gone = pidfd < 0 && errno == ESRCH;
1052 {
1053
1/2
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
39 std::lock_guard<std::mutex> lock(m_ptp4lProcMutex);
1054 39 m_ptp4lPid = pid;
1055 39 m_ptp4lPidfd = pidfd;
1056 39 }
1057
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 39 times.
39 if (gone) {
1058 // Exited and was reaped before we could even look.
1059 ✗ ForgetPtp4l();
1060 ✗ return false;
1061 }
1062
1063 // Give ptp4l a moment to start, then check it did not exit immediately
1064 // (bad config key, no hardware timestamping, interface down, ...).
1065 39 usleep(500000); // 500ms
1066
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 36 times.
39 if (!IsPtp4lRunning()) {
1067 3 ForgetPtp4l();
1068 3 return false;
1069 }
1070
1071 36 m_ptpHwTimestamping.store(hwTimestamping);
1072
2/4
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 36 times.
✗ Branch 3 not taken.
36 LogInfo(VB_MEDIAOUT, "AES67Manager: ptp4l started (PID %d) on %s — %s timestamping%s, domain %d, role %s\n",
1073 (int)m_ptp4lPid, m_config.ptpInterface.c_str(),
1074 hwTimestamping ? "hardware" : "software",
1075 includeDscp ? "" : ", DSCP disabled",
1076 m_config.ptpDomain, m_config.ptpRole.c_str());
1077 36 return true;
1078 }
1079
1080 62 bool AES67Manager::InitPTP() {
1081
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 62 times.
62 if (m_ptpInitialized) {
1082 ✗ return true;
1083 }
1084
1085 // Check if ptp4l binary exists
1086
2/2
✓ Branch 0 taken 25 times.
✓ Branch 1 taken 37 times.
62 if (!FileExists(PTP4L_BINARY)) {
1087 25 LogErr(VB_MEDIAOUT, "AES67Manager: ptp4l not found — install linuxptp package\n");
1088
2/4
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25 times.
✗ Branch 3 not taken.
75 WarningHolder::AddWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_NOT_INSTALLED);
1089 25 return false;
1090 }
1091
1092 37 m_ptpConfPath = "/tmp/fpp-ptp4l.conf";
1093
1094 37 LogInfo(VB_MEDIAOUT, "AES67Manager: Starting ptp4l on %s (AES67 profile, domain %d, role %s)\n",
1095 m_config.ptpInterface.c_str(), m_config.ptpDomain, m_config.ptpRole.c_str());
1096
1097 // Start attempts, most capable first. The last one drops the DSCP keys:
1098 // they are only understood by linuxptp >= 2.0, and an unknown key is a
1099 // hard config-parse failure, which would otherwise take PTP down entirely
1100 // on an older install rather than just losing the QoS marking.
1101
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 36 times.
37 if (!StartPtp4l(true, true)) {
1102 1 LogErr(VB_MEDIAOUT, "AES67Manager: ptp4l exited immediately — "
1103 "check hardware timestamping support on %s\n",
1104 m_config.ptpInterface.c_str());
1105 1 LogInfo(VB_MEDIAOUT, "AES67Manager: Retrying ptp4l with software timestamping\n");
1106
1107
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (!StartPtp4l(false, true)) {
1108 1 LogInfo(VB_MEDIAOUT, "AES67Manager: Retrying ptp4l without DSCP marking "
1109 "(linuxptp may predate dscp_event/dscp_general)\n");
1110
1111
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (!StartPtp4l(false, false)) {
1112 1 LogErr(VB_MEDIAOUT, "AES67Manager: ptp4l failed to start\n");
1113
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
3 WarningHolder::AddWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_START_FAILED);
1114 1 ForgetPtp4l();
1115 1 return false;
1116 }
1117 }
1118 }
1119
1120 // ptp4l is up, so none of the four "PTP could not start" warnings describes
1121 // anything current any more -- including one raised by an earlier attempt
1122 // on this same call, since the cascade above retries with weaker options.
1123 36 ClearAES67PtpStartWarnings();
1124
1125 // phc2sys is deliberately NOT started.
1126 //
1127 // Its only job was to copy PTP time onto the system clock, and nothing
1128 // needs that any more: the media clock reads the PTP time source directly
1129 // (the PHC with hardware timestamping, CLOCK_REALTIME with software), so
1130 // the pipeline no longer depends on the system clock tracking PTP.
1131 //
1132 // Running it is actively harmful against real grandmasters. PTP's
1133 // timescale is whatever the grandmaster distributes, and Dante/Brooklyn
1134 // devices commonly distribute an arbitrary epoch rather than wall time --
1135 // one measured at ~4411 seconds, i.e. time since the device booted.
1136 // "phc2sys -a -r" faithfully slaved CLOCK_REALTIME to that and dragged the
1137 // Pi's clock back to 1970 (reported on issue #2848), taking the scheduler,
1138 // logs and anything else on wall time with it.
1139 //
1140 // A box that wants PTP-disciplined system time should run phc2sys from
1141 // systemd with an offset appropriate to its grandmaster; that is a
1142 // deliberate site decision, not something an audio stream should impose.
1143
1144 // Passive reader for the grandmaster's IP address -- see
1145 // PtpAnnounceListenLoop(). Started only once ptp4l is up, so it lives
1146 // exactly as long as the domain membership it is reporting on.
1147
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 if (!m_ptpAnnounceThread.joinable()) {
1148 36 m_ptpAnnounceRunning.store(true);
1149
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 m_ptpAnnounceThread = std::thread(&AES67Manager::PtpAnnounceListenLoop, this);
1150 }
1151
1152 36 AnchorToPtpLock();
1153
2/6
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 36 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
36 m_ptpStartedWith = PtpParams{ m_config.ptpInterface, m_config.ptpDomain, m_config.ptpRole };
1154
1155 36 m_ptpInitialized = true;
1156 36 LogInfo(VB_MEDIAOUT, "AES67Manager: PTP initialized — ptp4l PID %d on %s\n",
1157 (int)m_ptp4lPid, m_config.ptpInterface.c_str());
1158 36 return true;
1159 }
1160
1161 43 void AES67Manager::AnchorToPtpLock() {
1162 // Do not let anything anchor to the clock until it has stopped moving.
1163 //
1164 // ptp4l being alive is not the same as the clock being right: as a
1165 // follower the PHC free-runs until BMCA picks a grandmaster and the first
1166 // measurement steps it, which on #2848 was 6.81 hours later than the value
1167 // four streams had already anchored to. See AES67::PTP_LOCK_OFFSET_NS.
1168 //
1169 // Timing out is not fatal -- the streams start anyway, because a box with
1170 // no grandmaster on the domain yet should still come up and play -- but it
1171 // does mean the anchors are provisional, so say so, and leave the step
1172 // detector in CheckPtpWatchdog() to rebuild them when the clock does lock.
1173 43 bool locked = false;
1174
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 33 times.
43 if (m_config.ptpLockWaitMs > 0) {
1175 10 locked = WaitForPtpLock(m_config.ptpLockWaitMs);
1176
2/2
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 3 times.
10 if (!locked) {
1177
2/4
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
7 LogWarn(VB_MEDIAOUT,
1178 "AES67Manager: PTP did not settle within %dms (state %s) — "
1179 "starting anyway; the RTP timelines will be re-anchored if "
1180 "the clock steps once it locks\n",
1181 m_config.ptpLockWaitMs, GetPtp4lState().c_str());
1182 7 RaisePtpNoLockWarning();
1183 } else {
1184 3 ClearPtpNoLockWarning();
1185 }
1186 } else {
1187 33 LogInfo(VB_MEDIAOUT,
1188 "AES67Manager: ptpLockWaitMs is 0 — not waiting for PTP to "
1189 "lock before anchoring the RTP timelines\n");
1190 }
1191 // With ptpMediaClock off nothing is anchored to PTP at all, so there is
1192 // no anchor for a late lock to invalidate and the question does not apply.
1193
3/4
✓ Branch 0 taken 40 times.
✓ Branch 1 taken 3 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 40 times.
43 m_ptpLockedAtStart.store(locked || !m_config.ptpMediaClock);
1194 43 }
1195
1196 // SIGTERM then SIGKILL, polling until the process is really gone.
1197 //
1198 // fppd installs SIGCHLD with SA_NOCLDWAIT (see fppd.cpp), so children are
1199 // reaped by init and waitpid() here fails with ECHILD immediately -- it never
1200 // actually waited. That let ApplyConfig() launch a replacement ptp4l while
1201 // the old one still held /var/run/ptp4l and the PTP ports, leaving two
1202 // daemons briefly running BMCA against each other on one interface.
1203 //
1204 // Signals go through the pidfd where there is one: under SA_NOCLDWAIT a
1205 // child that already died has given up its PID, and kill(pid) -- SIGKILL
1206 // included -- would land on whatever process took it.
1207 404 static void StopChildProcess(pid_t& pid, int& pidfd, const char* name) {
1208 404 auto forget = [&]() {
1209
2/2
✓ Branch 0 taken 36 times.
✓ Branch 1 taken 368 times.
404 if (pidfd >= 0) {
1210 36 close(pidfd);
1211 36 pidfd = -1;
1212 }
1213 404 pid = -1;
1214 808 };
1215
2/2
✓ Branch 0 taken 368 times.
✓ Branch 1 taken 36 times.
404 if (pid <= 0) {
1216
1/2
✓ Branch 0 taken 368 times.
✗ Branch 1 not taken.
368 forget();
1217 404 return;
1218 }
1219
3/4
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 34 times.
36 if (!ChildAlive(pid, pidfd, name)) {
1220
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogInfo(VB_MEDIAOUT, "AES67Manager: %s (PID %d) had already exited\n", name, (int)pid);
1221
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 forget();
1222 2 return;
1223 }
1224
1/2
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
34 LogInfo(VB_MEDIAOUT, "AES67Manager: Stopping %s (PID %d)\n", name, (int)pid);
1225
1/2
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
34 SignalChild(pid, pidfd, SIGTERM, name);
1226
1227 // Up to ~3s for a clean exit, then insist.
1228
1/2
✓ Branch 0 taken 68 times.
✗ Branch 1 not taken.
68 for (int i = 0; i < 60; i++) {
1229 // Harmless no-op under SA_NOCLDWAIT; reaps the child if a future
1230 // change turns that off.
1231 68 int status = 0;
1232
1/2
✓ Branch 0 taken 68 times.
✗ Branch 1 not taken.
68 waitpid(pid, &status, WNOHANG);
1233
3/4
✓ Branch 0 taken 68 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 34 times.
✓ Branch 3 taken 34 times.
68 if (!ChildAlive(pid, pidfd, name)) {
1234
1/2
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
34 forget();
1235 34 return;
1236 }
1237
1/2
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
34 usleep(50000);
1238 }
1239
1240 ✗ LogWarn(VB_MEDIAOUT, "AES67Manager: %s (PID %d) did not exit, sending SIGKILL\n",
1241 name, (int)pid);
1242 ✗ SignalChild(pid, pidfd, SIGKILL, name);
1243 ✗ for (int i = 0; i < 20; i++) {
1244 ✗ int status = 0;
1245 ✗ waitpid(pid, &status, WNOHANG);
1246 ✗ if (!ChildAlive(pid, pidfd, name)) {
1247 ✗ break;
1248 }
1249 ✗ usleep(50000);
1250 }
1251 ✗ forget();
1252 }
1253
1254 202 void AES67Manager::ShutdownPTP() {
1255 202 m_ptpAnnounceRunning.store(false);
1256
2/2
✓ Branch 0 taken 36 times.
✓ Branch 1 taken 166 times.
202 if (m_ptpAnnounceThread.joinable()) {
1257
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 m_ptpAnnounceThread.join();
1258 }
1259 {
1260
1/2
✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
202 std::lock_guard<std::mutex> lock(m_ptpAnnounceMutex);
1261 202 m_ptpAnnounceSources.clear();
1262 202 }
1263
1264
1/2
✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
202 StopChildProcess(m_phc2sysPid, m_phc2sysPidfd, "phc2sys");
1265 {
1266 // out of the members first, so a reader never polls the pidfd as it
1267 // is closed; the wait for the child is made without the lock
1268 pid_t pid;
1269 int pidfd;
1270 {
1271
1/2
✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
202 std::lock_guard<std::mutex> lock(m_ptp4lProcMutex);
1272 202 pid = m_ptp4lPid;
1273 202 pidfd = m_ptp4lPidfd;
1274 202 m_ptp4lPid = -1;
1275 202 m_ptp4lPidfd = -1;
1276 202 }
1277
1/2
✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
202 StopChildProcess(pid, pidfd, "ptp4l");
1278 }
1279 202 m_ptpHwTimestamping.store(false);
1280
1281
2/2
✓ Branch 0 taken 36 times.
✓ Branch 1 taken 166 times.
202 if (!m_ptpConfPath.empty()) {
1282 36 unlink(m_ptpConfPath.c_str());
1283 36 m_ptpConfPath.clear();
1284 }
1285 202 m_ptpInitialized = false;
1286 202 m_ptpLockedAtStart.store(false);
1287
1288 // Nothing is anchored to PTP any more, so a banner saying the anchors are
1289 // provisional describes streams that no longer exist. InitPTP() raises it
1290 // again if the next start still cannot reach a lock -- and likewise for the
1291 // start failures, which cannot be true of a daemon we have just stopped.
1292
1/2
✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
202 ClearPtpNoLockWarning();
1293
1/2
✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
202 ClearAES67PtpStartWarnings();
1294
1295 {
1296
1/2
✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
202 std::lock_guard<std::mutex> lock(m_ptpStepMutex);
1297 202 m_ptpMonotonicDeltaValid = false;
1298 202 }
1299
1300
1/2
✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
202 std::lock_guard<std::mutex> lock(m_ptpCacheMutex);
1301
1/2
✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
202 m_ptpCache = PtpQueryCache();
1302 202 }
1303
1304 1853 bool AES67Manager::IsPtp4lRunning() const {
1305 // SA_NOCLDWAIT means a dead ptp4l leaves no zombie holding its slot, so
1306 // the PID can be recycled by an unrelated process; see ChildAlive().
1307
1/2
✓ Branch 0 taken 1853 times.
✗ Branch 1 not taken.
1853 std::lock_guard<std::mutex> lock(m_ptp4lProcMutex);
1308
5/6
✓ Branch 0 taken 375 times.
✓ Branch 1 taken 1478 times.
✓ Branch 2 taken 375 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 371 times.
✓ Branch 5 taken 4 times.
3706 return m_ptp4lPid > 0 && ChildAlive(m_ptp4lPid, m_ptp4lPidfd, "ptp4l");
1309 1853 }
1310
1311 // Runs a `pmc` management query against ptp4l's UDS socket and returns the
1312 // raw text output, or an empty string if ptp4l isn't running / pmc fails.
1313 138 static std::string RunPmcQuery(const std::string& tlv, int domain) {
1314 struct PipeCloser {
1315 138 void operator()(FILE* f) const {
1316 // pclose() returns -1 under fppd's SA_NOCLDWAIT (init already
1317 // reaped the child); the output has been read by then, so the
1318 // status is of no use to us either way.
1319
1/2
✓ Branch 0 taken 138 times.
✗ Branch 1 not taken.
138 if (f) pclose(f);
1320 138 }
1321 };
1322 // ptp4l silently drops a management message whose domainNumber does not
1323 // match its own -- even over the UDS socket -- so -d is not optional once
1324 // the domain is configurable.
1325
2/4
✓ Branch 0 taken 138 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 138 times.
✗ Branch 3 not taken.
276 std::string cmd = std::string(PMC_BINARY) + " -u -b 0 -d " +
1326
4/8
✓ Branch 0 taken 138 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 138 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 138 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 138 times.
✗ Branch 7 not taken.
414 std::to_string(domain) + " '" + tlv + "' 2>/dev/null";
1327
1/2
✓ Branch 0 taken 138 times.
✗ Branch 1 not taken.
138 std::unique_ptr<FILE, PipeCloser> pipe(popen(cmd.c_str(), "r"));
1328
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 138 times.
138 if (!pipe) {
1329 ✗ return "";
1330 }
1331 138 std::string output;
1332 char buffer[256];
1333
3/4
✓ Branch 0 taken 1072 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 934 times.
✓ Branch 3 taken 138 times.
1072 while (fgets(buffer, sizeof(buffer), pipe.get()) != nullptr) {
1334
1/2
✓ Branch 0 taken 934 times.
✗ Branch 1 not taken.
934 output += buffer;
1335 }
1336 138 return output;
1337 138 }
1338
1339 // Reformats a pmc clockIdentity like "aabbcc.fffe.ddeeff" into the dashed
1340 // EUI-64 form used elsewhere in this file ("AA-BB-CC-FF-FE-DD-EE-FF").
1341 69 static std::string FormatPmcClockId(const std::string& raw) {
1342 69 std::string hex;
1343
2/2
✓ Branch 0 taken 1242 times.
✓ Branch 1 taken 69 times.
1311 for (char c : raw) {
1344
3/4
✓ Branch 0 taken 1104 times.
✓ Branch 1 taken 138 times.
✓ Branch 2 taken 1104 times.
✗ Branch 3 not taken.
1242 if (c != '.') hex += c;
1345 }
1346
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 69 times.
69 if (hex.length() != 16) {
1347 ✗ return raw;
1348 }
1349 69 std::string dashed;
1350
2/2
✓ Branch 0 taken 552 times.
✓ Branch 1 taken 69 times.
621 for (size_t i = 0; i < hex.length(); i += 2) {
1351
3/4
✓ Branch 0 taken 483 times.
✓ Branch 1 taken 69 times.
✓ Branch 2 taken 483 times.
✗ Branch 3 not taken.
552 if (i) dashed += '-';
1352
1/2
✓ Branch 0 taken 552 times.
✗ Branch 1 not taken.
552 dashed += (char)toupper((unsigned char)hex[i]);
1353
1/2
✓ Branch 0 taken 552 times.
✗ Branch 1 not taken.
552 dashed += (char)toupper((unsigned char)hex[i + 1]);
1354 }
1355 69 return dashed;
1356 69 }
1357
1358 // True if a ptp4l portState string means "this node is the domain master".
1359 // PRE_MASTER is deliberately excluded -- it is a transitional state on the
1360 // way to MASTER, not a settled BMCA outcome.
1361 853 static bool IsGrandmasterPortState(const std::string& portState) {
1362
4/4
✓ Branch 0 taken 850 times.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 849 times.
853 return portState == "MASTER" || portState == "GRAND_MASTER";
1363 }
1364
1365 // Runs both management queries at most once per PTP_QUERY_CACHE_MS and keeps
1366 // the parsed results. /aes67/status is HTTP-facing and the SAP thread polls
1367 // once a second while BMCA settles; without this each of those forks a pmc.
1368 264 void AES67Manager::RefreshPtpCache(bool force) {
1369
1/2
✓ Branch 0 taken 264 times.
✗ Branch 1 not taken.
264 std::lock_guard<std::mutex> lock(m_ptpCacheMutex);
1370
1371 264 auto now = std::chrono::steady_clock::now();
1372
4/4
✓ Branch 0 taken 207 times.
✓ Branch 1 taken 57 times.
✓ Branch 2 taken 195 times.
✓ Branch 3 taken 12 times.
264 if (!force && m_ptpCache.valid) {
1373
2/4
✓ Branch 0 taken 195 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 195 times.
✗ Branch 3 not taken.
195 auto age = std::chrono::duration_cast<std::chrono::milliseconds>(now - m_ptpCache.when);
1374
2/2
✓ Branch 0 taken 194 times.
✓ Branch 1 taken 1 times.
195 if (age.count() < AES67::PTP_QUERY_CACHE_MS) {
1375 194 return;
1376 }
1377 }
1378
1379
1/2
✓ Branch 0 taken 70 times.
✗ Branch 1 not taken.
70 PtpQueryCache fresh;
1380 70 fresh.when = now;
1381
1382
3/4
✓ Branch 0 taken 70 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 69 times.
70 if (!IsPtp4lRunning()) {
1383 1 fresh.valid = true;
1384
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 fresh.portState = "not running";
1385
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 m_ptpCache = fresh;
1386 1 return;
1387 }
1388
1389 // Grandmaster / offset
1390
1/2
✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
69 int domain = GetPtpDomain();
1391
2/4
✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 69 times.
✗ Branch 3 not taken.
69 std::string output = RunPmcQuery("GET TIME_STATUS_NP", domain);
1392
1/2
✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
69 std::istringstream iss(output);
1393 69 std::string line;
1394
4/6
✓ Branch 0 taken 589 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 589 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 520 times.
✓ Branch 5 taken 69 times.
589 while (std::getline(iss, line)) {
1395
1/2
✓ Branch 0 taken 520 times.
✗ Branch 1 not taken.
520 std::istringstream ls(line);
1396 520 std::string key;
1397
1/2
✓ Branch 0 taken 520 times.
✗ Branch 1 not taken.
520 ls >> key;
1398
3/4
✓ Branch 0 taken 520 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 62 times.
✓ Branch 3 taken 458 times.
520 if (key == "gmPresent") {
1399 62 std::string val;
1400
1/2
✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
62 ls >> val;
1401
1/2
✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
62 fresh.gmPresent = (val == "true");
1402
6/8
✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25 times.
✓ Branch 3 taken 37 times.
✓ Branch 4 taken 25 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 18 times.
✓ Branch 7 taken 7 times.
62 fresh.gmPresentValid = (val == "true" || val == "false");
1403 // Only ever raise validity here. gmIdentity sets it too, and a
1404 // malformed gmPresent must not retract what another field has
1405 // already established -- pmc happens to print gmPresent first
1406 // today, which is the only reason a plain assignment has not
1407 // bitten anyone.
1408
2/2
✓ Branch 0 taken 55 times.
✓ Branch 1 taken 7 times.
62 if (fresh.gmPresentValid) {
1409 55 fresh.valid = true;
1410 }
1411
3/4
✓ Branch 0 taken 458 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 69 times.
✓ Branch 3 taken 389 times.
520 } else if (key == "gmIdentity") {
1412 69 std::string val;
1413
1/2
✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
69 ls >> val;
1414
1/2
✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
69 if (!val.empty()) {
1415
1/2
✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
69 fresh.gmIdentity = FormatPmcClockId(val);
1416 69 fresh.valid = true;
1417 }
1418
3/4
✓ Branch 0 taken 389 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 44 times.
✓ Branch 3 taken 345 times.
458 } else if (key == "master_offset") {
1419 44 std::string val;
1420
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 ls >> val;
1421 try {
1422 44 size_t consumed = 0;
1423
2/2
✓ Branch 0 taken 37 times.
✓ Branch 1 taken 7 times.
44 fresh.offsetNs = std::stoll(val, &consumed);
1424 37 fresh.offsetValid = (consumed == val.size());
1425 7 } catch (...) {
1426 7 fresh.offsetValid = false;
1427
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 }
1428 44 }
1429 520 }
1430
1431 // Port state
1432
2/4
✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 69 times.
✗ Branch 3 not taken.
69 std::string portOutput = RunPmcQuery("GET PORT_DATA_SET", domain);
1433
1/2
✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
69 std::istringstream pss(portOutput);
1434
3/6
✓ Branch 0 taken 276 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 276 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 276 times.
✗ Branch 5 not taken.
276 while (std::getline(pss, line)) {
1435
1/2
✓ Branch 0 taken 276 times.
✗ Branch 1 not taken.
276 std::istringstream ls(line);
1436 276 std::string key;
1437
1/2
✓ Branch 0 taken 276 times.
✗ Branch 1 not taken.
276 ls >> key;
1438
3/4
✓ Branch 0 taken 276 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 69 times.
✓ Branch 3 taken 207 times.
276 if (key == "portState") {
1439 69 std::string val;
1440
1/2
✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
69 ls >> val;
1441
1/2
✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
69 if (!val.empty()) {
1442
1/2
✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
69 fresh.portState = val;
1443 69 break;
1444 }
1445
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 69 times.
69 }
1446
4/4
✓ Branch 0 taken 207 times.
✓ Branch 1 taken 69 times.
✓ Branch 2 taken 207 times.
✓ Branch 3 taken 69 times.
345 }
1447
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 69 times.
69 if (fresh.portState.empty()) {
1448 ✗ fresh.portState = "running (state unknown)";
1449 }
1450
1451
1/2
✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
69 m_ptpCache = fresh;
1452
4/4
✓ Branch 0 taken 69 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 69 times.
✓ Branch 3 taken 195 times.
265 }
1453
1454 108 std::string AES67Manager::GetPtp4lState() {
1455
1/2
✓ Branch 0 taken 108 times.
✗ Branch 1 not taken.
108 RefreshPtpCache();
1456
1/2
✓ Branch 0 taken 108 times.
✗ Branch 1 not taken.
108 std::lock_guard<std::mutex> lock(m_ptpCacheMutex);
1457
1/2
✓ Branch 0 taken 108 times.
✗ Branch 1 not taken.
216 return m_ptpCache.portState;
1458 108 }
1459
1460 // Is the PTP clock one that will not step under the RTP anchors? See
1461 // AES67::PTP_LOCK_OFFSET_NS for what went wrong without asking this and why
1462 // the offset -- not the port state -- is the signal.
1463 //
1464 // Two callers: WaitForPtpLock() spins on it before the anchors are built, and
1465 // ReviewPtpNoLockWarning() asks it again afterwards, so that a clock which
1466 // settles late is noticed rather than left described by a warning nobody
1467 // retracts. One definition, so the two cannot disagree about what "settled"
1468 // means.
1469 //
1470 // Three outcomes count as settled:
1471 //
1472 // MASTER / GRAND_MASTER -- we won the BMCA, so the PHC *is* the domain's
1473 // time and there is nothing for ptp4l to step it towards. Returns as
1474 // soon as the election is decided rather than burning the whole cap,
1475 // which matters because declaring yourself master takes announceReceipt-
1476 // Timeout x the announce interval (3 x 2s here) of listening first.
1477 //
1478 // SLAVE with a small offset from master -- ptp4l has measured against the
1479 // grandmaster and the correction it needed has already been applied.
1480 // Both halves have to be evidence rather than a default: a gmPresent
1481 // that actually read "true" and a master_offset that actually parsed.
1482 // A missing or malformed management field leaves the offset at zero,
1483 // which is indistinguishable from a perfect lock -- and zero is exactly
1484 // what #2848 settled on before the clock stepped 6.81 hours.
1485 //
1486 // LISTENING with no grandmaster on the domain, once the cap has run out --
1487 // there is nothing to be stepped towards, so the free-running PHC is as
1488 // settled as it is going to get. "No grandmaster" means gmPresent was
1489 // read and said false; a management query that failed outright says
1490 // nothing about the domain and is not an answer. This one is only
1491 // tested at the deadline: during the wait "no announce seen" and "no
1492 // announce yet" look identical, and returning on it early would defeat
1493 // the whole point on a follower whose grandmaster had not spoken yet.
1494 // It matters because a box that is about to become grandmaster spends
1495 // announceReceiptTimeout x the announce interval -- 3 x 2s here --
1496 // listening first, so a solo FPP would otherwise time out and cry wolf
1497 // on every single boot. Measured on a Pi 5 with nothing else on the
1498 // domain: LISTENING throughout, gmPresent false throughout, and
1499 // "assuming the grand master role" at t=7.46s -- well past the cap.
1500 // UNCALIBRATED or a wide SLAVE offset at the deadline is the opposite
1501 // case: a grandmaster is there and we have not caught up with it yet,
1502 // which is exactly when the caller should worry.
1503 //
1504 // Everything else is not settled, and the caller decides what to do about it.
1505 // The third outcome is returned as its own value rather than folded into
1506 // "settled" precisely because of the deadline caveat above: only a caller that
1507 // knows it has waited long enough may accept it.
1508 57 AES67Manager::PtpSettleState AES67Manager::PtpSettledNow(std::string* portStateOut,
1509 int64_t* offsetNsOut) {
1510 // force: the whole point is the live answer, and the cache is a second
1511 // deep -- four polls of the same stale reading would just burn the cap.
1512
1/2
✓ Branch 0 taken 57 times.
✗ Branch 1 not taken.
57 RefreshPtpCache(true);
1513 57 std::string portState;
1514 57 int64_t offsetNs = 0;
1515 57 bool gmPresent = false;
1516 57 bool gmPresentValid = false;
1517 57 bool offsetValid = false;
1518 {
1519
1/2
✓ Branch 0 taken 57 times.
✗ Branch 1 not taken.
57 std::lock_guard<std::mutex> lock(m_ptpCacheMutex);
1520
1/2
✓ Branch 0 taken 57 times.
✗ Branch 1 not taken.
57 portState = m_ptpCache.portState;
1521 57 offsetNs = m_ptpCache.offsetNs;
1522 57 gmPresent = m_ptpCache.gmPresent;
1523 57 gmPresentValid = m_ptpCache.gmPresentValid;
1524 57 offsetValid = m_ptpCache.offsetValid;
1525 57 }
1526
1/2
✓ Branch 0 taken 57 times.
✗ Branch 1 not taken.
57 if (portStateOut) {
1527
1/2
✓ Branch 0 taken 57 times.
✗ Branch 1 not taken.
57 *portStateOut = portState;
1528 }
1529
2/2
✓ Branch 0 taken 56 times.
✓ Branch 1 taken 1 times.
57 if (offsetNsOut) {
1530 56 *offsetNsOut = offsetNs;
1531 }
1532
1533
3/4
✓ Branch 0 taken 57 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 56 times.
57 if (IsGrandmasterPortState(portState)) {
1534 1 return PtpSettleState::Grandmaster;
1535 }
1536 // Missing/malformed management data is not a zero offset. Compare
1537 // signed bounds directly so INT64_MIN cannot overflow on negation.
1538
7/10
✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 23 times.
✓ Branch 3 taken 14 times.
✓ Branch 4 taken 23 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 21 times.
✓ Branch 8 taken 2 times.
✗ Branch 9 not taken.
93 if (portState == "SLAVE" && gmPresentValid && gmPresent && offsetValid &&
1539
5/6
✓ Branch 0 taken 37 times.
✓ Branch 1 taken 19 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 54 times.
93 offsetNs > -AES67::PTP_LOCK_OFFSET_NS &&
1540 offsetNs < AES67::PTP_LOCK_OFFSET_NS) {
1541 2 return PtpSettleState::SlaveLocked;
1542 }
1543
7/10
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✓ Branch 3 taken 47 times.
✓ Branch 4 taken 7 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 7 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 7 times.
✓ Branch 9 taken 47 times.
54 if (portState == "LISTENING" && gmPresentValid && !gmPresent) {
1544 7 return PtpSettleState::NoGrandmasterYet;
1545 }
1546 47 return PtpSettleState::NotSettled;
1547 57 }
1548
1549 // Block on PtpSettledNow() above until it says the clock has settled, or until
1550 // the caller's patience runs out. Returns true if it settled.
1551 10 bool AES67Manager::WaitForPtpLock(int timeoutMs) {
1552
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 10 times.
10 if (timeoutMs <= 0) {
1553 ✗ return false;
1554 }
1555 // Every answer here comes from pmc. Without it the wait can only ever
1556 // time out, so spending the cap to find that out helps nobody -- say so
1557 // once and carry on. (linuxptp ships both binaries, and InitPTP() has
1558 // already required ptp4l, so this is a broken install rather than an
1559 // ordinary one.)
1560
2/4
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 10 times.
10 if (!FileExists(PMC_BINARY)) {
1561 ✗ LogWarn(VB_MEDIAOUT,
1562 "AES67Manager: %s is missing — cannot tell whether PTP has "
1563 "locked, so not waiting for it\n", PMC_BINARY);
1564 ✗ return false;
1565 }
1566 10 const auto start = std::chrono::steady_clock::now();
1567
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 const auto deadline = start + std::chrono::milliseconds(timeoutMs);
1568
1569 10 std::string reported;
1570 for (;;) {
1571
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 56 times.
56 if (m_stopping.load()) {
1572 ✗ return false;
1573 }
1574
2/4
✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 56 times.
56 if (!IsPtp4lRunning()) {
1575 ✗ LogWarn(VB_MEDIAOUT,
1576 "AES67Manager: ptp4l exited while waiting for it to lock\n");
1577 ✗ return false;
1578 }
1579
1580 56 std::string portState;
1581 56 int64_t offsetNs = 0;
1582
1/2
✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
56 const PtpSettleState settled = PtpSettledNow(&portState, &offsetNs);
1583
1584 // One line per transition, not per poll: this runs four times a second
1585 // and the interesting thing is LISTENING -> UNCALIBRATED -> SLAVE.
1586
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 46 times.
56 if (portState != reported) {
1587
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 reported = portState;
1588
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 LogInfo(VB_MEDIAOUT, "AES67Manager: waiting for PTP — %s\n",
1589 portState.c_str());
1590 }
1591
1592 ✗ const double waitedS = std::chrono::duration<double>(
1593
2/4
✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 56 times.
✗ Branch 3 not taken.
56 std::chrono::steady_clock::now() - start).count();
1594
1595
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 55 times.
56 if (settled == PtpSettleState::Grandmaster) {
1596
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT,
1597 "AES67Manager: PTP settled after %.1fs — this node is the "
1598 "grandmaster, so the clock is already the domain's time\n",
1599 waitedS);
1600 1 return true;
1601 }
1602
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 54 times.
55 if (settled == PtpSettleState::SlaveLocked) {
1603
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT,
1604 "AES67Manager: PTP settled after %.1fs — SLAVE, offset "
1605 "%+lldns\n", waitedS, (long long)offsetNs);
1606 1 return true;
1607 }
1608
1609 54 const auto now = std::chrono::steady_clock::now();
1610
3/4
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✓ Branch 3 taken 46 times.
54 if (now >= deadline) {
1611
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 7 times.
8 if (settled == PtpSettleState::NoGrandmasterYet) {
1612
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 LogInfo(VB_MEDIAOUT,
1613 "AES67Manager: no PTP grandmaster on domain %d after "
1614 "%.1fs — nothing to step this clock, carrying on\n",
1615 GetPtpDomain(), waitedS);
1616 1 return true;
1617 }
1618 7 return false;
1619 }
1620 46 auto slice = std::chrono::milliseconds(AES67::PTP_LOCK_POLL_MS);
1621
1/2
✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
46 const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(
1622
1/2
✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
46 deadline - now);
1623
1/2
✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
46 std::this_thread::sleep_for(std::min(slice, left));
1624
2/2
✓ Branch 0 taken 46 times.
✓ Branch 1 taken 10 times.
102 }
1625 10 }
1626
1627 // Queries the *actual* domain grandmaster via `pmc GET TIME_STATUS_NP` —
1628 // this is the remote/upstream clock ptp4l has selected via BMCA, which may
1629 // or may not be this node. GetPTPClockId() (below) only ever returns this
1630 // node's own identity and must not be used to report "who is the master".
1631 797 bool AES67Manager::QueryPtp4lTimeStatus(bool& gmPresent, std::string& gmIdentity, int64_t& offsetNs,
1632 bool* offsetValid) {
1633
3/4
✓ Branch 0 taken 797 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 698 times.
✓ Branch 3 taken 99 times.
797 if (!IsPtp4lRunning()) {
1634 698 return false;
1635 }
1636
1/2
✓ Branch 0 taken 99 times.
✗ Branch 1 not taken.
99 RefreshPtpCache();
1637
1638
1/2
✓ Branch 0 taken 99 times.
✗ Branch 1 not taken.
99 std::lock_guard<std::mutex> lock(m_ptpCacheMutex);
1639
3/6
✓ Branch 0 taken 99 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 99 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 99 times.
99 if (!m_ptpCache.valid || m_ptpCache.gmIdentity.empty()) {
1640 ✗ return false;
1641 }
1642 99 gmPresent = m_ptpCache.gmPresent;
1643
1/2
✓ Branch 0 taken 99 times.
✗ Branch 1 not taken.
99 gmIdentity = m_ptpCache.gmIdentity;
1644 99 offsetNs = m_ptpCache.offsetNs;
1645
2/2
✓ Branch 0 taken 98 times.
✓ Branch 1 taken 1 times.
99 if (offsetValid) {
1646 98 *offsetValid = m_ptpCache.offsetValid;
1647 }
1648 99 return true;
1649 99 }
1650
1651 // The clock identity to advertise/report: the upstream grandmaster we follow,
1652 // or our own identity when we hold the role. Empty while ptp4l is still
1653 // converging, so callers can avoid announcing a refclk that is about to
1654 // change.
1655 1531 std::string AES67Manager::GetActiveGrandmasterId() {
1656
8/10
✓ Branch 0 taken 1531 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 76 times.
✓ Branch 3 taken 1455 times.
✓ Branch 4 taken 76 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 75 times.
✓ Branch 7 taken 1 times.
✓ Branch 8 taken 1530 times.
✓ Branch 9 taken 1 times.
1531 if (!IsPtpEnabled() || !IsPtp4lRunning()) {
1657
1/2
✓ Branch 0 taken 1530 times.
✗ Branch 1 not taken.
3060 return "";
1658 }
1659
1660 1 bool gmPresent = false;
1661 1 std::string gmId;
1662 1 int64_t offsetNs = 0;
1663
5/10
✓ 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.
1 if (QueryPtp4lTimeStatus(gmPresent, gmId, offsetNs) && gmPresent && !gmId.empty()) {
1664 1 return gmId;
1665 }
1666 ✗ if (IsGrandmasterPortState(GetPtp4lState())) {
1667 // We won the BMCA -- we are the refclk.
1668 ✗ return GetPTPClockId();
1669 }
1670 ✗ return "";
1671 1 }
1672
1673 // ──────────────────────────────────────────────────────────────────────────────
1674 // PTP Announce listener — resolves a grandmaster identity to an IP address
1675 //
1676 // "Which box is the clock?" is the first question at a commissioning, and a
1677 // clock identity does not answer it: it is an EUI-64 built from *a* MAC on the
1678 // device, which need not be the interface carrying PTP and cannot be looked up
1679 // in ARP. Nothing in PTP management gives the address either -- TIME_STATUS_NP
1680 // names the grandmaster by identity only.
1681 //
1682 // The address is therefore taken from where the Announce messages arrive from.
1683 // This is a passive second reader on the group ptp4l is already joined to:
1684 // SO_REUSEADDR (never SO_REUSEPORT, which would load-balance the flow away
1685 // from ptp4l instead of duplicating it) means both sockets get every packet.
1686 // ──────────────────────────────────────────────────────────────────────────────
1687
1688 // Parses one PTPv2 Announce and records the address it came from against the
1689 // grandmaster it advertises. Anything else on the group is ignored.
1690 4 void AES67Manager::HandlePtpAnnounce(const uint8_t* data, size_t len,
1691 const std::string& senderAddr) {
1692 // PTPv2 common header is 34 bytes. Within the Announce body:
1693 // originTimestamp(10) currentUtcOffset(2) reserved(1) priority1(1)
1694 // clockQuality(4) priority2(1) grandmasterIdentity(8) stepsRemoved(2).
1695 4 constexpr size_t PTP_HEADER_LEN = 34;
1696 4 constexpr size_t GM_ID_OFFSET = PTP_HEADER_LEN + 19; // 53
1697 4 constexpr size_t STEPS_OFFSET = PTP_HEADER_LEN + 27; // 61
1698 4 constexpr uint8_t MSG_ANNOUNCE = 0x0B;
1699
1700
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if (len < STEPS_OFFSET + 2) {
1701 2 return;
1702 }
1703
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if ((data[0] & 0x0F) != MSG_ANNOUNCE) {
1704 ✗ return;
1705 }
1706
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if ((data[1] & 0x0F) != 2) { // versionPTP
1707 ✗ return;
1708 }
1709 // Other domains share the multicast group; their grandmaster is not ours.
1710
3/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 3 times.
4 if (data[4] != (uint8_t)GetPtpDomain()) {
1711 1 return;
1712 }
1713
1714 char gmId[24];
1715 3 snprintf(gmId, sizeof(gmId), "%02X-%02X-%02X-%02X-%02X-%02X-%02X-%02X",
1716 3 data[GM_ID_OFFSET], data[GM_ID_OFFSET + 1], data[GM_ID_OFFSET + 2],
1717 3 data[GM_ID_OFFSET + 3], data[GM_ID_OFFSET + 4], data[GM_ID_OFFSET + 5],
1718 3 data[GM_ID_OFFSET + 6], data[GM_ID_OFFSET + 7]);
1719
1720 3 uint16_t stepsRemoved = ((uint16_t)data[STEPS_OFFSET] << 8) | data[STEPS_OFFSET + 1];
1721
1722
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 int64_t nowMs = std::chrono::duration_cast<std::chrono::milliseconds>(
1723 6 std::chrono::steady_clock::now().time_since_epoch()).count();
1724
1725
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 std::lock_guard<std::mutex> lock(m_ptpAnnounceMutex);
1726
1727 // A boundary clock relays the domain under the real grandmaster's
1728 // identity, so its address is the nearest master rather than the clock
1729 // itself. Never let one overwrite an address heard direct from the
1730 // grandmaster -- that is the one the user is looking for.
1731
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
6 auto it = m_ptpAnnounceSources.find(gmId);
1732
7/8
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✓ Branch 7 taken 2 times.
4 if (it != m_ptpAnnounceSources.end() && it->second.direct && stepsRemoved > 0 &&
1733
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 (nowMs - it->second.lastSeenMs) < AES67::PTP_ANNOUNCE_STALE_MS) {
1734 1 return;
1735 }
1736
1737 2 PtpAnnounceSource src;
1738
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 src.address = senderAddr;
1739 2 src.direct = (stepsRemoved == 0);
1740 2 src.lastSeenMs = nowMs;
1741
1742 // Drop clocks that have stopped announcing. A domain holds a handful of
1743 // masters at most, but BMCA churn would otherwise accumulate them for the
1744 // life of the process.
1745
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 2 times.
3 for (auto i = m_ptpAnnounceSources.begin(); i != m_ptpAnnounceSources.end();) {
1746
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if ((nowMs - i->second.lastSeenMs) > 10 * AES67::PTP_ANNOUNCE_STALE_MS) {
1747 ✗ i = m_ptpAnnounceSources.erase(i);
1748 } else {
1749 1 ++i;
1750 }
1751 }
1752
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
4 it = m_ptpAnnounceSources.find(gmId); // the prune may have erased it
1753
4/6
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 2 times.
3 if (it == m_ptpAnnounceSources.end() &&
1754 1 m_ptpAnnounceSources.size() >= AES67::MAX_PTP_ANNOUNCE_SOURCES) {
1755 ✗ auto oldest = std::min_element(m_ptpAnnounceSources.begin(), m_ptpAnnounceSources.end(),
1756 ✗ [](const auto& a, const auto& b) {
1757 ✗ return a.second.lastSeenMs < b.second.lastSeenMs;
1758 });
1759 ✗ m_ptpAnnounceSources.erase(oldest);
1760 }
1761
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.
4 m_ptpAnnounceSources[gmId] = src;
1762
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 1 times.
3 }
1763
1764 10 bool AES67Manager::GetGrandmasterAddress(const std::string& gmId, std::string& address,
1765 bool& viaBoundary) {
1766
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 10 times.
10 if (gmId.empty()) {
1767 ✗ return false;
1768 }
1769
1770
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 int64_t nowMs = std::chrono::duration_cast<std::chrono::milliseconds>(
1771 20 std::chrono::steady_clock::now().time_since_epoch()).count();
1772
1773
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 std::lock_guard<std::mutex> lock(m_ptpAnnounceMutex);
1774
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 auto it = m_ptpAnnounceSources.find(gmId);
1775
2/2
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 3 times.
10 if (it == m_ptpAnnounceSources.end()) {
1776 7 return false;
1777 }
1778 // Stale means the clock we are reporting is no longer announcing from
1779 // there -- a changeover, or the listener socket having been shut out.
1780
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
3 if ((nowMs - it->second.lastSeenMs) > AES67::PTP_ANNOUNCE_STALE_MS) {
1781 ✗ return false;
1782 }
1783
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 address = it->second.address;
1784 3 viaBoundary = !it->second.direct;
1785 3 return true;
1786 10 }
1787
1788 36 void AES67Manager::PtpAnnounceListenLoop() {
1789
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 LogInfo(VB_MEDIAOUT, "AES67 PTP announce listener thread started\n");
1790
1791 36 int sock = OpenSocket(AF_INET, SOCK_DGRAM, 0);
1792
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 36 times.
36 if (sock < 0) {
1793 ✗ LogWarn(VB_MEDIAOUT, "AES67 PTP announce: socket failed: %s\n", FPPstrerror(errno));
1794 ✗ return;
1795 }
1796
1797 // SO_REUSEADDR only: ptp4l holds this port and multicast is delivered to
1798 // every socket bound to it. SO_REUSEPORT would instead put us in a
1799 // load-balancing group with ptp4l and steal its Announce messages.
1800 36 int reuse = 1;
1801 36 setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
1802
1803 struct sockaddr_in bindAddr;
1804 36 memset(&bindAddr, 0, sizeof(bindAddr));
1805 36 bindAddr.sin_family = AF_INET;
1806 36 bindAddr.sin_port = htons(AES67::PTP_GENERAL_PORT);
1807 36 bindAddr.sin_addr.s_addr = htonl(INADDR_ANY);
1808
1809
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 36 times.
36 if (bind(sock, (struct sockaddr*)&bindAddr, sizeof(bindAddr)) < 0) {
1810 // Not fatal to anything: PTP itself is ptp4l's socket, so all that is
1811 // lost here is the grandmaster's address in the status display.
1812 ✗ LogWarn(VB_MEDIAOUT, "AES67 PTP announce: bind to port %d failed (%s) — "
1813 "grandmaster address will not be shown\n",
1814 AES67::PTP_GENERAL_PORT, FPPstrerror(errno));
1815 ✗ close(sock);
1816 ✗ return;
1817 }
1818
1819 struct ip_mreq mreq;
1820 36 memset(&mreq, 0, sizeof(mreq));
1821 36 inet_pton(AF_INET, AES67::PTP_MCAST_ADDRESS, &mreq.imr_multiaddr);
1822
1823
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 std::string iface = GetPtpInterface();
1824
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 if (!iface.empty()) {
1825
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 std::string ifIP = GetInterfaceIP(iface);
1826 36 inet_pton(AF_INET, ifIP.c_str(), &mreq.imr_interface);
1827 36 } else {
1828 ✗ mreq.imr_interface.s_addr = htonl(INADDR_ANY);
1829 }
1830
1831
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 36 times.
36 if (setsockopt(sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) {
1832 ✗ LogWarn(VB_MEDIAOUT, "AES67 PTP announce: join %s failed: %s\n",
1833 AES67::PTP_MCAST_ADDRESS, FPPstrerror(errno));
1834 ✗ close(sock);
1835 ✗ return;
1836 }
1837
1838 // Bounds how long ShutdownPTP() waits on the join.
1839 struct timeval tv;
1840 36 tv.tv_sec = 1;
1841 36 tv.tv_usec = 0;
1842 36 setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
1843
1844 uint8_t buf[512];
1845
2/2
✓ Branch 0 taken 127 times.
✓ Branch 1 taken 36 times.
163 while (m_ptpAnnounceRunning.load()) {
1846 struct sockaddr_in senderAddr;
1847 127 socklen_t addrLen = sizeof(senderAddr);
1848
1849
1/2
✓ Branch 0 taken 127 times.
✗ Branch 1 not taken.
127 ssize_t n = recvfrom(sock, buf, sizeof(buf), 0,
1850 (struct sockaddr*)&senderAddr, &addrLen);
1851
2/2
✓ Branch 0 taken 123 times.
✓ Branch 1 taken 4 times.
127 if (n <= 0) continue; // timeout or error
1852
1853 char senderIP[INET_ADDRSTRLEN];
1854 4 inet_ntop(AF_INET, &senderAddr.sin_addr, senderIP, sizeof(senderIP));
1855
1856
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
8 HandlePtpAnnounce(buf, (size_t)n, senderIP);
1857 }
1858
1859 36 setsockopt(sock, IPPROTO_IP, IP_DROP_MEMBERSHIP, &mreq, sizeof(mreq));
1860
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 close(sock);
1861
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 LogInfo(VB_MEDIAOUT, "AES67 PTP announce listener thread stopped\n");
1862
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 }
1863
1864 1533 std::string AES67Manager::GetPTPClockId() {
1865 // Derive EUI-64 clock ID from interface MAC address
1866 // Read /sys/class/net/<iface>/address → AA:BB:CC:DD:EE:FF
1867 // Insert FF:FE → AA-BB-CC-FF-FE-DD-EE-FF
1868
3/6
✓ Branch 0 taken 1533 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1533 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1533 times.
✗ Branch 5 not taken.
1533 std::string macPath = "/sys/class/net/" + GetPtpInterface() + "/address";
1869
1/2
✓ Branch 0 taken 1533 times.
✗ Branch 1 not taken.
1533 std::ifstream macFile(macPath);
1870
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1533 times.
1533 if (!macFile.is_open()) {
1871 ✗ LogWarn(VB_MEDIAOUT, "AES67Manager: Cannot read MAC from %s\n", macPath.c_str());
1872 ✗ return "00-00-00-FF-FE-00-00-00";
1873 }
1874
1875 1533 std::string mac;
1876
1/2
✓ Branch 0 taken 1533 times.
✗ Branch 1 not taken.
1533 std::getline(macFile, mac);
1877
1/2
✓ Branch 0 taken 1533 times.
✗ Branch 1 not taken.
1533 macFile.close();
1878
1879 // Parse MAC: "aa:bb:cc:dd:ee:ff"
1880 1533 unsigned int m[6] = {0};
1881 1533 if (sscanf(mac.c_str(), "%x:%x:%x:%x:%x:%x",
1882
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1533 times.
1533 &m[0], &m[1], &m[2], &m[3], &m[4], &m[5]) != 6) {
1883 ✗ return "00-00-00-FF-FE-00-00-00";
1884 }
1885
1886 // EUI-64: m0-m1-m2-FF-FE-m3-m4-m5
1887 char eui64[24];
1888 1533 snprintf(eui64, sizeof(eui64), "%02X-%02X-%02X-FF-FE-%02X-%02X-%02X",
1889 m[0], m[1], m[2], m[3], m[4], m[5]);
1890
1/2
✓ Branch 0 taken 1533 times.
✗ Branch 1 not taken.
3066 return std::string(eui64);
1891 1533 }
1892
1893 // ──────────────────────────────────────────────────────────────────────────────
1894 // AES67 media clock
1895 //
1896 // AES67 requires the RTP timestamp to be the PTP media clock count, and the SDP
1897 // we publish asserts exactly that with "a=mediaclk:direct=0". Honouring it
1898 // means the pipeline has to be driven by PTP time itself, not by GStreamer's
1899 // default monotonic system clock.
1900 //
1901 // Which clock actually holds PTP time depends on how ptp4l is running, and it
1902 // has to be right in BOTH roles:
1903 //
1904 // hardware timestamping -- ptp4l's clock is the NIC's PHC. As a follower it
1905 // disciplines the PHC to the grandmaster; as grandmaster it distributes
1906 // the PHC's own time. Either way the PHC is the domain's time, so we
1907 // read the PHC.
1908 //
1909 // software timestamping -- there is no PHC; ptp4l uses CLOCK_REALTIME as its
1910 // clock, disciplining it as a follower and distributing it as
1911 // grandmaster. So CLOCK_REALTIME is the domain's time.
1912 //
1913 // Note what we deliberately do NOT use:
1914 //
1915 // CLOCK_TAI / GST_CLOCK_TYPE_TAI -- only correct while something is setting
1916 // the kernel TAI offset, which phc2sys does as a follower and not at all
1917 // as grandmaster. Measured on a grandmaster box: adjtimex offset 0 and
1918 // CLOCK_TAI == CLOCK_REALTIME, i.e. silently not PTP time.
1919 //
1920 // GstPtpClock (gst_ptp_init) -- would run gst-ptp-helper as a second PTP
1921 // client on the same domain alongside our ptp4l, adding a participant to
1922 // a network we just stopped FPP from disrupting.
1923 // ──────────────────────────────────────────────────────────────────────────────
1924
1925 // Linux exposes a dynamic POSIX clock for an open /dev/ptpN fd.
1926 #define FPP_FD_TO_CLOCKID(fd) ((clockid_t)((((unsigned int)~(fd)) << 3) | 3))
1927
1928 // Resolve the PHC backing an interface via ETHTOOL_GET_TS_INFO. Returns -1
1929 // when the NIC has no PHC (software timestamping), which is not an error.
1930 96 static int PhcIndexForInterface(const std::string& iface) {
1931 #ifndef FPP_AES67_HAS_ETHTOOL
1932 return -1;
1933 #else
1934
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 96 times.
96 if (iface.empty()) {
1935 ✗ return -1;
1936 }
1937 96 int sock = OpenSocket(AF_INET, SOCK_DGRAM, 0);
1938
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 96 times.
96 if (sock < 0) {
1939 ✗ return -1;
1940 }
1941 struct ethtool_ts_info tsi;
1942 96 memset(&tsi, 0, sizeof(tsi));
1943 96 tsi.cmd = ETHTOOL_GET_TS_INFO;
1944
1945 struct ifreq ifr;
1946 96 memset(&ifr, 0, sizeof(ifr));
1947 96 snprintf(ifr.ifr_name, sizeof(ifr.ifr_name), "%s", iface.c_str());
1948 96 ifr.ifr_data = (char*)&tsi;
1949
1950 96 int idx = -1;
1951
1/2
✓ Branch 0 taken 96 times.
✗ Branch 1 not taken.
96 if (ioctl(sock, SIOCETHTOOL, &ifr) == 0) {
1952 96 idx = tsi.phc_index;
1953 }
1954
1/2
✓ Branch 0 taken 96 times.
✗ Branch 1 not taken.
96 close(sock);
1955 96 return idx;
1956 #endif
1957 }
1958
1959 // GstClock reading PTP time. Subclasses GstSystemClock so that all of its
1960 // wait/scheduling machinery keeps working -- only the time source changes,
1961 // since GstSystemClock routes its waits through the virtual get_internal_time.
1962 struct FppPtpClock {
1963 GstSystemClock parent;
1964 clockid_t clkid; // PHC dynamic clockid, or CLOCK_REALTIME
1965 int phcFd; // open /dev/ptpN, or -1 when using CLOCK_REALTIME
1966 };
1967 struct FppPtpClockClass {
1968 GstSystemClockClass parent_class;
1969 };
1970
1971
6/7
✓ Branch 0 taken 43 times.
✓ Branch 1 taken 18 times.
✓ Branch 2 taken 43 times.
✓ Branch 3 taken 43 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 43 times.
✓ Branch 6 taken 18 times.
294 G_DEFINE_TYPE(FppPtpClock, fpp_ptp_clock, GST_TYPE_SYSTEM_CLOCK)
1972
1973 10086 static GstClockTime fpp_ptp_clock_get_internal_time(GstClock* clock) {
1974 10086 FppPtpClock* self = (FppPtpClock*)clock;
1975 struct timespec ts;
1976
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 10086 times.
10086 if (clock_gettime(self->clkid, &ts) != 0) {
1977 // Losing the clock mid-stream would wedge every waiting element, so
1978 // fall back rather than returning an error the caller cannot act on.
1979 ✗ clock_gettime(CLOCK_REALTIME, &ts);
1980 }
1981 10086 return GST_TIMESPEC_TO_TIME(ts);
1982 }
1983
1984 61 static void fpp_ptp_clock_finalize(GObject* object) {
1985 61 FppPtpClock* self = (FppPtpClock*)object;
1986
2/2
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 56 times.
61 if (self->phcFd >= 0) {
1987 5 close(self->phcFd);
1988 5 self->phcFd = -1;
1989 }
1990 61 G_OBJECT_CLASS(fpp_ptp_clock_parent_class)->finalize(object);
1991 61 }
1992
1993 43 static void fpp_ptp_clock_class_init(FppPtpClockClass* klass) {
1994 43 GST_CLOCK_CLASS(klass)->get_internal_time = fpp_ptp_clock_get_internal_time;
1995 43 G_OBJECT_CLASS(klass)->finalize = fpp_ptp_clock_finalize;
1996 43 }
1997
1998 61 static void fpp_ptp_clock_init(FppPtpClock* self) {
1999 61 self->clkid = CLOCK_REALTIME;
2000 61 self->phcFd = -1;
2001 61 }
2002
2003
2004 // Opens the PTP time source and wraps it in a GstClock. One clock is shared by
2005 // every send pipeline: they must all carry the same media timeline, and a
2006 // second clock object would mean a second set of rate estimates.
2007 168 GstClock* AES67Manager::GetOrCreateMediaClock() {
2008
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 std::lock_guard<std::mutex> lock(m_ptpClockMutex);
2009
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 165 times.
168 if (m_ptpClock) {
2010 3 return m_ptpClock;
2011 }
2012
3/4
✓ Branch 0 taken 165 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 104 times.
✓ Branch 3 taken 61 times.
165 if (!IsPtpEnabled()) {
2013 104 return nullptr;
2014 }
2015
2016
2/4
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 61 times.
✗ Branch 3 not taken.
61 FppPtpClock* clock = (FppPtpClock*)g_object_new(fpp_ptp_clock_get_type(),
2017 "name", "fppaes67ptpclock", NULL);
2018
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 61 times.
61 if (!clock) {
2019 ✗ return nullptr;
2020 }
2021 // A GstClock is born floating, and m_ptpClock is our own reference,
2022 // released in ReleaseMediaClock(). The pipelines it goes to only ref it
2023 // (gst_object_replace), so the counts balance today, but anything that
2024 // ref_sinks it would take our reference over and our unref would then
2025 // free a clock still in use. Sink it here, as its owner.
2026
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 gst_object_ref_sink(clock);
2027
2028
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 std::string iface = GetPtpInterface();
2029 // The PHC is the domain's time only while ptp4l disciplines it. A NIC
2030 // that has one but rejected hardware timestamping leaves ptp4l on
2031 // software timestamping (it steers CLOCK_REALTIME), and with ptp4l not
2032 // running at all CLOCK_REALTIME is at least NTP-steered -- a free-running
2033 // PHC is neither, and every box would drift on its own crystal.
2034
3/4
✓ Branch 0 taken 40 times.
✓ Branch 1 taken 21 times.
✓ Branch 2 taken 40 times.
✗ Branch 3 not taken.
61 int phcIndex = m_ptpHwTimestamping.load() ? PhcIndexForInterface(iface) : -1;
2035
7/8
✓ Branch 0 taken 56 times.
✓ Branch 1 taken 5 times.
✓ Branch 2 taken 56 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 55 times.
✓ Branch 6 taken 1 times.
✓ Branch 7 taken 60 times.
61 if (phcIndex < 0 && PhcIndexForInterface(iface) >= 0) {
2036
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "AES67 media clock: %s has a PHC, but ptp4l is not running with "
2037 "hardware timestamping, so it is not disciplined\n", iface.c_str());
2038 }
2039
2/2
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 56 times.
61 if (phcIndex >= 0) {
2040
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 std::string dev = "/dev/ptp" + std::to_string(phcIndex);
2041
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 int fd = open(dev.c_str(), O_RDONLY);
2042
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 if (fd >= 0) {
2043 5 clock->phcFd = fd;
2044 5 clock->clkid = FPP_FD_TO_CLOCKID(fd);
2045 // Prove it is readable before we hand it to a pipeline -- a PHC
2046 // that exists but will not answer would stall every element
2047 // waiting on it.
2048 struct timespec ts;
2049
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 if (clock_gettime(clock->clkid, &ts) == 0) {
2050
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 LogInfo(VB_MEDIAOUT, "AES67 media clock: using PHC %s (interface %s)\n",
2051 dev.c_str(), iface.c_str());
2052
2/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
5 m_ptpClock = GST_CLOCK(clock);
2053 5 return m_ptpClock;
2054 }
2055 ✗ LogWarn(VB_MEDIAOUT, "AES67 media clock: %s is not readable (%s), "
2056 "falling back to CLOCK_REALTIME\n", dev.c_str(), FPPstrerror(errno));
2057 ✗ close(fd);
2058 ✗ clock->phcFd = -1;
2059 } else {
2060 ✗ LogWarn(VB_MEDIAOUT, "AES67 media clock: cannot open %s (%s), "
2061 "falling back to CLOCK_REALTIME\n", dev.c_str(), FPPstrerror(errno));
2062 }
2063
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 5 times.
5 }
2064
2065 // Software-timestamping path: ptp4l has no PHC and uses CLOCK_REALTIME as
2066 // its own clock, so that is the domain's time in both roles.
2067 56 clock->clkid = CLOCK_REALTIME;
2068
1/2
✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
56 LogInfo(VB_MEDIAOUT, "AES67 media clock: using CLOCK_REALTIME "
2069 "(no disciplined PHC on %s — software timestamping)\n", iface.c_str());
2070
2/4
✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 56 times.
✗ Branch 3 not taken.
56 m_ptpClock = GST_CLOCK(clock);
2071 56 return m_ptpClock;
2072 168 }
2073
2074 208 void AES67Manager::ReleaseMediaClock() {
2075
1/2
✓ Branch 0 taken 208 times.
✗ Branch 1 not taken.
208 std::lock_guard<std::mutex> lock(m_ptpClockMutex);
2076
2/2
✓ Branch 0 taken 61 times.
✓ Branch 1 taken 147 times.
208 if (m_ptpClock) {
2077
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 gst_object_unref(m_ptpClock);
2078 61 m_ptpClock = nullptr;
2079 }
2080 208 }
2081
2082 // ──────────────────────────────────────────────────────────────────────────────
2083 // Pipeline creation — Send
2084 // ──────────────────────────────────────────────────────────────────────────────
2085
2086 // Source-gap instrumentation, deliberately independent of every config switch.
2087 //
2088 // The same measurement used to live inside DriftResampleProbe, which meant it
2089 // only existed when ptpMediaClock and driftResample were both on -- and those
2090 // are exactly the flags a diagnostic needs to turn off. Testing whether the
2091 // PTP pipeline clock is what drains the source pool was impossible for that
2092 // reason: turning off the clock also turned off the instrument.
2093 //
2094 // Sits directly on pipewiresrc's src pad so it sees the source's own timeline
2095 // before any conversion, and reports each PTS discontinuity against the buffer
2096 // index it happened at. The pool empties after (N-1) quanta of accumulated
2097 // drift -- N with always-copy, which frees the slot a wrapped buffer would
2098 // hold through the chain -- and from then on the source skips one graph cycle
2099 // per rotation. Both the onset and the spacing are read off these lines.
2100 struct SourceGapState {
2101 int instanceId = 0;
2102 int rate = AES67::AUDIO_RATE;
2103 GstClockTime lastPts = 0;
2104 guint64 lastDur = 0;
2105 bool have = false;
2106 guint64 seen = 0;
2107 guint64 gaps = 0;
2108 };
2109
2110 168 static void DestroySourceGapState(gpointer data) {
2111
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 delete static_cast<SourceGapState*>(data);
2112 168 }
2113
2114 9463 static GstPadProbeReturn SourceGapProbe(GstPad* pad, GstPadProbeInfo* info,
2115 gpointer user) {
2116 9463 auto* st = static_cast<SourceGapState*>(user);
2117 9463 GstBuffer* buf = GST_PAD_PROBE_INFO_BUFFER(info);
2118
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 9463 times.
9463 if (!buf) {
2119 ✗ return GST_PAD_PROBE_OK;
2120 }
2121 9463 st->seen++;
2122
1/2
✓ Branch 0 taken 9463 times.
✗ Branch 1 not taken.
9463 if (GST_BUFFER_PTS_IS_VALID(buf)) {
2123 9463 const GstClockTime pts = GST_BUFFER_PTS(buf);
2124
3/4
✓ Branch 0 taken 9443 times.
✓ Branch 1 taken 20 times.
✓ Branch 2 taken 9443 times.
✗ Branch 3 not taken.
9463 if (st->have && pts > st->lastPts) {
2125 9443 const gint64 jump = (gint64)(pts - st->lastPts) - (gint64)st->lastDur;
2126
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 9440 times.
9443 if (jump > (gint64)GST_MSECOND) {
2127 3 st->gaps++;
2128 3 LogInfo(VB_MEDIAOUT,
2129 "AES67 source [%d]: gap %+.2f ms at buffer %llu "
2130 "(gap #%llu)\n",
2131 st->instanceId, (double)jump / (double)GST_MSECOND,
2132 (unsigned long long)st->seen,
2133 (unsigned long long)st->gaps);
2134 }
2135 }
2136 9463 st->lastPts = pts;
2137 18926 st->lastDur = GST_BUFFER_DURATION_IS_VALID(buf)
2138
1/2
✓ Branch 0 taken 9463 times.
✗ Branch 1 not taken.
9463 ? GST_BUFFER_DURATION(buf)
2139 : 0;
2140 9463 st->have = GST_BUFFER_DURATION_IS_VALID(buf);
2141 }
2142 9463 return GST_PAD_PROBE_OK;
2143 }
2144
2145 #ifdef FPP_HAVE_SAMPLERATE
2146 // ─────────────────────────────────────────────────────────────────────────────
2147 // Media clock drift correction
2148 //
2149 // The audio is produced on the sound card's crystal and the RTP timeline runs
2150 // on the NIC's PHC; on the reference hardware those differ by ~56ppm, which is
2151 // 3.4ms per minute. That empties any receiver's buffer, and it empties the
2152 // sinkPacing queue, so it has to be corrected in the sender.
2153 //
2154 // Three GStreamer elements were tried first and all three are recorded as dead
2155 // ends in AES67Config: "speed" and "pitch" accept a rate property and never
2156 // apply it to a live stream, and "audiorate" reconciles per-buffer timestamp
2157 // rounding rather than drift and then runs away. libsamplerate is used
2158 // instead because it is built for exactly this: src_process() takes a ratio per
2159 // block, interpolates it across the block so there is no zipper, and keeps a
2160 // fractional read pointer across calls.
2161 //
2162 // The control law deliberately leads with a direct measurement rather than a
2163 // feedback loop, because every previous attempt here oscillated:
2164 //
2165 // feedforward -- the card's true rate is (input frames / PHC seconds), which
2166 // is two crystals and therefore almost perfectly constant. The ratio that
2167 // cancels it is just AUDIO_RATE / that. This carries essentially all of
2168 // the correction and needs no gain.
2169 // feedback -- a small proportional term bleeds off accumulated offset, so a
2170 // startup transient or a rounding residue does not persist. It is
2171 // deliberately weak; it is a trim, not the controller.
2172 //
2173 // The failure that killed audiorate is handled explicitly: on DISCONT the
2174 // counters re-anchor rather than treating the gap as drift to be made up.
2175 struct DriftResampleState {
2176 SRC_STATE* src = nullptr;
2177 int channels = 2;
2178 GstClock* clock = nullptr;
2179 int instanceId = 0;
2180
2181 // Output timeline: monotonic, only re-anchored on a real discontinuity.
2182 bool anchored = false;
2183 GstClockTime anchorPts = 0;
2184 guint64 ptsFrames = 0;
2185
2186 // Control counters: zeroed again once the pipeline is up to speed. Keeping
2187 // these separate is the point -- a pipeline takes a moment to start
2188 // flowing, and counting that startup gap as drift makes the loop chase a
2189 // deficit it did not cause (measured: -188ms, which pinned the trim at the
2190 // clamp and ran the stream 2000ppm fast for minutes).
2191 bool warmed = false;
2192 GstClockTime ctlClock = 0;
2193 guint64 ctlIn = 0;
2194 guint64 ctlOut = 0;
2195 double ratio = 1.0;
2196
2197 std::vector<float> out;
2198 guint64 buffers = 0;
2199 guint64 shortReads = 0;
2200
2201 // Onset instrumentation for the periodic under-delivery. The absorb path
2202 // below only reports gaps over 50ms, because that is the threshold that
2203 // matters to the control loop -- but that hides when a burst actually
2204 // starts, since the first gaps can be a single graph quantum. Measuring
2205 // from the >50ms line put the onset at 275k-284k buffers across seven
2206 // runs; the 3% spread is most likely this bias, not the mechanism. These
2207 // fields report every discontinuity against the buffer index it happened
2208 // at, which is what separates a fixed-count trigger from an accumulation.
2209 GstClockTime lastPts = 0;
2210 long lastFrames = 0;
2211 bool lastPtsValid = false;
2212 guint64 seen = 0;
2213 guint64 gapsSeen = 0;
2214
2215 // Buffer index the last re-anchor happened at.
2216 guint64 lastResyncAt = 0;
2217 bool everResynced = false;
2218
2219 // Frames of silence owed to the timeline, from a source gap not yet filled.
2220 // See AES67Config::sourceSilenceFloor.
2221 bool fillGaps = false;
2222 guint64 fillFrames = 0;
2223
2224 // Rate estimate that survives a re-anchor. The card's rate against PTP is
2225 // a property of two crystals: it does not change because a track did. The
2226 // sliding window that measures it does get discarded at every re-anchor,
2227 // so without somewhere to keep the answer the loop has to re-learn a 54ppm
2228 // figure from scratch after every track boundary.
2229 double cardRateEst = 0.0;
2230
2231
2232 // Sliding window of (clock, input frames, output frames) used to measure
2233 // the card's rate. A cumulative average was used here first and it is what
2234 // made the loop oscillate: it is itself an integrator, so with the phase
2235 // feedback -- which integrates too -- the loop had two in series. It also
2236 // grew steadily less responsive as the run went on, which showed up as a
2237 // trim still crawling towards its answer 26 minutes in.
2238 struct Sample {
2239 GstClockTime t;
2240 guint64 in;
2241 guint64 out;
2242 };
2243 std::deque<Sample> window;
2244 };
2245
2246 63 static void DestroyDriftResampleState(gpointer data) {
2247 63 auto* st = static_cast<DriftResampleState*>(data);
2248
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 if (st->src) {
2249 63 src_delete(st->src);
2250 }
2251
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 if (st->clock) {
2252 63 gst_object_unref(st->clock);
2253 }
2254
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 delete st;
2255 63 }
2256
2257 9171 static GstPadProbeReturn DriftResampleProbe(GstPad* pad, GstPadProbeInfo* info,
2258 gpointer user) {
2259 9171 auto* st = static_cast<DriftResampleState*>(user);
2260 9171 const bool fillGaps = st->fillGaps;
2261 9171 GstBuffer* buf = GST_PAD_PROBE_INFO_BUFFER(info);
2262
3/6
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9171 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 9171 times.
9171 if (!buf || !st->src || !st->clock) {
2263 ✗ return GST_PAD_PROBE_OK;
2264 }
2265
2266 // A gap is not drift. Re-anchor and let the resampler start clean, rather
2267 // than trying to make up time that was never ours to make up.
2268
2/2
✓ Branch 0 taken 19 times.
✓ Branch 1 taken 9152 times.
9171 if (GST_BUFFER_FLAG_IS_SET(buf, GST_BUFFER_FLAG_DISCONT)) {
2269 // Worth logging: this resets the resampler and restarts the rate
2270 // estimate, and it was happening every few minutes with nothing in the
2271 // log to say so -- the only visible sign was the loop's own elapsed
2272 // counter starting over.
2273
1/2
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
19 LogInfo(VB_MEDIAOUT,
2274 "AES67 drift [%d]: source discontinuity, resetting\n",
2275 st->instanceId);
2276
1/2
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
19 src_reset(st->src);
2277 19 st->anchored = false;
2278 }
2279
2280 GstMapInfo map;
2281
2/4
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 9171 times.
9171 if (!gst_buffer_map(buf, &map, GST_MAP_READ)) {
2282 ✗ return GST_PAD_PROBE_OK;
2283 }
2284 9171 const int ch = st->channels;
2285 9171 const long inFrames = (long)(map.size / (sizeof(float) * ch));
2286
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 9171 times.
9171 if (inFrames <= 0) {
2287 ✗ gst_buffer_unmap(buf, &map);
2288 ✗ return GST_PAD_PROBE_OK;
2289 }
2290
2291 // Report source-side PTS discontinuities at full resolution, tagged with
2292 // the buffer index so the onset can be located exactly. 1ms is chosen to
2293 // sit far below one quantum (21.3ms at 48000, 23.2ms at 44100) while
2294 // staying above any rounding in the PTS arithmetic; every gap observed so
2295 // far has been an exact multiple of the quantum, most often 3x.
2296 9171 st->seen++;
2297
1/2
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
9171 if (GST_BUFFER_PTS_IS_VALID(buf)) {
2298 9171 const GstClockTime pts = GST_BUFFER_PTS(buf);
2299
3/4
✓ Branch 0 taken 9152 times.
✓ Branch 1 taken 19 times.
✓ Branch 2 taken 9152 times.
✗ Branch 3 not taken.
9171 if (st->lastPtsValid && pts > st->lastPts) {
2300 9152 const gint64 expected =
2301 9152 (gint64)st->lastFrames * GST_SECOND / AES67::AUDIO_RATE;
2302 9152 const gint64 jump = (gint64)(pts - st->lastPts) - expected;
2303
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 9149 times.
9152 if (jump > (gint64)GST_MSECOND) {
2304 3 st->gapsSeen++;
2305 // Owe the timeline this much silence. The RTP timestamp is a
2306 // sample count, so audio that never arrives puts the media
2307 // clock permanently behind PTP -- it cannot be corrected
2308 // later, because the samples are not late, they are absent.
2309 // Filling keeps the count aligned and keeps AES67 in step with
2310 // the local sound card, which already plays the same pause.
2311 // Capped so a pathological jump cannot ask for a huge
2312 // allocation; anything longer is a stream restart, not a gap.
2313
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 if (fillGaps) {
2314
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 const gint64 f = gst_util_uint64_scale(
2315 3 (guint64)jump, AES67::AUDIO_RATE, GST_SECOND);
2316 6 st->fillFrames = (guint64)std::min<gint64>(
2317 3 f, (gint64)AES67::AUDIO_RATE); // <= 1 second
2318 }
2319
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 LogInfo(VB_MEDIAOUT,
2320 "AES67 drift [%d]: source gap %+.2f ms at buffer %llu "
2321 "(gap #%llu)\n",
2322 st->instanceId, (double)jump / (double)GST_MSECOND,
2323 (unsigned long long)st->seen,
2324 (unsigned long long)st->gapsSeen);
2325 }
2326 }
2327 9171 st->lastPts = pts;
2328 9171 st->lastFrames = inFrames;
2329 9171 st->lastPtsValid = true;
2330 }
2331
2332
1/2
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
9171 const GstClockTime now = gst_clock_get_time(st->clock);
2333
2/2
✓ Branch 0 taken 19 times.
✓ Branch 1 taken 9152 times.
9171 if (!st->anchored) {
2334 19 st->anchored = true;
2335
1/2
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
19 st->anchorPts = GST_BUFFER_PTS_IS_VALID(buf) ? GST_BUFFER_PTS(buf) : 0;
2336 19 st->ptsFrames = 0;
2337 19 st->warmed = false;
2338 19 st->ctlClock = now;
2339 19 st->ctlIn = 0;
2340 19 st->ctlOut = 0;
2341 19 st->ratio = 1.0;
2342 }
2343
2344 // A PTP clock can step backwards, which happens when ptp4l first locks to
2345 // a grandmaster -- i.e. only when this device is a follower, never when it
2346 // is the grandmaster itself. Without this the elapsed time pins at zero
2347 // until the clock catches back up to where it was, and for a large step
2348 // (a TAI/UTC correction is 37 seconds) the loop would sit idle that whole
2349 // time. Re-anchoring costs nothing: the learned trim is kept, and only
2350 // the measurement window restarts.
2351
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 9171 times.
9171 if (now < st->ctlClock) {
2352 ✗ LogInfo(VB_MEDIAOUT,
2353 "AES67 drift [%d]: clock stepped back, re-anchoring\n",
2354 st->instanceId);
2355 ✗ st->ctlClock = now;
2356 ✗ st->ctlIn = 0;
2357 ✗ st->ctlOut = 0;
2358 ✗ st->warmed = false;
2359 ✗ st->window.clear();
2360 }
2361
2362 9171 double elapsed =
2363
2/2
✓ Branch 0 taken 9152 times.
✓ Branch 1 taken 19 times.
9171 (now > st->ctlClock) ? (double)(now - st->ctlClock) / GST_SECOND : 0.0;
2364
2365 // Start controlling from a steady state, not from the pipeline's first
2366 // gasp. Everything before this is discarded rather than corrected.
2367
4/4
✓ Branch 0 taken 2342 times.
✓ Branch 1 taken 6829 times.
✓ Branch 2 taken 7 times.
✓ Branch 3 taken 2335 times.
9171 if (!st->warmed && elapsed > 5.0) {
2368 7 st->warmed = true;
2369 7 st->ctlClock = now;
2370 7 st->ctlIn = 0;
2371 7 st->ctlOut = 0;
2372 7 st->window.clear();
2373 7 elapsed = 0.0;
2374 }
2375
2376 // A large offset is a gap, not drift, and chasing it is the exact mistake
2377 // that made audiorate unusable. Absorb it instead: realign the output
2378 // timeline to PTP, restart the rate estimate, and keep the trim already
2379 // learned. Measured before this was added: a ~200ms startup gap held the
2380 // trim at its clamp for minutes, audibly pitching the stream while it
2381 // clawed back time that was never lost to drift.
2382 9171 constexpr double MAX_OFFSET_S = 0.05;
2383 9171 bool resync = false;
2384 18342 double err = st->warmed
2385
2/2
✓ Branch 0 taken 6836 times.
✓ Branch 1 taken 2335 times.
9171 ? (double)st->ctlOut - (double)AES67::AUDIO_RATE * elapsed
2386 : 0.0;
2387 // Do not absorb a gap we are about to fill. Absorbing re-anchors the
2388 // timeline to skip the hole; filling puts the missing samples back. Doing
2389 // both means the loop re-anchors and then the fill pushes the timeline
2390 // again -- measured, 4 absorptions still fired alongside 8 successful fills
2391 // and the media clock stayed at -54.7 ppm. Filling is the correct response
2392 // when it is available, so it wins.
2393
4/4
✓ Branch 0 taken 6836 times.
✓ Branch 1 taken 2335 times.
✓ Branch 2 taken 6834 times.
✓ Branch 3 taken 2 times.
9171 if (st->warmed && st->fillFrames == 0 &&
2394
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 6834 times.
6834 std::fabs(err) > MAX_OFFSET_S * AES67::AUDIO_RATE) {
2395 ✗ LogInfo(VB_MEDIAOUT,
2396 "AES67 drift [%d]: %+.0f ms gap absorbed, re-anchoring "
2397 "at buffer %llu\n",
2398 st->instanceId, err * 1000.0 / AES67::AUDIO_RATE,
2399 (unsigned long long)st->seen);
2400 ✗ st->ctlClock = now;
2401 ✗ st->ctlIn = 0;
2402 ✗ st->ctlOut = 0;
2403 ✗ st->window.clear();
2404 ✗ if (GST_BUFFER_PTS_IS_VALID(buf)) {
2405 ✗ st->anchorPts = GST_BUFFER_PTS(buf);
2406 }
2407 ✗ st->ptsFrames = 0;
2408 ✗ elapsed = 0.0;
2409 ✗ err = 0.0;
2410 ✗ resync = true;
2411 // Re-warm rather than resume controlling straight away. Re-anchoring
2412 // rewrites the output timeline, and the buffers right after it measure
2413 // that as a fresh offset -- so absorbing a gap and immediately
2414 // controlling again makes the loop re-trigger on itself. Measured
2415 // under a 25s file on repeat it re-anchored three times in four
2416 // buffers, and a fixed buffer hold-off only paced the oscillation
2417 // rather than stopping it: the spacing histogram came back with 526
2418 // re-anchors at exactly the hold-off length.
2419 //
2420 // The warm-up path above already does the right thing -- it waits 5s,
2421 // discards what it saw meanwhile, and starts the measurement clean --
2422 // and the learned trim is deliberately kept across it, so rate
2423 // correction continues at the last good value while adaptation pauses.
2424 ✗ st->warmed = false;
2425 ✗ st->lastResyncAt = st->seen;
2426 ✗ st->everResynced = true;
2427 }
2428
2429 // Hold the trim already learned rather than snapping back to 1.0 whenever
2430 // there is not yet enough data. The rate being corrected is a property of
2431 // two crystals, so the last good value is always a better guess than "no
2432 // correction" -- and resetting it is what turned a burst of absorbed gaps
2433 // into a death spiral: trim collapsed to 0, the pacing queue then drained
2434 // for want of correction, the drain starved the source into more gaps, and
2435 // those gaps absorbed away every chance to re-learn the rate. Measured:
2436 // 9 minutes of correct operation, then trim pinned at 0.0ppm with the send
2437 // rate falling from 250/s to 155/s and absorptions climbing past 3500.
2438 9171 double target = st->ratio;
2439 9171 double cardRate = 0.0;
2440
2/2
✓ Branch 0 taken 6836 times.
✓ Branch 1 taken 2335 times.
9171 if (st->warmed) {
2441 6836 constexpr double WINDOW_S = 60.0;
2442
1/2
✓ Branch 0 taken 6836 times.
✗ Branch 1 not taken.
6836 st->window.push_back({now, st->ctlIn, st->ctlOut});
2443
3/4
✓ Branch 0 taken 6829 times.
✓ Branch 1 taken 7 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 6836 times.
13665 while (st->window.size() > 1 &&
2444
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 6829 times.
6829 (double)(now - st->window.front().t) / GST_SECOND > WINDOW_S) {
2445 ✗ st->window.pop_front();
2446 }
2447
2448 6836 const auto& a = st->window.front();
2449 6836 const double span = (double)(now - a.t) / GST_SECOND;
2450
2451 // Long enough to actually resolve the figure being measured. 54ppm
2452 // over 5 seconds is 270us, which sample quantisation and normal jitter
2453 // swamp -- measured, a 5s minimum left trim thrashing between +59, +29
2454 // and +22 ppm after every track boundary. Over 30s the same drift is
2455 // 1.6ms and stands clear of the noise.
2456 // Two thresholds, because one cannot serve both jobs. Updating a
2457 // settled estimate wants a long window: 54ppm over 5s is 270us, which
2458 // sample quantisation and jitter swamp, and a 5s minimum left trim
2459 // thrashing between +59, +29 and +22 ppm after every track boundary.
2460 // But bootstrapping wants a short one -- a re-anchor clears the window,
2461 // so if tracks change every 25s a 30s minimum is never reached at all
2462 // and the estimate stays unset forever. Measured with only the long
2463 // threshold: trim sat at +0.0 ppm with no feedforward and the media
2464 // clock ran to -4442 ppm.
2465
2/2
✓ Branch 0 taken 4371 times.
✓ Branch 1 taken 2465 times.
6836 const double MIN_ESTIMATE_S = (st->cardRateEst > 1000.0) ? 30.0 : 10.0;
2466
2/2
✓ Branch 0 taken 2401 times.
✓ Branch 1 taken 4435 times.
6836 if (span > MIN_ESTIMATE_S) {
2467 // Feedforward over the window, not since the anchor. The card's
2468 // rate against PTP is two crystals, so this is near-constant; the
2469 // window only keeps the estimate from being anchored to whatever
2470 // the first few seconds happened to look like.
2471 2401 const double raw = (double)(st->ctlIn - a.in) / span;
2472
1/2
✓ Branch 0 taken 2401 times.
✗ Branch 1 not taken.
2401 if (raw > 1000.0) {
2473 // Smooth into the persistent estimate rather than replacing it,
2474 // so one noisy window cannot move the trim far.
2475 2401 st->cardRateEst = (st->cardRateEst > 1000.0)
2476
2/2
✓ Branch 0 taken 2398 times.
✓ Branch 1 taken 3 times.
2401 ? st->cardRateEst * 0.8 + raw * 0.2
2477 : raw;
2478 }
2479 }
2480 // Use the persistent estimate, which is still valid across the gap the
2481 // window just lost. A track change disturbs phase, not rate.
2482 6836 cardRate = st->cardRateEst;
2483 }
2484
2485
2/2
✓ Branch 0 taken 4374 times.
✓ Branch 1 taken 4797 times.
9171 if (cardRate > 1000.0) {
2486 4374 const double ff = (double)AES67::AUDIO_RATE / cardRate;
2487
2488 // Phase feedback: pull the accumulated offset back towards zero with a
2489 // first-order lag. The plant is already an integrator (a rate error
2490 // accumulates into offset), so proportional is the right shape and a
2491 // second integrator here is what previously rang. Clamping it well
2492 // below the feedforward guarantees it can trim but never take over.
2493 // Gain set by measurement, not by theory. At 3000 the loop settled
2494 // with a standing 23ms offset it would not remove; 30000 drives it to
2495 // +/-0.05ms and holds there, with the trim still sitting on the
2496 // feedforward value. Offset then decays with a ~33s time constant.
2497 4374 constexpr double KP_PPM_PER_SEC = 30000.0;
2498 // 100ppm is 0.17 cents -- inaudible -- and well under the ~55ppm
2499 // feedforward, so this can trim but never take over.
2500 4374 constexpr double MAX_FEEDBACK_PPM = 100.0;
2501 4374 double fbPpm = -KP_PPM_PER_SEC * err / (double)AES67::AUDIO_RATE;
2502
1/2
✓ Branch 0 taken 4374 times.
✗ Branch 1 not taken.
4374 fbPpm = std::clamp(fbPpm, -MAX_FEEDBACK_PPM, MAX_FEEDBACK_PPM);
2503
2504 4374 target = ff * (1.0 + fbPpm * 1e-6);
2505 }
2506
2507 // Hard clamp, then slew limit. 300ppm is far beyond any real crystal pair
2508 // (the reference hardware needs 56ppm), so hitting the clamp means the
2509 // estimate is wrong and the right response is to refuse to act on it
2510 // rather than to pitch-shift the audio chasing it.
2511 9171 constexpr double MAX_TRIM = 0.0003; // +/-300ppm
2512 9171 constexpr double MAX_STEP = 0.000002; // 2ppm per buffer
2513
1/2
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
9171 target = std::clamp(target, 1.0 - MAX_TRIM, 1.0 + MAX_TRIM);
2514
1/2
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
9171 st->ratio = std::clamp(target, st->ratio - MAX_STEP, st->ratio + MAX_STEP);
2515
2516 9171 const size_t capacity = (size_t)(inFrames / st->ratio) + 32;
2517
2/2
✓ Branch 0 taken 20 times.
✓ Branch 1 taken 9151 times.
9171 if (st->out.size() < capacity * ch) {
2518
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 st->out.resize(capacity * ch);
2519 }
2520
2521 SRC_DATA d;
2522 9171 memset(&d, 0, sizeof(d));
2523 9171 d.data_in = reinterpret_cast<const float*>(map.data);
2524 9171 d.input_frames = inFrames;
2525 9171 d.data_out = st->out.data();
2526 9171 d.output_frames = (long)capacity;
2527 9171 d.src_ratio = st->ratio;
2528 9171 d.end_of_input = 0;
2529
2530
1/2
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
9171 const int rc = src_process(st->src, &d);
2531
1/2
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
9171 gst_buffer_unmap(buf, &map);
2532
2533
2/4
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 9171 times.
9171 if (rc != 0 || d.output_frames_gen <= 0) {
2534 ✗ if (rc != 0) {
2535 ✗ LogWarn(VB_MEDIAOUT, "AES67 drift [%d]: src_process: %s\n",
2536 st->instanceId, src_strerror(rc));
2537 }
2538 ✗ return GST_PAD_PROBE_OK;
2539 }
2540
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 9171 times.
9171 if (d.input_frames_used < inFrames) {
2541 // Would mean silently discarding input, so it is counted rather than
2542 // ignored; the output buffer is sized so this should never happen.
2543 ✗ st->shortReads++;
2544 }
2545
2546 // Any silence owed from a source gap goes ahead of this buffer's audio, so
2547 // the sample count downstream stays continuous across a track change.
2548 9171 const size_t fill = (size_t)st->fillFrames;
2549 9171 const size_t fillBytes = fill * ch * sizeof(float);
2550 9171 const size_t bytes = (size_t)d.output_frames_gen * ch * sizeof(float);
2551
1/2
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
9171 GstBuffer* out = gst_buffer_new_allocate(nullptr, fillBytes + bytes, nullptr);
2552
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 9171 times.
9171 if (!out) {
2553 ✗ return GST_PAD_PROBE_OK;
2554 }
2555
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 9168 times.
9171 if (fill) {
2556
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 gst_buffer_memset(out, 0, 0, fillBytes);
2557
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 LogInfo(VB_MEDIAOUT,
2558 "AES67 drift [%d]: filled %.1f ms of source gap with silence\n",
2559 st->instanceId,
2560 (double)fill * 1000.0 / (double)AES67::AUDIO_RATE);
2561 3 st->fillFrames = 0;
2562 }
2563
1/2
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
9171 gst_buffer_fill(out, fillBytes, st->out.data(), bytes);
2564
2565 // Timestamp from the output sample count, so the timeline downstream
2566 // advances at exactly AUDIO_RATE. Producing AUDIO_RATE samples per PHC
2567 // second is what the loop above enforces, so media time tracks PTP.
2568 9171 GST_BUFFER_PTS(out) =
2569
1/2
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
9171 st->anchorPts + gst_util_uint64_scale(st->ptsFrames, GST_SECOND,
2570 AES67::AUDIO_RATE);
2571 18342 GST_BUFFER_DURATION(out) = gst_util_uint64_scale(
2572
1/2
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
9171 (guint64)d.output_frames_gen, GST_SECOND, AES67::AUDIO_RATE);
2573
3/4
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✓ Branch 3 taken 9152 times.
9171 if (resync || GST_BUFFER_FLAG_IS_SET(buf, GST_BUFFER_FLAG_DISCONT)) {
2574 19 GST_BUFFER_FLAG_SET(out, GST_BUFFER_FLAG_DISCONT);
2575 }
2576
2577 9171 st->ptsFrames += (guint64)fill + (guint64)d.output_frames_gen;
2578 // Count the filled silence as input as well as output. It is synthetic
2579 // input standing in for audio that never arrived, so leaving it out of the
2580 // input side makes the loop measure a source that is slow by exactly the
2581 // gaps -- measured, cardRate read 47235/s (-15926 ppm) and the trim pinned
2582 // at its +300 ppm clamp with the offset growing 11.5 -> 22.9 ms, even
2583 // though the wire was correct at +1.2 ppm. Counting it on both sides keeps
2584 // the rate estimate honest: after filling, the effective input really is
2585 // aligned to real time.
2586 9171 st->ctlIn += (guint64)fill + (guint64)d.input_frames_used;
2587 9171 st->ctlOut += (guint64)fill + (guint64)d.output_frames_gen;
2588
2589
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 9169 times.
9171 if ((++st->buffers % 2000) == 0) {
2590
3/6
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
2 LogInfo(VB_MEDIAOUT,
2591 "AES67 drift [%d]: trim %+.1f ppm, card %.1f/s (%+.1f ppm), offset %+.2f ms over %.0fs%s [buffer %llu, %llu gaps]\n",
2592 st->instanceId, (st->ratio - 1.0) * 1e6, cardRate,
2593 cardRate > 1000.0
2594 ? (cardRate / (double)AES67::AUDIO_RATE - 1.0) * 1e6
2595 : 0.0,
2596 // err as the controller saw it. Recomputing here instead
2597 // reads one buffer high -- a whole graph quantum, 23.2ms on a
2598 // 1024/44100 graph -- which looked exactly like a stuck offset
2599 // the loop was failing to correct.
2600 err * 1000.0 / AES67::AUDIO_RATE,
2601 elapsed,
2602 st->shortReads ? " (SHORT READS)" : "",
2603 (unsigned long long)st->seen,
2604 (unsigned long long)st->gapsSeen);
2605 }
2606
2607
1/2
✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
9171 gst_buffer_unref(buf);
2608 9171 GST_PAD_PROBE_INFO_DATA(info) = out;
2609 9171 return GST_PAD_PROBE_OK;
2610 }
2611 #endif // FPP_HAVE_SAMPLERATE
2612
2613
2614 // Byte counters at both ends of the send pipeline. See
2615 // AES67Config::pipelineStats.
2616 static int PipeWireGraphRate();
2617
2618 struct PipelineStatsState {
2619 int instanceId = 0;
2620 guint64 inBytes = 0;
2621 guint64 outBytes = 0;
2622 GstClockTime start = 0;
2623 GstClockTime lastLog = 0;
2624 guint64 inAtLast = 0;
2625 guint64 outAtLast = 0;
2626 };
2627
2628 ✗ static void DestroyPipelineStatsState(gpointer data) {
2629 ✗ delete static_cast<PipelineStatsState*>(data);
2630 ✗ }
2631
2632 ✗ static GstPadProbeReturn PipelineStatsIn(GstPad*, GstPadProbeInfo* info,
2633 gpointer user) {
2634 ✗ auto* st = static_cast<PipelineStatsState*>(user);
2635 ✗ GstBuffer* b = GST_PAD_PROBE_INFO_BUFFER(info);
2636 ✗ if (!b) {
2637 ✗ return GST_PAD_PROBE_OK;
2638 }
2639 ✗ st->inBytes += gst_buffer_get_size(b);
2640
2641 ✗ const GstClockTime now = gst_util_get_timestamp();
2642 ✗ if (st->start == 0) {
2643 ✗ st->start = now;
2644 ✗ st->lastLog = now;
2645 ✗ return GST_PAD_PROBE_OK;
2646 }
2647 ✗ if (now - st->lastLog < 30 * GST_SECOND) {
2648 ✗ return GST_PAD_PROBE_OK;
2649 }
2650 ✗ const double dt = (double)(now - st->lastLog) / GST_SECOND;
2651 ✗ LogInfo(VB_MEDIAOUT,
2652 "AES67 stats [%d]: pipewiresrc %.0f B/s, udpsink %.0f B/s, "
2653 "ratio %.3f, totals in %llu out %llu\n",
2654 st->instanceId, (double)(st->inBytes - st->inAtLast) / dt,
2655 (double)(st->outBytes - st->outAtLast) / dt,
2656 (st->inBytes > st->inAtLast)
2657 ? (double)(st->outBytes - st->outAtLast) /
2658 (double)(st->inBytes - st->inAtLast)
2659 : 0.0,
2660 (unsigned long long)st->inBytes, (unsigned long long)st->outBytes);
2661 ✗ st->lastLog = now;
2662 ✗ st->inAtLast = st->inBytes;
2663 ✗ st->outAtLast = st->outBytes;
2664 ✗ return GST_PAD_PROBE_OK;
2665 }
2666
2667 ✗ static GstPadProbeReturn PipelineStatsOut(GstPad*, GstPadProbeInfo* info,
2668 gpointer user) {
2669 ✗ auto* st = static_cast<PipelineStatsState*>(user);
2670 ✗ GstBuffer* b = GST_PAD_PROBE_INFO_BUFFER(info);
2671 ✗ if (b) {
2672 ✗ st->outBytes += gst_buffer_get_size(b);
2673 }
2674 ✗ return GST_PAD_PROBE_OK;
2675 }
2676
2677 169 bool AES67Manager::CreateSendPipeline(const AES67Instance& inst) {
2678
2/4
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
169 std::string nodeName = SafeNodeName(inst.name) + "_send";
2679
1/2
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
169 std::string sourceIP = GetInterfaceIP(inst.interface);
2680
2681 // Calculate ptime in nanoseconds for rtpL24pay
2682 169 int64_t ptimeNs = (int64_t)inst.ptime * 1000000LL;
2683
2684 // Build pipeline string
2685 // pipewiresrc registers itself as a PipeWire node with the expected name
2686 // so that the audio group's filter-chain output (which has
2687 // node.target=<nodeName>) can connect to it. We do NOT use
2688 // target-object because this node is the DESTINATION, not the source.
2689 //
2690 // Pipeline:
2691 // pipewiresrc stream-properties="props,node.name=<node>"
2692 // ! audioconvert ! audioresample ! audioconvert
2693 // ! audio/x-raw,format=S24BE,rate=48000,channels=N
2694 // ! rtpL24pay pt=96 min-ptime=<ns> max-ptime=<ns>
2695 // ! application/x-rtp,clock-rate=48000
2696 // ! udpsink host=<multicast> port=<port> multicast-iface=<iface> ttl-mc=4 auto-multicast=true sync=true
2697 //
2698 // The resampler is not optional. AES67 mandates 48 kHz on the wire, and the
2699 // PipeWire graph no longer runs there: since the graph clock started
2700 // following the output card it sits at whatever that card is configured for,
2701 // 44100 by default. pipewiresrc cannot satisfy a caps filter that asks for
2702 // a different rate AND a format PipeWire does not carry natively (S24BE, the
2703 // big-endian packing L24 needs) -- it does not fail, it simply never
2704 // negotiates, so set_state() blocks and eventually returns FAILURE, which is
2705 // the "AES67: audio send stream failed to start" warning.
2706 //
2707 // Measured against the live 44100 graph:
2708 // audioconvert ! S24BE@48000 hangs
2709 // audioconvert ! S24BE@44100 works (no rate change)
2710 // audioresample ! audioconvert ! S24BE@48000 hangs
2711 // audioconvert ! audioresample ! audioconvert ! S24BE@48000 works
2712 //
2713 // So the conversion has to be split: reach a format the resampler is happy
2714 // to work in, resample, and only then pack to S24BE. This costs nothing
2715 // when the graph already runs at 48000, where the resampler passes through.
2716
2717
1/2
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
169 std::ostringstream oss;
2718 // Media clock -- see GetOrCreateMediaClock(). Everything below that makes
2719 // the RTP timestamps mean something depends on having it.
2720
3/4
✓ Branch 0 taken 168 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
169 GstClock* ptpClock = m_config.ptpMediaClock ? GetOrCreateMediaClock() : nullptr;
2721 // Correcting the rate only means anything when the timeline it is being
2722 // corrected against is PTP.
2723
4/4
✓ Branch 0 taken 64 times.
✓ Branch 1 taken 105 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 63 times.
169 const bool driftControl = (ptpClock != nullptr) && m_config.adaptiveResample;
2724 169 const bool sinkPacing = m_config.sinkPacing;
2725
2726 // Caps above stereo must name the channel layout or nothing negotiates --
2727 // see ChannelMaskFor(). Emitted into both caps filters below.
2728 169 const guint64 mask = AES67::ChannelMaskFor(inst.channels);
2729 const std::string chanMask =
2730 ✗ mask ? (",channel-mask=(bitmask)0x" + [&] {
2731 char b[32];
2732 169 snprintf(b, sizeof(b), "%llx", (unsigned long long)mask);
2733
1/2
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
338 return std::string(b);
2734
2/6
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
338 }())
2735
2/4
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
338 : std::string();
2736 169 const bool rateMatch = m_config.rateMatch;
2737 #ifdef FPP_HAVE_SAMPLERATE
2738
4/4
✓ Branch 0 taken 168 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 64 times.
✓ Branch 3 taken 104 times.
169 const bool driftResample = m_config.driftResample && (ptpClock != nullptr);
2739 // sourceSilenceFloor is applied by DriftResampleProbe, and that probe is
2740 // only installed when driftResample is on -- so the flag on its own does
2741 // nothing. Say so rather than accepting it into silence: a set flag that
2742 // no-ops reads as a fault in the sender when someone tests against real
2743 // receiver hardware.
2744
3/4
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 105 times.
✓ Branch 3 taken 64 times.
169 if (m_config.sourceSilenceFloor && !driftResample) {
2745
2/4
✗ Branch 0 not taken.
✓ Branch 1 taken 105 times.
✓ Branch 2 taken 105 times.
✗ Branch 3 not taken.
105 LogWarn(VB_MEDIAOUT,
2746 "AES67 send [%d]: sourceSilenceFloor is set but has no effect "
2747 "without driftResample%s -- source gaps will not be filled\n",
2748 inst.id, ptpClock ? "" : " and a PTP clock");
2749 }
2750 #else
2751 const bool driftResample = false;
2752 if (m_config.driftResample) {
2753 LogWarn(VB_MEDIAOUT,
2754 "AES67 send [%d]: driftResample requested but this build has "
2755 "no libsamplerate\n", inst.id);
2756 }
2757 if (m_config.sourceSilenceFloor) {
2758 LogWarn(VB_MEDIAOUT,
2759 "AES67 send [%d]: sourceSilenceFloor requested but this build "
2760 "has no libsamplerate -- source gaps will not be filled\n",
2761 inst.id);
2762 }
2763 #endif
2764
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 168 times.
169 if (!m_config.ptpMediaClock) {
2765
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "AES67 send [%d]: PTP media clock disabled by config\n", inst.id);
2766 }
2767
2768 // Ask PipeWire for the graph's own rate. Without this the AES67 rate
2769 // propagates all the way up and PipeWire resamples for this node alone,
2770 // which is what degrades after ~11 minutes. See
2771 // AES67Config::nativeSourceRate. audioresample below then does the
2772 // conversion, which is what it is there for.
2773
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 169 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
169 const int graphRate = m_config.nativeSourceRate ? PipeWireGraphRate() : 0;
2774
2775 // Multichannel needs the graph clocked at 48kHz. Above stereo, PipeWire
2776 // resampling every quantum into the 48kHz AES67 node delays buffer
2777 // delivery enough that they reach udpsink already past their PTS, and
2778 // sync=true cannot pace what is already late -- it sends back-to-back to
2779 // catch up. Measured at 8 channels on a 44.1kHz graph: 74% of packets
2780 // back-to-back even with always-copy on; at 48kHz, clean, with jitter
2781 // inside the 1ms AES67 recommends. Stereo is unaffected either way.
2782 //
2783 // The graph rate follows the selected playback card's achieved rate (see
2784 // FPPINIT_Audio), so this is not always the AudioFormat setting alone.
2785
2/2
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 161 times.
169 if (inst.channels > AES67::MULTICHANNEL_NEEDS_BUFFER_COPY) {
2786
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 const int actualRate = PipeWireGraphRate();
2787
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 8 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
8 if (actualRate > 0 && actualRate != AES67::AUDIO_RATE) {
2788 ✗ LogWarn(VB_MEDIAOUT,
2789 "AES67 send [%d]: %d channels on a %dHz graph clock. "
2790 "Above stereo this paces badly -- the graph has to be %dHz. "
2791 "Set AudioFormat to 48kHz and use a playback card that runs "
2792 "at 48kHz, or keep this stream at 2 channels.\n",
2793 inst.id, inst.channels, actualRate, AES67::AUDIO_RATE);
2794 }
2795 }
2796
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 169 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
169 if (graphRate > 0 && graphRate != AES67::AUDIO_RATE) {
2797 ✗ LogInfo(VB_MEDIAOUT,
2798 "AES67 send [%d]: taking source at graph rate %d, converting "
2799 "to %d in the pipeline\n",
2800 inst.id, graphRate, AES67::AUDIO_RATE);
2801 }
2802
2803 oss << "pipewiresrc name=pwsrc"
2804
3/6
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 169 times.
✗ Branch 5 not taken.
169 << " min-buffers=" << m_config.sourceMinBuffers
2805 << " always-copy="
2806 338 << ((m_config.sourceBufferCopy ||
2807
2/2
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 161 times.
169 inst.channels > AES67::MULTICHANNEL_NEEDS_BUFFER_COPY)
2808 ? "true" : "false")
2809 << " "
2810 ✗ << ((graphRate > 0 && graphRate != AES67::AUDIO_RATE)
2811
2/12
✗ Branch 0 not taken.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✓ Branch 4 taken 169 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 169 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
338 ? ("! audio/x-raw,rate=" + std::to_string(graphRate) + " ")
2812
2/6
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 169 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
338 : "")
2813 << "! audioconvert "
2814 << "! audioresample "
2815 << "! audioconvert "
2816 // The master volume (see SetMasterVolume()). Passes buffers through
2817 // untouched at 100%.
2818
13/26
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 169 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 169 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 169 times.
✓ Branch 10 taken 169 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 169 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 169 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 169 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 169 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 169 times.
✗ Branch 21 not taken.
✓ Branch 22 taken 169 times.
✗ Branch 23 not taken.
✓ Branch 24 taken 169 times.
✗ Branch 25 not taken.
507 << "! volume name=mastervol volume=" << masterVolumeGain(getVolume()) << " "
2819 // Rate trim for the drift control loop. Sits in the float domain
2820 // because that is all this element accepts, and ahead of the S24BE
2821 // conversion and the packet split so both still see a clean stream.
2822 // Pin the format AND channel count across the rate trim. "speed"
2823 // advertises channels [1, MAX] and nothing downstream forces a count
2824 // until the S24BE capsfilter, so negotiation happily settles on mono
2825 // and audioconvert quietly upmixes back to stereo afterwards: correct
2826 // packet sizes, correct timestamps, valid SDP, and a mono stream.
2827 // Nothing at the packet level catches it -- it showed up as a mono
2828 // feed in the PipeWire graph.
2829 << (driftControl
2830
7/16
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 168 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✓ Branch 7 taken 168 times.
✓ Branch 8 taken 1 times.
✓ Branch 9 taken 168 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
339 ? ("! audio/x-raw,format=F32LE,channels=" + std::to_string(inst.channels) +
2831 " ! pitch name=drift "
2832
6/12
✓ 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 taken 168 times.
✓ Branch 6 taken 1 times.
✓ Branch 7 taken 168 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
340 "! audio/x-raw,format=F32LE,channels=" + std::to_string(inst.channels) + " ")
2833
4/6
✓ Branch 0 taken 168 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 168 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
337 : "")
2834 // Tap point for drift correction. identity does nothing itself; the
2835 // resampling happens in a pad probe on its src pad, in the float
2836 // domain and before the audio is cut into packets, so every packet
2837 // downstream is still exactly one ptime long.
2838 << (driftResample
2839
2/6
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 105 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
233 ? ("! audio/x-raw,format=F32LE,rate=" +
2840
8/16
✓ 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 taken 105 times.
✓ Branch 6 taken 64 times.
✓ Branch 7 taken 105 times.
✓ Branch 8 taken 64 times.
✓ Branch 9 taken 105 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
297 std::to_string(AES67::AUDIO_RATE) + ",channels=" +
2841
6/14
✓ 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 taken 105 times.
✓ Branch 6 taken 64 times.
✓ Branch 7 taken 105 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
466 std::to_string(inst.channels) + chanMask +
2842 " ! identity name=driftpoint ")
2843
4/6
✓ Branch 0 taken 105 times.
✓ Branch 1 taken 64 times.
✓ Branch 2 taken 64 times.
✓ Branch 3 taken 105 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
274 : "")
2844 << "! audioconvert "
2845
10/16
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 168 times.
✓ Branch 4 taken 169 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 64 times.
✓ Branch 7 taken 105 times.
✓ Branch 8 taken 169 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 169 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 169 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 169 times.
✗ Branch 15 not taken.
507 << "! audio/x-raw,format=S24BE,rate=" << AES67::AUDIO_RATE
2846
2/4
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
169 << ",channels=" << inst.channels << chanMask << " "
2847 // Re-block the audio into exactly one packet per buffer, on a timeline
2848 // aligned to sample boundaries. pipewiresrc hands us whatever the graph
2849 // quantum produced, with timestamps that do not land on packet
2850 // boundaries -- and the payloader then has to choose between following
2851 // those timestamps (RTP increments wobble +/-1 sample, audibly
2852 // distorted) or counting samples itself (increments exact, but the
2853 // timeline drifts away from PTP on every dropped buffer, measured at
2854 // -180ms). Splitting first removes the choice: buffers are exactly
2855 // ptime long and correctly timestamped, so the payloader can follow the
2856 // running time (= PTP time) and still step exactly one packet each time.
2857 // Reconcile the card's sample count with PTP time before the audio is
2858 // cut into packets, so the payloader sees a timeline that already
2859 // advances at the PTP rate. See AES67Config::rateMatch.
2860 << (rateMatch ? "! audiorate name=ratematch " : "")
2861 // Also required by sinkPacing, and for a second reason: the payloader
2862 // stamps every packet it emits from one input buffer with that
2863 // buffer's timestamp, so a whole quantum's worth of packets come out
2864 // sharing a PTS. udpsink then has nothing to pace against and sends
2865 // them together -- sync=true on its own leaves the burst untouched
2866 // (measured: 5.8 packets per 23.22ms, identical to sync=false).
2867 // Splitting first gives each packet its own send time.
2868
2/2
✓ Branch 0 taken 104 times.
✓ Branch 1 taken 1 times.
105 << ((ptpClock || sinkPacing)
2869
2/6
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
506 ? ("! audiobuffersplit output-buffer-duration=" +
2870
7/12
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 168 times.
✓ Branch 4 taken 168 times.
✓ Branch 5 taken 1 times.
✓ Branch 6 taken 168 times.
✓ Branch 7 taken 1 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
506 std::to_string(inst.ptime) + "/1000 ") : "")
2871
9/16
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 169 times.
✓ Branch 6 taken 169 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 105 times.
✓ Branch 9 taken 64 times.
✓ Branch 10 taken 169 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 169 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 169 times.
✗ Branch 15 not taken.
338 << "! rtpL24pay name=pay pt=" << AES67::RTP_PAYLOAD_TYPE
2872 // Explicit, not GStreamer's 1400 default: the payloader silently
2873 // splits a payload larger than this, which would contradict the
2874 // min-ptime=max-ptime pair right below. ParseConfig has already
2875 // clamped ptime so one packet fits.
2876
2/4
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
169 << " mtu=" << AES67::MAX_RTP_PACKET_BYTES
2877
2/4
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
169 << " min-ptime=" << ptimeNs
2878
2/4
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
169 << " max-ptime=" << ptimeNs
2879 // timestamp-offset=0 anchors the RTP timeline to PTP (see the media
2880 // clock note below). perfect-rtptime stays TRUE -- deriving each
2881 // packet's timestamp from the buffer running time instead made the
2882 // increments wobble by +/-1 sample (191/193/194 rather than a clean
2883 // 192), and a receiver that places samples by RTP timestamp has to
2884 // absorb that wobble on every single packet. With it true the
2885 // payloader counts samples, so the timeline is anchored to PTP at
2886 // start and then advances exactly one packet at a time.
2887 // perfect-rtptime=false: follow the (now exactly aligned) running time
2888 // so the RTP timeline stays anchored to PTP instead of free-running
2889 // off a sample counter. See the audiobuffersplit note above.
2890 // With splitClockDomains the pipeline is no longer on PTP, so running
2891 // time is not PTP and perfect-rtptime=false would unanchor the RTP
2892 // timeline entirely (measured: -36,135,744 ms of presentation error).
2893 // perfect-rtptime=true makes the payloader count samples instead, and
2894 // driftResample already guarantees exactly 48000 of them per PTP
2895 // second -- so the counter runs at PTP rate whatever clock the pipeline
2896 // uses. The remaining job is anchoring its start via timestamp-offset,
2897 // which is why the absolute lead is still wrong here.
2898 << (ptpClock
2899 64 ? (m_config.splitClockDomains
2900
1/2
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
64 ? " timestamp-offset=0 perfect-rtptime=true"
2901 : " timestamp-offset=0 perfect-rtptime=false")
2902 : "")
2903 << " "
2904
6/10
✓ Branch 0 taken 64 times.
✓ Branch 1 taken 105 times.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 169 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 169 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 169 times.
✗ Branch 9 not taken.
233 << "! application/x-rtp,clock-rate=" << AES67::AUDIO_RATE << " "
2905 // A queue here is what makes sink pacing safe: it runs the sink on its
2906 // own thread, so the sink blocking until a packet's send time cannot
2907 // push back into the live pipewiresrc. Non-leaky on purpose -- in
2908 // steady state it drains exactly as fast as it fills, and dropping a
2909 // packet to keep up would defeat the point. See sinkPacing.
2910 // Bounded by time only. A buffer-count cap is a ptime trap: 64 buffers
2911 // is 256ms at 4ms ptime but only 64ms at 1ms, and the queue has to hold
2912 // the sink latency plus a whole graph quantum's burst -- about 61
2913 // buffers at 1ms. That put the cap right on top of normal steady
2914 // state, the queue ran permanently full, and the backpressure silenced
2915 // the stream entirely on a Yamaha MRX7-D at 1ms ptime (reported on
2916 // #2848: "queue 64 buffers / 64ms" repeating, with no audio).
2917 << (sinkPacing ? "! queue name=sinkq max-size-bytes=0 "
2918 "max-size-buffers=0 max-size-time=200000000 " : "")
2919 169 << "! udpsink name=usink host=" << inst.multicastIP
2920
8/14
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 169 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 169 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 169 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 169 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 169 times.
✗ Branch 13 not taken.
169 << " port=" << inst.port
2921
2/4
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
169 << " ttl-mc=" << AES67::AUDIO_RTP_TTL
2922
2/4
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
169 << " qos-dscp=" << AES67::AUDIO_DSCP
2923
4/6
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 169 times.
✗ Branch 5 not taken.
169 << " auto-multicast=true sync=" << (sinkPacing ? "true" : "false");
2924
2925
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 166 times.
169 if (!inst.interface.empty()) {
2926
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 oss << " multicast-iface=" << inst.interface;
2927 }
2928
2929
1/2
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
169 std::string pipelineStr = oss.str();
2930
1/2
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
169 LogInfo(VB_MEDIAOUT, "AES67 send pipeline [%d] %s: %s\n",
2931 inst.id, inst.name.c_str(), pipelineStr.c_str());
2932
2933 169 GError* error = nullptr;
2934
1/2
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
169 GstElement* pipeline = gst_parse_launch(pipelineStr.c_str(), &error);
2935
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 168 times.
169 if (error) {
2936
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogErr(VB_MEDIAOUT, "AES67 send pipeline error [%d]: %s\n",
2937 inst.id, error->message);
2938
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 g_error_free(error);
2939 // gst_parse_launch() can return a non-null (partial) pipeline even
2940 // when it also reports an error -- release it so we don't leak.
2941
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (pipeline) {
2942
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 gst_object_unref(pipeline);
2943 }
2944 1 return false;
2945 }
2946
2947 // Set stream-properties post-launch — inline GstStructure values in
2948 // gst_parse_launch can crash gst_value_deserialize on some platforms.
2949
3/6
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 168 times.
✗ Branch 5 not taken.
168 GstElement* pwsrc = gst_bin_get_by_name(GST_BIN(pipeline), "pwsrc");
2950
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 if (pwsrc) {
2951 // Ask PipeWire for a quantum no larger than one RTP packet.
2952 //
2953 // This is what makes the stream evenly paced. At the stock 1024-sample
2954 // quantum pipewiresrc hands us ~21ms of audio at once and the packets
2955 // for it leave back-to-back in microseconds, then nothing for 21ms --
2956 // which is far outside the receive window of a Dante/AES67 receiver.
2957 //
2958 // Pacing it at the sink instead (udpsink sync=true) does not work here:
2959 // buffers arrive with a PTS already one quantum in the past, so the
2960 // sink blocks, backpressure reaches the live pipewiresrc, and it drops
2961 // audio rather than stalling. Measured during playback that cost ~36%
2962 // of the stream. Fixing the cadence at the source has no such failure
2963 // mode -- and it cuts sender latency, which the receiver has to absorb
2964 // as link offset.
2965 //
2966 // PipeWire clamps this to its min-quantum, and the graph quantum is the
2967 // smallest any node asks for, so this does raise CPU for the whole
2968 // graph. That is the trade for a stream that is actually usable.
2969 336 std::string nodeLatency = std::to_string(inst.ptime * AES67::AUDIO_RATE / 1000) +
2970
2/4
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
504 "/" + std::to_string(AES67::AUDIO_RATE);
2971
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 GstStructure* props = gst_structure_new("props",
2972 "node.name", G_TYPE_STRING, nodeName.c_str(),
2973 "node.autoconnect", G_TYPE_BOOLEAN, FALSE,
2974 // Keep this node scheduled even when nothing is feeding it, so the
2975 // AES67 side sees one continuous stream -- silence when idle, audio
2976 // when a track is playing -- instead of a hole at every transition.
2977 //
2978 // Idle already works: with no media at all the stream still runs at
2979 // 100% delivery with no gaps, because PipeWire hands us silence.
2980 // The hole appears only at the moment fppd_stream_1 disconnects and
2981 // the graph relinks, when this node is briefly unscheduled: measured
2982 // as a +232 ms PTS jump at every track change. That matters because
2983 // the RTP timestamp is a sample count, so samples that never arrive
2984 // put the media clock permanently behind PTP -- measured at
2985 // -6078 ppm under a 25s file on repeat, which is AES67 running ahead
2986 // of the local sound card by ~180 ms per track and accumulating.
2987 //
2988 // Upstream sets the same property on module-rtp-sink's stream for
2989 // the same reason (pipewire-aes67.conf).
2990 "node.always-process", G_TYPE_BOOLEAN, TRUE,
2991 NULL);
2992
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 168 times.
168 if (m_config.sourcePacing) {
2993 ✗ gst_structure_set(props, "node.latency", G_TYPE_STRING, nodeLatency.c_str(), NULL);
2994 } else {
2995
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 LogInfo(VB_MEDIAOUT, "AES67 send [%d]: source pacing disabled by config\n", inst.id);
2996 }
2997 // Schedule this node from PTP rather than the card. See
2998 // AES67Config::sourcePtpGroup. This has to go in *this* structure --
2999 // g_object_set replaces the property wholesale, so setting it in the
3000 // gst_parse_launch string is silently discarded here.
3001
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 168 times.
168 if (m_config.sourcePtpGroup) {
3002 ✗ gst_structure_set(props, "node.group", G_TYPE_STRING,
3003 m_config.sourcePtpGroupName.c_str(), NULL);
3004 // node.group alone stalls: joining the PTP group puts this node in
3005 // its own driver domain, and the link back to the card-driven graph
3006 // then has nothing to bridge it -- measured, delivery went to zero
3007 // within two minutes. node.async is the mechanism PipeWire 1.2
3008 // added for exactly that link: output ports write to the
3009 // (cycle+1)&1 async buffer slot and input ports read (cycle&1), so
3010 // the two ends need not be in the same cycle. Costs one quantum of
3011 // latency and needs at least two buffers negotiated on the link.
3012 ✗ if (m_config.sourceAsync) {
3013 ✗ gst_structure_set(props, "node.async", G_TYPE_BOOLEAN, TRUE, NULL);
3014 }
3015 ✗ LogInfo(VB_MEDIAOUT,
3016 "AES67 send [%d]: source node joining PTP driver group %s%s\n",
3017 inst.id, m_config.sourcePtpGroupName.c_str(),
3018 m_config.sourceAsync ? " (async)" : "");
3019 }
3020
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 g_object_set(pwsrc, "stream-properties", props, NULL);
3021
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 gst_structure_free(props);
3022
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 gst_object_unref(pwsrc);
3023 168 }
3024
3025 // Put the pipeline on PTP time, and line the RTP timeline up with it.
3026 //
3027 // "a=mediaclk:direct=0" in our SDP asserts that the RTP timestamp IS the
3028 // media clock count on the reference clock, with zero offset. Three things
3029 // have to be true together for that to hold:
3030 //
3031 // 1. the pipeline clock is PTP time (use_clock below)
3032 // 2. base time is zero, so a buffer's running time IS absolute PTP time
3033 // rather than time-since-this-pipeline-started
3034 // 3. the payloader adds no offset of its own (timestamp-offset=0)
3035 //
3036 // rtpL24pay computes RTP ts = timestamp-offset + running_time * 48000 / 1e9,
3037 // so with (2) and (3) that is exactly PTP nanoseconds scaled to 48 kHz
3038 // samples and wrapped at 2^32 -- which is what a receiver reconstructs from
3039 // its own PTP time. Previously the offset was random and the running time
3040 // was relative to pipeline start, so the mapping was wrong by an arbitrary
3041 // amount up to 2^32 samples (~24 hours) and no conformant receiver could
3042 // place the audio.
3043 //
3044 // Setting the start time to NONE stops GStreamer recalculating base time on
3045 // every PAUSED->PLAYING. That is what keeps one continuous media timeline
3046 // across track changes and flushes: RTP timestamps stay tied to wall-clock
3047 // PTP instead of jumping whenever the pipeline is disturbed, which is how a
3048 // Dante/AES67 receiver expects a transmitter to behave.
3049 // splitClockDomains: leave the pipeline on the graph's own clock and use
3050 // PTP only where it is actually required. Putting the whole pipeline on
3051 // PTP is what drains the source ring: pipewiresrc pulls on pipeline time
3052 // while PipeWire fills at the sound card's rate, so at ~54ppm the ring
3053 // empties after (min-buffers - 1) quanta and the source then skips a graph
3054 // cycle per rotation forever. Measured both ways, min-buffers=8:
3055 //
3056 // pipeline on PTP gap burst at buffer 145407 (~56 min)
3057 // pipeline on graph no burst in 74 min (~190000 buffers)
3058 //
3059 // driftResample keeps its own reference to ptpClock, so the output is still
3060 // converted to exactly 48000 samples per PTP second and its PTS are still
3061 // anchored to PTP -- the RTP timestamps stay conformant either way. What
3062 // this does give up is sink pacing against PTP, since udpsink syncs to the
3063 // pipeline clock; that is the ts-offset servo's job.
3064
3/4
✓ Branch 0 taken 63 times.
✓ Branch 1 taken 105 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 63 times.
168 if (ptpClock && !m_config.splitClockDomains) {
3065 ✗ gst_pipeline_use_clock(GST_PIPELINE(pipeline), ptpClock);
3066 ✗ gst_element_set_start_time(pipeline, GST_CLOCK_TIME_NONE);
3067 ✗ gst_element_set_base_time(pipeline, 0);
3068
2/2
✓ Branch 0 taken 63 times.
✓ Branch 1 taken 105 times.
168 } else if (ptpClock) {
3069 // Deliberately do NOT pin start time or force base time to 0 here.
3070 // Those exist so running time equals PTP time on the stock path, where
3071 // the pipeline clock *is* PTP. Carrying them over when the pipeline is
3072 // on the graph clock is what made the sink burst: base time 0 makes
3073 // running time equal raw clock time, which the source's PTS never match,
3074 // so udpsink judges every buffer already overdue and flushes the lot --
3075 // measured, 83% of packets back-to-back in bursts of one graph quantum.
3076 // With normal base-time handling the sink can pace again, and RTP
3077 // timestamps no longer depend on running time anyway now that
3078 // perfect-rtptime counts samples.
3079
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 LogInfo(VB_MEDIAOUT,
3080 "AES67 send [%d]: split clock domains -- pipeline on the graph "
3081 "clock, PTP for the media clock and RTP timestamps\n", inst.id);
3082 }
3083
3084 // splitClockDomains: anchor the RTP timeline to PTP, then hold the lead.
3085 //
3086 // The anchor sets where the timeline starts; the servo keeps it there.
3087 // Both are needed. perfect-rtptime makes the payloader count samples, so
3088 // the RTP timestamp no longer derives from the PTS and the sinkPacing PTS
3089 // shift no longer produces the transmit lead -- the offset below does.
3090 // It must be set before PLAYING: rtpL24pay latches its base on the segment,
3091 // and setting it from a probe on the first buffer was measured to do
3092 // nothing at all.
3093 //
3094 // That makes it provisional. It is taken when the pipeline is built, and
3095 // the first audio reaches the sink whenever the graph first feeds this node
3096 // -- 100 ms later on a Pi, a second or more when a group links it late.
3097 // Every packet then carries an RTP time that much earlier than it should,
3098 // and the servo below could only ever answer that by moving transmission
3099 // earlier, past the point where the audio has arrived at all: from then on
3100 // udpsink sent each buffer the moment it arrived, a quantum at a time, and
3101 // the lead it measured (its own projection) still read +3 ms while the
3102 // wire was tens of ms late. So the servo's probe re-anchors the timeline
3103 // on the first packet that actually reaches the sink, by shifting the RTP
3104 // timestamp it carries -- and every one after it by the same amount.
3105 //
3106 // On its own that anchor does not hold. Every source discontinuity makes
3107 // the drift loop re-anchor its PTS timeline to absorb the gap ("-231 ms gap
3108 // absorbed"), while the RTP sample counter does not move with it, so the
3109 // fixed relationship the anchor established is broken and the lead steps.
3110 // Measured across a night of media restarts it ratcheted one way only,
3111 // +23.2 -> +30.6 -> +34.1 -> +40.6 -> +44.5 ms, roughly 4-7ms per restart,
3112 // never recovering, with the pacing degrading alongside it. An open-loop
3113 // servo cannot fix that by construction: it corrects the clock difference
3114 // it is told about, not the error it actually produces.
3115 //
3116 // So the servo measures the lead and drives it to targetLeadMs. For each
3117 // outgoing packet it reads the RTP timestamp straight out of the header
3118 // (bytes 4-7; no gstreamer-rtp dependency for four bytes), works out when
3119 // the sink will actually transmit it, projects that onto the sample rate,
3120 // and compares. The subtraction is done in guint32 so the RTP wrap at
3121 // 2^32 handles itself.
3122
3/4
✓ Branch 0 taken 63 times.
✓ Branch 1 taken 105 times.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
168 if (ptpClock && m_config.splitClockDomains) {
3123
3/6
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 63 times.
✗ Branch 5 not taken.
63 GstElement* payEl = gst_bin_get_by_name(GST_BIN(pipeline), "pay");
3124
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 if (payEl) {
3125
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 const GstClockTime target = gst_clock_get_time(ptpClock) +
3126 63 (GstClockTime)m_config.sinkPacingMs * GST_MSECOND;
3127
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 const guint32 base = (guint32)gst_util_uint64_scale(
3128 63 target, AES67::AUDIO_RATE, GST_SECOND);
3129
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 g_object_set(payEl, "timestamp-offset", base, NULL);
3130
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 LogInfo(VB_MEDIAOUT,
3131 "AES67 send [%d]: RTP timeline anchored to PTP "
3132 "(timestamp-offset %u, holding %dms lead)\n",
3133 inst.id, base, m_config.targetLeadMs);
3134
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 gst_object_unref(payEl);
3135 }
3136
3137 // No reference to the sink here: the probe sits on the sink's own pad
3138 // and pads only drop their probes when their element is disposed, so a
3139 // ref held by the probe kept the sink -- and with it this struct, the
3140 // PTP clock and its /dev/ptp fd -- alive forever after the pipeline was
3141 // freed, one more on every rebuild. The probe reads the sink off the
3142 // pad instead.
3143 struct LeadServo {
3144 GstClock* ptp;
3145 GstClock* pipe;
3146 gint64 targetNs;
3147 guint64 tick;
3148 int instanceId;
3149 bool logged;
3150 gint64 latency; // sink render delay; -1 until queried
3151 gint64 anchorNs; // RTP time ahead of PTP at the first packet
3152 bool anchored; // rtpShift taken from the first packet
3153 guint32 rtpShift; // added to every RTP timestamp from then on
3154 };
3155
3/6
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 63 times.
✗ Branch 5 not taken.
63 GstElement* sinkEl = gst_bin_get_by_name(GST_BIN(pipeline), "usink");
3156
2/4
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
63 GstPad* sinkPad = sinkEl ? gst_element_get_static_pad(sinkEl, "sink") : nullptr;
3157
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 if (sinkPad) {
3158
2/4
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
63 auto* sv = new LeadServo{ GST_CLOCK(gst_object_ref(ptpClock)),
3159
2/4
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
63 gst_pipeline_get_pipeline_clock(GST_PIPELINE(pipeline)),
3160 63 (gint64)m_config.targetLeadMs * GST_MSECOND,
3161 63 0, inst.id, false, -1,
3162 63 (gint64)m_config.sinkPacingMs * GST_MSECOND,
3163
4/10
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 63 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 63 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
189 false, 0 };
3164
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 gst_pad_add_probe(
3165 sinkPad, GST_PAD_PROBE_TYPE_BUFFER,
3166 ✗ [](GstPad* pad, GstPadProbeInfo* info, gpointer user) -> GstPadProbeReturn {
3167 196169 auto* v = static_cast<LeadServo*>(user);
3168 // Valid for the call: the pad's element is the one running it.
3169
2/4
✓ Branch 0 taken 196169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 196169 times.
✗ Branch 3 not taken.
196169 GstElement* sink = GST_ELEMENT(GST_OBJECT_PARENT(pad));
3170
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 196169 times.
196169 if (!sink) {
3171 ✗ return GST_PAD_PROBE_OK;
3172 }
3173 196169 GstBuffer* b = GST_PAD_PROBE_INFO_BUFFER(info);
3174
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 196169 times.
196169 if (!b) {
3175 ✗ return GST_PAD_PROBE_OK;
3176 }
3177 // Anchor the RTP timeline where the build-time anchor would
3178 // have put it had the first audio arrived at once -- see
3179 // above. The payloader counts samples from there, so one
3180 // fixed shift keeps every later timestamp contiguous.
3181
3/4
✓ Branch 0 taken 196150 times.
✓ Branch 1 taken 19 times.
✓ Branch 2 taken 196150 times.
✗ Branch 3 not taken.
196169 if (!v->anchored || v->rtpShift != 0) {
3182
1/2
✓ Branch 0 taken 196169 times.
✗ Branch 1 not taken.
196169 b = gst_buffer_make_writable(b);
3183 196169 GST_PAD_PROBE_INFO_DATA(info) = b;
3184 GstMapInfo wm;
3185
2/4
✓ Branch 0 taken 196169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 196169 times.
✗ Branch 3 not taken.
196169 if (gst_buffer_map(b, &wm, GST_MAP_READWRITE)) {
3186
1/2
✓ Branch 0 taken 196169 times.
✗ Branch 1 not taken.
196169 if (wm.size >= 12) {
3187 196169 guint32 ts = ((guint32)wm.data[4] << 24) |
3188 196169 ((guint32)wm.data[5] << 16) |
3189 196169 ((guint32)wm.data[6] << 8) | wm.data[7];
3190
2/2
✓ Branch 0 taken 19 times.
✓ Branch 1 taken 196150 times.
196169 if (!v->anchored) {
3191 19 v->anchored = true;
3192 38 const guint32 want = (guint32)gst_util_uint64_scale(
3193
2/4
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
19 gst_clock_get_time(v->ptp) + (GstClockTime)v->anchorNs,
3194 19 AES67::AUDIO_RATE, GST_SECOND);
3195 19 v->rtpShift = want - ts;
3196
1/2
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
19 LogInfo(VB_MEDIAOUT,
3197 "AES67 send [%d]: RTP timeline re-anchored at the "
3198 "first packet (%+.1f ms from the build-time anchor)\n",
3199 v->instanceId,
3200 (double)(gint32)v->rtpShift * 1000.0 / AES67::AUDIO_RATE);
3201 }
3202 196169 ts += v->rtpShift;
3203 196169 wm.data[4] = (guint8)(ts >> 24);
3204 196169 wm.data[5] = (guint8)(ts >> 16);
3205 196169 wm.data[6] = (guint8)(ts >> 8);
3206 196169 wm.data[7] = (guint8)ts;
3207 }
3208
1/2
✓ Branch 0 taken 196169 times.
✗ Branch 1 not taken.
196169 gst_buffer_unmap(b, &wm);
3209 }
3210 }
3211 // ~1Hz at any ptime; the correction is tens of microseconds
3212 // and does not need to be finer.
3213
5/6
✓ Branch 0 taken 770 times.
✓ Branch 1 taken 195399 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 770 times.
✓ Branch 4 taken 195399 times.
✓ Branch 5 taken 770 times.
196169 if ((++v->tick % 250) != 0 || !GST_BUFFER_PTS_IS_VALID(b)) {
3214 195399 return GST_PAD_PROBE_OK;
3215 }
3216 GstMapInfo m;
3217
2/4
✓ Branch 0 taken 770 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 770 times.
770 if (!gst_buffer_map(b, &m, GST_MAP_READ)) {
3218 ✗ return GST_PAD_PROBE_OK;
3219 }
3220
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 770 times.
770 if (m.size < 12) {
3221 ✗ gst_buffer_unmap(b, &m);
3222 ✗ return GST_PAD_PROBE_OK;
3223 }
3224 770 const guint32 rtpTs = ((guint32)m.data[4] << 24) |
3225 770 ((guint32)m.data[5] << 16) |
3226 770 ((guint32)m.data[6] << 8) | m.data[7];
3227
1/2
✓ Branch 0 taken 770 times.
✗ Branch 1 not taken.
770 gst_buffer_unmap(b, &m);
3228
3229 // A sink renders at PTS + base + latency + ts-offset. The
3230 // latency term is not optional: leaving it out biases every
3231 // measurement by exactly the pipeline latency, and the servo
3232 // then drives the real lead that far past target -- measured,
3233 // it ran a 20ms target down to -4.10ms, which is the
3234 // receiver-discards-everything failure.
3235
2/2
✓ Branch 0 taken 19 times.
✓ Branch 1 taken 751 times.
770 if (v->latency < 0) {
3236
1/2
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
19 GstQuery* lq = gst_query_new_latency();
3237
2/4
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
19 if (gst_element_query(sink, lq)) {
3238 19 gboolean live = FALSE;
3239 19 GstClockTime minL = 0, maxL = 0;
3240
1/2
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
19 gst_query_parse_latency(lq, &live, &minL, &maxL);
3241
1/2
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
19 v->latency = GST_CLOCK_TIME_IS_VALID(minL) ? (gint64)minL : 0;
3242 }
3243
1/2
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
19 gst_query_unref(lq);
3244
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 19 times.
19 if (v->latency < 0) {
3245 ✗ v->latency = 0;
3246 }
3247 }
3248 770 gint64 tsOff = 0;
3249
1/2
✓ Branch 0 taken 770 times.
✗ Branch 1 not taken.
770 g_object_get(sink, "ts-offset", &tsOff, NULL);
3250
1/2
✓ Branch 0 taken 770 times.
✗ Branch 1 not taken.
770 const GstClockTime baseT = gst_element_get_base_time(sink);
3251
1/2
✓ Branch 0 taken 770 times.
✗ Branch 1 not taken.
770 const GstClockTime pipeNow = gst_clock_get_time(v->pipe);
3252
1/2
✓ Branch 0 taken 770 times.
✗ Branch 1 not taken.
770 const GstClockTime ptpNow = gst_clock_get_time(v->ptp);
3253 // When the sink will put this packet on the wire, expressed
3254 // in PTP time so it is comparable with the RTP timestamp.
3255 // A buffer whose render time has already passed is sent as it
3256 // arrives, now, so that is when it goes out.
3257 770 const gint64 txPipe = (gint64)GST_BUFFER_PTS(b) + (gint64)baseT +
3258 770 v->latency + tsOff;
3259 770 const bool late = txPipe < (gint64)pipeNow;
3260 770 const gint64 txPtp = (gint64)ptpNow +
3261
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 767 times.
770 (late ? 0 : txPipe - (gint64)pipeNow);
3262
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 770 times.
770 if (txPtp <= 0) {
3263 ✗ return GST_PAD_PROBE_OK;
3264 }
3265
1/2
✓ Branch 0 taken 770 times.
✗ Branch 1 not taken.
770 const guint32 txRtp = (guint32)gst_util_uint64_scale(
3266 770 (guint64)txPtp, AES67::AUDIO_RATE, GST_SECOND);
3267 770 const gint64 leadNs = (gint64)(gint32)(rtpTs - txRtp) *
3268 GST_SECOND / AES67::AUDIO_RATE;
3269
3270 // Delaying transmission (larger ts-offset) reduces the lead.
3271 //
3272 // Step for a large error, slew for a small one -- the same
3273 // split linuxptp makes with step_threshold, and for the same
3274 // reason. Every source discontinuity re-anchors the drift
3275 // loop's PTS timeline while the RTP sample counter stays
3276 // put, so the lead takes a step; slew-only cannot survive
3277 // that if the steps arrive faster than the slew rate.
3278 // Measured with a 25s file on repeat (a restart every ~25s
3279 // against a 200us/s slew, i.e. 12ms/min of authority): the
3280 // lead ran away to +3614 ms in sawtooth cycles, each ending
3281 // in under-delivery and a watchdog rebuild. Stepping puts
3282 // it back at the moment of the disturbance, which is the
3283 // right moment -- the transmission timeline is already
3284 // discontinuous there.
3285 770 const gint64 err = leadNs - v->targetNs;
3286 770 const gint64 kStepThreshold = 5 * GST_MSECOND;
3287 770 const gint64 kSlew = 200 * GST_USECOND;
3288 gint64 corr;
3289
4/4
✓ Branch 0 taken 751 times.
✓ Branch 1 taken 19 times.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 749 times.
770 if (err > kStepThreshold || err < -kStepThreshold) {
3290 21 corr = err;
3291 } else {
3292 749 corr = err / 4;
3293
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 749 times.
749 if (corr > kSlew) corr = kSlew;
3294
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 748 times.
749 if (corr < -kSlew) corr = -kSlew;
3295 }
3296 // Transmitting earlier cannot help a packet that is already
3297 // late; it only moves every later one into the past too.
3298
3/4
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 767 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
770 if (late && corr < 0) {
3299 3 corr = 0;
3300 }
3301
2/2
✓ Branch 0 taken 20 times.
✓ Branch 1 taken 750 times.
770 if (corr != 0) {
3302
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 g_object_set(sink, "ts-offset", tsOff + corr, NULL);
3303 }
3304 // Diagnostic: the servo drove the lead to -56ms once and
3305 // took 50 minutes to recover, which no combination of its
3306 // own gains explains. Log what it actually sees so the
3307 // measurement can be checked against aes67_verify rather
3308 // than reasoned about.
3309
2/2
✓ Branch 0 taken 9 times.
✓ Branch 1 taken 761 times.
770 if ((v->tick % 15000) == 0) {
3310
4/8
✗ Branch 0 not taken.
✓ Branch 1 taken 9 times.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 9 times.
✓ Branch 6 taken 9 times.
✗ Branch 7 not taken.
9 LogInfo(VB_MEDIAOUT,
3311 "AES67 send [%d]: servo lead %+.2f ms err %+.2f "
3312 "corr %+.3f ms ts-offset %+.2f ms%s%s\n",
3313 v->instanceId,
3314 (double)leadNs / GST_MSECOND,
3315 (double)err / GST_MSECOND,
3316 (double)corr / GST_MSECOND,
3317 (double)(tsOff + corr) / GST_MSECOND,
3318 (err > kStepThreshold || err < -kStepThreshold)
3319 ? " STEP" : "",
3320 late ? " LATE" : "");
3321 }
3322
2/2
✓ Branch 0 taken 19 times.
✓ Branch 1 taken 751 times.
770 if (!v->logged) {
3323 19 v->logged = true;
3324
1/2
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
19 LogInfo(VB_MEDIAOUT,
3325 "AES67 send [%d]: lead servo active "
3326 "(lead %.2f ms, target %.0f ms, sink latency "
3327 "%.1f ms)\n",
3328 v->instanceId, (double)leadNs / GST_MSECOND,
3329 (double)v->targetNs / GST_MSECOND,
3330 (double)v->latency / GST_MSECOND);
3331 }
3332 770 return GST_PAD_PROBE_OK;
3333 },
3334 sv,
3335 ✗ [](gpointer user) {
3336 63 auto* v = static_cast<LeadServo*>(user);
3337
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 if (v->ptp) gst_object_unref(v->ptp);
3338
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 if (v->pipe) gst_object_unref(v->pipe);
3339
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 delete v;
3340 63 });
3341
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 gst_object_unref(sinkPad);
3342 }
3343
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 if (sinkEl) {
3344
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 gst_object_unref(sinkEl);
3345 }
3346 }
3347
3348
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 168 times.
168 if (m_config.pipelineStats) {
3349 ✗ GstElement* src = gst_bin_get_by_name(GST_BIN(pipeline), "pwsrc");
3350 ✗ GstElement* snk = gst_bin_get_by_name(GST_BIN(pipeline), "usink");
3351 ✗ GstPad* sp = src ? gst_element_get_static_pad(src, "src") : nullptr;
3352 ✗ GstPad* kp = snk ? gst_element_get_static_pad(snk, "sink") : nullptr;
3353 ✗ if (sp && kp) {
3354 ✗ auto* ps = new PipelineStatsState();
3355 ✗ ps->instanceId = inst.id;
3356 ✗ gst_pad_add_probe(kp, GST_PAD_PROBE_TYPE_BUFFER, PipelineStatsOut,
3357 ps, nullptr);
3358 ✗ gst_pad_add_probe(sp, GST_PAD_PROBE_TYPE_BUFFER, PipelineStatsIn,
3359 ps, DestroyPipelineStatsState);
3360 ✗ LogInfo(VB_MEDIAOUT, "AES67 send [%d]: pipeline stats on\n",
3361 inst.id);
3362 }
3363 ✗ if (sp) gst_object_unref(sp);
3364 ✗ if (kp) gst_object_unref(kp);
3365 ✗ if (src) gst_object_unref(src);
3366 ✗ if (snk) gst_object_unref(snk);
3367 }
3368
3369 // Raise the packet-pacing thread to SCHED_FIFO. GStreamer creates this
3370 // thread itself and offers no way to set its scheduling, so we take the
3371 // one opportunity we have to run code on it: a probe fires on the
3372 // streaming thread, so the first buffer through can elevate the thread
3373 // that carried it. The probe removes itself immediately afterwards and
3374 // costs nothing for the rest of the stream.
3375 //
3376 // This is not a splitClockDomains refinement -- an unpaced tail is worse
3377 // in every mode -- so it is unconditional. EPERM is expected and
3378 // survivable: fppd.service sets LimitRTPRIO, but a hand-started fppd or
3379 // a container without CAP_SYS_NICE will not have it, and the stream is
3380 // still conformant without it, just with a wider tail.
3381 //
3382 // The thread is GStreamer's, from a pool it shares with every pipeline,
3383 // so it has to be handed back as it was: left at SCHED_FIFO, each rebuild
3384 // (config apply, watchdog, PTP step, bus error) put one more pool thread
3385 // at FIFO 80 that later media decode or video pipelines could land on,
3386 // above the channel output thread (RT 10). The task that runs the thread
3387 // gets a leave callback that drops it back to SCHED_OTHER when the task
3388 // stops.
3389 {
3390
3/6
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 168 times.
✗ Branch 5 not taken.
168 GstElement* snk = gst_bin_get_by_name(GST_BIN(pipeline), "usink");
3391
2/4
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
168 GstPad* kp = snk ? gst_element_get_static_pad(snk, "sink") : nullptr;
3392
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 if (kp) {
3393
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 gst_pad_add_probe(
3394 kp, GST_PAD_PROBE_TYPE_BUFFER,
3395 ✗ [](GstPad* pad, GstPadProbeInfo*, gpointer user) -> GstPadProbeReturn {
3396 20 const int id = GPOINTER_TO_INT(user);
3397 // The task whose thread carried this buffer: the nearest
3398 // pad upstream that runs one (sinkq's, with sink pacing).
3399 20 GstTask* task = nullptr;
3400 20 GstPad* up = gst_pad_get_peer(pad);
3401
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 for (int hop = 0; up && !task && hop < 16; hop++) {
3402 20 GST_OBJECT_LOCK(up);
3403
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 if (GST_PAD_TASK(up)) {
3404 20 task = GST_TASK(gst_object_ref(GST_PAD_TASK(up)));
3405 }
3406 20 GST_OBJECT_UNLOCK(up);
3407
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 if (task) {
3408 20 break;
3409 }
3410 ✗ GstElement* el = gst_pad_get_parent_element(up);
3411 ✗ gst_object_unref(up);
3412 ✗ up = nullptr;
3413 ✗ GstPad* in = el ? gst_element_get_static_pad(el, "sink") : nullptr;
3414 ✗ if (el) gst_object_unref(el);
3415 ✗ if (in) {
3416 ✗ up = gst_pad_get_peer(in);
3417 ✗ gst_object_unref(in);
3418 }
3419 }
3420
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 if (up) gst_object_unref(up);
3421
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 20 times.
20 if (!task) {
3422 // Without a task to hand it back through, don't raise it.
3423 ✗ LogWarn(VB_MEDIAOUT, "AES67 send [%d]: pacing thread not raised: "
3424 "could not find its streaming task\n", id);
3425 ✗ return GST_PAD_PROBE_REMOVE;
3426 }
3427
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 gst_task_set_leave_callback(task, [](GstTask*, GThread*, gpointer) {
3428 20 struct sched_param param = {};
3429 20 pthread_setschedparam(pthread_self(), SCHED_OTHER, &param);
3430 20 }, nullptr, nullptr);
3431 20 gst_object_unref(task);
3432
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 if (SetThreadRealtimePriority(AES67::SINK_RT_PRIORITY)) {
3433 20 LogInfo(VB_MEDIAOUT,
3434 "AES67 send [%d]: pacing thread raised to "
3435 "SCHED_FIFO %d\n", id, AES67::SINK_RT_PRIORITY);
3436 } else {
3437 ✗ LogWarn(VB_MEDIAOUT,
3438 "AES67 send [%d]: could not raise pacing thread to "
3439 "SCHED_FIFO %d (%s); packets will occasionally "
3440 "transmit up to a ptime late\n",
3441 id, AES67::SINK_RT_PRIORITY, strerror(errno));
3442 }
3443 20 return GST_PAD_PROBE_REMOVE;
3444 },
3445
0/2
✗ Branch 0 not taken.
✗ Branch 1 not taken.
168 GINT_TO_POINTER(inst.id), nullptr);
3446
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 gst_object_unref(kp);
3447 }
3448
2/4
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
168 if (snk) gst_object_unref(snk);
3449 }
3450
3451 // Always on: see SourceGapProbe. Costs one comparison per buffer and is
3452 // silent unless the source actually skips a cycle.
3453 {
3454
3/6
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 168 times.
✗ Branch 5 not taken.
168 GstElement* pw = gst_bin_get_by_name(GST_BIN(pipeline), "pwsrc");
3455
2/4
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
168 GstPad* pwpad = pw ? gst_element_get_static_pad(pw, "src") : nullptr;
3456
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 if (pwpad) {
3457
2/4
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 168 times.
168 auto* gs = new SourceGapState();
3458 168 gs->instanceId = inst.id;
3459
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 gst_pad_add_probe(pwpad, GST_PAD_PROBE_TYPE_BUFFER, SourceGapProbe,
3460 gs, DestroySourceGapState);
3461
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 gst_object_unref(pwpad);
3462 }
3463
2/4
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
168 if (pw) gst_object_unref(pw);
3464 }
3465
3466 #ifdef FPP_HAVE_SAMPLERATE
3467
2/2
✓ Branch 0 taken 63 times.
✓ Branch 1 taken 105 times.
168 if (driftResample) {
3468
3/6
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 63 times.
✗ Branch 5 not taken.
63 GstElement* dp = gst_bin_get_by_name(GST_BIN(pipeline), "driftpoint");
3469
2/4
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
63 GstPad* dpad = dp ? gst_element_get_static_pad(dp, "src") : nullptr;
3470 63 int err = 0;
3471
3/8
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 63 times.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
63 auto* st = new DriftResampleState();
3472 63 st->channels = inst.channels;
3473 63 st->instanceId = inst.id;
3474
3/6
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 63 times.
✗ Branch 5 not taken.
63 st->clock = GST_CLOCK(gst_object_ref(ptpClock));
3475 // SINC_FASTEST is bandlimited and far above what a 56ppm correction
3476 // needs; the cost is a few percent of one core on a Pi.
3477
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 st->src = src_new(SRC_SINC_FASTEST, inst.channels, &err);
3478 63 st->fillGaps = m_config.sourceSilenceFloor;
3479
3480
2/4
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
63 if (dpad && st->src) {
3481
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 gst_pad_add_probe(dpad, GST_PAD_PROBE_TYPE_BUFFER,
3482 DriftResampleProbe, st,
3483 DestroyDriftResampleState);
3484
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 LogInfo(VB_MEDIAOUT,
3485 "AES67 send [%d]: drift correction on (libsamplerate)\n",
3486 inst.id);
3487 } else {
3488 ✗ LogWarn(VB_MEDIAOUT,
3489 "AES67 send [%d]: drift correction unavailable (%s)\n",
3490 inst.id, st->src ? "no driftpoint pad" : src_strerror(err));
3491 ✗ DestroyDriftResampleState(st);
3492 }
3493
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 if (dpad) {
3494
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 gst_object_unref(dpad);
3495 }
3496
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 if (dp) {
3497
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 gst_object_unref(dp);
3498 }
3499 }
3500 #endif
3501
3502
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 168 times.
168 if (rateMatch) {
3503 ✗ GstElement* rm = gst_bin_get_by_name(GST_BIN(pipeline), "ratematch");
3504 ✗ if (rm) {
3505 // skip-to-first is mandatory here, not a tuning choice. With the
3506 // PTP media clock the base time is 0, so a buffer's timestamp is
3507 // absolute PTP nanoseconds -- roughly 1.8e18 at present. Without
3508 // this audiorate treats that as a gap starting at zero and fills
3509 // it with silence: measured "in 1081, added 343632000" within a
3510 // minute, i.e. it was manufacturing hours of silence.
3511 ✗ g_object_set(rm,
3512 "tolerance", m_config.rateMatchToleranceNs,
3513 "skip-to-first", TRUE,
3514 NULL);
3515 ✗ gst_object_unref(rm);
3516 ✗ LogInfo(VB_MEDIAOUT,
3517 "AES67 send [%d]: rate matching on, tolerance %lluns\n",
3518 inst.id,
3519 (unsigned long long)m_config.rateMatchToleranceNs);
3520 }
3521 }
3522
3523
2/2
✓ Branch 0 taken 167 times.
✓ Branch 1 taken 1 times.
168 if (sinkPacing) {
3524 // Hold each packet back so it is still in the future when the sink
3525 // gets it. Without the delay every buffer arrives past its running
3526 // time, udpsink renders each one immediately, and the burst is
3527 // unchanged -- pacing that never waits is not pacing.
3528 //
3529 // This has to be ts-offset on the sink rather than
3530 // gst_pipeline_set_latency(): the pipeline latency is recomputed from
3531 // a LATENCY query when the pipeline goes to PLAYING, so a value set
3532 // beforehand is replaced by the automatic one. That is what made an
3533 // earlier attempt pace for the first few seconds and then burst for
3534 // the rest of the run. ts-offset is applied per-buffer and survives.
3535 // Shift the buffer timestamps forward rather than offsetting the sink.
3536 //
3537 // ts-offset delays only the transmission; the RTP timestamps stay
3538 // where they were, so every packet goes out after the playout deadline
3539 // it declares and a conformant receiver drops the lot -- measured at
3540 // -23.7ms with pacing on against +7.8ms without it, and reported on
3541 // #2848 as a Yamaha MRX7-D sitting subscribed, green and silent on a
3542 // stream carrying perfectly paced, valid stereo L24.
3543 //
3544 // Moving the PTS instead carries both with it: the payloader derives
3545 // the RTP timestamp from the shifted PTS, and the sink renders at that
3546 // same shifted time. The lead over transmission therefore stays
3547 // whatever it was without pacing, whatever latency we choose, instead
3548 // of needing a correction factor fitted to one machine.
3549 // Held on the heap so the value can be corrected once the pipeline is
3550 // PLAYING and its real latency is known. See the retune below.
3551 // Reused rather than reallocated per rebuild. The watchdog rebuilds
3552 // the pipeline on sustained under-delivery, and a probe on the old
3553 // pipeline can still be in flight when the new one is built, so this
3554 // cell has to outlive both -- freeing it here would be a
3555 // use-after-free, and allocating a fresh one each time would leak on
3556 // every rebuild. One cell per instance id, alive for the process.
3557
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 auto& slot = m_sinkPacingShift[inst.id];
3558
2/2
✓ Branch 0 taken 55 times.
✓ Branch 1 taken 112 times.
167 if (!slot) {
3559
2/4
✓ Branch 0 taken 55 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 55 times.
55 slot = new std::atomic<GstClockTime>(0);
3560 }
3561 167 slot->store((GstClockTime)m_config.sinkPacingMs * GST_MSECOND,
3562 std::memory_order_relaxed);
3563 167 auto* shiftCell = slot;
3564
3565
3/6
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 167 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 167 times.
✗ Branch 5 not taken.
167 GstElement* pay = gst_bin_get_by_name(GST_BIN(pipeline), "pay");
3566
2/4
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 167 times.
✗ Branch 3 not taken.
167 GstPad* payPad = pay ? gst_element_get_static_pad(pay, "sink") : nullptr;
3567
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 if (payPad) {
3568
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 gst_pad_add_probe(
3569 payPad, GST_PAD_PROBE_TYPE_BUFFER,
3570 ✗ [](GstPad*, GstPadProbeInfo* info, gpointer user) {
3571 202928 GstBuffer* b = GST_PAD_PROBE_INFO_BUFFER(info);
3572
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 202928 times.
202928 if (!b) {
3573 ✗ return GST_PAD_PROBE_OK;
3574 }
3575 const GstClockTime shift =
3576 202928 static_cast<std::atomic<GstClockTime>*>(user)->load(
3577 std::memory_order_relaxed);
3578 202928 b = gst_buffer_make_writable(b);
3579
1/2
✓ Branch 0 taken 202928 times.
✗ Branch 1 not taken.
202928 if (GST_BUFFER_PTS_IS_VALID(b)) {
3580 202928 GST_BUFFER_PTS(b) += shift;
3581 }
3582
2/2
✓ Branch 0 taken 98 times.
✓ Branch 1 taken 202830 times.
202928 if (GST_BUFFER_DTS_IS_VALID(b)) {
3583 98 GST_BUFFER_DTS(b) += shift;
3584 }
3585 202928 GST_PAD_PROBE_INFO_DATA(info) = b;
3586 202928 return GST_PAD_PROBE_OK;
3587 },
3588 (gpointer)shiftCell, nullptr);
3589
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 gst_object_unref(payPad);
3590
3591 // Cancel the shift again at the sink. The payloader has already
3592 // taken its RTP timestamp from the shifted PTS, so undoing it here
3593 // moves only the transmission time back, leaving the timestamp
3594 // ahead of the wire by the shift.
3595 //
3596 // This is what the earlier attempts each got wrong. ts-offset
3597 // alone moved transmission but not the timestamp; shifting the PTS
3598 // alone moved both, so the lead did not change at all (-23.73ms
3599 // before, -23.73ms after -- identical, which is what gave it
3600 // away). The -23.2ms is GStreamer's own latency compensation:
3601 // sinks render at PTS + pipeline latency, and the pipeline latency
3602 // here is one graph quantum.
3603
3/6
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 167 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 167 times.
✗ Branch 5 not taken.
167 GstElement* usink2 = gst_bin_get_by_name(GST_BIN(pipeline), "usink");
3604
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 if (usink2) {
3605 167 g_object_set(usink2, "ts-offset",
3606
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 -((gint64)m_config.sinkPacingMs * GST_MSECOND),
3607 NULL);
3608
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 gst_object_unref(usink2);
3609 }
3610
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 LogInfo(VB_MEDIAOUT,
3611 "AES67 send [%d]: sink pacing on, %dms timestamp lead\n",
3612 inst.id, m_config.sinkPacingMs);
3613 } else {
3614 ✗ LogWarn(VB_MEDIAOUT,
3615 "AES67 send [%d]: sink pacing on but payloader not found\n",
3616 inst.id);
3617 }
3618
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 if (pay) {
3619
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 gst_object_unref(pay);
3620 }
3621 }
3622
3623 // Store the bus for polling in the watchdog thread.
3624 // gst_bus_add_watch() requires a running GLib main loop which fppd does
3625 // not have — use gst_bus_pop() polling instead.
3626
3/6
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 168 times.
✗ Branch 5 not taken.
168 GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline));
3627
3628 // Start pipeline in PLAYING state OUTSIDE the mutex — this can block
3629 // waiting for PipeWire and must not hold m_pipelineMutex.
3630
1/2
✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
168 GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING);
3631
2/2
✓ Branch 0 taken 148 times.
✓ Branch 1 taken 20 times.
168 if (ret == GST_STATE_CHANGE_FAILURE) {
3632
1/2
✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
148 LogErr(VB_MEDIAOUT, "AES67 send pipeline [%d] failed to start\n", inst.id);
3633
2/4
✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 148 times.
✗ Branch 3 not taken.
444 WarningHolder::AddWarning(AES67::WARNING_ID_PIPELINE, AES67::WARNING_SEND_FAILED);
3634 // Drop back to NULL before unreffing -- elements may already hold
3635 // READY/PAUSED resources (sockets, threads, PipeWire connections)
3636 // that gst_object_unref() alone will not release.
3637
1/2
✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
148 gst_element_set_state(pipeline, GST_STATE_NULL);
3638
1/2
✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
148 gst_object_unref(bus);
3639
1/2
✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
148 gst_object_unref(pipeline);
3640 148 return false;
3641 }
3642
3643 // The latency-derived retune happens in the watchdog, not here: at this
3644 // point set_state(PLAYING) has only been *requested*, the pipeline has not
3645 // prerolled, and the latency query returns 0 -- which silently left the
3646 // shift at its configured default, defeating the whole point.
3647
3648 {
3649
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 std::unique_lock<std::mutex> lock(m_pipelineMutex);
3650 // If a pipeline already exists for this instance id (e.g. two
3651 // ApplyConfig() runs interleaved), stop it properly instead of
3652 // erasing the map entry directly -- a raw erase() would destroy
3653 // the AES67Pipeline (dropping its GstElement* refs) without ever
3654 // calling gst_element_set_state(NULL), leaking a still-PLAYING
3655 // pipeline (refs, threads, sockets).
3656
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 auto node = m_sendPipelines.extract(inst.id);
3657
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 20 times.
20 if (!node.empty()) {
3658 // StopPipeline() can block on a GStreamer state change to
3659 // PipeWire -- release the lock while it runs, matching the
3660 // no-block-while-locked policy used elsewhere in this file
3661 // (see StopAllPipelines()).
3662 ✗ lock.unlock();
3663 ✗ StopPipeline(node.mapped());
3664 ✗ lock.lock();
3665 }
3666
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 auto [it, ok] = m_sendPipelines.try_emplace(inst.id);
3667 20 it->second.instanceId = inst.id;
3668 20 it->second.channels = inst.channels;
3669 20 it->second.ptime = inst.ptime;
3670 20 it->second.isSend = true;
3671 20 it->second.pipeline = pipeline;
3672 20 it->second.bus = bus;
3673 20 it->second.running = true;
3674 // The gain in the description is the one when it was built; a change
3675 // since then went to the pipelines already in this map, not this one.
3676
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 SetPipelineMasterVolume(pipeline, getVolume());
3677 20 }
3678
3679
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 LogInfo(VB_MEDIAOUT, "AES67 send pipeline [%d] %s started → %s:%d (%dch, %dms ptime)\n",
3680 inst.id, inst.name.c_str(), inst.multicastIP.c_str(), inst.port,
3681 inst.channels, inst.ptime);
3682 20 return true;
3683 169 }
3684
3685 // ──────────────────────────────────────────────────────────────────────────────
3686 // Pipeline creation — Receive
3687 // ──────────────────────────────────────────────────────────────────────────────
3688 9 bool AES67Manager::CreateRecvPipeline(const AES67Instance& inst) {
3689
2/4
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
9 std::string nodeName = SafeNodeName(inst.name) + "_recv";
3690
3691 // Build pipeline:
3692 // udpsrc multicast-group=<ip> port=<port> auto-multicast=true
3693 // ! application/x-rtp,media=audio,clock-rate=48000,encoding-name=L24,channels=N,payload=96
3694 // ! rtpjitterbuffer latency=<ms>
3695 // ! rtpL24depay
3696 // ! audioconvert
3697 // ! pipewiresink target-object=<node>
3698 // stream-properties="props,media.class=Audio/Source,node.name=<node>"
3699
3700
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 std::ostringstream oss;
3701 9 oss << "udpsrc multicast-group=" << inst.multicastIP
3702
4/8
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 9 times.
✗ Branch 7 not taken.
9 << " port=" << inst.port
3703
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 << " auto-multicast=true";
3704
3705
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 9 times.
9 if (!inst.interface.empty()) {
3706 ✗ oss << " multicast-iface=" << inst.interface;
3707 }
3708
3709
2/4
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
9 oss << " ! application/x-rtp,media=audio,clock-rate=" << AES67::AUDIO_RATE
3710
2/4
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
9 << ",encoding-name=L24,channels=" << inst.channels
3711
2/4
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
9 << ",payload=" << AES67::RTP_PAYLOAD_TYPE << " "
3712
3/6
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
9 << "! rtpjitterbuffer latency=" << inst.latency << " "
3713 << "! rtpL24depay "
3714 << "! audioconvert "
3715
4/8
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 9 times.
✗ Branch 7 not taken.
9 << "! pipewiresink name=pwsink";
3716
3717
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 std::string pipelineStr = oss.str();
3718
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 LogInfo(VB_MEDIAOUT, "AES67 recv pipeline [%d] %s: %s\n",
3719 inst.id, inst.name.c_str(), pipelineStr.c_str());
3720
3721 9 GError* error = nullptr;
3722
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 GstElement* pipeline = gst_parse_launch(pipelineStr.c_str(), &error);
3723
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 9 times.
9 if (error) {
3724 ✗ LogErr(VB_MEDIAOUT, "AES67 recv pipeline error [%d]: %s\n",
3725 inst.id, error->message);
3726 ✗ g_error_free(error);
3727 // gst_parse_launch() can return a non-null (partial) pipeline even
3728 // when it also reports an error -- release it so we don't leak.
3729 ✗ if (pipeline) {
3730 ✗ gst_object_unref(pipeline);
3731 }
3732 ✗ return false;
3733 }
3734
3735 // Set stream-properties post-launch — gst_parse_launch cannot
3736 // deserialize GstStructure values containing '/' (e.g. Audio/Source)
3737 // which crashes gst_value_deserialize.
3738
2/4
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
9 std::string recvNodeDesc = SafeNodeName(inst.name) + "_recv";
3739
3/6
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
9 GstElement* pwsink = gst_bin_get_by_name(GST_BIN(pipeline), "pwsink");
3740
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 if (pwsink) {
3741
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 GstStructure* props = gst_structure_new("props",
3742 "media.class", G_TYPE_STRING, "Audio/Source",
3743 "node.name", G_TYPE_STRING, nodeName.c_str(),
3744 "node.description", G_TYPE_STRING, recvNodeDesc.c_str(),
3745 NULL);
3746
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 g_object_set(pwsink, "stream-properties", props, NULL);
3747
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 gst_structure_free(props);
3748
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 gst_object_unref(pwsink);
3749 }
3750
3751 // Store the bus for polling in the watchdog thread (no GLib main loop).
3752
3/6
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
9 GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline));
3753
3754 // Note: the receive path runs on GStreamer's default clock. Only the send
3755 // path is driven by PTP time (see CreateSendPipeline).
3756
3757 // Start pipeline OUTSIDE the mutex — GStreamer state changes can block
3758 // waiting for PipeWire.
3759
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING);
3760
2/2
✓ Branch 0 taken 6 times.
✓ Branch 1 taken 3 times.
9 if (ret == GST_STATE_CHANGE_FAILURE) {
3761
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 LogErr(VB_MEDIAOUT, "AES67 recv pipeline [%d] failed to start\n", inst.id);
3762
2/4
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
18 WarningHolder::AddWarning(AES67::WARNING_ID_PIPELINE, AES67::WARNING_RECV_FAILED);
3763 // Drop back to NULL before unreffing -- elements may already hold
3764 // READY/PAUSED resources (sockets, threads, PipeWire connections)
3765 // that gst_object_unref() alone will not release.
3766
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 gst_element_set_state(pipeline, GST_STATE_NULL);
3767
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 gst_object_unref(bus);
3768
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 gst_object_unref(pipeline);
3769 6 return false;
3770 }
3771
3772 {
3773
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 std::unique_lock<std::mutex> lock(m_pipelineMutex);
3774 // See CreateSendPipeline() for why we stop any existing pipeline
3775 // for this id instead of erasing the map entry directly.
3776
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 auto node = m_recvPipelines.extract(inst.id);
3777
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
3 if (!node.empty()) {
3778 ✗ lock.unlock();
3779 ✗ StopPipeline(node.mapped());
3780 ✗ lock.lock();
3781 }
3782
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 auto [it, ok] = m_recvPipelines.try_emplace(inst.id);
3783 3 it->second.instanceId = inst.id;
3784 3 it->second.channels = inst.channels;
3785 3 it->second.ptime = inst.ptime;
3786 3 it->second.isSend = false;
3787 3 it->second.pipeline = pipeline;
3788 3 it->second.bus = bus;
3789 3 it->second.running = true;
3790 3 }
3791
3792
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 LogInfo(VB_MEDIAOUT, "AES67 recv pipeline [%d] %s started ← %s:%d (%dch, %dms latency)\n",
3793 inst.id, inst.name.c_str(), inst.multicastIP.c_str(), inst.port,
3794 inst.channels, inst.latency);
3795 3 return true;
3796 9 }
3797
3798 // ──────────────────────────────────────────────────────────────────────────────
3799 // Pipeline management
3800 // ──────────────────────────────────────────────────────────────────────────────
3801 23 void AES67Manager::StopPipeline(AES67Pipeline& p) {
3802
1/2
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
23 if (p.pipeline) {
3803 23 gst_element_set_state(p.pipeline, GST_STATE_NULL);
3804
3805 // Remove the buffer-drop probe installed by FlushSendPipelines()
3806 // (if any) and release our ref on the pad it was installed on.
3807 // Without this, the probe stays registered on the pad with
3808 // &p.dropCounter as userdata -- a dangling pointer once this
3809 // AES67Pipeline is erased from its owning map -- and the pad ref
3810 // taken in FlushSendPipelines() leaks.
3811
3/4
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 21 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
23 if (p.probeId != 0 && p.probePad) {
3812 2 gst_pad_remove_probe(p.probePad, p.probeId);
3813 2 p.probeId = 0;
3814 }
3815
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 21 times.
23 if (p.probePad) {
3816 2 gst_object_unref(p.probePad);
3817 2 p.probePad = nullptr;
3818 }
3819
3820 // Release the bus ref we stored at pipeline creation
3821
1/2
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
23 if (p.bus) {
3822 23 gst_object_unref(p.bus);
3823 23 p.bus = nullptr;
3824 }
3825 23 gst_object_unref(p.pipeline);
3826 23 p.pipeline = nullptr;
3827 23 p.running = false;
3828 }
3829 23 }
3830
3831 208 void AES67Manager::StopAllPipelines() {
3832 // Extract pipelines from the maps under the lock, then release the lock
3833 // before calling gst_element_set_state(NULL) — state changes can block
3834 // on PipeWire and must not hold m_pipelineMutex.
3835 208 std::map<int, AES67Pipeline> sendCopy, recvCopy;
3836 {
3837
1/2
✓ Branch 0 taken 208 times.
✗ Branch 1 not taken.
208 std::lock_guard<std::mutex> lock(m_pipelineMutex);
3838 208 sendCopy.swap(m_sendPipelines);
3839 208 recvCopy.swap(m_recvPipelines);
3840 // Nothing is configured to be running any more, so no sender is
3841 // waiting for a source either. ApplyConfig() refills this after its
3842 // create pass; every other caller is a teardown.
3843 208 m_deferredSenders.clear();
3844 208 }
3845
3846
2/2
✓ Branch 0 taken 20 times.
✓ Branch 1 taken 208 times.
228 for (auto& [id, p] : sendCopy) {
3847
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 LogDebug(VB_MEDIAOUT, "AES67Manager: Stopping send pipeline [%d]\n", id);
3848
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 StopPipeline(p);
3849 }
3850
3851
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 208 times.
211 for (auto& [id, p] : recvCopy) {
3852
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 LogDebug(VB_MEDIAOUT, "AES67Manager: Stopping recv pipeline [%d]\n", id);
3853
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 StopPipeline(p);
3854 }
3855 208 }
3856
3857 // Pad probe callback: drops buffers while dropCounter > 0, passes through otherwise.
3858 // Installed once on pipewiresrc's src pad and stays active for the pipeline's lifetime.
3859 632 static GstPadProbeReturn DropBufferProbe(GstPad* pad, GstPadProbeInfo* info, gpointer userData) {
3860 632 std::atomic<gint64>* remaining = static_cast<std::atomic<gint64>*>(userData);
3861
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 632 times.
632 if (!(info->type & GST_PAD_PROBE_TYPE_BUFFER))
3862 ✗ return GST_PAD_PROBE_OK;
3863
1/2
✓ Branch 0 taken 632 times.
✗ Branch 1 not taken.
1264 if (remaining->load() <= 0)
3864 632 return GST_PAD_PROBE_OK;
3865
3866 ✗ GstBuffer* b = GST_PAD_PROBE_INFO_BUFFER(info);
3867 ✗ GstClockTime dur = b ? GST_BUFFER_DURATION(b) : GST_CLOCK_TIME_NONE;
3868 ✗ if (!GST_CLOCK_TIME_IS_VALID(dur)) {
3869 // Nothing to subtract, so stop rather than drop indefinitely. Under-
3870 // dropping leaves a little stale audio; over-dropping is the bug this
3871 // whole change exists to fix.
3872 ✗ remaining->store(0);
3873 ✗ return GST_PAD_PROBE_DROP;
3874 }
3875
3876 // Only drop a buffer that FITS in what is left, and stop otherwise.
3877 // Buffers are whole graph quanta, so testing "is there budget left" and
3878 // subtracting afterwards overshoots by up to a quantum every time: at the
3879 // 21.333ms quantum of a Pi 5 at 48kHz, a 50ms budget took three buffers
3880 // and cut 64.00ms. A tester measured exactly that, and it put the gap
3881 // back over the sink queue's depth, which is the one thing this must not
3882 // do. Stopping short instead is bounded by construction -- 42.67ms at
3883 // that quantum, 46.44ms at 44.1kHz -- and never exceeds what was asked.
3884 ✗ if ((GstClockTime)remaining->load() < dur) {
3885 ✗ remaining->store(0);
3886 ✗ return GST_PAD_PROBE_OK;
3887 }
3888 ✗ remaining->fetch_sub((gint64)dur);
3889 ✗ return GST_PAD_PROBE_DROP;
3890 }
3891
3892 3106 void AES67Manager::SetMasterVolume(int volume) {
3893
1/2
✓ Branch 0 taken 3106 times.
✗ Branch 1 not taken.
3106 std::lock_guard<std::mutex> lock(m_pipelineMutex);
3894
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 3106 times.
3107 for (auto& kv : m_sendPipelines) {
3895
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (kv.second.pipeline) {
3896
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 SetPipelineMasterVolume(kv.second.pipeline, volume);
3897 }
3898 }
3899 3106 }
3900
3901 10 void AES67Manager::FlushSendPipelines() {
3902
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 std::lock_guard<std::mutex> lock(m_pipelineMutex);
3903
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 10 times.
20 for (auto& kv : m_sendPipelines) {
3904 10 AES67Pipeline& p = kv.second;
3905
2/4
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 10 times.
10 if (!p.pipeline || !p.running)
3906 8 continue;
3907
3908 // Drop SOURCE_FLUSH_MS of audio from pipewiresrc's src pad. This
3909 // discards whatever was queued in GStreamer elements between the old
3910 // track stopping and the new one starting, without disrupting the
3911 // pipeline's event flow (no flush-start/stop, no state change).
3912 //
3913 // Three call sites in GStreamerOut fire at a single track change, so
3914 // restart the window rather than adding to it -- the old counter was
3915 // reset to 10 buffers by each one, and only stayed bounded because
3916 // the calls happened to land within one window. Log just the first,
3917 // since three identical lines per transition made this look like
3918 // three separate events in the logs testers sent back.
3919 // Three call sites in GStreamerOut fire at a single track change.
3920 // Re-arming the budget on each one lets a later call land mid-sequence
3921 // and extend the drop: a tester saw transitions alternate between
3922 // three and four quanta (64.00ms and 85.33ms) for one 50ms request.
3923 // Ignore a call while a flush is still running, so one track change
3924 // discards one budget's worth however many code paths announce it.
3925 10 const gint64 target = (gint64)AES67::SOURCE_FLUSH_MS * GST_MSECOND;
3926
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 10 times.
20 if (p.dropRemainingNs.load() > 0) {
3927 ✗ continue; // already flushing; probe is installed
3928 }
3929
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 LogInfo(VB_MEDIAOUT,
3930 "AES67 send pipeline [%d]: discarding up to %dms of stale audio\n",
3931 p.instanceId, AES67::SOURCE_FLUSH_MS);
3932 10 p.dropRemainingNs.store(target);
3933
3934 // Install the probe once; subsequent calls just reset the counter.
3935
2/2
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 2 times.
10 if (p.probeId != 0)
3936 8 continue;
3937
3938 // Find pipewiresrc's src pad and install the permanent probe
3939
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.
2 GstIterator* it = gst_bin_iterate_sources(GST_BIN(p.pipeline));
3940 2 GValue val = G_VALUE_INIT;
3941 2 GstElement* srcElem = nullptr;
3942
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 if (gst_iterator_next(it, &val) == GST_ITERATOR_OK) {
3943
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.
2 srcElem = GST_ELEMENT(g_value_get_object(&val));
3944
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_object_ref(srcElem);
3945
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 g_value_unset(&val);
3946 }
3947
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_iterator_free(it);
3948
3949
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 if (srcElem) {
3950
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 GstPad* srcpad = gst_element_get_static_pad(srcElem, "src");
3951
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 if (srcpad) {
3952 2 p.probePad = srcpad; // takes ownership of ref
3953 2 p.probeId = gst_pad_add_probe(
3954 srcpad,
3955 GST_PAD_PROBE_TYPE_BUFFER,
3956 DropBufferProbe,
3957
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 &p.dropRemainingNs,
3958 nullptr);
3959 }
3960
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_object_unref(srcElem);
3961 }
3962 }
3963 10 }
3964
3965 // ──────────────────────────────────────────────────────────────────────────────
3966 // GStreamer bus callback
3967 // ──────────────────────────────────────────────────────────────────────────────
3968 // ──────────────────────────────────────────────────────────────────────────────
3969 // Pipeline watchdog — polls bus messages and recovers crashed pipelines.
3970 // Called from WatchdogLoop() (every SAP_ANNOUNCE_INTERVAL_S seconds).
3971 // fppd does not run a GLib main loop, so gst_bus_add_watch() callbacks would
3972 // never fire. We poll the bus manually and promote any ERROR/WARNING messages.
3973 // ──────────────────────────────────────────────────────────────────────────────
3974 // Restart ptp4l/phc2sys if they have died. Nothing else supervised them: a
3975 // link flap, an OOM kill or a stray `killall ptp4l` left FPP silently running
3976 // with no clock discipline at all, reporting "not running" forever.
3977 //
3978 // Called from WatchdogLoop() alongside the pipeline watchdog, so it shares
3979 // that thread's cadence. Note this means PTP is only supervised while the SAP
3980 // announcer runs (i.e. there is at least one SAP-enabled send instance).
3981
3982 // Trims the send stream's sample rate so the media timeline advances at exactly
3983 // PTP rate. See AES67Config::adaptiveResample for why this is not optional.
3984 //
3985 // The loop is deliberately slow and gentle. What it is correcting is a crystal
3986 // offset -- constant, order 100ppm -- not a fast disturbance, so it samples
3987 // every couple of seconds and moves in small steps. Two terms:
3988 //
3989 // rate the media clock's measured advance per unit of PTP time. Feeding
3990 // that straight back (speed *= measured ratio) nulls the offset in
3991 // rate, which is the bulk of the correction.
3992 // offset a slow pull that burns off however much lag accumulated before the
3993 // rate term settled, so the stream ends up at the right rate AND back
3994 // at the latency it started from.
3995 ✗ void AES67Manager::DriftControlLoop() {
3996 ✗ LogInfo(VB_MEDIAOUT, "AES67 drift control thread started\n");
3997
3998 ✗ constexpr int SAMPLE_INTERVAL_S = 2;
3999 // The payloader's last-emitted RTP timestamp only advances when a burst of
4000 // packets goes out -- one graph quantum at a time -- so sampling it
4001 // asynchronously quantises the reading by ~21ms. Over a 30s endpoint
4002 // measurement that is +/-750ppm of noise on a signal of ~100ppm, and the
4003 // loop simply hunts. Fit a slope across the whole window instead, and make
4004 // the window long: noise falls with both the span and the sample count.
4005 ✗ constexpr int WINDOW_S = 120; // measurement baseline
4006 // Actuate far more slowly than we sample. The measurement is a 120s
4007 // window, so a correction takes ~60s to show up in it; correcting every 2s
4008 // means ~30 further corrections are applied before the first is visible,
4009 // and the loop oscillates (measured: trim swinging +140 to -377ppm, drift
4010 // swinging +236 to -99ppm). Classic dead-time instability -- the cure is
4011 // to make the control interval comparable to the lag, not to lower the
4012 // gain until the ringing is slow.
4013 ✗ constexpr int ACTUATE_EVERY = 15; // iterations, i.e. every 30s
4014 ✗ constexpr double KP_RATE = 0.3; // fraction of the rate error to take per step
4015 ✗ constexpr double KP_OFFSET = 0.00002; // per second of accumulated lag
4016 ✗ constexpr double MAX_TRIM = 0.0005; // +/-500ppm, well beyond any crystal
4017 ✗ constexpr double MAX_STEP = 0.00002; // +/-20ppm per iteration
4018
4019 struct Sample {
4020 GstClockTime clock;
4021 double samples; // cumulative RTP samples emitted
4022 };
4023 struct PipelineState {
4024 std::deque<Sample> window;
4025 double cumulative = 0.0;
4026 guint lastTs = 0;
4027 GstClockTime lastClock = 0;
4028 bool primed = false;
4029 double speed = 1.0;
4030 int sinceActuate = 0;
4031 };
4032 ✗ std::map<int, PipelineState> state;
4033
4034 ✗ while (m_driftRunning.load()) {
4035 ✗ for (int i = 0; i < SAMPLE_INTERVAL_S * 10 && m_driftRunning.load(); i++) {
4036 ✗ std::this_thread::sleep_for(std::chrono::milliseconds(100));
4037 }
4038 ✗ if (!m_driftRunning.load()) {
4039 ✗ break;
4040 }
4041
4042 ✗ std::unique_lock<std::mutex> lock(m_pipelineMutex, std::try_to_lock);
4043 ✗ if (!lock.owns_lock()) {
4044 ✗ continue;
4045 }
4046
4047 ✗ for (auto& [id, p] : m_sendPipelines) {
4048 ✗ if (!p.running || !p.pipeline) {
4049 ✗ continue;
4050 }
4051 ✗ GstElement* drift = gst_bin_get_by_name(GST_BIN(p.pipeline), "drift");
4052 ✗ GstElement* pay = gst_bin_get_by_name(GST_BIN(p.pipeline), "pay");
4053 ✗ if (!drift || !pay) {
4054 ✗ if (drift) gst_object_unref(drift);
4055 ✗ if (pay) gst_object_unref(pay);
4056 ✗ continue;
4057 }
4058 ✗ GstClock* clock = gst_element_get_clock(p.pipeline);
4059 ✗ if (!clock) {
4060 ✗ gst_object_unref(drift);
4061 ✗ gst_object_unref(pay);
4062 ✗ continue;
4063 }
4064
4065 // Measure against the RTP timestamps we actually emitted. These
4066 // step by exactly one packet, so the only error is a single sample
4067 // (21us) over the whole window -- unlike a position query, which
4068 // reports the last buffer the sink handled and therefore jitters by
4069 // a full graph quantum (+/-10ms measured). That jitter is ~45x the
4070 // drift being corrected, and an earlier version of this loop spent
4071 // its life chasing it into the slew limit.
4072 ✗ guint ts = 0;
4073 ✗ g_object_get(pay, "timestamp", &ts, NULL);
4074 ✗ GstClockTime now = gst_clock_get_time(clock);
4075 ✗ gst_object_unref(clock);
4076 ✗ gst_object_unref(pay);
4077
4078 ✗ PipelineState& st = state[id];
4079 ✗ if (!st.primed) {
4080 ✗ st.lastTs = ts;
4081 ✗ st.lastClock = now;
4082 ✗ st.primed = true;
4083 ✗ st.window.push_back({now, 0.0});
4084 ✗ gst_object_unref(drift);
4085 ✗ continue;
4086 }
4087
4088 ✗ double emitted = (double)((guint32)(ts - st.lastTs)); // wraps correctly
4089 ✗ st.lastTs = ts;
4090
4091 // Reject discontinuities rather than measuring them as drift.
4092 // Between tracks, and whenever FlushSendPipelines() drops buffers,
4093 // the stream simply stops for a while; the media timeline then
4094 // legitimately loses time against the wall clock, which is
4095 // indistinguishable from a very slow clock if taken at face value.
4096 // Measured with five track restarts in a window it reported
4097 // -7500ppm and nearly a second of "lag" -- all of it gaps.
4098 ✗ double expected = (double)(now - st.lastClock) / 1e9 * AES67::AUDIO_RATE;
4099 ✗ st.lastClock = now;
4100 ✗ if (expected > 0 && (emitted < expected * 0.9 || emitted > expected * 1.1)) {
4101 ✗ LogDebug(VB_MEDIAOUT,
4102 "AES67 drift [%d]: stream discontinuity (%.0f of %.0f samples), "
4103 "restarting measurement\n", id, emitted, expected);
4104 ✗ st.window.clear();
4105 ✗ st.cumulative = 0.0;
4106 ✗ st.window.push_back({now, 0.0});
4107 ✗ gst_object_unref(drift);
4108 ✗ continue;
4109 }
4110
4111 ✗ st.cumulative += emitted;
4112 ✗ st.window.push_back({now, st.cumulative});
4113 ✗ while (st.window.size() > 2 &&
4114 ✗ (now - st.window.front().clock) > (GstClockTime)WINDOW_S * GST_SECOND) {
4115 ✗ st.window.pop_front();
4116 }
4117 ✗ if (st.window.size() < 3) {
4118 ✗ gst_object_unref(drift);
4119 ✗ continue;
4120 }
4121
4122 ✗ const Sample& a = st.window.front();
4123 ✗ const Sample& b = st.window.back();
4124 ✗ double dClockS = (double)(b.clock - a.clock) / 1e9;
4125 ✗ if (dClockS < (double)WINDOW_S * 0.5) {
4126 // Not enough baseline yet to measure anything at this precision.
4127 ✗ gst_object_unref(drift);
4128 ✗ continue;
4129 }
4130
4131 // Least-squares slope of media seconds against PTP seconds across
4132 // the window. Averaging down the per-sample quantisation is the
4133 // whole point; endpoints alone are far too noisy.
4134 ✗ double n = 0, sx = 0, sy = 0, sxx = 0, sxy = 0;
4135 ✗ for (const Sample& w : st.window) {
4136 ✗ double x = (double)(w.clock - a.clock) / 1e9;
4137 ✗ double y = (w.samples - a.samples) / (double)AES67::AUDIO_RATE;
4138 ✗ n += 1; sx += x; sy += y; sxx += x * x; sxy += x * y;
4139 }
4140 ✗ double denom = n * sxx - sx * sx;
4141 ✗ if (denom <= 0) {
4142 ✗ gst_object_unref(drift);
4143 ✗ continue;
4144 }
4145 ✗ double ratio = (n * sxy - sx * sy) / denom; // 1.0 = locked to PTP
4146 ✗ double lagS = dClockS - (b.samples - a.samples) / (double)AES67::AUDIO_RATE;
4147
4148 // Sign: the "speed" element plays at `speed` x rate, so it emits
4149 // FEWER samples as speed rises -- media time advances as 1/speed.
4150 // Media running slow (ratio < 1) therefore needs speed to come
4151 // DOWN. Getting this backwards drives the loop straight into the
4152 // clamp and makes the drift worse, which is exactly what the first
4153 // rewrite of this loop did (-111ppm became -770ppm at +500ppm trim).
4154 //
4155 // Rate term takes a fraction of the error so the loop settles
4156 // rather than ringing; offset term is tiny and only bleeds off
4157 // accumulated lag.
4158 // Keep accumulating the window every iteration, but only move the
4159 // trim once the previous move has had time to show up.
4160 ✗ if (++st.sinceActuate < ACTUATE_EVERY) {
4161 ✗ gst_object_unref(drift);
4162 ✗ continue;
4163 }
4164 ✗ st.sinceActuate = 0;
4165
4166 ✗ double next = st.speed * (1.0 + KP_RATE * (ratio - 1.0)) * (1.0 - KP_OFFSET * lagS);
4167 ✗ if (next > st.speed + MAX_STEP) next = st.speed + MAX_STEP;
4168 ✗ if (next < st.speed - MAX_STEP) next = st.speed - MAX_STEP;
4169 ✗ if (next < 1.0 - MAX_TRIM) next = 1.0 - MAX_TRIM;
4170 ✗ if (next > 1.0 + MAX_TRIM) next = 1.0 + MAX_TRIM;
4171
4172 ✗ if (fabs(next - st.speed) > 1e-9) {
4173 ✗ st.speed = next;
4174 // "pitch" (soundtouch) exposes the rate as "rate". The
4175 // previous element, "speed", accepted its property and read it
4176 // back while doing nothing to a live source -- so verify any
4177 // replacement by watching the measured drift respond, never by
4178 // reading the property back.
4179 ✗ g_object_set(drift, "rate", (gfloat)st.speed, NULL);
4180 }
4181
4182 ✗ LogDebug(VB_MEDIAOUT,
4183 "AES67 drift [%d]: over %.0fs media/PTP %.7f (%+.1f ppm), lag %+.2f ms, trim %+.1f ppm\n",
4184 id, dClockS, ratio, (ratio - 1.0) * 1e6, lagS * 1000.0,
4185 (st.speed - 1.0) * 1e6);
4186 ✗ gst_object_unref(drift);
4187 }
4188 ✗ }
4189 ✗ LogInfo(VB_MEDIAOUT, "AES67 drift control thread stopped\n");
4190 ✗ }
4191
4192 // PTP time minus CLOCK_MONOTONIC. This is the quantity a send pipeline's RTP
4193 // anchor depends on: the anchor is a PTP instant frozen at build time, and the
4194 // pipeline that has to hit it runs on the graph clock, which is a system clock
4195 // like this one. When the two move apart by more than drift, the anchor is
4196 // wrong by exactly that much.
4197 146 bool AES67Manager::PtpMonotonicDelta(int64_t& deltaNs) {
4198
1/2
✓ Branch 0 taken 146 times.
✗ Branch 1 not taken.
146 std::lock_guard<std::mutex> lock(m_ptpClockMutex);
4199
2/2
✓ Branch 0 taken 83 times.
✓ Branch 1 taken 63 times.
146 if (!m_ptpClock) {
4200 83 return false;
4201 }
4202
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 const GstClockTime ptpNow = gst_clock_get_time(m_ptpClock);
4203
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 63 times.
63 if (!GST_CLOCK_TIME_IS_VALID(ptpNow)) {
4204 ✗ return false;
4205 }
4206 struct timespec ts;
4207
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 63 times.
63 if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) {
4208 ✗ return false;
4209 }
4210 63 deltaNs = (int64_t)ptpNow - (int64_t)GST_TIMESPEC_TO_TIME(ts);
4211 63 return true;
4212 146 }
4213
4214 144 void AES67Manager::ResetPtpStepReference() {
4215 144 int64_t delta = 0;
4216
1/2
✓ Branch 0 taken 144 times.
✗ Branch 1 not taken.
144 const bool have = PtpMonotonicDelta(delta);
4217
1/2
✓ Branch 0 taken 144 times.
✗ Branch 1 not taken.
144 std::lock_guard<std::mutex> lock(m_ptpStepMutex);
4218 144 m_ptpMonotonicDeltaNs = delta;
4219 144 m_ptpMonotonicDeltaValid = have;
4220
4221 // Same sample, different job: this one is the fixed point
4222 // ReviewPtpNoLockWarning() measures total drift against, so it is taken
4223 // once here -- where the anchors it describes have just been built -- and
4224 // never re-taken while the warning stands.
4225
2/2
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 137 times.
144 if (m_ptpNoLockWarned.load()) {
4226 7 m_ptpNoLockBaselineNs = delta;
4227 7 m_ptpNoLockBaselineValid = have;
4228 }
4229 144 }
4230
4231 // True when the PTP clock has moved out from under the anchors since the last
4232 // sample. Re-samples either way, so what it measures is always one watchdog
4233 // interval of movement rather than everything since the pipelines were built.
4234 1 bool AES67Manager::PtpClockStepped() {
4235 1 int64_t delta = 0;
4236
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
1 if (!PtpMonotonicDelta(delta)) {
4237 // No PTP media clock, so nothing is anchored to PTP and there is
4238 // nothing for a step to invalidate.
4239 ✗ std::lock_guard<std::mutex> lock(m_ptpStepMutex);
4240 ✗ m_ptpMonotonicDeltaValid = false;
4241 ✗ return false;
4242 ✗ }
4243
4244
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::lock_guard<std::mutex> lock(m_ptpStepMutex);
4245 1 const bool had = m_ptpMonotonicDeltaValid;
4246 1 const int64_t previous = m_ptpMonotonicDeltaNs;
4247 1 m_ptpMonotonicDeltaNs = delta;
4248 1 m_ptpMonotonicDeltaValid = true;
4249
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (!had) {
4250 ✗ return false;
4251 }
4252 1 const int64_t moved = delta - previous;
4253 1 const int64_t absMoved = moved < 0 ? -moved : moved;
4254
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (absMoved < AES67::PTP_STEP_DETECT_NS) {
4255 1 return false;
4256 }
4257 ✗ LogWarn(VB_MEDIAOUT,
4258 "AES67 watchdog: PTP clock stepped %+.3f s — the RTP timelines are "
4259 "anchored to where it used to be\n",
4260 (double)moved / 1e9);
4261 ✗ return true;
4262 1 }
4263
4264 // The banner, the flag the watchdog gates on, and the baseline it measures
4265 // against all describe one fact, so they are set and cleared in one place.
4266 //
4267 // The baseline is not taken here. InitPTP() runs before any pipeline exists,
4268 // so the media clock has not been opened yet and PtpMonotonicDelta() has
4269 // nothing to read; ResetPtpStepReference() seeds it instead, at the point
4270 // ApplyConfig() has finished anchoring everything, which is the moment the
4271 // baseline is supposed to describe.
4272 7 void AES67Manager::RaisePtpNoLockWarning() {
4273 {
4274
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 std::lock_guard<std::mutex> lock(m_ptpStepMutex);
4275 7 m_ptpNoLockBaselineValid = false;
4276 7 }
4277 7 m_ptpNoLockWarned.store(true);
4278
2/4
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
21 WarningHolder::AddWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_NO_LOCK);
4279 7 }
4280
4281 206 void AES67Manager::ClearPtpNoLockWarning() {
4282 206 m_ptpNoLockWarned.store(false);
4283 {
4284
1/2
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
206 std::lock_guard<std::mutex> lock(m_ptpStepMutex);
4285 206 m_ptpNoLockBaselineValid = false;
4286 206 }
4287
3/6
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 206 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 206 times.
✗ Branch 5 not taken.
618 WarningHolder::RemoveWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_NO_LOCK);
4288 206 }
4289
4290 // WARNING_PTP_NO_LOCK says the RTP timelines were anchored to a clock that had
4291 // not settled yet. That stops being true on its own: ptp4l commonly reaches
4292 // SLAVE with a usable offset a few seconds after ptpLockWaitMs ran out.
4293 // Nothing used to notice -- the warning has no timeout, ShutdownPTP() was the
4294 // only thing that retracted it, and PtpClockStepped() below only asks for a
4295 // rebuild on a jump of PTP_STEP_DETECT_NS or more *within one watchdog
4296 // interval*. A clock that settles by slewing moves far less than that per
4297 // interval, so the banner and the "rebuilding when it does" badge outlived the
4298 // problem all the way to the next fppd restart. That is what the user sees on
4299 // an ordinary boot where the grandmaster is simply slower to appear than the
4300 // five-second cap allows.
4301 //
4302 // Settling is not on its own enough to retract it, though: the anchors are
4303 // still where InitPTP() left them, so what matters is how far the clock has
4304 // travelled since. Measured against the baseline taken when the warning went
4305 // up -- see m_ptpNoLockBaselineNs for why the step detector's own reference
4306 // cannot answer this.
4307 //
4308 // moved less than a step -> the anchors turned out fine; retract.
4309 // moved a step or more -> they did not; ask for the rebuild, which
4310 // retracts the warning via ShutdownPTP().
4311 //
4312 // Returns true when the caller should rebuild.
4313 1 bool AES67Manager::ReviewPtpNoLockWarning() {
4314
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (!m_ptpNoLockWarned.load()) {
4315 ✗ return false;
4316 }
4317
4318 1 std::string portState;
4319
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 const PtpSettleState settled = PtpSettledNow(&portState);
4320 // NoGrandmasterYet counts as an answer here. Unlike WaitForPtpLock(),
4321 // this runs at least a watchdog interval after the wait gave up, which is
4322 // long past the point where "no announce seen" and "no announce yet" stop
4323 // looking the same.
4324
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (settled == PtpSettleState::NotSettled) {
4325 ✗ return false;
4326 }
4327
4328 1 int64_t delta = 0;
4329
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 const bool haveDelta = PtpMonotonicDelta(delta);
4330 1 int64_t baseline = 0;
4331 1 bool haveBaseline = false;
4332 {
4333
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::lock_guard<std::mutex> lock(m_ptpStepMutex);
4334 1 baseline = m_ptpNoLockBaselineNs;
4335 1 haveBaseline = m_ptpNoLockBaselineValid;
4336 1 }
4337
4338
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 if (haveDelta && haveBaseline) {
4339 1 const int64_t moved = delta - baseline;
4340 1 const int64_t absMoved = moved < 0 ? -moved : moved;
4341
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (absMoved >= AES67::PTP_STEP_DETECT_NS) {
4342 ✗ LogWarn(VB_MEDIAOUT,
4343 "AES67 watchdog: PTP has settled (%s) but moved %+.3f s "
4344 "getting there — the RTP timelines are anchored to where it "
4345 "used to be\n",
4346 portState.c_str(), (double)moved / 1e9);
4347 ✗ return true;
4348 }
4349 }
4350
4351
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT,
4352 "AES67 watchdog: PTP has settled (%s) close to where the streams "
4353 "were anchored — clearing the provisional-anchor warning\n",
4354 portState.c_str());
4355
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 ClearPtpNoLockWarning();
4356 // The badge this drives promises a rebuild that is no longer coming.
4357 1 m_ptpLockedAtStart.store(true);
4358 1 return false;
4359 1 }
4360
4361 9 bool AES67Manager::CheckPtpWatchdog() {
4362
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 7 times.
9 if (!m_config.ptpEnabled) {
4363 2 return false;
4364 }
4365
2/2
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 2 times.
7 if (!m_ptpInitialized) {
4366 // Its start at apply time or a restart below failed (interface down,
4367 // the port briefly held). Everything after this used to return here,
4368 // so PTP stayed down until someone applied the config again.
4369 5 auto now = std::chrono::steady_clock::now();
4370
5/8
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 2 times.
✗ Branch 6 not taken.
✓ Branch 7 taken 5 times.
5 if (m_ptpRetryDelayS > 0 && now < m_ptpRetryAt) {
4371 ✗ return false;
4372 }
4373
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 3 times.
5 m_ptpRetryDelayS = m_ptpRetryDelayS == 0 ? 30 : std::min(m_ptpRetryDelayS * 2, 300);
4374
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 m_ptpRetryAt = now + std::chrono::seconds(m_ptpRetryDelayS);
4375
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 LogInfo(VB_MEDIAOUT, "AES67 watchdog: PTP is not running, starting it\n");
4376
3/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 1 times.
5 if (!InitPTP()) {
4377
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 LogWarn(VB_MEDIAOUT, "AES67 watchdog: PTP start failed, trying again in %ds\n", m_ptpRetryDelayS);
4378 4 return false;
4379 }
4380 1 m_ptpRetryDelayS = 0;
4381 // The streams were built without it (or against the ptp4l that died):
4382 // build them again on the clock that is disciplined now.
4383
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "AES67 watchdog: PTP started, rebuilding the streams on it\n");
4384 1 return true;
4385 }
4386
4387
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 if (!IsPtp4lRunning()) {
4388 1 LogWarn(VB_MEDIAOUT, "AES67 watchdog: ptp4l is gone — restarting PTP\n");
4389 // Tear down first: InitPTP() is a no-op while m_ptpInitialized is set,
4390 // and phc2sys must not be left pointed at a dead daemon.
4391 1 ShutdownPTP();
4392
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (InitPTP()) {
4393 1 LogInfo(VB_MEDIAOUT, "AES67 watchdog: ptp4l restarted\n");
4394 } else {
4395 // Retried on the next passes; see above.
4396 ✗ m_ptpRetryDelayS = 0;
4397 ✗ LogErr(VB_MEDIAOUT, "AES67 watchdog: ptp4l restart failed\n");
4398 }
4399 // A fresh ptp4l may have stepped the clock on its way back to lock, and
4400 // the reference we were holding describes the old one either way.
4401 1 ResetPtpStepReference();
4402 1 return false;
4403 }
4404
4405 // A clock that locks *after* the pipelines were built breaks them exactly
4406 // as the cold-boot race did (issue #2848): the grandmaster that boots
4407 // slower than the Pi, or a BMCA changeover to a device on a different
4408 // epoch. rtpL24pay latches timestamp-offset on the segment, so there is
4409 // no re-anchoring in place -- the only fix is to build them again, which
4410 // is cheap and is what the under-delivery path already does 90 seconds
4411 // later. This just gets there without waiting for the symptom.
4412
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (PtpClockStepped()) {
4413 ✗ return true;
4414 }
4415
4416 // No step, but the clock may still have quietly settled since the streams
4417 // started -- in which case a warning is standing that nothing else will
4418 // ever take down.
4419 1 return ReviewPtpNoLockWarning();
4420 }
4421
4422 9 bool AES67Manager::PollPipelinesWatchdog() {
4423 9 bool needsRebuild = false;
4424
4425 { // scope for pipeline mutex
4426
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 std::lock_guard<std::mutex> lock(m_pipelineMutex);
4427
4428 18 auto checkPipelines = [this, &needsRebuild](std::map<int, AES67Pipeline>& pipelines, const char* direction) {
4429
2/2
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 18 times.
23 for (auto& kv : pipelines) {
4430 5 AES67Pipeline& p = kv.second;
4431
2/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 5 times.
6 if (!p.pipeline || !p.running) continue;
4432
4433 // Drain bus messages (error, warning, state changes)
4434
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 if (p.bus) {
4435 GstMessage* msg;
4436
3/4
✓ Branch 0 taken 64102 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 64097 times.
✓ Branch 3 taken 5 times.
64102 while ((msg = gst_bus_pop(p.bus)) != nullptr) {
4437
3/3
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 63959 times.
✓ Branch 2 taken 137 times.
64097 switch (GST_MESSAGE_TYPE(msg)) {
4438 1 case GST_MESSAGE_ERROR: {
4439 1 GError* err = nullptr; gchar* dbg = nullptr;
4440
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 gst_message_parse_error(msg, &err, &dbg);
4441
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogErr(VB_MEDIAOUT,
4442 "AES67 %s pipeline [%d] bus error: %s\n",
4443 direction, p.instanceId, err->message);
4444
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
2 p.errorMessage = std::string("GStreamer error: ") + err->message;
4445 1 p.busError = true;
4446
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 g_error_free(err); g_free(dbg);
4447 1 break;
4448 }
4449 63959 case GST_MESSAGE_WARNING: {
4450 63959 GError* err = nullptr; gchar* dbg = nullptr;
4451
1/2
✓ Branch 0 taken 63959 times.
✗ Branch 1 not taken.
63959 gst_message_parse_warning(msg, &err, &dbg);
4452
1/2
✓ Branch 0 taken 63959 times.
✗ Branch 1 not taken.
63959 LogWarn(VB_MEDIAOUT,
4453 "AES67 %s pipeline [%d] bus warning: %s\n",
4454 direction, p.instanceId, err->message);
4455
2/4
✓ Branch 0 taken 63959 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63959 times.
✗ Branch 3 not taken.
63959 g_error_free(err); g_free(dbg);
4456 63959 break;
4457 }
4458 137 default: break;
4459 }
4460
1/2
✓ Branch 0 taken 64097 times.
✗ Branch 1 not taken.
64097 gst_message_unref(msg);
4461 }
4462 }
4463
4464 // Already known dead; the bus-error backoff below decides when it
4465 // is rebuilt, and the stall checks would only bypass it.
4466
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 4 times.
5 if (p.busError) continue;
4467
4468 // Check current pipeline state
4469 4 GstState curState = GST_STATE_NULL, pendingState = GST_STATE_VOID_PENDING;
4470
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_element_get_state(p.pipeline, &curState, &pendingState, 0 /* no blocking */);
4471
4472
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
4 if (curState != GST_STATE_PLAYING && pendingState != GST_STATE_PLAYING) {
4473 // Don't attempt recovery while holding m_pipelineMutex —
4474 // gst_element_set_state can block on PipeWire indefinitely.
4475 // Just flag for a full rebuild instead.
4476 ✗ LogWarn(VB_MEDIAOUT,
4477 "AES67 %s pipeline [%d] is in %s state — flagging for rebuild\n",
4478 direction, p.instanceId,
4479 gst_element_state_get_name(curState));
4480 ✗ p.running = false;
4481 ✗ p.errorMessage = "Watchdog: not in PLAYING state";
4482 ✗ needsRebuild = true;
4483
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 } else if (p.isSend) {
4484 // Zombie detection for send pipelines: check if udpsink
4485 // is actually pushing bytes. pipewiresrc can lose its
4486 // PipeWire socket connection (e.g. PipeWire restart) while
4487 // GStreamer still reports PLAYING — producing no output.
4488
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 GstElement* usink = gst_bin_get_by_name(GST_BIN(p.pipeline), "usink");
4489
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 if (usink) {
4490 4 guint64 bytesSent = 0;
4491
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 g_object_get(usink, "bytes-served", &bytesSent, NULL);
4492
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_object_unref(usink);
4493
4494 // Retune the pacing shift once, when the pipeline has
4495 // actually prerolled and can answer a latency query. The
4496 // presentation lead is (shift - latency), and latency is
4497 // one graph quantum -- 21.3ms at 1024/48000 but 42.7ms at
4498 // 2048. A fixed 40ms shift is comfortably positive on the
4499 // first and negative on the second, and negative means a
4500 // conformant receiver discards every packet.
4501
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
4 if (!p.pacingTuned) {
4502
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 auto sit = m_sinkPacingShift.find(p.instanceId);
4503
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.
2 if (sit != m_sinkPacingShift.end() && sit->second) {
4504
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 GstQuery* lq = gst_query_new_latency();
4505
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 if (gst_element_query(p.pipeline, lq)) {
4506 2 gboolean live = FALSE;
4507 2 GstClockTime minL = 0, maxL = 0;
4508
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_query_parse_latency(lq, &live, &minL, &maxL);
4509
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 if (GST_CLOCK_TIME_IS_VALID(minL) && minL > 0) {
4510 2 const GstClockTime want =
4511 2 minL + 16 * GST_MSECOND;
4512 const GstClockTime cur =
4513 2 sit->second->load(
4514 2 std::memory_order_relaxed);
4515 const GstClockTime shift =
4516 2 std::max(want, cur);
4517
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 if (shift != cur) {
4518 ✗ sit->second->store(
4519 shift, std::memory_order_relaxed);
4520 ✗ GstElement* us2 = gst_bin_get_by_name(
4521 ✗ GST_BIN(p.pipeline), "usink");
4522 ✗ if (us2) {
4523 ✗ g_object_set(us2, "ts-offset",
4524 ✗ -(gint64)shift, NULL);
4525 ✗ gst_object_unref(us2);
4526 }
4527 }
4528
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogInfo(VB_MEDIAOUT,
4529 "AES67 send [%d]: pipeline latency %.1fms, timestamp lead %.1fms\n",
4530 p.instanceId,
4531 (double)minL / GST_MSECOND,
4532 (double)shift / GST_MSECOND);
4533 2 p.pacingTuned = true;
4534 }
4535 }
4536
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_query_unref(lq);
4537 }
4538 }
4539
4540 // Sink-pacing diagnostics. The queue is the whole reason
4541 // pacing can be done without starving the source, so its
4542 // depth is what explains a stream that paces correctly for
4543 // a minute and then reverts to bursting: empty means the
4544 // sink is starved and every buffer arrives past its send
4545 // time, full means backpressure has reached pipewiresrc and
4546 // audio is being dropped there. No element by this name
4547 // exists unless sink pacing is on, so this costs nothing
4548 // and needs no lock on the config.
4549 // Whether the rate matcher is actually correcting is a
4550 // readable fact here, not an inference -- which is the
4551 // whole reason it was chosen over "speed" and "pitch".
4552 // At ~56ppm this should climb by ~2.7 samples/s.
4553
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 GstElement* rm = gst_bin_get_by_name(GST_BIN(p.pipeline),
4554 "ratematch");
4555
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if (rm) {
4556 ✗ guint64 added = 0, dropped = 0, in = 0, out = 0;
4557 ✗ g_object_get(rm, "add", &added, "drop", &dropped,
4558 "in", &in, "out", &out, NULL);
4559 ✗ gst_object_unref(rm);
4560 ✗ LogInfo(VB_MEDIAOUT,
4561 "AES67 send [%d] ratematch: in %llu out %llu, added %llu dropped %llu\n",
4562 p.instanceId, (unsigned long long)in,
4563 (unsigned long long)out,
4564 (unsigned long long)added,
4565 (unsigned long long)dropped);
4566 }
4567
4568
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 GstElement* sinkq = gst_bin_get_by_name(GST_BIN(p.pipeline),
4569 "sinkq");
4570
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 if (sinkq) {
4571 4 guint qBuffers = 0;
4572 4 guint64 qTime = 0;
4573
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 g_object_get(sinkq, "current-level-buffers", &qBuffers,
4574 "current-level-time", &qTime, NULL);
4575
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_object_unref(sinkq);
4576
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 LogInfo(VB_MEDIAOUT,
4577 "AES67 send [%d] pacing: queue %u buffers / %llums, +%llu bytes since last check\n",
4578 p.instanceId, qBuffers,
4579 (unsigned long long)(qTime / GST_MSECOND),
4580 (unsigned long long)(bytesSent - p.lastByteCount));
4581 }
4582
4583 // Compare throughput against what this instance should
4584 // be emitting. Payload is ptime worth of 24-bit frames,
4585 // and at nominal that is AUDIO_RATE * 3 * channels bytes a
4586 // second regardless of ptime. bytes-served counts whole
4587 // RTP packets, though, so each packet's 12-byte header is
4588 // in it too: leaving that out had a healthy stream read
4589 // 104% at 1ms ptime (101% at 4ms), and a stream delivering
4590 // 94% of its audio read 98% and never tripped the check.
4591 4 const auto nowT = std::chrono::steady_clock::now();
4592
4/4
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 3 times.
6 if (p.lastByteTime.time_since_epoch().count() != 0 &&
4593
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 bytesSent > p.lastByteCount) {
4594 const double secs =
4595
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 std::chrono::duration<double>(nowT - p.lastByteTime)
4596 1 .count();
4597 1 constexpr double RTP_HEADER_BYTES = 12.0;
4598 1 const double expected =
4599 1 (double)AES67::AUDIO_RATE * 3.0 * p.channels +
4600
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 (p.ptime > 0 ? RTP_HEADER_BYTES * 1000.0 / p.ptime : 0.0);
4601
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 if (secs > 5.0 && expected > 0) {
4602 1 const double got =
4603 1 (double)(bytesSent - p.lastByteCount) / secs;
4604 // 95%, not something looser: the degradation
4605 // settles at 89-94% of nominal, so an 85% trigger
4606 // sat below every case actually observed and would
4607 // never have fired. A healthy stream measures
4608 // 100.0% consistently, so the margin is real, and
4609 // three consecutive checks means ~90s of sustained
4610 // under-delivery -- far longer than the brief dip a
4611 // track change produces.
4612
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (got < expected * 0.95) {
4613 1 p.lowRateCount++;
4614
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT,
4615 "AES67 %s pipeline [%d] under-delivering: %.0f of %.0f B/s (%.0f%%), check %d\n",
4616 direction, p.instanceId, got, expected,
4617 100.0 * got / expected, p.lowRateCount);
4618
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (p.lowRateCount >= 3) {
4619 ✗ p.running = false;
4620 p.errorMessage =
4621 ✗ "Watchdog: sustained under-delivery";
4622 ✗ needsRebuild = true;
4623 }
4624 } else {
4625 ✗ p.lowRateCount = 0;
4626 }
4627 }
4628 }
4629 4 p.lastByteTime = nowT;
4630
4631
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
4 if (bytesSent == p.lastByteCount) {
4632 2 p.stallCount++;
4633
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 if (p.stallCount >= 2) {
4634
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT,
4635 "AES67 %s pipeline [%d] stalled (bytes-sent=%llu for %d checks) — scheduling rebuild\n",
4636 direction, p.instanceId, (unsigned long long)bytesSent, p.stallCount);
4637 1 p.running = false;
4638
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 p.errorMessage = "Watchdog: pipeline stalled";
4639 1 needsRebuild = true;
4640 }
4641 } else {
4642 2 p.stallCount = 0;
4643 }
4644 4 p.lastByteCount = bytesSent;
4645 }
4646 }
4647 }
4648 18 };
4649
4650
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 checkPipelines(m_sendPipelines, "send");
4651
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 checkPipelines(m_recvPipelines, "recv");
4652
4653 // An ERROR stops the pipeline's streaming for good, and a receive
4654 // pipeline has nothing else that would ever notice. The first one
4655 // rebuilds at once; one that keeps coming back waits 30s, 60s, ... up to
4656 // 8 minutes between rebuilds, and the count starts over after 10 quiet
4657 // minutes. A stream that failed to start at all is never stored, so
4658 // nothing above sees it; it is retried on the same backoff.
4659 {
4660 9 const auto now = std::chrono::steady_clock::now();
4661 9 std::vector<AES67Pipeline*> errored;
4662
2/2
✓ Branch 0 taken 18 times.
✓ Branch 1 taken 9 times.
27 for (auto* pipelines : { &m_sendPipelines, &m_recvPipelines }) {
4663
2/2
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 18 times.
23 for (auto& kv : *pipelines) {
4664
3/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 4 times.
5 if (kv.second.pipeline && kv.second.busError) {
4665
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 errored.push_back(&kv.second);
4666 }
4667 }
4668 }
4669
2/10
✗ Branch 0 not taken.
✓ Branch 1 taken 9 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 taken 9 times.
9 if (m_errorRebuilds > 0 && now - m_lastErrorRebuild > std::chrono::minutes(10)) {
4670 ✗ m_errorRebuilds = 0;
4671 }
4672
6/6
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 4 times.
✓ Branch 4 taken 5 times.
✓ Branch 5 taken 4 times.
9 if (!errored.empty() || m_failedStarts > 0) {
4673 5 const auto wait = std::chrono::seconds(30) * (1 << std::min(std::max(m_errorRebuilds - 1, 0), 4));
4674
3/12
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 5 times.
✗ 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 taken 5 times.
✗ Branch 11 not taken.
5 if (needsRebuild || m_errorRebuilds == 0 || now - m_lastErrorRebuild >= wait) {
4675
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 if (!needsRebuild) {
4676 5 m_errorRebuilds++;
4677 5 m_lastErrorRebuild = now;
4678 }
4679 5 needsRebuild = true;
4680
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 5 times.
6 for (AES67Pipeline* p : errored) {
4681 1 p->running = false;
4682 }
4683
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 LogWarn(VB_MEDIAOUT, "AES67 watchdog: %d pipeline(s) stopped on a bus error, "
4684 "%d failed to start\n",
4685 (int)errored.size(), m_failedStarts);
4686 } else {
4687 ✗ LogWarn(VB_MEDIAOUT,
4688 "AES67 watchdog: %d pipeline(s) stopped on a bus error, %d failed to start, "
4689 "rebuilding in %llds (error rebuild %d)\n",
4690 (int)errored.size(), m_failedStarts,
4691 (long long)std::chrono::duration_cast<std::chrono::seconds>(
4692 wait - (now - m_lastErrorRebuild)).count(),
4693 m_errorRebuilds + 1);
4694 }
4695 }
4696 9 }
4697
4698 // If any pipeline needs rebuilding, re-apply config.
4699 // ApplyConfig() tears down all pipelines and recreates them with fresh
4700 // PipeWire connections.
4701
2/2
✓ Branch 0 taken 6 times.
✓ Branch 1 taken 3 times.
9 if (needsRebuild) {
4702
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 LogWarn(VB_MEDIAOUT, "AES67 watchdog: triggering full pipeline rebuild\n");
4703 }
4704 9 } // end pipeline mutex scope
4705
4706 9 return needsRebuild;
4707 }
4708
4709 // ──────────────────────────────────────────────────────────────────────────────
4710 // Network helpers
4711 // ──────────────────────────────────────────────────────────────────────────────
4712 2033 std::string AES67Manager::GetInterfaceIP(const std::string& iface) {
4713 2033 struct ifaddrs* addrs = nullptr;
4714
1/2
✓ Branch 0 taken 2033 times.
✗ Branch 1 not taken.
2033 std::string result = "0.0.0.0";
4715
4716
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2033 times.
2033 if (getifaddrs(&addrs) != 0) {
4717 ✗ return result;
4718 }
4719
4720
2/2
✓ Branch 0 taken 10175 times.
✓ Branch 1 taken 2023 times.
12198 for (struct ifaddrs* ifa = addrs; ifa; ifa = ifa->ifa_next) {
4721
3/4
✓ Branch 0 taken 10175 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6101 times.
✓ Branch 3 taken 4074 times.
10175 if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) continue;
4722
7/8
✓ Branch 0 taken 3742 times.
✓ Branch 1 taken 332 times.
✓ Branch 2 taken 3742 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1867 times.
✓ Branch 5 taken 1875 times.
✓ Branch 6 taken 2199 times.
✓ Branch 7 taken 1875 times.
4074 if (iface.empty() || iface == ifa->ifa_name) {
4723
4/6
✓ Branch 0 taken 2199 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2199 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2023 times.
✓ Branch 5 taken 176 times.
4398 if (std::string(ifa->ifa_name) == "lo") continue;
4724 176 struct sockaddr_in* addr = (struct sockaddr_in*)ifa->ifa_addr;
4725 char buf[INET_ADDRSTRLEN];
4726 176 inet_ntop(AF_INET, &addr->sin_addr, buf, sizeof(buf));
4727
1/2
✓ Branch 0 taken 176 times.
✗ Branch 1 not taken.
176 result = buf;
4728
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 166 times.
176 if (!iface.empty()) break; // found the specific interface
4729 }
4730 }
4731
4732 2033 freeifaddrs(addrs);
4733 2033 return result;
4734 ✗ }
4735
4736 357 std::string AES67Manager::SafeNodeName(const std::string& name) {
4737
1/2
✓ Branch 0 taken 357 times.
✗ Branch 1 not taken.
357 std::string result = "aes67_";
4738
2/2
✓ Branch 0 taken 788 times.
✓ Branch 1 taken 357 times.
1145 for (char c : name) {
4739
3/4
✓ Branch 0 taken 6 times.
✓ Branch 1 taken 782 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 6 times.
788 if (std::isalnum(c) || c == '_') {
4740
1/2
✓ Branch 0 taken 782 times.
✗ Branch 1 not taken.
782 result += std::tolower(c);
4741 } else {
4742
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 result += '_';
4743 }
4744 }
4745 357 return result;
4746 ✗ }
4747
4748 // ──────────────────────────────────────────────────────────────────────────────
4749 // SAP Announcer — RFC 2974 compliant
4750 // Replaces external fpp_aes67_sap Python daemon
4751 // ──────────────────────────────────────────────────────────────────────────────
4752 1 uint16_t AES67Manager::ComputeSAPHash(const std::string& sdp) {
4753 // RFC 2974 §6: the message id hash identifies "the precise version of this
4754 // announcement" and MUST change when the payload changes. It is therefore
4755 // computed over the SDP text, not over the instance: an announcement whose
4756 // refclk we corrected but whose hash stayed put is discarded as a repeat by
4757 // a compliant receiver, which is how a stale ts-refclk survives forever.
4758 //
4759 // Session *identity* is carried by the SDP o= line (stable sess-id, rising
4760 // sess-version), so a changed hash reads as "this session was modified",
4761 // not as a second session.
4762 1 uint32_t hash = 2166136261u;
4763
2/2
✓ Branch 0 taken 233 times.
✓ Branch 1 taken 1 times.
234 for (char c : sdp) {
4764 233 hash ^= (uint32_t)(unsigned char)c;
4765 233 hash *= 16777619u;
4766 }
4767 1 return (uint16_t)(hash & 0xFFFF);
4768 }
4769
4770 // Monotonic SDP o= version. Seeded from the wall clock so it keeps rising
4771 // across fppd restarts -- a receiver that has cached version N ignores a
4772 // re-announcement numbered below it.
4773 1 void AES67Manager::LoadSDPVersion() {
4774
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (m_sdpVersionPath.empty()) {
4775
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
2 m_sdpVersionPath = getFPPMediaDir("/config/.aes67-sdp-version");
4776 }
4777
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::ifstream f(m_sdpVersionPath);
4778
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (!f.is_open()) {
4779 1 return;
4780 }
4781 ✗ std::string key;
4782 ✗ uint64_t ver = 0;
4783 ✗ f >> key >> ver;
4784 ✗ if (!key.empty() && ver > 0) {
4785 ✗ m_sdpBodyKey = key;
4786 ✗ m_lastSdpVersion.store((uint32_t)ver, std::memory_order_relaxed);
4787 }
4788
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 }
4789
4790 1 void AES67Manager::SaveSDPVersion() {
4791
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (m_sdpVersionPath.empty()) {
4792 ✗ return;
4793 }
4794
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::ofstream f(m_sdpVersionPath, std::ios::trunc);
4795
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (f.is_open()) {
4796
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.
2 f << m_sdpBodyKey << " " << m_lastSdpVersion.load(std::memory_order_relaxed) << "\n";
4797 }
4798 1 }
4799
4800 // Returns the version to stamp on this announcement. Same body as last time =
4801 // same version, so the SDP (and therefore the SAP msg id hash derived from it)
4802 // is byte-identical and receivers see one continuing session rather than a new
4803 // one per restart.
4804 1 uint32_t AES67Manager::SDPVersionFor(const std::string& body) {
4805
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (m_sdpVersionPath.empty()) {
4806
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LoadSDPVersion();
4807 }
4808
4809 1 uint32_t h = 2166136261u;
4810
2/2
✓ Branch 0 taken 224 times.
✓ Branch 1 taken 1 times.
225 for (char c : body) {
4811 224 h ^= (uint32_t)(unsigned char)c;
4812 224 h *= 16777619u;
4813 }
4814 char keyBuf[16];
4815 1 snprintf(keyBuf, sizeof(keyBuf), "%08x", h);
4816
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::string key(keyBuf);
4817
4818
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (key == m_sdpBodyKey) {
4819 ✗ uint32_t existing = m_lastSdpVersion.load(std::memory_order_relaxed);
4820 ✗ if (existing > 0) {
4821 ✗ return existing;
4822 }
4823 }
4824
4825
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 uint32_t v = NextSDPVersion();
4826
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 m_sdpBodyKey = key;
4827
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 SaveSDPVersion();
4828
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "AES67 SAP: announcement content changed, SDP version now %u\n", v);
4829 1 return v;
4830 1 }
4831
4832 1 uint32_t AES67Manager::NextSDPVersion() {
4833 1 uint32_t candidate = (uint32_t)time(nullptr);
4834 2 uint32_t prev = m_lastSdpVersion.load(std::memory_order_relaxed);
4835 uint32_t next;
4836 do {
4837
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 next = (candidate > prev) ? candidate : prev + 1;
4838
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
2 } while (!m_lastSdpVersion.compare_exchange_weak(prev, next, std::memory_order_relaxed));
4839 1 return next;
4840 }
4841
4842 1467 std::string AES67Manager::BuildSDP(const AES67Instance& inst,
4843 const std::string& sourceIP,
4844 const std::string& ptpClockId,
4845 uint32_t sdpVersion) {
4846 // AES67-compliant SDP — unique session ID per device + stream.
4847 // Combine source IP, stream name, multicast IP, and port so that
4848 // different FPP boxes (or different streams on the same box) always
4849 // produce distinct o= lines.
4850
2/6
✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
2934 std::string key = sourceIP + ":" + inst.name + ":" +
4851
4/8
✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1467 times.
✗ Branch 7 not taken.
4401 inst.multicastIP + ":" + std::to_string(inst.port);
4852 // FNV-1a hash → deterministic, stable across restarts, unique per key
4853 1467 uint32_t h = 2166136261u;
4854
2/2
✓ Branch 0 taken 36741 times.
✓ Branch 1 taken 1467 times.
38208 for (char c : key) {
4855 36741 h ^= (uint32_t)(unsigned char)c;
4856 36741 h *= 16777619u;
4857 }
4858 1467 int sessionId = (int)(h & 0x3FFFFFFFu); // 30-bit positive value
4859
4860 // o=<user> <sess-id> <sess-version> ... RFC 4566: sess-id identifies the
4861 // session and must stay put for its lifetime; sess-version rises each time
4862 // the description is modified, which is how a receiver knows to re-read a
4863 // session it already has (e.g. after BMCA changed the ts-refclk).
4864
1/2
✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
1467 std::ostringstream sdp;
4865 sdp << "v=0\r\n"
4866
5/10
✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1467 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1467 times.
✗ Branch 9 not taken.
1467 << "o=- " << sessionId << " " << sdpVersion << " IN IP4 " << sourceIP << "\r\n"
4867 1467 << "s=" << inst.sessionName << "\r\n"
4868
10/20
✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1467 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1467 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 1467 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 1467 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 1467 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 1467 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 1467 times.
✗ Branch 19 not taken.
1467 << "c=IN IP4 " << inst.multicastIP << "/" << AES67::AUDIO_RTP_TTL << "\r\n"
4869 << "t=0 0\r\n"
4870
6/12
✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1467 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1467 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 1467 times.
✗ Branch 11 not taken.
1467 << "m=audio " << inst.port << " RTP/AVP " << AES67::RTP_PAYLOAD_TYPE << "\r\n"
4871
4/8
✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1467 times.
✗ Branch 7 not taken.
1467 << "a=rtpmap:" << AES67::RTP_PAYLOAD_TYPE << " L24/"
4872
3/6
✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
1467 << AES67::AUDIO_RATE << "/" << inst.channels << "\r\n"
4873 << "a=sendonly\r\n"
4874
4/8
✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1467 times.
✗ Branch 7 not taken.
1467 << "a=ptime:" << inst.ptime << "\r\n"
4875 << "a=ts-refclk:ptp=IEEE1588-2008:" << ptpClockId << ":0\r\n"
4876
5/10
✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1467 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1467 times.
✗ Branch 9 not taken.
1467 << "a=mediaclk:direct=0\r\n";
4877 // NOTE: AES67 requires a=mediaclk:direct, and "direct=0" asserts that the
4878 // RTP timestamp is derived directly from the reference clock with zero
4879 // offset. That is not yet true here -- the payloader's timestamps come
4880 // from the pipeline's monotonic clock with its own start offset, so the
4881 // rate is PTP-locked but the phase is arbitrary. Making the assertion
4882 // true needs a GstPtpClock on the send pipeline plus an explicit
4883 // rtpL24pay timestamp-offset derived from PTP time.
4884
4885
1/2
✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
2934 return sdp.str();
4886 1467 }
4887
4888 // Filename to offer for a downloaded .sdp. Not SafeNodeName(): that prefixes
4889 // "aes67_" for the PipeWire node namespace, and instance names usually already
4890 // start with "AES67", which produced aes67_aes67_stream_1.sdp.
4891 1465 static std::string SDPFileName(const AES67Instance& inst) {
4892 1465 std::string base;
4893
2/2
✓ Branch 0 taken 2987 times.
✓ Branch 1 taken 1465 times.
4452 for (char c : inst.name) {
4894
4/6
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 2982 times.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 5 times.
2987 if (std::isalnum((unsigned char)c) || c == '_' || c == '-') {
4895
1/2
✓ Branch 0 taken 2982 times.
✗ Branch 1 not taken.
2982 base += (char)std::tolower((unsigned char)c);
4896
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 } else if (!base.empty() && base.back() != '_') {
4897
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 base += '_';
4898 }
4899 }
4900
3/6
✓ Branch 0 taken 1465 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1465 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 1465 times.
1465 while (!base.empty() && base.back() == '_') {
4901 ✗ base.pop_back();
4902 }
4903
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1465 times.
1465 if (base.empty()) {
4904 ✗ base = "aes67_" + std::to_string(inst.id);
4905 }
4906
1/2
✓ Branch 0 taken 1465 times.
✗ Branch 1 not taken.
2930 return base + ".sdp";
4907 1465 }
4908
4909 1465 std::string AES67Manager::ExportSDPFor(const AES67Instance& inst,
4910 const AES67Config& cfg) {
4911 // Same refclk rule as the announcer: advertise the domain's grandmaster,
4912 // not our own identity, and fall back to our own only when BMCA has not
4913 // settled or PTP is off.
4914
1/2
✓ Branch 0 taken 1465 times.
✗ Branch 1 not taken.
1465 std::string gm = GetActiveGrandmasterId();
4915
4/6
✓ Branch 0 taken 1464 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1465 std::string ptpClockId = gm.empty() ? GetPTPClockId() : gm;
4916 1465 std::string sourceIP = GetInterfaceIP(inst.interface.empty() ?
4917
3/4
✓ Branch 0 taken 1462 times.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 1465 times.
✗ Branch 3 not taken.
1465 cfg.ptpInterface : inst.interface);
4918
4919 // Reuse whatever version the announcer last stamped so an exported file
4920 // and the SAP announcement describe the same revision of the session.
4921 // Not SDPVersionFor(): that mints a new version and rewrites the version
4922 // file, and a GET must not renumber the sessions being announced. With
4923 // SAP off, or before the first announcement, there is no version to match
4924 // and 1 will do -- sess-version only ever gets compared against an earlier
4925 // reading of the same session.
4926 1465 uint32_t version = m_lastSdpVersion.load(std::memory_order_relaxed);
4927
1/2
✓ Branch 0 taken 1465 times.
✗ Branch 1 not taken.
1465 if (version == 0) {
4928 1465 version = 1;
4929 }
4930
1/2
✓ Branch 0 taken 1465 times.
✗ Branch 1 not taken.
2930 return BuildSDP(inst, sourceIP, ptpClockId, version);
4931 1465 }
4932
4933 2 std::vector<uint8_t> AES67Manager::BuildSAPPacket(const std::string& sourceIP,
4934 uint16_t msgIdHash,
4935 const std::string& sdp,
4936 bool isDeletion) {
4937 // RFC 2974 SAP packet:
4938 // Byte 0: V=1 (bits 7-5), A=0 (bit 4), R=0 (bit 3), T=isDeletion (bit 2),
4939 // E=0 (bit 1), C=0 (bit 0)
4940 // Byte 1: Auth length = 0
4941 // Bytes 2-3: Message ID hash (network byte order)
4942 // Bytes 4-7: Originating source (IPv4)
4943 // Payload type: "application/sdp\0"
4944 // SDP data
4945
4946
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 std::string payloadType = "application/sdp";
4947
4948 2 uint8_t header0 = (AES67::SAP_VERSION << 5); // V=1
4949
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 if (isDeletion) {
4950 1 header0 |= 0x04; // T=1 (deletion)
4951 }
4952
4953 // Parse source IP
4954 struct in_addr srcAddr;
4955 2 inet_pton(AF_INET, sourceIP.c_str(), &srcAddr);
4956
4957 2 std::vector<uint8_t> packet;
4958
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 packet.push_back(header0);
4959
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 packet.push_back(0); // auth length
4960
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 packet.push_back((msgIdHash >> 8) & 0xFF);
4961
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 packet.push_back(msgIdHash & 0xFF);
4962
4963 // Source IP (4 bytes, network order)
4964 2 uint8_t* ipBytes = (uint8_t*)&srcAddr.s_addr;
4965
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 packet.push_back(ipBytes[0]);
4966
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 packet.push_back(ipBytes[1]);
4967
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 packet.push_back(ipBytes[2]);
4968
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 packet.push_back(ipBytes[3]);
4969
4970 // Payload type string + NUL
4971
2/2
✓ Branch 0 taken 30 times.
✓ Branch 1 taken 2 times.
32 for (char c : payloadType) {
4972
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 packet.push_back((uint8_t)c);
4973 }
4974
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 packet.push_back(0);
4975
4976 // SDP data
4977
2/2
✓ Branch 0 taken 466 times.
✓ Branch 1 taken 2 times.
468 for (char c : sdp) {
4978
1/2
✓ Branch 0 taken 466 times.
✗ Branch 1 not taken.
466 packet.push_back((uint8_t)c);
4979 }
4980
4981 2 return packet;
4982 2 }
4983
4984 1 void AES67Manager::SAPAnnounceLoop() {
4985
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "AES67 SAP announcer thread started\n");
4986
4987 // Create UDP socket for SAP multicast
4988 1 int sock = OpenSocket(AF_INET, SOCK_DGRAM, 0);
4989
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (sock < 0) {
4990 ✗ LogErr(VB_MEDIAOUT, "AES67 SAP: Failed to create socket: %s\n", FPPstrerror(errno));
4991 ✗ return;
4992 }
4993
4994 // Set TTL
4995 1 int ttl = AES67::SAP_TTL;
4996 1 setsockopt(sock, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl));
4997
4998 // Set multicast interface if specified
4999
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (!m_config.ptpInterface.empty()) {
5000
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::string ifIP = GetInterfaceIP(m_config.ptpInterface);
5001 struct in_addr localAddr;
5002 1 inet_pton(AF_INET, ifIP.c_str(), &localAddr);
5003 1 setsockopt(sock, IPPROTO_IP, IP_MULTICAST_IF, &localAddr, sizeof(localAddr));
5004 1 }
5005
5006 struct sockaddr_in sapAddr;
5007 1 memset(&sapAddr, 0, sizeof(sapAddr));
5008 1 sapAddr.sin_family = AF_INET;
5009 1 sapAddr.sin_port = htons(AES67::SAP_PORT);
5010 1 inet_pton(AF_INET, AES67::SAP_MCAST_ADDRESS, &sapAddr.sin_addr);
5011
5012 // SDP's ts-refclk must identify the domain's actual grandmaster (RFC 7273),
5013 // not this node's own identity — otherwise a follower incorrectly
5014 // advertises itself as the clock source. Fall back to our own derived
5015 // ID only if PTP is disabled or ptp4l hasn't selected a grandmaster yet.
5016 63 auto queryPtpClockId = [this]() -> std::string {
5017
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 std::string gm = GetActiveGrandmasterId();
5018
2/6
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
126 return gm.empty() ? GetPTPClockId() : gm;
5019 63 };
5020
5021 // Build all SAP packets for send instances
5022 struct SAPEntry {
5023 uint16_t hash;
5024 std::vector<uint8_t> announcePacket;
5025 std::vector<uint8_t> deletePacket;
5026 };
5027 // Senders held idle because nothing feeds them are not on the wire, so
5028 // they must not be announced -- a receiver that subscribes to an announced
5029 // stream carrying no packets has no way to tell that from a broken sender.
5030 // Snapshotted here rather than read per announce: ApplyConfig() restarts
5031 // this thread whenever the set can change.
5032 1 std::set<int> heldSenders;
5033 {
5034
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::lock_guard<std::mutex> lock(m_pipelineMutex);
5035
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 for (const auto& [id, d] : m_deferredSenders) {
5036 ✗ heldSenders.insert(id);
5037 }
5038 1 }
5039
5040 1 auto buildEntries = [this, &heldSenders](const std::string& ptpClockId) -> std::vector<SAPEntry> {
5041 1 std::vector<SAPEntry> result;
5042
5043 // Version the announcement by what is in it. Building the bodies with
5044 // a fixed placeholder version first gives a stable key to compare
5045 // against the last announcement -- see SDPVersionFor().
5046 1 std::string bodyKey;
5047
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 for (const auto& inst : m_config.instances) {
5048
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
1 if (!inst.enabled || !inst.sapEnabled) continue;
5049
3/10
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 1 times.
1 if (inst.mode != "send" && inst.mode != "both") continue;
5050
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
1 if (heldSenders.count(inst.id)) continue;
5051 1 std::string sourceIP = GetInterfaceIP(inst.interface.empty() ?
5052
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 m_config.ptpInterface : inst.interface);
5053
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 bodyKey += BuildSDP(inst, sourceIP, ptpClockId, 0);
5054 1 }
5055
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 uint32_t sdpVersion = SDPVersionFor(bodyKey);
5056
5057
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 for (const auto& inst : m_config.instances) {
5058
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (!inst.enabled) continue;
5059
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (!inst.sapEnabled) continue;
5060
3/10
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 1 times.
1 if (inst.mode != "send" && inst.mode != "both") continue;
5061
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
1 if (heldSenders.count(inst.id)) continue;
5062
5063 1 std::string sourceIP = GetInterfaceIP(inst.interface.empty() ?
5064
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 m_config.ptpInterface : inst.interface);
5065
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::string sdp = BuildSDP(inst, sourceIP, ptpClockId, sdpVersion);
5066
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 uint16_t hash = ComputeSAPHash(sdp);
5067
5068 1 SAPEntry entry;
5069 1 entry.hash = hash;
5070
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 entry.announcePacket = BuildSAPPacket(sourceIP, hash, sdp, false);
5071
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 entry.deletePacket = BuildSAPPacket(sourceIP, hash, sdp, true);
5072
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 result.push_back(entry);
5073 1 }
5074 1 return result;
5075 1 };
5076
5077
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::string ptpClockId = queryPtpClockId();
5078
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::vector<SAPEntry> entries = buildEntries(ptpClockId);
5079
5080
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (entries.empty()) {
5081 ✗ LogWarn(VB_MEDIAOUT, "AES67 SAP: No SAP-enabled send instances — announcer has nothing to send\n");
5082 } else {
5083
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "AES67 SAP: Announcing %d stream(s) to %s:%d every %ds\n",
5084 (int)entries.size(), AES67::SAP_MCAST_ADDRESS, AES67::SAP_PORT,
5085 AES67::SAP_ANNOUNCE_INTERVAL_S);
5086 }
5087
5088 3 auto announceAll = [&]() {
5089
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 3 times.
6 for (const auto& entry : entries) {
5090
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 ssize_t sent = sendto(sock, entry.announcePacket.data(), entry.announcePacket.size(), 0,
5091 (struct sockaddr*)&sapAddr, sizeof(sapAddr));
5092
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
3 if (sent < 0) {
5093 ✗ LogErr(VB_MEDIAOUT, "AES67 SAP: sendto failed: %s\n", FPPstrerror(errno));
5094 }
5095 }
5096 3 };
5097
5098 // Rebuild and re-announce immediately when BMCA changes the refclk, rather
5099 // than letting a wrong ts-refclk stand for the rest of the announce cycle.
5100 62 auto refreshRefclk = [&]() -> bool {
5101
1/2
✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
62 std::string current = queryPtpClockId();
5102
1/2
✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
62 if (current == ptpClockId) {
5103 62 return false;
5104 }
5105 ✗ LogInfo(VB_MEDIAOUT, "AES67 SAP: PTP refclk changed (%s -> %s), rebuilding SDP\n",
5106 ptpClockId.c_str(), current.c_str());
5107 ✗ ptpClockId = current;
5108 // The rebuilt entries carry a new msg id hash (it is derived from the
5109 // SDP text). We deliberately do NOT delete the old hash first: the
5110 // o= sess-id is unchanged, so a receiver reads this as a modification
5111 // of a session it already has, whereas a deletion could make it tear
5112 // the stream down for the moment before the new announcement lands.
5113 ✗ entries = buildEntries(ptpClockId);
5114 ✗ return true;
5115 62 };
5116
5117 // Announce loop
5118 1 auto threadStart = std::chrono::steady_clock::now();
5119
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 1 times.
4 while (m_sapAnnounceRunning.load()) {
5120
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 refreshRefclk();
5121
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 announceAll();
5122
5123 // Sleep for SAP_ANNOUNCE_INTERVAL_S, checking shutdown flag every second.
5124 //
5125 // ptp4l needs several seconds after startup to finish BMCA and adopt an
5126 // upstream master; until then it reports itself as its own grandmaster.
5127 // Poll every second through that window so the corrected refclk goes out
5128 // within a second of the election settling instead of up to a full
5129 // announce interval later. Results are cached (PTP_QUERY_CACHE_MS), so
5130 // this is one pmc query per second at worst, and only for a minute.
5131
6/6
✓ Branch 0 taken 62 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 61 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 61 times.
✓ Branch 5 taken 3 times.
64 for (int i = 0; i < AES67::SAP_ANNOUNCE_INTERVAL_S && m_sapAnnounceRunning.load(); i++) {
5132
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 std::this_thread::sleep_for(std::chrono::seconds(1));
5133
5134
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
5135
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
122 std::chrono::steady_clock::now() - threadStart).count();
5136
5/6
✓ Branch 0 taken 59 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 59 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 59 times.
✓ Branch 5 taken 2 times.
61 if (elapsed < AES67::PTP_CONVERGENCE_WINDOW_S && m_sapAnnounceRunning.load()) {
5137
2/4
✓ Branch 0 taken 59 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 59 times.
59 if (refreshRefclk()) {
5138 ✗ announceAll();
5139 }
5140 }
5141 }
5142
5143 // Pipeline recovery and PTP supervision run in WatchdogLoop(), which
5144 // doesn't depend on anything announcing.
5145 }
5146
5147 // Send deletion packets on shutdown
5148
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 for (const auto& entry : entries) {
5149
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 sendto(sock, entry.deletePacket.data(), entry.deletePacket.size(), 0,
5150 (struct sockaddr*)&sapAddr, sizeof(sapAddr));
5151 }
5152
5153
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 close(sock);
5154
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "AES67 SAP announcer thread stopped (deletion packets sent)\n");
5155 1 }
5156
5157 // ──────────────────────────────────────────────────────────────────────────────
5158 // SAP Receiver — listens for remote AES67 announcements
5159 // ──────────────────────────────────────────────────────────────────────────────
5160 45 void AES67Manager::SAPReceiveLoop() {
5161
1/2
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
45 LogInfo(VB_MEDIAOUT, "AES67 SAP receiver thread started\n");
5162
5163 45 int sock = OpenSocket(AF_INET, SOCK_DGRAM, 0);
5164
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 45 times.
45 if (sock < 0) {
5165 ✗ LogErr(VB_MEDIAOUT, "AES67 SAP recv: Failed to create socket: %s\n", FPPstrerror(errno));
5166 ✗ return;
5167 }
5168
5169 // Allow address reuse
5170 45 int reuse = 1;
5171 45 setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
5172
5173 // Bind to SAP port
5174 struct sockaddr_in bindAddr;
5175 45 memset(&bindAddr, 0, sizeof(bindAddr));
5176 45 bindAddr.sin_family = AF_INET;
5177 45 bindAddr.sin_port = htons(AES67::SAP_PORT);
5178 45 bindAddr.sin_addr.s_addr = htonl(INADDR_ANY);
5179
5180
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 45 times.
45 if (bind(sock, (struct sockaddr*)&bindAddr, sizeof(bindAddr)) < 0) {
5181 ✗ LogErr(VB_MEDIAOUT, "AES67 SAP recv: bind failed: %s\n", FPPstrerror(errno));
5182 ✗ close(sock);
5183 ✗ return;
5184 }
5185
5186 // Join SAP multicast group
5187 struct ip_mreq mreq;
5188 45 inet_pton(AF_INET, AES67::SAP_MCAST_ADDRESS, &mreq.imr_multiaddr);
5189
5190
1/2
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
45 if (!m_config.ptpInterface.empty()) {
5191
1/2
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
45 std::string ifIP = GetInterfaceIP(m_config.ptpInterface);
5192 45 inet_pton(AF_INET, ifIP.c_str(), &mreq.imr_interface);
5193 45 } else {
5194 ✗ mreq.imr_interface.s_addr = htonl(INADDR_ANY);
5195 }
5196
5197
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 45 times.
45 if (setsockopt(sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) {
5198 ✗ LogWarn(VB_MEDIAOUT, "AES67 SAP recv: join multicast failed: %s\n", FPPstrerror(errno));
5199 }
5200
5201 // Set receive timeout so we can check the shutdown flag
5202 struct timeval tv;
5203 45 tv.tv_sec = 2;
5204 45 tv.tv_usec = 0;
5205 45 setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
5206
5207 uint8_t buf[4096];
5208
2/2
✓ Branch 0 taken 351 times.
✓ Branch 1 taken 45 times.
396 while (m_sapRecvRunning.load()) {
5209 struct sockaddr_in senderAddr;
5210 351 socklen_t addrLen = sizeof(senderAddr);
5211
5212
1/2
✓ Branch 0 taken 351 times.
✗ Branch 1 not taken.
351 ssize_t n = recvfrom(sock, buf, sizeof(buf), 0,
5213 (struct sockaddr*)&senderAddr, &addrLen);
5214
2/2
✓ Branch 0 taken 42 times.
✓ Branch 1 taken 309 times.
351 if (n <= 0) continue; // timeout or error
5215
5216 char senderIP[INET_ADDRSTRLEN];
5217 309 inet_ntop(AF_INET, &senderAddr.sin_addr, senderIP, sizeof(senderIP));
5218
5219
2/4
✓ Branch 0 taken 309 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 309 times.
✗ Branch 3 not taken.
618 HandleSAPPacket(buf, (size_t)n, senderIP);
5220 }
5221
5222 // Leave multicast group
5223 45 setsockopt(sock, IPPROTO_IP, IP_DROP_MEMBERSHIP, &mreq, sizeof(mreq));
5224
1/2
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
45 close(sock);
5225
1/2
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
45 LogInfo(VB_MEDIAOUT, "AES67 SAP receiver thread stopped\n");
5226 }
5227
5228 309 void AES67Manager::HandleSAPPacket(const uint8_t* data, size_t len,
5229 const std::string& senderAddr) {
5230 // Minimum SAP header: 8 bytes (4 header + 4 source IP)
5231
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 309 times.
310 if (len < 8) return;
5232
5233 309 uint8_t header0 = data[0];
5234 309 int version = (header0 >> 5) & 0x07;
5235 309 bool isDeletion = (header0 & 0x04) != 0;
5236 // bool isIPv6 = (header0 & 0x10) != 0; // A bit — we only handle IPv4
5237
5238
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 309 times.
309 if (version != AES67::SAP_VERSION) return;
5239
5240 309 uint16_t msgIdHash = ((uint16_t)data[2] << 8) | data[3];
5241
5242 // Skip auth data
5243 309 uint8_t authLen = data[1];
5244 309 size_t payloadStart = 8 + authLen * 4;
5245
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 309 times.
309 if (payloadStart >= len) return;
5246
5247 // Find end of payload type string (NUL terminated)
5248 309 size_t sdpStart = payloadStart;
5249
3/4
✓ Branch 0 taken 4944 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4635 times.
✓ Branch 3 taken 309 times.
4944 while (sdpStart < len && data[sdpStart] != 0) {
5250 4635 sdpStart++;
5251 }
5252 309 sdpStart++; // skip NUL
5253
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 309 times.
309 if (sdpStart >= len) return;
5254
5255 // Ignore our own announcements
5256
1/2
✓ Branch 0 taken 309 times.
✗ Branch 1 not taken.
309 std::string ourIP = GetInterfaceIP(m_config.ptpInterface);
5257
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 309 times.
309 if (senderAddr == ourIP) return;
5258
5259
1/2
✓ Branch 0 taken 309 times.
✗ Branch 1 not taken.
309 const auto key = std::make_pair(senderAddr, msgIdHash);
5260
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 308 times.
309 if (isDeletion) {
5261
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::lock_guard<std::mutex> lock(m_discoveredMutex);
5262
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 auto it = m_discoveredStreams.find(key);
5263
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (it != m_discoveredStreams.end()) {
5264
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "AES67 SAP: Stream deleted: %s\n",
5265 it->second.sessionName.c_str());
5266
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 m_discoveredStreams.erase(it);
5267 }
5268 1 return;
5269 1 }
5270
5271 // Parse SDP for stream info
5272
1/2
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
308 std::string sdp((const char*)data + sdpStart, len - sdpStart);
5273 924 auto clamp = [](std::string v) {
5274
2/2
✓ Branch 0 taken 300 times.
✓ Branch 1 taken 624 times.
924 if (v.size() > AES67::MAX_SAP_FIELD_LEN) {
5275 300 v.resize(AES67::MAX_SAP_FIELD_LEN);
5276 }
5277 924 return v;
5278 };
5279
5280 308 SAPDiscoveredStream stream;
5281 308 stream.msgIdHash = msgIdHash;
5282
1/2
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
308 stream.originAddress = senderAddr;
5283
1/2
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
308 stream.lastSeenMs = std::chrono::duration_cast<std::chrono::milliseconds>(
5284 616 std::chrono::steady_clock::now().time_since_epoch()).count();
5285
5286 // Parse SDP fields
5287
1/2
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
308 std::istringstream sdpStream(sdp);
5288 308 std::string line;
5289
4/6
✓ Branch 0 taken 3086 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3086 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2778 times.
✓ Branch 5 taken 308 times.
3086 while (std::getline(sdpStream, line)) {
5290 // Remove \r
5291
3/6
✓ Branch 0 taken 2778 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2778 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2778 times.
✗ Branch 5 not taken.
2778 if (!line.empty() && line.back() == '\r') line.pop_back();
5292
5293
4/6
✓ Branch 0 taken 2778 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2778 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 308 times.
✓ Branch 5 taken 2470 times.
2778 if (line.substr(0, 2) == "s=") {
5294
2/4
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✗ Branch 3 not taken.
308 stream.sessionName = clamp(line.substr(2));
5295
4/6
✓ Branch 0 taken 2470 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2470 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 308 times.
✓ Branch 5 taken 2162 times.
2470 } else if (line.substr(0, 2) == "c=") {
5296 // c=IN IP4 239.69.0.1/255
5297 308 size_t ip4Pos = line.find("IP4 ");
5298
1/2
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
308 if (ip4Pos != std::string::npos) {
5299
1/2
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
308 std::string addr = line.substr(ip4Pos + 4);
5300 308 size_t slash = addr.find('/');
5301
2/4
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✗ Branch 3 not taken.
308 if (slash != std::string::npos) addr = addr.substr(0, slash);
5302
2/4
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✗ Branch 3 not taken.
308 stream.multicastIP = clamp(addr);
5303 308 }
5304
4/6
✓ Branch 0 taken 2162 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2162 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 308 times.
✓ Branch 5 taken 1854 times.
2162 } else if (line.substr(0, 8) == "m=audio ") {
5305 // m=audio 5004 RTP/AVP 96
5306 308 int port = 0;
5307
1/2
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
308 if (sscanf(line.c_str(), "m=audio %d", &port) == 1) {
5308 308 stream.port = port;
5309 }
5310
4/6
✓ Branch 0 taken 1854 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1854 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 308 times.
✓ Branch 5 taken 1546 times.
1854 } else if (line.substr(0, 11) == "a=rtpmap:96") {
5311 // a=rtpmap:96 L24/48000/2
5312 308 int ch = 2;
5313
1/2
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
308 if (sscanf(line.c_str(), "a=rtpmap:96 L24/%*d/%d", &ch) == 1) {
5314 308 stream.channels = ch;
5315 }
5316
4/6
✓ Branch 0 taken 1546 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1546 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 308 times.
✓ Branch 5 taken 1238 times.
1546 } else if (line.substr(0, 8) == "a=ptime:") {
5317
1/2
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
308 stream.ptime = std::atoi(line.substr(8).c_str());
5318
3/4
✓ Branch 0 taken 1238 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✓ Branch 3 taken 930 times.
1238 } else if (line.rfind("a=ts-refclk:ptp=IEEE1588-", 0) == 0) {
5319 // a=ts-refclk:ptp=IEEE1588-2008:AA-BB-CC-FF-FE-DD-EE-FF:0
5320 // (The domain suffix is optional.) This used to compare a 26-char
5321 // prefix against the 25-char literal, so it never matched and the
5322 // grandmaster of a discovered stream was never reported.
5323 308 size_t colon1 = line.find(':', 15);
5324
1/2
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
308 if (colon1 != std::string::npos) {
5325 308 size_t colon2 = line.find(':', colon1 + 1);
5326 std::string id = line.substr(colon1 + 1, colon2 == std::string::npos ? std::string::npos
5327
2/4
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✗ Branch 3 not taken.
308 : colon2 - colon1 - 1);
5328
4/8
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 308 times.
✗ Branch 6 not taken.
✓ Branch 7 taken 308 times.
308 while (!id.empty() && (id.back() == '\r' || id.back() == ' ')) {
5329 ✗ id.pop_back();
5330 }
5331
2/4
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✗ Branch 3 not taken.
308 stream.ptpClockId = clamp(id);
5332 308 }
5333 }
5334 }
5335
5336 {
5337
1/2
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
308 std::lock_guard<std::mutex> lock(m_discoveredMutex);
5338
1/2
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
308 bool isNew = (m_discoveredStreams.find(key) == m_discoveredStreams.end());
5339
6/6
✓ Branch 0 taken 306 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 48 times.
✓ Branch 3 taken 258 times.
✓ Branch 4 taken 48 times.
✓ Branch 5 taken 260 times.
308 if (isNew && m_discoveredStreams.size() >= AES67::MAX_SAP_DISCOVERED) {
5340
1/2
✓ Branch 0 taken 48 times.
✗ Branch 1 not taken.
48 auto oldest = std::min_element(m_discoveredStreams.begin(), m_discoveredStreams.end(),
5341 12240 [](const auto& a, const auto& b) {
5342 12240 return a.second.lastSeenMs < b.second.lastSeenMs;
5343 });
5344
1/2
✓ Branch 0 taken 48 times.
✗ Branch 1 not taken.
48 m_discoveredStreams.erase(oldest);
5345 }
5346
2/4
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✗ Branch 3 not taken.
308 m_discoveredStreams[key] = stream;
5347
5348
2/2
✓ Branch 0 taken 306 times.
✓ Branch 1 taken 2 times.
308 if (isNew) {
5349
1/2
✓ Branch 0 taken 306 times.
✗ Branch 1 not taken.
306 LogInfo(VB_MEDIAOUT, "AES67 SAP: Discovered stream '%s' from %s → %s:%d (%dch)\n",
5350 stream.sessionName.c_str(), senderAddr.c_str(),
5351 stream.multicastIP.c_str(), stream.port, stream.channels);
5352 }
5353
5354 // Prune stale entries. A remote sender that stops announcing
5355 // without ever transmitting an explicit SAP deletion packet (e.g.
5356 // it crashes, loses power, or drops off the network) would
5357 // otherwise leave its stream in m_discoveredStreams forever. Drop
5358 // anything not seen within ~10 announce intervals.
5359
1/2
✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
308 uint64_t nowMs = std::chrono::duration_cast<std::chrono::milliseconds>(
5360 616 std::chrono::steady_clock::now().time_since_epoch()).count();
5361 308 constexpr uint64_t staleThresholdMs =
5362 (uint64_t)AES67::SAP_ANNOUNCE_INTERVAL_S * 1000ULL * 10ULL;
5363
2/2
✓ Branch 0 taken 45188 times.
✓ Branch 1 taken 308 times.
45496 for (auto sit = m_discoveredStreams.begin(); sit != m_discoveredStreams.end(); ) {
5364
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 45188 times.
45188 if (nowMs - sit->second.lastSeenMs > staleThresholdMs) {
5365 ✗ LogInfo(VB_MEDIAOUT, "AES67 SAP: Stream '%s' timed out (no announcement in %llus) — removing\n",
5366 sit->second.sessionName.c_str(),
5367 (unsigned long long)(staleThresholdMs / 1000));
5368 ✗ sit = m_discoveredStreams.erase(sit);
5369 } else {
5370 45188 ++sit;
5371 }
5372 }
5373 308 }
5374
4/4
✓ Branch 0 taken 308 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 308 times.
✓ Branch 3 taken 1 times.
310 }
5375
5376 // ──────────────────────────────────────────────────────────────────────────────
5377 // Status reporting — for PHP API
5378 // ──────────────────────────────────────────────────────────────────────────────
5379 // The rate the PipeWire graph is actually clocked at. Mirrors the fallback in
5380 // GStreamerOut.cpp's GetPipeWireGraphRate(), which is file-local there: the
5381 // per-card file carries the rate the hardware really achieved and sorts after
5382 // the defaults, so it wins wherever both exist.
5383 804 static int PipeWireGraphRate() {
5384 static const char* confs[] = {
5385 "/etc/pipewire/pipewire.conf.d/95-fpp-alsa-sink.conf",
5386 "/etc/pipewire/pipewire.conf.d/90-fpp.conf"
5387 };
5388
2/2
✓ Branch 0 taken 1608 times.
✓ Branch 1 taken 804 times.
2412 for (const char* conf : confs) {
5389
2/4
✓ Branch 0 taken 1608 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1608 times.
✗ Branch 3 not taken.
1608 std::string contents = GetFileContents(conf);
5390 1608 size_t p = contents.find("default.clock.rate");
5391
1/2
✓ Branch 0 taken 1608 times.
✗ Branch 1 not taken.
1608 if (p == std::string::npos)
5392 1608 continue;
5393 ✗ p = contents.find('=', p);
5394 ✗ if (p == std::string::npos)
5395 ✗ continue;
5396 ✗ int rate = atoi(contents.c_str() + p + 1);
5397 ✗ if (rate > 0)
5398 ✗ return rate;
5399
1/3
✗ Branch 0 not taken.
✓ Branch 1 taken 1608 times.
✗ Branch 2 not taken.
1608 }
5400 804 return 0;
5401 }
5402
5403 // The rate pipewiresrc negotiated with the graph for this pipeline, or 0 if it
5404 // has not negotiated yet. Read from the live pad rather than from any config
5405 // file: per-card and per-group rates sit between the graph clock and what this
5406 // stream is actually fed, so default.clock.rate can say 44100 while the stream
5407 // is getting a clean 48000 (or the reverse).
5408 ✗ static int NegotiatedSourceRate(GstElement* pipeline) {
5409 ✗ if (!pipeline) {
5410 ✗ return 0;
5411 }
5412 ✗ GstElement* pwsrc = gst_bin_get_by_name(GST_BIN(pipeline), "pwsrc");
5413 ✗ if (!pwsrc) {
5414 ✗ return 0;
5415 }
5416 ✗ int rate = 0;
5417 ✗ GstPad* pad = gst_element_get_static_pad(pwsrc, "src");
5418 ✗ if (pad) {
5419 ✗ GstCaps* caps = gst_pad_get_current_caps(pad);
5420 ✗ if (caps) {
5421 ✗ const GstStructure* st = gst_caps_get_structure(caps, 0);
5422 ✗ if (st) {
5423 ✗ gst_structure_get_int(st, "rate", &rate);
5424 }
5425 ✗ gst_caps_unref(caps);
5426 }
5427 ✗ gst_object_unref(pad);
5428 }
5429 ✗ gst_object_unref(pwsrc);
5430 ✗ return rate;
5431 }
5432
5433 796 AES67Manager::Status AES67Manager::GetStatus() {
5434 796 Status status;
5435
5436 // Snapshot the config BEFORE taking the pipeline lock, and use the copy
5437 // from here on. Reading m_config directly would race LoadConfig(), which
5438 // reallocates the instance vector on another thread -- and taking the two
5439 // locks together would create an ordering to get wrong. See m_configMutex.
5440
1/2
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
796 AES67Config config = GetConfigSnapshot();
5441 796 status.ptpEnabled = config.ptpEnabled;
5442 796 status.ptpDomain = config.ptpDomain;
5443
1/2
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
796 status.ptpRole = config.ptpRole;
5444
1/2
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
796 status.graphSampleRate = PipeWireGraphRate();
5445
5446 // Pipeline status — use try_lock to avoid blocking HTTP handlers
5447 // indefinitely if another thread holds m_pipelineMutex during a
5448 // long GStreamer state change.
5449 {
5450 796 std::unique_lock<std::mutex> lock(m_pipelineMutex, std::try_to_lock);
5451
2/2
✓ Branch 0 taken 795 times.
✓ Branch 1 taken 1 times.
796 if (lock.owns_lock()) {
5452
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 795 times.
795 for (const auto& [id, p] : m_sendPipelines) {
5453 ✗ Status::PipelineStatus ps;
5454 ✗ ps.instanceId = id;
5455 ✗ ps.mode = "send";
5456 ✗ ps.running = p.running;
5457 ✗ ps.error = p.errorMessage;
5458 ✗ ps.sourceRate = NegotiatedSourceRate(p.pipeline);
5459
5460 ✗ for (const auto& inst : config.instances) {
5461 ✗ if (inst.id == id) {
5462 ✗ ps.name = inst.name;
5463 ✗ break;
5464 }
5465 }
5466 ✗ status.pipelines.push_back(ps);
5467 ✗ }
5468
5469
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 795 times.
795 for (const auto& [id, p] : m_recvPipelines) {
5470 ✗ Status::PipelineStatus ps;
5471 ✗ ps.instanceId = id;
5472 ✗ ps.mode = "receive";
5473 ✗ ps.running = p.running;
5474 ✗ ps.error = p.errorMessage;
5475
5476 ✗ for (const auto& inst : config.instances) {
5477 ✗ if (inst.id == id) {
5478 ✗ ps.name = inst.name;
5479 ✗ break;
5480 }
5481 }
5482 ✗ status.pipelines.push_back(ps);
5483 ✗ }
5484
5485 // Senders deliberately held idle. Reported alongside the real
5486 // pipelines rather than omitted: a stream the user enabled and
5487 // cannot see anywhere reads as FPP having lost the config.
5488
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 795 times.
796 for (const auto& [id, reason] : m_deferredSenders) {
5489 1 Status::PipelineStatus ps;
5490 1 ps.instanceId = id;
5491
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 ps.mode = "send";
5492 1 ps.running = false;
5493 1 ps.waitingForSource = true;
5494
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 ps.note = reason;
5495
5496
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 for (const auto& inst : config.instances) {
5497
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (inst.id == id) {
5498
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 ps.name = inst.name;
5499 1 break;
5500 }
5501 }
5502
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 status.pipelines.push_back(ps);
5503 1 }
5504 } else {
5505 // Could not acquire lock — return partial status
5506 1 Status::PipelineStatus ps;
5507 1 ps.instanceId = -1;
5508
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 ps.name = "(status unavailable — pipeline operation in progress)";
5509
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 ps.mode = "unknown";
5510 1 ps.running = false;
5511
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 status.pipelines.push_back(ps);
5512 1 }
5513 796 }
5514 // PTP status — query ptp4l for the actual selected grandmaster (may be a
5515 // remote/upstream clock) rather than assuming this node is the master.
5516 {
5517 796 bool gmPresent = false;
5518 796 std::string gmId;
5519 796 int64_t offsetNs = 0;
5520 796 bool offsetValid = false;
5521
5522
7/12
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 98 times.
✓ Branch 3 taken 698 times.
✓ Branch 4 taken 98 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 698 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 698 times.
✓ Branch 9 taken 98 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
1494 status.ptpPortState = IsPtp4lRunning() ? GetPtp4lState() : "not running";
5523
1/2
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
796 status.ptpIsGrandmaster = IsGrandmasterPortState(status.ptpPortState);
5524 796 status.ptpLockedAtStart = m_ptpLockedAtStart.load();
5525
5526
8/10
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 98 times.
✓ Branch 3 taken 698 times.
✓ Branch 4 taken 10 times.
✓ Branch 5 taken 88 times.
✓ Branch 6 taken 10 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 10 times.
✓ Branch 9 taken 786 times.
796 if (QueryPtp4lTimeStatus(gmPresent, gmId, offsetNs, &offsetValid) && gmPresent && !gmId.empty()) {
5527 10 status.ptpSynced = true;
5528
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 status.ptpGrandmasterId = gmId;
5529
2/2
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 3 times.
10 status.ptpOffsetNs = offsetValid ? offsetNs : 0;
5530 10 status.ptpOffsetValid = offsetValid;
5531 10 GetGrandmasterAddress(gmId, status.ptpGrandmasterAddress,
5532
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 status.ptpGrandmasterViaBoundary);
5533
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 783 times.
786 } else if (status.ptpIsGrandmaster) {
5534 // We won the BMCA and ARE the domain grandmaster. pmc reports
5535 // gmPresent=false in that case because there is no *remote* GM to
5536 // report -- which is not the same thing as "unsynced". Being the
5537 // clock source is a legitimate AES67 configuration (FPP driving
5538 // the show clock), so report ourselves as the grandmaster with a
5539 // zero offset rather than showing the user a broken PTP status.
5540 3 status.ptpSynced = true;
5541
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 status.ptpGrandmasterId = GetPTPClockId();
5542 3 status.ptpOffsetNs = 0;
5543 3 status.ptpOffsetValid = true;
5544 // Taken from the interface rather than the Announce listener: we
5545 // are the one clock whose address is known without hearing it.
5546
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 status.ptpGrandmasterAddress = GetInterfaceIP(GetPtpInterface());
5547 } else {
5548 // ptp4l not running, or no grandmaster selected yet (e.g. still
5549 // in LISTENING/PRE_MASTER) — do not claim we're synced or report
5550 // our own identity as if it were the grandmaster.
5551 783 status.ptpSynced = false;
5552
1/2
✓ Branch 0 taken 783 times.
✗ Branch 1 not taken.
783 status.ptpGrandmasterId = "";
5553 783 status.ptpOffsetNs = 0;
5554 783 status.ptpOffsetValid = false;
5555 }
5556 796 }
5557
5558 // Discovered streams
5559 {
5560
1/2
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
796 std::lock_guard<std::mutex> lock(m_discoveredMutex);
5561
2/2
✓ Branch 0 taken 259 times.
✓ Branch 1 taken 796 times.
1055 for (const auto& [key, stream] : m_discoveredStreams) {
5562
1/2
✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
259 status.discoveredStreams.push_back(stream);
5563 }
5564 796 }
5565
5566 796 return status;
5567 796 }
5568
5569 // ──────────────────────────────────────────────────────────────────────────────
5570 // Self-test — validates AES67 subsystem components (7.10)
5571 // ──────────────────────────────────────────────────────────────────────────────
5572 3 std::vector<AES67Manager::TestResult> AES67Manager::RunSelfTest() {
5573 // Snapshot once -- this runs on an HTTP/command thread and would otherwise
5574 // read m_config while ApplyConfig() reloads it. See m_configMutex.
5575
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 AES67Config config = GetConfigSnapshot();
5576 3 std::vector<TestResult> results;
5577
5578 // Test 1: GStreamer initialization
5579 {
5580 3 TestResult r;
5581
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.testName = "gstreamer_init";
5582
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.passed = gst_is_initialized();
5583
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 r.message = r.passed ? "GStreamer is initialized" : "GStreamer is NOT initialized";
5584
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 results.push_back(r);
5585 3 }
5586
5587 // Test 2: Required GStreamer elements available
5588 {
5589 3 const char* requiredElements[] = {
5590 "rtpL24pay", "rtpL24depay", "rtpjitterbuffer",
5591 "udpsrc", "udpsink", "audioconvert", "audioresample",
5592 "pipewiresrc", "pipewiresink", nullptr
5593 };
5594
2/2
✓ Branch 0 taken 27 times.
✓ Branch 1 taken 3 times.
30 for (int i = 0; requiredElements[i]; i++) {
5595 27 TestResult r;
5596
2/4
✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 27 times.
✗ Branch 3 not taken.
54 r.testName = std::string("element_") + requiredElements[i];
5597
1/2
✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
27 GstElementFactory* factory = gst_element_factory_find(requiredElements[i]);
5598 27 r.passed = (factory != nullptr);
5599 27 r.message = r.passed
5600
4/14
✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 27 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 27 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✓ Branch 8 taken 27 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
108 ? std::string(requiredElements[i]) + " element available"
5601
3/12
✗ Branch 0 not taken.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 27 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 27 times.
✓ Branch 6 taken 27 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
54 : std::string(requiredElements[i]) + " element NOT FOUND";
5602
2/4
✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 27 times.
✗ Branch 3 not taken.
27 if (factory) gst_object_unref(factory);
5603
1/2
✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
27 results.push_back(r);
5604 27 }
5605 }
5606
5607 // Test 3: PTP daemon (ptp4l)
5608 {
5609 3 TestResult r;
5610
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.testName = "ptp_initialized";
5611 3 r.passed = m_ptpInitialized;
5612
2/4
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 r.message = r.passed ? "PTP subsystem initialized" : "PTP subsystem not initialized";
5613
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 results.push_back(r);
5614 3 }
5615 {
5616 3 TestResult r;
5617
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.testName = "ptp4l_running";
5618
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.passed = IsPtp4lRunning();
5619
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 std::string state = GetPtp4lState();
5620 3 r.message = r.passed
5621
4/20
✗ Branch 0 not taken.
✓ Branch 1 taken 3 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 taken 3 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✓ Branch 11 taken 3 times.
✗ Branch 12 not taken.
✓ Branch 13 taken 3 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.
12 ? "ptp4l is running (PID " + std::to_string(m_ptp4lPid) + ") — " + state
5622
2/6
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
9 : "ptp4l is NOT running";
5623
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 results.push_back(r);
5624 3 }
5625 {
5626 3 TestResult r;
5627
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.testName = "ptp4l_binary";
5628
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.passed = FileExists(PTP4L_BINARY);
5629
2/4
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 r.message = r.passed ? "ptp4l binary found at /usr/sbin/ptp4l" : "ptp4l binary NOT found — install linuxptp package";
5630
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 results.push_back(r);
5631 3 }
5632 {
5633 // Without pmc every grandmaster query fails, and PTP silently reports
5634 // "not synced" no matter how well it is actually locked.
5635 3 TestResult r;
5636
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.testName = "pmc_binary";
5637
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.passed = FileExists(PMC_BINARY);
5638 3 r.message = r.passed
5639
3/14
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 3 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
12 ? std::string("pmc binary found at ") + PMC_BINARY
5640
6/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 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 3 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✓ Branch 11 taken 3 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.
12 : std::string("pmc binary NOT found at ") + PMC_BINARY +
5641 3 " — grandmaster status cannot be queried (install linuxptp)";
5642
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 results.push_back(r);
5643 3 }
5644 {
5645 // Surfaces the two settings behind "why did my Pi become the master?"
5646 // and "why does the Q-SYS core not see us?".
5647 3 TestResult r;
5648
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.testName = "ptp_grandmaster";
5649
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 std::string gm = GetActiveGrandmasterId();
5650
4/12
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 3 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
6 std::string state = IsPtp4lRunning() ? GetPtp4lState() : "not running";
5651
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
3 r.passed = !config.ptpEnabled || !gm.empty();
5652
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
3 if (!config.ptpEnabled) {
5653 ✗ r.message = "PTP is disabled — SDP advertises this node's own clock identity";
5654
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 } else if (gm.empty()) {
5655
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
6 r.message = "No grandmaster selected yet (port state " + state + ") — domain " +
5656
3/6
✓ 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 not taken.
9 std::to_string(config.ptpDomain) + ", role " + config.ptpRole;
5657 } else {
5658 ✗ std::string where;
5659 ✗ std::string gmAddr;
5660 ✗ bool viaBoundary = false;
5661 ✗ if (IsGrandmasterPortState(state)) {
5662 ✗ where = " at " + GetInterfaceIP(config.ptpInterface);
5663 ✗ } else if (GetGrandmasterAddress(gm, gmAddr, viaBoundary)) {
5664 ✗ where = (viaBoundary ? " via boundary clock " : " at ") + gmAddr;
5665 }
5666 ✗ r.message = "Grandmaster " + gm + where + " (port state " + state + ") — domain " +
5667 ✗ std::to_string(config.ptpDomain) + ", role " + config.ptpRole +
5668 ✗ (IsGrandmasterPortState(state) ? " — this node holds the role" : "");
5669 ✗ }
5670
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 results.push_back(r);
5671 3 }
5672
5673 // Test 4: Config file
5674 {
5675 3 TestResult r;
5676
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.testName = "config_file";
5677
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 std::ifstream test(m_configPath);
5678
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.passed = test.good();
5679 3 r.message = r.passed
5680
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
6 ? "Config file found: " + m_configPath
5681
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 : "Config file missing: " + m_configPath;
5682
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 results.push_back(r);
5683 3 }
5684
5685 // Test 5: Config loaded and has instances
5686 {
5687 3 TestResult r;
5688
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.testName = "config_instances";
5689 3 r.passed = !config.instances.empty();
5690 3 r.message = r.passed
5691
2/10
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
9 ? std::to_string(config.instances.size()) + " instance(s) configured"
5692
2/6
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
9 : "No instances configured";
5693
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 results.push_back(r);
5694 3 }
5695
5696 // Test 6: Network interface availability
5697 {
5698 3 TestResult r;
5699
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.testName = "network_interface";
5700
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 std::string ip = GetInterfaceIP(config.ptpInterface);
5701 3 r.passed = !ip.empty();
5702 3 r.message = r.passed
5703
5/16
✓ 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 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✓ Branch 10 taken 3 times.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
9 ? "Interface " + config.ptpInterface + " has IP: " + ip
5704
2/8
✗ Branch 0 not taken.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
6 : "Interface " + config.ptpInterface + " not found or has no IP";
5705
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 results.push_back(r);
5706 3 }
5707
5708 // Test 7: PTP clock ID derivation (EUI-64 from MAC)
5709 {
5710 3 TestResult r;
5711
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.testName = "ptp_clock_id";
5712
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 std::string clockId = GetPTPClockId();
5713
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 r.passed = !clockId.empty() && clockId.length() == 23; // XX-XX-XX-XX-XX-XX-XX-XX
5714 3 r.message = r.passed
5715
2/6
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
6 ? "PTP Clock ID: " + clockId
5716
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
6 : "Could not derive PTP Clock ID from MAC address";
5717
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 results.push_back(r);
5718 3 }
5719
5720 // Test 8: Send pipeline status
5721 {
5722
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 std::lock_guard<std::mutex> lock(m_pipelineMutex);
5723
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
3 for (const auto& [id, p] : m_sendPipelines) {
5724 ✗ TestResult r;
5725 ✗ r.testName = "send_pipeline_" + std::to_string(id);
5726 ✗ r.passed = p.running;
5727 ✗ r.message = p.running
5728 ✗ ? "Send pipeline " + std::to_string(id) + " is running"
5729 ✗ : "Send pipeline " + std::to_string(id) + " is NOT running: " + p.errorMessage;
5730 ✗ results.push_back(r);
5731 ✗ }
5732
5733
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
3 for (const auto& [id, p] : m_recvPipelines) {
5734 ✗ TestResult r;
5735 ✗ r.testName = "recv_pipeline_" + std::to_string(id);
5736 ✗ r.passed = p.running;
5737 ✗ r.message = p.running
5738 ✗ ? "Receive pipeline " + std::to_string(id) + " is running"
5739 ✗ : "Receive pipeline " + std::to_string(id) + " is NOT running: " + p.errorMessage;
5740 ✗ results.push_back(r);
5741 ✗ }
5742
5743 // Held senders have no pipeline to report on, and a configured stream
5744 // that appears in no test at all reads as a lost config. Passing:
5745 // this is the state FPP intends for a sender nothing feeds, not a
5746 // fault to chase.
5747
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
3 for (const auto& [id, reason] : m_deferredSenders) {
5748 ✗ TestResult r;
5749 ✗ r.testName = "send_pipeline_" + std::to_string(id);
5750 ✗ r.passed = true;
5751 ✗ r.message = "Send stream " + std::to_string(id) + " is idle: " + reason;
5752 ✗ results.push_back(r);
5753 ✗ }
5754 3 }
5755
5756 // Test 9: SAP announcer running
5757 {
5758 // Nothing to announce is not a failure. The announcer only runs when
5759 // a sender is actually on the wire, so a receive-only box -- or one
5760 // whose senders are all held idle waiting for an Audio Output Group --
5761 // correctly has no announcer thread, and reporting that as a failed
5762 // test sends the user looking for a fault that is not there.
5763 3 bool anySendRunning = false;
5764 {
5765
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 std::lock_guard<std::mutex> lock(m_pipelineMutex);
5766 3 anySendRunning = !m_sendPipelines.empty();
5767 3 }
5768 3 const bool running = m_sapAnnounceRunning.load();
5769
5770 3 TestResult r;
5771
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.testName = "sap_announcer";
5772
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 r.passed = running || !anySendRunning;
5773 r.message = running
5774 ? "SAP announcer thread running"
5775
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
3 : (anySendRunning ? "SAP announcer thread not running"
5776
2/4
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
6 : "No SAP announcer needed — no send stream is running");
5777
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 results.push_back(r);
5778 3 }
5779
5780 // Test 10: SAP receiver running
5781 {
5782 3 TestResult r;
5783
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.testName = "sap_receiver";
5784 3 r.passed = m_sapRecvRunning.load();
5785
2/4
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 r.message = r.passed ? "SAP receiver thread running" : "SAP receiver thread not running";
5786
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 results.push_back(r);
5787 3 }
5788
5789 // Test 11: Multicast socket test — try binding to SAP port (quick check)
5790 {
5791 3 TestResult r;
5792
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.testName = "multicast_capability";
5793 3 int sock = OpenSocket(AF_INET, SOCK_DGRAM, 0);
5794
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 if (sock >= 0) {
5795 3 int reuse = 1;
5796 3 setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
5797 3 struct sockaddr_in addr{};
5798 3 addr.sin_family = AF_INET;
5799 3 addr.sin_port = 0; // any port
5800 3 addr.sin_addr.s_addr = INADDR_ANY;
5801 3 r.passed = (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) == 0);
5802
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 r.message = r.passed ? "UDP socket creation and bind OK" : "Failed to create/bind UDP socket";
5803
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 close(sock);
5804 } else {
5805 ✗ r.passed = false;
5806 ✗ r.message = "Failed to create UDP socket";
5807 }
5808
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 results.push_back(r);
5809 3 }
5810
5811 // Test 12: SDP generation test — verify we can build a valid SDP
5812 {
5813 3 TestResult r;
5814
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.testName = "sdp_generation";
5815
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
3 if (!config.instances.empty()) {
5816 ✗ std::string sourceIP = GetInterfaceIP(config.ptpInterface);
5817 ✗ std::string clockId = GetPTPClockId();
5818 // Version 0: this is a rendering for the test output, not an
5819 // announcement, and must not advance the announced version.
5820 ✗ std::string sdp = BuildSDP(config.instances[0], sourceIP, clockId, 0);
5821 ✗ r.passed = !sdp.empty() && sdp.find("v=0") != std::string::npos
5822 ✗ && sdp.find("ts-refclk") != std::string::npos
5823 ✗ && sdp.find("mediaclk") != std::string::npos
5824 ✗ && sdp.find("L24") != std::string::npos;
5825 ✗ r.message = r.passed
5826 ✗ ? "SDP generation OK (" + std::to_string(sdp.size()) + " bytes)"
5827 ✗ : "SDP generation failed or incomplete";
5828 ✗ } else {
5829 3 r.passed = false;
5830
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 r.message = "No instances configured — cannot test SDP generation";
5831 }
5832
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 results.push_back(r);
5833 3 }
5834
5835 3 return results;
5836 3 }
5837
5838 // ──────────────────────────────────────────────────────────────────────────────
5839 // HTTP API endpoint — registered at /aes67
5840 // ──────────────────────────────────────────────────────────────────────────────
5841 1382 HttpResponsePtr AES67Manager::render_GET(const HttpRequestPtr& req) {
5842
5843
2/4
✓ Branch 0 taken 1382 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1382 times.
✗ Branch 3 not taken.
1382 std::string url(req->path());
5844
5845 // Strip leading /aes67/
5846
2/2
✓ Branch 0 taken 1377 times.
✓ Branch 1 taken 5 times.
1382 if (url.find("/aes67/") == 0) {
5847
1/2
✓ Branch 0 taken 1377 times.
✗ Branch 1 not taken.
1377 url = url.substr(7);
5848
2/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
5 } else if (url == "/aes67") {
5849
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 url = "status";
5850 }
5851
5852
3/4
✓ Branch 0 taken 1382 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✓ Branch 3 taken 586 times.
1382 if (url == "status") {
5853
1/2
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
796 Status st = GetStatus();
5854
1/2
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
796 Json::Value result;
5855
5856 // Pipelines
5857
1/2
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
796 Json::Value pipelines(Json::arrayValue);
5858
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 796 times.
798 for (const auto& p : st.pipelines) {
5859
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 Json::Value pj;
5860
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 pj["instanceId"] = p.instanceId;
5861
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 pj["name"] = p.name;
5862
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 pj["mode"] = p.mode;
5863
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 pj["running"] = p.running;
5864
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 if (p.sourceRate > 0) {
5865 ✗ pj["sourceRate"] = p.sourceRate;
5866 }
5867
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 if (!p.error.empty()) {
5868 ✗ pj["error"] = p.error;
5869 }
5870 // Distinct from "error" on purpose: the UI renders a failure in
5871 // red and this as guidance, so collapsing the two would put a
5872 // brand new instance back in the alarming state this replaced.
5873
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 if (p.waitingForSource) {
5874
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 pj["waitingForSource"] = true;
5875 }
5876
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 if (!p.note.empty()) {
5877
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 pj["note"] = p.note;
5878 }
5879
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 pipelines.append(pj);
5880 2 }
5881
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 result["pipelines"] = pipelines;
5882
5883 // PTP
5884
1/2
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
796 Json::Value ptp;
5885
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 ptp["synced"] = st.ptpSynced;
5886 // null, not 0, when pmc gave nothing usable -- a zero here is the
5887 // same lie WaitForPtpLock() used to believe. The UI already tests
5888 // `typeof offsetNs === 'number'` and falls back to an em dash.
5889
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 786 times.
796 if (st.ptpOffsetValid) {
5890
2/4
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
10 ptp["offsetNs"] = (Json::Int64)st.ptpOffsetNs;
5891 } else {
5892
2/4
✓ Branch 0 taken 786 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 786 times.
✗ Branch 3 not taken.
786 ptp["offsetNs"] = Json::Value::null;
5893 }
5894
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 ptp["grandmasterId"] = st.ptpGrandmasterId;
5895
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 ptp["grandmasterAddress"] = st.ptpGrandmasterAddress;
5896
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 ptp["grandmasterViaBoundary"] = st.ptpGrandmasterViaBoundary;
5897
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 ptp["portState"] = st.ptpPortState;
5898
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 ptp["isGrandmaster"] = st.ptpIsGrandmaster;
5899
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 ptp["lockedAtStart"] = st.ptpLockedAtStart;
5900
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 ptp["enabled"] = st.ptpEnabled;
5901
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 ptp["domain"] = st.ptpDomain;
5902
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 ptp["role"] = st.ptpRole;
5903
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 result["ptp"] = ptp;
5904
5905 // Discovered streams
5906
1/2
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
796 Json::Value discovered(Json::arrayValue);
5907
2/2
✓ Branch 0 taken 259 times.
✓ Branch 1 taken 796 times.
1055 for (const auto& s : st.discoveredStreams) {
5908
1/2
✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
259 Json::Value sj;
5909
2/4
✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 259 times.
✗ Branch 3 not taken.
259 sj["sessionName"] = s.sessionName;
5910
2/4
✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 259 times.
✗ Branch 3 not taken.
259 sj["originAddress"] = s.originAddress;
5911
2/4
✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 259 times.
✗ Branch 3 not taken.
259 sj["multicastIP"] = s.multicastIP;
5912
2/4
✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 259 times.
✗ Branch 3 not taken.
259 sj["port"] = s.port;
5913
2/4
✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 259 times.
✗ Branch 3 not taken.
259 sj["channels"] = s.channels;
5914
2/4
✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 259 times.
✗ Branch 3 not taken.
259 sj["ptime"] = s.ptime;
5915
2/4
✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 259 times.
✗ Branch 3 not taken.
259 sj["ptpClockId"] = s.ptpClockId;
5916
1/2
✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
259 discovered.append(sj);
5917 259 }
5918
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 result["discoveredStreams"] = discovered;
5919
5920
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 result["graphSampleRate"] = st.graphSampleRate;
5921
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 result["active"] = m_active.load();
5922
5923
1/2
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
796 Json::StreamWriterBuilder wbuilder;
5924
3/6
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 796 times.
✗ Branch 5 not taken.
2388 wbuilder["indentation"] = "";
5925
1/2
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
796 std::string resultStr = Json::writeString(wbuilder, result);
5926
5927
2/4
✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
796 return makeStringResponse(resultStr, 200, "application/json");
5928 796 }
5929
5930 // SDP export. SAP only reaches receivers on the same subnet with a tool
5931 // that speaks it; anything else -- Stream Monitor on a laptop, VLC, a
5932 // scope on another VLAN -- needs the session description handed to it as a
5933 // file. "sdp" lists every send instance as JSON for the UI; "sdp/<id>"
5934 // returns one instance as a raw .sdp for curl and direct download.
5935
8/10
✓ Branch 0 taken 586 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✓ Branch 3 taken 581 times.
✓ Branch 4 taken 5 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✓ Branch 7 taken 2 times.
✓ Branch 8 taken 584 times.
✓ Branch 9 taken 2 times.
586 if (url == "sdp" || url.compare(0, 4, "sdp/") == 0) {
5936
1/2
✓ Branch 0 taken 584 times.
✗ Branch 1 not taken.
584 AES67Config cfg = GetConfigSnapshot();
5937
1/2
✓ Branch 0 taken 584 times.
✗ Branch 1 not taken.
584 bool single = (url != "sdp");
5938
5/8
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 581 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✓ Branch 5 taken 581 times.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
584 int wantId = single ? atoi(url.substr(4).c_str()) : 0;
5939
5940
1/2
✓ Branch 0 taken 584 times.
✗ Branch 1 not taken.
584 Json::Value streams(Json::arrayValue);
5941 584 std::string singleSDP;
5942 584 std::string singleName;
5943
2/2
✓ Branch 0 taken 1475 times.
✓ Branch 1 taken 583 times.
2058 for (const auto& inst : cfg.instances) {
5944 // Only a sender has an SDP to publish; a receive-only instance is
5945 // described by whoever is transmitting to it.
5946
7/10
✓ Branch 0 taken 1475 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✓ Branch 3 taken 1469 times.
✓ Branch 4 taken 6 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 6 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 6 times.
✓ Branch 9 taken 1469 times.
1475 if (inst.mode != "send" && inst.mode != "both") {
5947 10 continue;
5948 }
5949
4/4
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 1464 times.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 1 times.
1469 if (single && inst.id != wantId) {
5950 4 continue;
5951 }
5952
1/2
✓ Branch 0 taken 1465 times.
✗ Branch 1 not taken.
1465 std::string sdp = ExportSDPFor(inst, cfg);
5953
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1464 times.
1465 if (single) {
5954
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 singleSDP = sdp;
5955
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 singleName = SDPFileName(inst);
5956 1 break;
5957 }
5958
1/2
✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
1464 Json::Value sj;
5959
2/4
✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
1464 sj["instanceId"] = inst.id;
5960
2/4
✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
1464 sj["name"] = inst.name;
5961
2/4
✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
1464 sj["sessionName"] = inst.sessionName;
5962 // Exported even while disabled or with SAP off -- that is exactly
5963 // when an external monitor is the only way to look at the stream
5964 // -- so say which it is and let the UI warn.
5965
2/4
✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
1464 sj["enabled"] = inst.enabled;
5966
2/4
✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
1464 sj["sapEnabled"] = inst.sapEnabled;
5967
2/4
✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
1464 sj["multicastIP"] = inst.multicastIP;
5968
2/4
✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
1464 sj["port"] = inst.port;
5969
2/4
✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
1464 sj["channels"] = inst.channels;
5970
2/4
✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
1464 sj["ptime"] = inst.ptime;
5971
3/6
✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1464 times.
✗ Branch 5 not taken.
1464 sj["filename"] = SDPFileName(inst);
5972
2/4
✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
1464 sj["sdp"] = sdp;
5973
1/2
✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
1464 streams.append(sj);
5974
2/2
✓ Branch 0 taken 1464 times.
✓ Branch 1 taken 1 times.
1465 }
5975
5976
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 581 times.
584 if (single) {
5977
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 1 times.
3 if (singleSDP.empty()) {
5978
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
10 return makeStringResponse("{\"error\":\"no such send instance\"}",
5979
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 404, "application/json");
5980 }
5981
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 auto resp = makeStringResponse(singleSDP, 200, "application/sdp");
5982
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
2 resp->addHeader("Content-Disposition",
5983
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
2 "attachment; filename=\"" + singleName + "\"");
5984 1 return resp;
5985 1 }
5986
5987
1/2
✓ Branch 0 taken 581 times.
✗ Branch 1 not taken.
581 Json::Value result;
5988
2/4
✓ Branch 0 taken 581 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 581 times.
✗ Branch 3 not taken.
581 result["streams"] = streams;
5989
1/2
✓ Branch 0 taken 581 times.
✗ Branch 1 not taken.
581 Json::StreamWriterBuilder wbuilder;
5990
3/6
✓ Branch 0 taken 581 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 581 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 581 times.
✗ Branch 5 not taken.
1743 wbuilder["indentation"] = "";
5991
2/4
✓ Branch 0 taken 581 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 581 times.
✗ Branch 3 not taken.
1162 return makeStringResponse(Json::writeString(wbuilder, result), 200,
5992
1/2
✓ Branch 0 taken 581 times.
✗ Branch 1 not taken.
581 "application/json");
5993 584 }
5994
5995
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 if (url == "test") {
5996
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 auto tests = RunSelfTest();
5997
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 Json::Value result;
5998
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 Json::Value testArray(Json::arrayValue);
5999 2 int passed = 0, failed = 0;
6000
6001
2/2
✓ Branch 0 taken 46 times.
✓ Branch 1 taken 2 times.
48 for (const auto& t : tests) {
6002
1/2
✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
46 Json::Value tj;
6003
2/4
✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 46 times.
✗ Branch 3 not taken.
46 tj["test"] = t.testName;
6004
2/4
✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 46 times.
✗ Branch 3 not taken.
46 tj["passed"] = t.passed;
6005
2/4
✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 46 times.
✗ Branch 3 not taken.
46 tj["message"] = t.message;
6006
1/2
✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
46 testArray.append(tj);
6007
2/2
✓ Branch 0 taken 28 times.
✓ Branch 1 taken 18 times.
46 if (t.passed) passed++; else failed++;
6008 46 }
6009
6010
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 result["tests"] = testArray;
6011
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.
2 result["summary"]["total"] = (int)tests.size();
6012
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.
2 result["summary"]["passed"] = passed;
6013
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.
2 result["summary"]["failed"] = failed;
6014
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.
2 result["summary"]["allPassed"] = (failed == 0);
6015
6016
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 Json::StreamWriterBuilder wbuilder;
6017
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 wbuilder["indentation"] = " ";
6018
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 std::string resultStr = Json::writeString(wbuilder, result);
6019
6020
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 return makeStringResponse(resultStr, 200, "application/json");
6021 2 }
6022
6023 ✗ return makeStringResponse("{\"error\":\"unknown endpoint\"}", 404, "application/json");
6024 1382 }
6025
6026 #endif // HAS_AES67_GSTREAMER
6027