GCC Code Coverage Report


Directory: ./
File: mediaoutput/GStreamerOut.cpp
Date: 2026-10-29 08:11:58
Exec Total Coverage
Lines: 2453 2903 84.5%
Functions: 110 114 96.5%
Branches: 2882 5606 51.4%

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
17 #include "Warnings.h" // WarningHolder -- needed directly for NOPCH builds
18 #include <cmath>
19
20 #include "GStreamerOut.h"
21
22 #ifdef HAS_GSTREAMER
23
24 #include <gst/gst.h>
25 #include <gst/app/gstappsink.h>
26 #include <cstdlib>
27 #include <cctype>
28 #include <cstring>
29 #include <dirent.h>
30 #include <fcntl.h>
31 #include <algorithm>
32 #include <fstream>
33 #include <map>
34 #include <memory>
35 #include <mutex>
36 #include <unistd.h>
37 #include <vector>
38 #include <sstream>
39 #include <condition_variable>
40 #include <thread>
41 #include <sys/ioctl.h>
42 #include <sys/stat.h>
43 // DRM/KMS is Linux-only. Without libdrm (macOS) there is no kmssink to hand a
44 // DRM fd or plane to, so the helpers below report "none" and every caller
45 // already falls back on that.
46 #ifdef PLATFORM_OSX
47 // No DRM/KMS on macOS: the display output is a window (MacOSApp.mm) that
48 // glimagesink renders into through GstVideoOverlay.
49 #include <gst/video/videooverlay.h>
50 #include "../MacOSApp.h"
51 #endif
52 #if __has_include(<xf86drm.h>)
53 #define FPP_GST_HAS_DRM
54 #include <libdrm/drm.h>
55 #include <libdrm/drm_mode.h>
56 #include <xf86drm.h>
57 #include <xf86drmMode.h>
58 #endif
59
60 #include "common.h"
61 #include "log.h"
62 #include "PipeWireOutputStream.h"
63 #include "StreamSlotManager.h"
64 #include "VideoOutputManager.h"
65 #include "RTSPOutputManager.h"
66 #include "mediadetails.h"
67 #include "mediaoutput.h"
68 #include "settings.h"
69 #include "Timers.h"
70 #include "channeloutput/channeloutputthread.h"
71 #include "../MultiSync.h"
72 #include "../FileMonitor.h"
73 #include "overlays/PixelOverlay.h"
74 #include "overlays/PixelOverlayModel.h"
75
76 // Static instance pointer for callbacks
77 GStreamerOutput* GStreamerOutput::m_currentInstance = nullptr;
78 std::mutex GStreamerOutput::m_overlayOutputsLock;
79 std::vector<GStreamerOutput*> GStreamerOutput::m_overlayOutputs;
80
81 // Raised once per run, so long enough to outlast a looping playlist's pass.
82 static constexpr int MEDIA_FAILURE_WARNING_SECONDS = 300;
83
84 // ──────────────────────────────────────────────────────────────────────────────
85 // Wedged-decoder recovery (issue #2695).
86 //
87 // The Pi's V4L2 hardware decoder can wedge after ~24h of continuous use even
88 // with CMA healthy (confirmed on the 2026-07-03 soak: preroll failure with
89 // 402MB CmaFree). Once wedged, two things go wrong in-process:
90 // 1. gst_element_set_state(NULL) on the wedged pipeline blocks FOREVER —
91 // the soak's playlist thread hung 2h25m in that call and then died with
92 // SIGBUS. Teardown is therefore supervised: the state change runs on a
93 // reaper thread with a deadline, and on timeout the pipeline is
94 // deliberately ABANDONED (leaked, ~40 fds + threads) so the playlist
95 // thread survives. One leaked pipeline is recoverable; a hung playlist
96 // thread is not.
97 // 2. The decoder stays wedged, so subsequent videos fail preroll too.
98 // Consecutive wedge events (preroll-watchdog fires, abandoned
99 // teardowns) are counted here; at the limit fppd restarts itself via
100 // RestartFPPDResumingPlaylist() — a fresh process re-opens the V4L2
101 // device (and picks the show back up where it left off), which
102 // bounds the abandoned-pipeline fd leakage and recovers the decoder in
103 // the cases we can recover. The count resets the moment any pipeline
104 // reports a valid playback position (decoder demonstrably healthy).
105 // ──────────────────────────────────────────────────────────────────────────────
106 static constexpr int PIPELINE_TEARDOWN_TIMEOUT_MS = 10000;
107
108 // Gain the File Manager's Normalize stored for a song in the music folder or a
109 // video's sound (<config>/media_gain.json, written by
110 // www/common/audioNormalize.inc.php): { "<name relative to the music dir>" or
111 // "videos/<name relative to the videos dir>": { "gainDb": -3.2, "size": 1234, ... } }.
112 // Applied at playback instead of rewriting the file, so timing, quality and
113 // format never change. An entry whose size no longer matches the file (the
114 // song was replaced) is ignored. Returns a linear factor; 1.0 = none.
115 //
116 // The file is parsed once and again whenever FileMonitor says it changed
117 // (inotify, or a timestamp poll where there is none; the PHP side replaces it
118 // by rename, which FileMonitor follows), not at every song start. Slots start
119 // on different threads, so the parsed copy is shared under a lock and handed
120 // out as a snapshot.
121 static std::mutex s_normalizeGainsLock;
122 static std::shared_ptr<const Json::Value> s_normalizeGains;
123
124 180 static void LoadNormalizeGains(const std::string& gainFile) {
125 180 std::shared_ptr<Json::Value> root;
126
3/4
✓ Branch 0 taken 180 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✓ Branch 3 taken 175 times.
180 if (FileExists(gainFile)) {
127
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 root = std::make_shared<Json::Value>();
128
5/10
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 5 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 5 times.
✗ Branch 8 not taken.
✓ Branch 9 taken 5 times.
5 if (!LoadJsonFromFile(gainFile, *root) || !root->isObject()) {
129 // An empty one restored from a backup is "[]": PHP's json_encode
130 // of an empty array.
131 ✗ if (!(root->isArray() && root->empty())) {
132 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: %s is not a JSON object; normalize gains not applied\n", gainFile.c_str());
133 }
134 ✗ root.reset();
135 }
136 }
137
1/2
✓ Branch 0 taken 180 times.
✗ Branch 1 not taken.
180 std::unique_lock<std::mutex> l(s_normalizeGainsLock);
138 180 s_normalizeGains = root;
139 180 }
140
141 1968 static std::shared_ptr<const Json::Value> NormalizeGains() {
142 static std::once_flag watching;
143
1/2
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
2145 std::call_once(watching, []() {
144
3/6
✓ Branch 0 taken 177 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 177 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 177 times.
✗ Branch 5 not taken.
199 const std::string gainFile = FPP_DIR_CONFIG("/media_gain.json");
145
4/8
✓ Branch 0 taken 177 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 177 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 177 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 177 times.
✗ Branch 7 not taken.
487 FileMonitor::INSTANCE.AddFile("GStreamer:media_gain", gainFile, [gainFile]() {
146 3 LoadNormalizeGains(gainFile);
147 3 });
148
1/2
✓ Branch 0 taken 177 times.
✗ Branch 1 not taken.
177 LoadNormalizeGains(gainFile);
149 177 });
150
1/2
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
1968 std::unique_lock<std::mutex> l(s_normalizeGainsLock);
151 3936 return s_normalizeGains;
152 1968 }
153
154 1968 static double LookupNormalizeGain(const std::string& fullPath) {
155
3/6
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1968 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1968 times.
✗ Branch 5 not taken.
3936 const std::string musicDir = FPP_DIR_MUSIC("/");
156
3/6
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1968 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1968 times.
✗ Branch 5 not taken.
1968 const std::string videoDir = FPP_DIR_VIDEO("/");
157 1968 std::string name;
158
3/4
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1905 times.
✓ Branch 3 taken 63 times.
1968 if (fullPath.compare(0, musicDir.size(), musicDir) == 0) {
159
1/2
✓ Branch 0 taken 1905 times.
✗ Branch 1 not taken.
1905 name = fullPath.substr(musicDir.size());
160
2/4
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
63 } else if (fullPath.compare(0, videoDir.size(), videoDir) == 0) {
161
2/4
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
63 name = "videos/" + fullPath.substr(videoDir.size());
162 } else {
163 ✗ return 1.0;
164 }
165
1/2
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
1968 std::shared_ptr<const Json::Value> root = NormalizeGains();
166
2/2
✓ Branch 0 taken 1958 times.
✓ Branch 1 taken 10 times.
1968 if (!root) {
167 1958 return 1.0;
168 }
169
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 const Json::Value& e = (*root)[name];
170
11/18
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✓ Branch 3 taken 2 times.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 8 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 8 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 8 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 8 times.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✓ Branch 15 taken 8 times.
✓ Branch 16 taken 2 times.
✓ Branch 17 taken 8 times.
10 if (!e.isObject() || !e["gainDb"].isNumeric() || !e["size"].isNumeric()) {
171 2 return 1.0;
172 }
173 struct stat st;
174
7/10
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✓ Branch 7 taken 7 times.
✓ Branch 8 taken 1 times.
✓ Branch 9 taken 7 times.
8 if (stat(fullPath.c_str(), &st) != 0 || (Json::Int64)st.st_size != e["size"].asInt64()) {
175
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogDebug(VB_MEDIAOUT, "GStreamer: normalize gain for %s ignored (file changed since it was measured)\n", name.c_str());
176 1 return 1.0;
177 }
178 // +20 dB = 10.0, the volume element's maximum.
179
3/6
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 7 times.
✗ Branch 5 not taken.
7 double db = std::clamp(e["gainDb"].asDouble(), -30.0, 20.0);
180
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 LogDebug(VB_MEDIAOUT, "GStreamer: normalize gain for %s: %+.1f dB\n", name.c_str(), db);
181 7 return pow(10.0, db / 20.0);
182 1968 }
183 // Longest a track waits for its slot's PipeWire output stream to reach the
184 // card. A cold open is ~0.6 s on an AM335x.
185 static constexpr int PW_STREAM_READY_TIMEOUT_MS = 3000;
186 // Longest a track waits for the previous one's leftovers to play out of the
187 // stream (normally ~100 ms: the feed's lead plus the tail-flush silence).
188 static constexpr int PW_STREAM_DRAIN_MAX_MS = 500;
189 static std::atomic<int> s_consecutiveWedgeEvents{ 0 };
190 // Ensures the escalation decision (restart vs reboot) is made exactly once
191 // per process — additional wedge events racing in while shutdown is already
192 // underway must not re-record restarts or re-trigger anything.
193 static std::atomic<bool> s_wedgeShutdownTriggered{ false };
194
195 // Wedge-triggered restart history, kept in tmpfs: survives fppd restarts but
196 // is cleared by a reboot — exactly the lifetime of the VideoCore firmware
197 // state we're trying to recover. On platforms without /run/fppd (macOS,
198 // Docker) the file is unusable and the count stays at 1, which keeps the
199 // reboot rung disengaged there by construction.
200 static constexpr const char* WEDGE_RESTART_STATE_FILE = "/run/fppd/gstreamer_wedge_restarts";
201 static constexpr long long WEDGE_RESTART_WINDOW_MS = 2LL * 60 * 60 * 1000; // 2 hours
202
203 // Record that a wedge-triggered fppd restart is happening now and return how
204 // many such restarts (including this one) occurred within the window.
205 2 static int RecordWedgeRestartAndCount() {
206
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 long long now = GetTimeMS();
207 2 std::vector<long long> stamps;
208 {
209
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 std::ifstream in(WEDGE_RESTART_STATE_FILE);
210 2 std::string line;
211
3/12
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 2 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 not taken.
✓ Branch 11 taken 2 times.
2 while (in && std::getline(in, line)) {
212 ✗ long long v = atoll(line.c_str());
213 ✗ if (v > 0 && v <= now && (now - v) < WEDGE_RESTART_WINDOW_MS) {
214 ✗ stamps.push_back(v);
215 }
216 }
217 2 }
218
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 stamps.push_back(now);
219
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 std::ofstream out(WEDGE_RESTART_STATE_FILE, std::ios::trunc);
220
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 2 times.
2 if (!out) {
221 ✗ return 1;
222 }
223
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
4 for (long long s : stamps) {
224
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 out << s << "\n";
225 }
226 2 return (int)stamps.size();
227 2 }
228
229 // Set on a decodebin that chose a hardware video decoder (OnAutoplugSelect).
230 static constexpr const char* HW_DECODER_CHOSEN_KEY = "fpp-hw-decoder-chosen";
231
232 // Whether `pipeline` holds a hardware video decoder (element class
233 // "Codec/Decoder/Video/Hardware" -- the V4L2 stateful and stateless decoders),
234 // or its decodebin chose one that never got made. decodebin chooses from the
235 // registry, and making the element loads its plugin, whose init opens every
236 // V4L2 device to register the decoders. A decoder driver that has died (a
237 // bcm2835-codec oops leaves its mutex held) blocks that open() for good, and
238 // every later load waits behind it, so no decoder shows up in the pipeline.
239 // A pipeline that chose one and holds no video decoder at all is stuck there;
240 // one that fell back to a software decoder is not.
241 1912 static bool PipelineHasHwVideoDecoder(GstElement* pipeline) {
242
8/16
✓ Branch 0 taken 1912 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1912 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 1912 times.
✓ Branch 6 taken 1912 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 1912 times.
✓ Branch 10 taken 1912 times.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✓ Branch 13 taken 1912 times.
✗ Branch 14 not taken.
✓ Branch 15 taken 1912 times.
1912 if (!pipeline || !GST_IS_BIN(pipeline)) {
243 ✗ return false;
244 }
245 1912 bool found = false;
246 1912 bool chosen = false;
247 1912 bool videoDecoder = false;
248
3/6
✓ Branch 0 taken 1912 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1912 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1912 times.
✗ Branch 5 not taken.
1912 GstIterator* it = gst_bin_iterate_recurse(GST_BIN(pipeline));
249 1912 GValue item = G_VALUE_INIT;
250 1912 bool done = false;
251
3/4
✓ Branch 0 taken 30915 times.
✓ Branch 1 taken 1912 times.
✓ Branch 2 taken 30915 times.
✗ Branch 3 not taken.
32827 while (!done && !found) {
252
3/5
✓ Branch 0 taken 30915 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 29003 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1912 times.
30915 switch (gst_iterator_next(it, &item)) {
253 29003 case GST_ITERATOR_OK: {
254
3/6
✓ Branch 0 taken 29003 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 29003 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 29003 times.
✗ Branch 5 not taken.
29003 GstElement* element = GST_ELEMENT(g_value_get_object(&item));
255
1/2
✓ Branch 0 taken 29003 times.
✗ Branch 1 not taken.
29003 GstElementFactory* f = gst_element_get_factory(element);
256
2/4
✓ Branch 0 taken 29003 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 29003 times.
✗ Branch 3 not taken.
29003 const gchar* klass = f ? gst_element_factory_get_metadata(f, GST_ELEMENT_METADATA_KLASS) : nullptr;
257
5/6
✓ Branch 0 taken 29003 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2003 times.
✓ Branch 3 taken 27000 times.
✓ Branch 4 taken 47 times.
✓ Branch 5 taken 1956 times.
29003 if (klass && strstr(klass, "Decoder") && strstr(klass, "Video")) {
258 47 videoDecoder = true;
259 47 found = strstr(klass, "Hardware") != nullptr;
260 }
261
4/6
✓ Branch 0 taken 29003 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 29003 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✓ Branch 5 taken 29000 times.
29003 if (g_object_get_data(G_OBJECT(element), HW_DECODER_CHOSEN_KEY)) {
262 3 chosen = true;
263 }
264
1/2
✓ Branch 0 taken 29003 times.
✗ Branch 1 not taken.
29003 g_value_reset(&item);
265 29003 break;
266 }
267 ✗ case GST_ITERATOR_RESYNC:
268 ✗ gst_iterator_resync(it);
269 ✗ break;
270 1912 default:
271 1912 done = true;
272 1912 break;
273 }
274 }
275
1/2
✓ Branch 0 taken 1912 times.
✗ Branch 1 not taken.
1912 g_value_unset(&item);
276
1/2
✓ Branch 0 taken 1912 times.
✗ Branch 1 not taken.
1912 gst_iterator_free(it);
277
4/6
✓ Branch 0 taken 1912 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✓ Branch 3 taken 1909 times.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
1912 return found || (chosen && !videoDecoder);
278 }
279
280 // The restart and reboot rungs below exist for one failure: a hardware video
281 // decoder (V4L2 / VideoCore firmware) that has wedged and only a process
282 // restart or a reboot recovers. A pipeline without one that times out --
283 // an audio-only track whose pipewiresink stalled in teardown, a stream that
284 // never prerolled on a BeagleBone -- has nothing a reboot fixes, and counting
285 // it restarted fppd and rebooted show controllers that have no such decoder.
286 // Those are logged and left at that.
287 // The UI warning for a pipeline that never got to PLAYING with no hardware
288 // decoder to blame: in practice a PipeWire daemon restarted under fppd.
289 // Raised by whichever of the start thread and Process()'s preroll watchdog
290 // sees it first; the two are the same warning, so it shows once.
291 static const char* const kMediaDidNotStartWarning =
292 "Media playback did not start (audio backend connection lost — restart FPPD)";
293
294 16 static void RecordWedgeEventAndMaybeRestart(const char* what, bool hwDecoder) {
295
2/2
✓ Branch 0 taken 13 times.
✓ Branch 1 taken 3 times.
16 if (!hwDecoder) {
296 13 LogErr(VB_MEDIAOUT, "GStreamer: %s (no hardware decoder involved, not counted toward a restart)\n", what);
297 13 return;
298 }
299 3 int fails = s_consecutiveWedgeEvents.fetch_add(1) + 1;
300 // 0 disables the self-restart escalation (teardown abandonment still applies)
301 3 int limit = getSettingInt("GStreamerWedgeRestartLimit", 3);
302 3 LogErr(VB_MEDIAOUT, "GStreamer: %s (consecutive decoder-wedge events: %d, restart limit: %d)\n",
303 what, fails, limit);
304
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
3 if (limit <= 0 || fails < limit) {
305 ✗ return;
306 }
307
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 2 times.
3 if (s_wedgeShutdownTriggered.exchange(true)) {
308 1 return; // restart/reboot already in flight
309 }
310
311 // A process restart re-opens the V4L2 device, which recovers the decoder
312 // when the wedge is driver-side. But the dmesg evidence from issue #2727
313 // shows the wedge can live in the VideoCore FIRMWARE (VCHIQ stops
314 // answering, "invalid message context") — no userspace restart resets
315 // that; only a reboot reinitializes the firmware. So: restart first,
316 // and if wedge-triggered restarts keep happening within the window, the
317 // restart rung clearly isn't working — escalate to a system reboot.
318 // GStreamerWedgeRebootLimit is the number of wedge-triggered restarts
319 // within 2 hours that triggers the reboot (default 2: one restart gets a
320 // chance to fix it, the second escalates). 0 disables the reboot rung.
321 2 int rebootLimit = getSettingInt("GStreamerWedgeRebootLimit", 2);
322 2 int recentRestarts = RecordWedgeRestartAndCount();
323
3/4
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
2 if (rebootLimit > 0 && recentRestarts >= rebootLimit) {
324 ✗ LogErr(VB_MEDIAOUT, "GStreamer: %d wedge-triggered fppd restarts within %d minutes did not recover the decoder — "
325 "VideoCore firmware is wedged (issues #2695/#2727), REBOOTING the system\n",
326 recentRestarts, (int)(WEDGE_RESTART_WINDOW_MS / 60000));
327 // Permanent: the reboot that follows clears it.
328 ✗ WarningHolder::AddWarning(37, "Rebooting: hardware video decoder firmware wedged; fppd restart did not recover it");
329 ✗ sync();
330 ✗ if (system("/usr/sbin/reboot") != 0) {
331 // Reboot unavailable (container, dev box) — fall back to the
332 // restart rung rather than doing nothing.
333 ✗ LogErr(VB_MEDIAOUT, "GStreamer: reboot command failed, falling back to fppd restart\n");
334 ✗ RestartFPPDResumingPlaylist();
335 ✗ return;
336 }
337 // Exit cleanly while the system goes down.
338 ✗ ShutdownFPPD(false);
339 ✗ return;
340 }
341
342 2 LogErr(VB_MEDIAOUT, "GStreamer: %d consecutive pipeline wedges — hardware decoder is not recoverable in-process, restarting fppd (restart %d/%d before reboot escalation)\n",
343 fails, recentRestarts, rebootLimit);
344
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 WarningHolder::AddWarningTimeout(300, 37, "Restarting fppd: hardware media decoder wedged (repeated pipeline hangs, issue #2695)");
345 // Resume whatever was playing: the restart exists to keep the show (and
346 // the remotes syncing to it) alive, not just to reset the decoder.
347 2 RestartFPPDResumingPlaylist();
348 }
349
350 // Reaper threads still inside gst_element_set_state(NULL): the teardowns in
351 // progress, plus every abandoned one that has not let go.
352 static std::atomic<int> s_liveReapers{ 0 };
353 // How many of those may be stuck before fppd restarts. Each abandoned
354 // teardown keeps its pipeline, its streaming threads and its reaper for good,
355 // and the decoder-wedge ladder above does not bound that: a pipeline without
356 // a hardware decoder is never counted, and the count resets whenever a clip
357 // plays. A box whose every teardown wedged (a leaked DRM lease left each
358 // kmssink after it sharing the card's fd) abandoned 226 pipelines in 50
359 // minutes, until a 32-bit fppd could not create another thread and aborted.
360 // A teardown that stalls and then lets go leaves this count again, so only
361 // pipelines that are genuinely stuck add up.
362 static constexpr int STUCK_TEARDOWN_RESTART_LIMIT = 4;
363
364 // Restart fppd, resuming the playlist, because abandoned teardowns are piling
365 // up. Not a decoder recovery: it does not feed the reboot rung.
366 2 static void RestartForStuckTeardowns(const char* why) {
367
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 if (getSettingInt("GStreamerWedgeRestartLimit", 3) <= 0) {
368 ✗ return; // self-restarts are turned off; the pipelines stay leaked
369 }
370
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 if (s_wedgeShutdownTriggered.exchange(true)) {
371 ✗ return; // restart/reboot already in flight
372 }
373 2 LogErr(VB_MEDIAOUT, "GStreamer: %s, restarting fppd\n", why);
374
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 WarningHolder::AddWarningTimeout(300, 37, "Restarting fppd: media pipeline teardowns stuck");
375 2 RestartFPPDResumingPlaylist();
376 }
377
378 // Drive a pipeline to GST_STATE_NULL on a reaper thread with a deadline.
379 // Returns true when the pipeline reached NULL in time. Returns false when
380 // the transition wedged: the reaper thread keeps its own ref and either
381 // finishes late (logged) or blocks forever, in which case the pipeline and
382 // its resources are intentionally leaked. After a false return the caller
383 // must NOT release the pipeline's kernel-side resources (DRM planes, shared
384 // DRM fd refs) — the wedged pipeline still owns them.
385 3474 static bool SetPipelineToNullSupervised(GstElement* pipeline, int timeoutMs) {
386 struct TeardownCtx {
387 std::mutex m;
388 std::condition_variable cv;
389 bool done = false;
390 };
391
1/2
✓ Branch 0 taken 3474 times.
✗ Branch 1 not taken.
3474 auto ctx = std::make_shared<TeardownCtx>();
392
1/2
✓ Branch 0 taken 3474 times.
✗ Branch 1 not taken.
3474 gst_object_ref(pipeline);
393 3474 s_liveReapers++;
394 try {
395
0/2
✗ Branch 0 not taken.
✗ Branch 1 not taken.
10422 std::thread([pipeline, ctx, timeoutMs]() {
396
2/4
✓ Branch 0 taken 3474 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3474 times.
✗ Branch 3 not taken.
3474 SetThreadName("FPP-GstReaper");
397 3474 uint64_t start = GetMonotonicTimeMS();
398 3474 gst_element_set_state(pipeline, GST_STATE_NULL);
399 3460 gst_object_unref(pipeline);
400 3460 s_liveReapers--;
401 3460 uint64_t took = GetMonotonicTimeMS() - start;
402
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3460 times.
3460 if (took > (uint64_t)timeoutMs) {
403 // The caller already gave up and abandoned this pipeline —
404 // record that the kernel/GStreamer eventually let go so a
405 // "late but not never" wedge is distinguishable in soak logs.
406 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: abandoned pipeline teardown eventually completed after %llu ms\n",
407 (unsigned long long)took);
408 }
409 {
410
1/2
✓ Branch 0 taken 3460 times.
✗ Branch 1 not taken.
3460 std::lock_guard<std::mutex> l(ctx->m);
411 3460 ctx->done = true;
412 3460 }
413 3460 ctx->cv.notify_all();
414
2/4
✓ Branch 0 taken 3474 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3474 times.
✗ Branch 3 not taken.
6934 }).detach();
415 ✗ } catch (const std::system_error& e) {
416 // Out of threads. The pipeline is still running, so its last unref
417 // must not happen here: keep the reaper's ref and abandon it like a
418 // wedged one.
419 ✗ s_liveReapers--;
420 ✗ LogErr(VB_MEDIAOUT, "GStreamer: could not start a teardown thread (%s), abandoning the pipeline\n", e.what());
421 ✗ RestartForStuckTeardowns("out of threads for pipeline teardowns");
422 ✗ return false;
423 ✗ }
424
425
1/2
✓ Branch 0 taken 3474 times.
✗ Branch 1 not taken.
3474 std::unique_lock<std::mutex> l(ctx->m);
426
3/4
✓ Branch 0 taken 3473 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3460 times.
✓ Branch 3 taken 13 times.
10420 if (ctx->cv.wait_for(l, std::chrono::milliseconds(timeoutMs), [&] { return ctx->done; })) {
427 3460 return true;
428 }
429
1/2
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
13 l.unlock();
430 13 int stuck = s_liveReapers.load();
431
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 11 times.
13 if (stuck >= STUCK_TEARDOWN_RESTART_LIMIT) {
432 char why[96];
433 2 snprintf(why, sizeof(why), "%d abandoned pipeline teardowns are still stuck", stuck);
434
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 RestartForStuckTeardowns(why);
435 }
436 13 return false;
437 3473 }
438
439 // Static audio sample buffer for WLED audio-reactive effects
440 std::array<float, GStreamerOutput::SAMPLE_BUFFER_SIZE> GStreamerOutput::s_sampleBuffer = {};
441 int GStreamerOutput::s_sampleWritePos = 0;
442 int GStreamerOutput::s_sampleRate = 0;
443 std::mutex GStreamerOutput::s_sampleMutex;
444 uint64_t GStreamerOutput::s_sampleLastWriteMs = 0;
445 std::atomic<uint64_t> GStreamerOutput::s_sampleTapReadMs{ 0 };
446
447 // One-time GStreamer initialization.
448 //
449 // Every entry point that touches the GStreamer API has to come through here
450 // first: gst_parse_launch() against an uninitialized library dereferences the
451 // (still null) value table and segfaults inside gst_value_deserialize() rather
452 // than reporting a GError. Media playback used to be the only such entry
453 // point, so a lazy init on the playback path was enough; the video input and
454 // output managers now build pipelines from a config reload with no media in
455 // sight, which on a box that had never played anything took fppd down.
456 //
457 // Guarded on gst_is_initialized() rather than a local flag so it stays correct
458 // alongside any other caller that reached gst_init() first, and serialized
459 // because the WLED overlay, the managers and the playback path can all arrive
460 // here from different threads.
461 static std::mutex s_gstInitMutex;
462
463 // Caller holds s_gstInitMutex.
464 3774 static void SetGStreamerEnvLocked() {
465 static bool done = false;
466
2/2
✓ Branch 0 taken 1887 times.
✓ Branch 1 taken 1887 times.
3774 if (done) {
467 1887 return;
468 }
469 1887 done = true;
470 // Set PipeWire env vars so pipewiresink can find the FPP PipeWire runtime.
471 // Both Simple PipeWire and PipeWire Advanced share the same runtime stack.
472
2/2
✓ Branch 0 taken 1881 times.
✓ Branch 1 taken 6 times.
1887 if (isPipeWireBackend()) {
473 1881 setenv("PIPEWIRE_RUNTIME_DIR", "/run/pipewire-fpp", 1);
474 1881 setenv("XDG_RUNTIME_DIR", "/run/pipewire-fpp", 1);
475 1881 setenv("PULSE_RUNTIME_PATH", "/run/pipewire-fpp/pulse", 1);
476 1881 LogInfo(VB_MEDIAOUT, "GStreamer: Set PipeWire env (PIPEWIRE_RUNTIME_DIR=/run/pipewire-fpp)\n");
477 } else {
478
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 std::string mediaBackend = getSetting("MediaBackend");
479
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 LogInfo(VB_MEDIAOUT, "GStreamer: MediaBackend='%s', not setting PipeWire env\n", mediaBackend.c_str());
480 6 }
481 }
482
483 // videoconvert's I420 kernels, as compiled by ORC 0.4.41 for arm64 (the version
484 // Raspberry Pi OS trixie ships), read two bytes past the end of the frame: the
485 // chroma load for the last pixels of each row takes 4 bytes where 2 remain.
486 // Inside a frame that lands in the next row, harmlessly. At the end of the
487 // last V row it lands past the buffer -- also harmless in system memory, which
488 // has slack after it, but a hardware decoder's frames are mmapped V4L2 buffers
489 // sized exactly to the image. When the image needs no alignment padding
490 // (1280x384, 640x192, ...) the frame ends on a page boundary and the over-read
491 // faults, killing fppd on the first frame of every play. Reproduced against
492 // the stock libraries with a frame placed before an inaccessible page; ORC's C
493 // backup code reads the same frame cleanly.
494 //
495 // So a frame that a videoconvert is going to convert is first copied into
496 // system memory with tail padding, which is the slack GStreamer allocates for
497 // exactly this kind of SIMD over-read. Frames already in system memory
498 // (software decoders) and converters running in passthrough (the HDMI
499 // branches, which hand the decoder's buffers to kmssink untouched) are left
500 // alone, so zero-copy playback keeps its zero copy.
501 3202 static GstPadProbeReturn CopyDeviceFrameForConvert(GstPad* pad, GstPadProbeInfo* info, gpointer) {
502 3202 GstBuffer* buf = GST_PAD_PROBE_INFO_BUFFER(info);
503
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3202 times.
3202 if (!buf) {
504 ✗ return GST_PAD_PROBE_OK;
505 }
506 3202 bool systemMemory = true;
507
3/4
✓ Branch 0 taken 3202 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3202 times.
✓ Branch 3 taken 3197 times.
6399 for (guint i = 0, n = gst_buffer_n_memory(buf); i < n; i++) {
508
4/6
✓ Branch 0 taken 3202 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3202 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 5 times.
✓ Branch 5 taken 3197 times.
3202 if (!gst_memory_is_type(gst_buffer_peek_memory(buf, i), GST_ALLOCATOR_SYSMEM)) {
509 5 systemMemory = false;
510 5 break;
511 }
512 }
513
2/2
✓ Branch 0 taken 3197 times.
✓ Branch 1 taken 5 times.
3202 if (systemMemory) {
514 3197 return GST_PAD_PROBE_OK;
515 }
516
517 // Passthrough: identical caps on both sides, so the frame is never read.
518
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 GstElement* convert = gst_pad_get_parent_element(pad);
519
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 if (convert) {
520 5 bool passthrough = false;
521
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 GstPad* src = gst_element_get_static_pad(convert, "src");
522
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 GstCaps* inCaps = gst_pad_get_current_caps(pad);
523
2/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
5 GstCaps* outCaps = src ? gst_pad_get_current_caps(src) : nullptr;
524
2/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
5 if (inCaps && outCaps) {
525
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 passthrough = gst_caps_is_equal(inCaps, outCaps);
526 }
527
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 if (inCaps) {
528
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_caps_unref(inCaps);
529 }
530
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 if (outCaps) {
531
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_caps_unref(outCaps);
532 }
533
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 if (src) {
534
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_object_unref(src);
535 }
536
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_object_unref(convert);
537
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 5 times.
5 if (passthrough) {
538 ✗ return GST_PAD_PROBE_OK;
539 }
540 }
541
542
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gsize size = gst_buffer_get_size(buf);
543 GstAllocationParams params;
544
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_allocation_params_init(&params);
545 5 params.align = 15;
546 5 params.padding = 64;
547
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 GstBuffer* copy = gst_buffer_new_allocate(nullptr, size, &params);
548
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 5 times.
5 if (!copy) {
549 ✗ return GST_PAD_PROBE_OK;
550 }
551 GstMapInfo map;
552
2/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 5 times.
5 if (!gst_buffer_map(copy, &map, GST_MAP_WRITE)) {
553 ✗ gst_buffer_unref(copy);
554 ✗ return GST_PAD_PROBE_OK;
555 }
556
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_buffer_extract(buf, 0, map.data, size);
557
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_buffer_unmap(copy, &map);
558
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_buffer_copy_into(copy, buf,
559 (GstBufferCopyFlags)(GST_BUFFER_COPY_FLAGS | GST_BUFFER_COPY_TIMESTAMPS | GST_BUFFER_COPY_META),
560 0, -1);
561 // ...except that gst_buffer_copy_into() leaves out every meta tagged
562 // "memory" unless the memory itself is copied too, and GstVideoMeta is
563 // one: it records where the planes sit. Its offsets are buffer-relative
564 // and the copy keeps every byte where it was, so it still holds for the
565 // copy -- and without it videoconvert assumes the caps' default layout.
566 // That default happens to match a plain linear frame, but not a padded
567 // or tiled one: the Pi 4 HEVC decoder's NV12_128C8 pads each 128-byte
568 // column to 1088 rows, and read as 1080 the picture shears and the
569 // chroma comes from the wrong place. So carry it over by hand, through
570 // the meta's own copy transform. Looked up by name because libfpp does
571 // not link gstreamer-video; if the type was never registered, no buffer
572 // can be carrying one.
573
2/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
5 if (GType videoMetaApi = g_type_from_name("GstVideoMetaAPI")) {
574 5 gpointer state = nullptr;
575
3/4
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✓ Branch 3 taken 5 times.
10 while (GstMeta* meta = gst_buffer_iterate_meta_filtered(buf, &state, videoMetaApi)) {
576
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 if (meta->info->transform_func) {
577 5 GstMetaTransformCopy whole = { FALSE, 0, (gsize)-1 };
578
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 meta->info->transform_func(copy, meta, buf, _gst_meta_transform_copy, &whole);
579 }
580 5 }
581 }
582
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_buffer_unref(buf);
583 5 GST_PAD_PROBE_INFO_DATA(info) = copy;
584 5 return GST_PAD_PROBE_OK;
585 }
586
587 // The media path goes to filesrc as a property, not spliced into the
588 // gst_parse_launch() description. Inside the description a '"' in the
589 // filename (uploads keep them) ended the quoted value early, so the file never
590 // played, and a crafted name could add elements to the pipeline.
591 1967 static void SetFileSourceLocation(GstElement* pipeline, const std::string& path) {
592
1/2
✓ Branch 0 taken 1967 times.
✗ Branch 1 not taken.
1967 if (GstElement* fsrc = gst_bin_get_by_name(GST_BIN(pipeline), "fsrc")) {
593 1967 g_object_set(fsrc, "location", path.c_str(), NULL);
594 1967 gst_object_unref(fsrc);
595 }
596 1967 }
597
598 2346 static void GuardVideoConvert(GstElement* element) {
599 2346 GstElementFactory* factory = gst_element_get_factory(element);
600
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2346 times.
2346 if (!factory) {
601 ✗ return;
602 }
603 2346 const char* name = gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(factory));
604
3/4
✓ Branch 0 taken 2193 times.
✓ Branch 1 taken 153 times.
✓ Branch 2 taken 2193 times.
✗ Branch 3 not taken.
2346 if (strcmp(name, "videoconvert") != 0 && strcmp(name, "videoconvertscale") != 0) {
605 2193 return;
606 }
607 153 GstPad* sink = gst_element_get_static_pad(element, "sink");
608
1/2
✓ Branch 0 taken 153 times.
✗ Branch 1 not taken.
153 if (sink) {
609 153 gst_pad_add_probe(sink, GST_PAD_PROBE_TYPE_BUFFER, CopyDeviceFrameForConvert, nullptr, nullptr);
610 153 gst_object_unref(sink);
611 }
612 }
613
614 // decodebin's "autoplug-select", asked before it makes each element: note a
615 // hardware video decoder on the decodebin (see PipelineHasHwVideoDecoder) and
616 // let it go ahead. 0 is GST_AUTOPLUG_SELECT_TRY, whose enum is not public.
617 214 static gint OnAutoplugSelect(GstElement* decodebin, GstPad*, GstCaps*, GstElementFactory* factory, gpointer) {
618 214 const gchar* klass = gst_element_factory_get_metadata(factory, GST_ELEMENT_METADATA_KLASS);
619
7/8
✓ Branch 0 taken 214 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 132 times.
✓ Branch 3 taken 82 times.
✓ Branch 4 taken 66 times.
✓ Branch 5 taken 66 times.
✓ Branch 6 taken 3 times.
✓ Branch 7 taken 63 times.
214 if (klass && strstr(klass, "Decoder") && strstr(klass, "Video") && strstr(klass, "Hardware")) {
620 3 g_object_set_data(G_OBJECT(decodebin), HW_DECODER_CHOSEN_KEY, GINT_TO_POINTER(1));
621 }
622 214 return 0;
623 }
624
625 2346 static void WatchDecoderChoice(GstElement* element) {
626 2346 GstElementFactory* factory = gst_element_get_factory(element);
627
5/6
✓ Branch 0 taken 2346 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 93 times.
✓ Branch 3 taken 2253 times.
✓ Branch 4 taken 93 times.
✓ Branch 5 taken 2253 times.
2346 if (factory && strcmp(gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(factory)), "decodebin") == 0) {
628 93 g_signal_connect(element, "autoplug-select", G_CALLBACK(OnAutoplugSelect), nullptr);
629 }
630 2346 }
631
632 1523 static void OnDeepElementAdded(GstBin*, GstBin*, GstElement* element, gpointer) {
633 1523 GuardVideoConvert(element);
634 1523 WatchDecoderChoice(element);
635 1523 }
636
637 152 void GStreamerOutput::GuardVideoConvertInputs(GstElement* pipeline) {
638
8/16
✓ Branch 0 taken 152 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 152 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 152 times.
✓ Branch 6 taken 152 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 152 times.
✓ Branch 10 taken 152 times.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✓ Branch 13 taken 152 times.
✗ Branch 14 not taken.
✓ Branch 15 taken 152 times.
152 if (!pipeline || !GST_IS_BIN(pipeline)) {
639 ✗ return;
640 }
641 // Converters added from here on: deferred branches, and the ones
642 // decodebin and friends plug for themselves.
643
1/2
✓ Branch 0 taken 152 times.
✗ Branch 1 not taken.
152 g_signal_connect(pipeline, "deep-element-added", G_CALLBACK(OnDeepElementAdded), nullptr);
644 // ...and the ones already there.
645
3/6
✓ Branch 0 taken 152 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 152 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 152 times.
✗ Branch 5 not taken.
152 GstIterator* it = gst_bin_iterate_recurse(GST_BIN(pipeline));
646 152 GValue item = G_VALUE_INIT;
647 152 bool done = false;
648
2/2
✓ Branch 0 taken 975 times.
✓ Branch 1 taken 152 times.
1127 while (!done) {
649
3/5
✓ Branch 0 taken 975 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 823 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 152 times.
975 switch (gst_iterator_next(it, &item)) {
650 823 case GST_ITERATOR_OK:
651
4/8
✓ Branch 0 taken 823 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 823 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 823 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 823 times.
✗ Branch 7 not taken.
823 GuardVideoConvert(GST_ELEMENT(g_value_get_object(&item)));
652
4/8
✓ Branch 0 taken 823 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 823 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 823 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 823 times.
✗ Branch 7 not taken.
823 WatchDecoderChoice(GST_ELEMENT(g_value_get_object(&item)));
653
1/2
✓ Branch 0 taken 823 times.
✗ Branch 1 not taken.
823 g_value_reset(&item);
654 823 break;
655 ✗ case GST_ITERATOR_RESYNC:
656 ✗ gst_iterator_resync(it);
657 ✗ break;
658 152 default:
659 152 done = true;
660 152 break;
661 }
662 }
663
1/2
✓ Branch 0 taken 152 times.
✗ Branch 1 not taken.
152 g_value_unset(&item);
664
1/2
✓ Branch 0 taken 152 times.
✗ Branch 1 not taken.
152 gst_iterator_free(it);
665 }
666
667 6959 void GStreamerOutput::EnsureGStreamerInit() {
668
1/2
✓ Branch 0 taken 6959 times.
✗ Branch 1 not taken.
6959 std::lock_guard<std::mutex> lock(s_gstInitMutex);
669
3/4
✓ Branch 0 taken 6959 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1887 times.
✓ Branch 3 taken 5072 times.
6959 if (!gst_is_initialized()) {
670
1/2
✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
1887 LogDebug(VB_MEDIAOUT, "GStreamer: EnsureGStreamerInit() entered\n");
671
1/2
✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
1887 SetGStreamerEnvLocked();
672
1/2
✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
1887 LogDebug(VB_MEDIAOUT, "GStreamer: Calling gst_init()...\n");
673
1/2
✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
1887 gst_init(nullptr, nullptr);
674
2/4
✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1887 times.
✗ Branch 3 not taken.
1887 LogInfo(VB_MEDIAOUT, "GStreamer initialized: %s\n", gst_version_string());
675 }
676 6959 }
677
678 1887 void GStreamerOutput::PreloadAsync() {
679 {
680 // setenv() is not safe against a concurrent getenv() on another
681 // thread, so it happens here, on the caller's, rather than on the
682 // background thread below.
683
1/2
✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
1887 std::lock_guard<std::mutex> lock(s_gstInitMutex);
684
1/2
✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
1887 SetGStreamerEnvLocked();
685 1887 }
686 3774 std::thread([]() {
687
2/4
✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1887 times.
✗ Branch 3 not taken.
1887 SetThreadName("FPP-GstInit");
688 1887 EnsureGStreamerInit();
689
2/4
✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1887 times.
✗ Branch 3 not taken.
3774 }).detach();
690 1887 }
691
692 4135 std::string GStreamerOutput::PipeWireSinkNameForSlot(int slot) {
693 4135 std::string sinkName = getSetting("PipeWireSinkName");
694 // For multi-stream slots > 1, check for a per-slot PipeWire sink setting.
695 // Format: PipeWireSinkName_2, PipeWireSinkName_3, etc.
696 // If not set, falls back to the global PipeWireSinkName.
697
2/2
✓ Branch 0 taken 2205 times.
✓ Branch 1 taken 1930 times.
4135 if (slot > 1) {
698
2/4
✓ Branch 0 taken 2205 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2205 times.
✗ Branch 3 not taken.
2205 std::string slotSinkName = getSetting(("PipeWireSinkName_" + std::to_string(slot)).c_str());
699
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2205 times.
2205 if (!slotSinkName.empty()) {
700 ✗ sinkName = slotSinkName;
701 }
702 2205 }
703 4135 return sinkName;
704 ✗ }
705
706 // Pick the best available ALSA-direct audio sink element.
707 //
708 // We prefer alsasink (avoids autoaudiosink's slow pulsesink probe in
709 // ALSA-only mode), but the gstreamer1.0-alsa plugin is not present on
710 // every install — older/upgraded systems may lack it. If alsasink is
711 // not registered, gst_parse_launch() / gst_element_factory_make() fail
712 // with "no element alsasink" and audio never starts. Fall back to
713 // autoaudiosink so playback always works. Result is cached; must be
714 // called after gst_init().
715 3 static const char* GetAlsaDirectSinkName() {
716 #ifdef PLATFORM_OSX
717 // No ALSA on macOS; autoaudiosink picks osxaudiosink (the default output
718 // device), and there is no slow pulsesink probe to avoid.
719 return "autoaudiosink";
720 #endif
721 static const char* cached = nullptr;
722
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 if (!cached) {
723 3 GstElementFactory* f = gst_element_factory_find("alsasink");
724
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 if (f) {
725 3 gst_object_unref(f);
726 3 cached = "alsasink";
727 } else {
728 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: alsasink not available "
729 "(gstreamer1.0-alsa not installed) — "
730 "falling back to autoaudiosink\n");
731 ✗ cached = "autoaudiosink";
732 }
733 }
734 3 return cached;
735 }
736
737 // Resolve a DRM connector name (e.g., "HDMI-A-1") to its card path, connector ID,
738 // connection status, and current display resolution by scanning sysfs.
739 // Works on all Pi models (Pi 3/4/5) and x86 — no libdrm ioctls needed.
740 64 GStreamerOutput::DrmConnectorInfo GStreamerOutput::ResolveDrmConnector(const std::string& connectorName) {
741 64 DrmConnectorInfo info;
742
743 // Scan /sys/class/drm/ for cardN-<connectorName>
744
2/2
✓ Branch 0 taken 407 times.
✓ Branch 1 taken 49 times.
456 for (int cardNum = 0; cardNum < 8; cardNum++) {
745
3/6
✓ Branch 0 taken 407 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 407 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 407 times.
✗ Branch 5 not taken.
407 std::string sysBase = "/sys/class/drm/card" + std::to_string(cardNum) + "-" + connectorName;
746
1/2
✓ Branch 0 taken 407 times.
✗ Branch 1 not taken.
407 std::string statusPath = sysBase + "/status";
747
3/4
✓ Branch 0 taken 407 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 392 times.
✓ Branch 3 taken 15 times.
407 if (!FileExists(statusPath))
748 392 continue;
749
750
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 info.cardPath = "/dev/dri/card" + std::to_string(cardNum);
751
752 // Read connection status
753
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 std::string status = GetFileContents(statusPath);
754
2/4
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
30 info.connected = (status.find("connected") != std::string::npos &&
755 15 status.find("disconnected") == std::string::npos);
756
757 // Read connector ID (available since Linux 5.x)
758
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 std::string cidPath = sysBase + "/connector_id";
759
2/4
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
15 if (FileExists(cidPath)) {
760
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 std::string cidStr = GetFileContents(cidPath);
761 15 info.connectorId = atoi(cidStr.c_str());
762 15 }
763
764 // Read display resolution from first available mode
765
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 std::string modesPath = sysBase + "/modes";
766
2/4
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
15 if (FileExists(modesPath)) {
767
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 std::ifstream mf(modesPath);
768 15 std::string firstMode;
769
5/10
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 15 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 15 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 15 times.
✗ Branch 9 not taken.
15 if (std::getline(mf, firstMode) && !firstMode.empty()) {
770 // Format: "1920x1080" or "1280x720"
771 15 size_t xpos = firstMode.find('x');
772
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 if (xpos != std::string::npos) {
773
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 info.displayWidth = atoi(firstMode.substr(0, xpos).c_str());
774
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 info.displayHeight = atoi(firstMode.substr(xpos + 1).c_str());
775 }
776 }
777 15 }
778
779
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 LogDebug(VB_MEDIAOUT, "GStreamer DRM: %s on card%d connector_id=%d connected=%d display=%dx%d\n",
780 connectorName.c_str(), cardNum, info.connectorId, info.connected,
781 info.displayWidth, info.displayHeight);
782 15 break;
783
4/4
✓ Branch 0 taken 392 times.
✓ Branch 1 taken 15 times.
✓ Branch 2 taken 392 times.
✓ Branch 3 taken 15 times.
829 }
784
785 64 return info;
786 ✗ }
787
788 // ──────────────────────────────────────────────────────────────────────────────
789 // Read the maximum per-member delay (ms) across all enabled PipeWire audio
790 // output groups. This compensates the GStreamer ts-offset so that audio
791 // through the longest-delayed PipeWire path still lines up with video.
792 // ──────────────────────────────────────────────────────────────────────────────
793 1968 static int GetMaxPipeWireGroupDelayMs() {
794
3/6
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1968 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1968 times.
✗ Branch 5 not taken.
1968 std::string configPath = FPP_DIR_CONFIG("/pipewire-audio-groups.json");
795
3/4
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1964 times.
✓ Branch 3 taken 4 times.
1968 if (!FileExists(configPath))
796 1964 return 0;
797
798
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 Json::Value root;
799
7/10
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 3 times.
✓ Branch 8 taken 1 times.
✓ Branch 9 taken 3 times.
4 if (!LoadJsonFromFile(configPath, root, JsonRoot::Object) || !root.isMember("groups"))
800 1 return 0;
801
802 3 int maxDelay = 0;
803 // A value of the wrong JSON type threw from here, on every media start,
804 // to std::terminate; use what could be read.
805 try {
806
8/14
✓ 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 2 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 5 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 3 times.
✓ Branch 13 taken 2 times.
5 for (const auto& group : root["groups"]) {
807
5/8
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 1 times.
✓ Branch 6 taken 2 times.
✗ Branch 7 not taken.
5 if (!group.get("enabled", false).asBool())
808 2 continue;
809 ✗ for (const auto& member : group["members"]) {
810 ✗ int delayMs = member.get("delayMs", 0).asInt();
811 ✗ if (delayMs > maxDelay)
812 ✗ maxDelay = delayMs;
813 }
814 }
815
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 } catch (const std::exception& e) {
816
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-audio-groups.json", e.what());
817 1 }
818 3 return maxDelay;
819 1968 }
820
821 // ──────────────────────────────────────────────────────────────────────────────
822 // Sample rate the PipeWire graph clock is running at, or 0 if it can't be
823 // determined. Every stream that arrives at a different rate is resampled by
824 // its client-side adapter for as long as it plays, so this is what the decode
825 // chain wants to line up with.
826 //
827 // The live daemon is the authority (a card can refine the configured rate
828 // upward, and force-rate can override it outright), so ask it; the generated
829 // conf is only a fallback for when the query fails. Cached for the life of
830 // the process: the graph rate only changes when the PipeWire daemon restarts,
831 // and fppd restarts with it.
832 // ──────────────────────────────────────────────────────────────────────────────
833 6007 static int GetPipeWireGraphRate() {
834 // Atomic: media starts on several threads can ask at once.
835 static std::atomic<int> cachedRate{ -1 };
836
2/2
✓ Branch 0 taken 5751 times.
✓ Branch 1 taken 256 times.
6007 if (cachedRate >= 0)
837 5751 return cachedRate;
838
839 // pw-metadata prints lines of the form
840 // update: id:0 key:'clock.rate' value:'44100' type:''
841 512 auto metadataValue = [](const std::string& out, const std::string& key) -> int {
842
2/4
✓ Branch 0 taken 512 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 512 times.
✗ Branch 3 not taken.
512 std::string needle = "key:'" + key + "' value:'";
843 512 size_t p = out.find(needle);
844
2/2
✓ Branch 0 taken 60 times.
✓ Branch 1 taken 452 times.
512 if (p == std::string::npos)
845 60 return 0;
846 452 p += needle.size();
847 452 size_t e = out.find('\'', p);
848
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 452 times.
452 if (e == std::string::npos)
849 ✗ return 0;
850
1/2
✓ Branch 0 taken 452 times.
✗ Branch 1 not taken.
452 return atoi(out.substr(p, e - p).c_str());
851 512 };
852
853 // Bounded: this runs inside Start(), which StartMediaOutput() calls with
854 // mediaOutputLock held, and pw-metadata waits forever on a PipeWire
855 // daemon that is up but not answering.
856 256 std::string dump;
857
4/8
✓ Branch 0 taken 256 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 256 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 256 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 256 times.
✗ Branch 7 not taken.
256 if (FILE* p = popen(FileExists("/usr/bin/timeout")
858 ? "/usr/bin/timeout -k 1 3 /usr/bin/pw-metadata -n settings 2>/dev/null"
859 : "/usr/bin/pw-metadata -n settings 2>/dev/null",
860 "r")) {
861 char buf[1024];
862 size_t r;
863
3/4
✓ Branch 0 taken 482 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 226 times.
✓ Branch 3 taken 256 times.
482 while ((r = fread(buf, 1, sizeof(buf), p)) > 0)
864
1/2
✓ Branch 0 taken 226 times.
✗ Branch 1 not taken.
226 dump.append(buf, r);
865
1/2
✓ Branch 0 taken 256 times.
✗ Branch 1 not taken.
256 pclose(p);
866 }
867 // force-rate pins the graph outright; clock.rate is the default it would
868 // otherwise settle on.
869
2/4
✓ Branch 0 taken 256 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 256 times.
✗ Branch 3 not taken.
256 int rate = metadataValue(dump, "clock.force-rate");
870
1/2
✓ Branch 0 taken 256 times.
✗ Branch 1 not taken.
256 if (rate <= 0)
871
2/4
✓ Branch 0 taken 256 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 256 times.
✗ Branch 3 not taken.
512 rate = metadataValue(dump, "clock.rate");
872
873
2/2
✓ Branch 0 taken 30 times.
✓ Branch 1 taken 226 times.
256 if (rate <= 0) {
874 // Daemon unreachable — fall back to what FPP told it to run at. The
875 // per-card file is written from the rate the hardware actually clocks
876 // at and sorts after the defaults, so it wins where both exist.
877 static const char* confs[] = {
878 "/etc/pipewire/pipewire.conf.d/95-fpp-alsa-sink.conf",
879 "/etc/pipewire/pipewire.conf.d/90-fpp.conf"
880 };
881
2/2
✓ Branch 0 taken 60 times.
✓ Branch 1 taken 30 times.
90 for (const char* conf : confs) {
882
2/4
✓ Branch 0 taken 60 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 60 times.
✗ Branch 3 not taken.
60 std::string contents = GetFileContents(conf);
883 60 size_t p = contents.find("default.clock.rate");
884
1/2
✓ Branch 0 taken 60 times.
✗ Branch 1 not taken.
60 if (p == std::string::npos)
885 60 continue;
886 ✗ p = contents.find('=', p);
887 ✗ if (p == std::string::npos)
888 ✗ continue;
889 ✗ rate = atoi(contents.c_str() + p + 1);
890 ✗ if (rate > 0)
891 ✗ break;
892
1/3
✗ Branch 0 not taken.
✓ Branch 1 taken 60 times.
✗ Branch 2 not taken.
60 }
893 }
894
895 256 cachedRate = rate > 0 ? rate : 0;
896
1/2
✓ Branch 0 taken 256 times.
✗ Branch 1 not taken.
256 LogDebug(VB_MEDIAOUT, "GStreamer: PipeWire graph clock rate: %d\n", cachedRate.load());
897 256 return cachedRate;
898 256 }
899
900 // ──────────────────────────────────────────────────────────────────────────────
901 // Rate to pin the decode chain to before the tee, or 0 to leave it
902 // unconstrained and let the source's own rate flow through.
903 //
904 // A file whose rate differs from the graph's gets resampled by somebody; the
905 // only question is who. Measured on a single-core AM335x feeding a 44100 Hz
906 // graph, 30 s of stereo audio, summing gstreamer + pipewire + wireplumber as a
907 // percentage of the one core:
908 //
909 // source pinned 48000 unpinned pinned to graph rate
910 // 44.1 kHz 25.2% 18.0% 18.0%
911 // 48 kHz 20.9% 20.6% 24.0%
912 // 88.2 kHz -- 41.0% 34.5%
913 // 96 kHz 42.8% 41.2% 37.0%
914 //
915 // PipeWire's resampler is roughly three times cheaper than audioresample here,
916 // so leaving the rate alone wins whenever the two are close — including the
917 // 48 kHz-into-44.1 kHz case, where GStreamer converting costs more than the
918 // 9% extra samples it would save. That flips once the source runs at twice
919 // the graph rate or more: carrying that many extra samples through volume, the
920 // tee, the sample tap and the sink outweighs the slower resampler, so drop the
921 // rate early.
922 //
923 // The old unconditional 48000 was the worst of both worlds on a 44.1 kHz
924 // graph, which is what an I2S cape or a 44.1 kHz USB card gives you:
925 // audioresample converted 44100 up to 48000 and PipeWire converted it straight
926 // back down.
927 // ──────────────────────────────────────────────────────────────────────────────
928 // Add every element to `pipeline` and link them head to tail. The kmssink
929 // branches below vary in length -- a crop element appears only for an output
930 // showing a sub-region, scaling elements only for a stretched one -- and
931 // spelling out a gst_element_link_many() per combination does not scale past
932 // the second optional element.
933 //
934 // gst_bin_add() refuses an element whose name is already in the bin, and then
935 // drops the floating ref it was handed -- the element is freed. Linking it
936 // afterwards is a use-after-free, so stop before linking anything.
937 56 static bool AddAndLinkChain(GstElement* pipeline, const std::vector<GstElement*>& chain) {
938
2/2
✓ Branch 0 taken 222 times.
✓ Branch 1 taken 56 times.
278 for (GstElement* e : chain) {
939
4/8
✓ Branch 0 taken 222 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 222 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 222 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 222 times.
222 if (!gst_bin_add(GST_BIN(pipeline), e))
940 ✗ return false;
941 }
942
2/2
✓ Branch 0 taken 166 times.
✓ Branch 1 taken 56 times.
222 for (size_t i = 1; i < chain.size(); i++) {
943
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 166 times.
166 if (!gst_element_link(chain[i - 1], chain[i]))
944 ✗ return false;
945 }
946 56 return true;
947 }
948
949 // The elements an HDMI output puts between its converter and its kmssink, in
950 // link order. A region crop has to come first: it selects from the source, so
951 // anything that rescales the frame ahead of it would make it slice up an
952 // already-resized picture. Either list may be empty -- a plain fitted,
953 // whole-frame output gets nothing at all, which is the common case.
954 16 static std::vector<GstElement*> BuildHdmiScaleChain(const VideoCropRect& crop,
955 const std::string& scaling,
956 int displayWidth, int displayHeight,
957 const std::string& namePrefix) {
958 16 std::vector<GstElement*> chain;
959
4/6
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 16 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 14 times.
16 if (GstElement* cropElem = VideoOutputManager::CreateCropElement(crop, namePrefix + "_crop"))
960
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 chain.push_back(cropElem);
961 16 for (GstElement* e : VideoOutputManager::CreateScaleElements(scaling, displayWidth,
962
2/4
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 16 times.
32 displayHeight, namePrefix))
963
0/2
✗ Branch 0 not taken.
✗ Branch 1 not taken.
16 chain.push_back(e);
964 16 return chain;
965 ✗ }
966
967 // decodebin's "autoplug-query": asked on behalf of an element it has plugged
968 // but not yet linked downstream, which is exactly when a decoder settles its
969 // output format. Left alone, a decoder sees no downstream preference and takes
970 // the first format in its own template -- S16 for mpg123 -- and audioconvert
971 // then turns that straight back into the F32 the persistent PipeWire stream
972 // wants: two conversions of every sample where none is needed, about 2% of a
973 // single-core board for an mp3. Offering F32 first lets a decoder that can
974 // produce it do so; one that can't still sees every other raw format.
975 5 static gboolean PreferFloatDecode(GstElement* bin, GstPad* pad, GstElement* element, GstQuery* query, gpointer userData) {
976
2/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 5 times.
5 if (GST_QUERY_TYPE(query) != GST_QUERY_CAPS || GST_PAD_DIRECTION(pad) != GST_PAD_SRC) {
977 ✗ return FALSE;
978 }
979 static GstStaticCaps rawAudio = GST_STATIC_CAPS("audio/x-raw");
980 static GstStaticCaps floatFirst = GST_STATIC_CAPS("audio/x-raw,format=F32LE,layout=interleaved; audio/x-raw");
981
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 GstCaps* raw = gst_static_caps_get(&rawAudio);
982
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 GstCaps* tmpl = gst_pad_get_pad_template_caps(pad);
983
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 bool rawAudioOut = gst_caps_can_intersect(tmpl, raw);
984
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_caps_unref(tmpl);
985
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_caps_unref(raw);
986
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 5 times.
5 if (!rawAudioOut) {
987 // A parser or demuxer, or a video decoder: not ours to answer.
988 ✗ return FALSE;
989 }
990
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 GstCaps* prefer = gst_static_caps_get(&floatFirst);
991 5 GstCaps* filter = nullptr;
992
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_query_parse_caps(query, &filter);
993
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 if (filter) {
994
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 GstCaps* both = gst_caps_intersect_full(prefer, filter, GST_CAPS_INTERSECT_FIRST);
995
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_caps_unref(prefer);
996 5 prefer = both;
997 }
998
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_query_set_caps_result(query, prefer);
999
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_caps_unref(prefer);
1000 5 return TRUE;
1001 }
1002
1003 1968 static int ChooseDecodeRate(int mediaRate, bool usePipeWire) {
1004 // Only PipeWire has a fixed graph rate to line up with. With alsasink the
1005 // sink negotiates against the device directly and audioresample fills in
1006 // whatever conversion that needs.
1007
4/4
✓ Branch 0 taken 1965 times.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 64 times.
✓ Branch 3 taken 1901 times.
1968 if (!usePipeWire || mediaRate <= 0)
1008 67 return 0;
1009 // No graph FPP configures runs below 44100, so nothing at or under 48000
1010 // can reach the 2x threshold — skip the query entirely for the rates
1011 // essentially all show media uses.
1012
2/2
✓ Branch 0 taken 1899 times.
✓ Branch 1 taken 2 times.
1901 if (mediaRate <= 48000)
1013 1899 return 0;
1014 2 int graphRate = GetPipeWireGraphRate();
1015
3/4
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
2 if (graphRate > 0 && mediaRate >= 2 * graphRate)
1016 1 return graphRate;
1017 1 return 0;
1018 }
1019
1020 // ---------------------------------------------------------------------------
1021 // gst-pipewire channel-order quirk detection (FalconChristmas/fpp#2620)
1022 //
1023 // pipewiresink's caps→SPA conversion (set_default_channels() in
1024 // gstpipewireformat.c, present through at least pipewire 1.4.2) ignores the
1025 // GStreamer channel-mask and stamps a hard-coded PulseAudio-order position
1026 // table onto the stream — [FL FR RL RR FC LFE SL SR] for 7.1 — while the
1027 // samples stay in GStreamer/WAV canonical order [FL FR FC LFE RL RR SL SR].
1028 // Every label-matched PipeWire link downstream then swaps FC/LFE with RL/RR
1029 // (5.0/5.1 layouts are affected the same way; mono/stereo/quad tables happen
1030 // to match; 3ch/7ch get no position table at all).
1031 //
1032 // Rather than keying a workaround off a version number (a distro backport
1033 // would silently break it in either direction), probe the installed plugin
1034 // once: connect a silent unrouted 7.1 stream and read back the channel
1035 // positions it declares. The verdict is cached keyed to the plugin binary's
1036 // identity, so a plugin upgrade re-probes and a fixed plugin automatically
1037 // disables the workaround.
1038 4 static bool IsGstPipeWireChannelOrderQuirky() {
1039 static int cachedVerdict = -1; // -1 unknown, 0 clean, 1 quirky (per fppd run)
1040
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if (cachedVerdict >= 0)
1041 ✗ return cachedVerdict == 1;
1042 4 cachedVerdict = 0; // default: no workaround unless proven needed
1043
1044 // Identity of the installed gst-pipewire plugin binary
1045 4 std::string pluginSig;
1046
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 GstPlugin* plugin = gst_registry_find_plugin(gst_registry_get(), "pipewire");
1047
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 if (plugin) {
1048
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 const gchar* fn = gst_plugin_get_filename(plugin);
1049
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 if (fn) {
1050 struct stat st;
1051
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 if (stat(fn, &st) == 0) {
1052
7/14
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 4 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 4 times.
✗ Branch 13 not taken.
12 pluginSig = std::string(fn) + ":" + std::to_string(st.st_mtime) + ":" + std::to_string(st.st_size);
1053 }
1054 }
1055
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_object_unref(plugin);
1056 }
1057
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if (pluginSig.empty()) {
1058 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: cannot identify pipewire plugin; channel-order workaround disabled\n");
1059 ✗ return false;
1060 }
1061
1062 // Cached verdict from a previous run for this exact plugin binary?
1063
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 const std::string cachePath = getFPPMediaDir() + "/config/gst-pipewire-quirks.json";
1064
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 4 times.
4 if (FileExists(cachePath)) {
1065 ✗ Json::Value cache;
1066 ✗ if (LoadJsonFromFile(cachePath, cache, JsonRoot::Object)
1067 ✗ && cache.get("pluginSig", "").asString() == pluginSig) {
1068 ✗ cachedVerdict = cache.get("channelOrderQuirky", false).asBool() ? 1 : 0;
1069 ✗ LogInfo(VB_MEDIAOUT, "GStreamer: pipewire channel-order quirk (cached): %s\n",
1070 cachedVerdict ? "PRESENT (workaround active)" : "absent");
1071 ✗ return cachedVerdict == 1;
1072 }
1073 ✗ }
1074
1075 // Probe: silent 7.1 stream, not linked to anything (node.autoconnect=false),
1076 // then read the position array the plugin declared from pw-dump.
1077
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 LogInfo(VB_MEDIAOUT, "GStreamer: probing pipewire plugin for channel-order quirk...\n");
1078 4 GError* perr = nullptr;
1079
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 GstElement* probe = gst_parse_launch(
1080 "audiotestsrc wave=silence is-live=true ! "
1081 "audio/x-raw,format=S16LE,rate=48000,channels=8,channel-mask=(bitmask)0xc3f ! "
1082 "pipewiresink sync=false "
1083 "stream-properties=\"props,node.name=(string)fpp_chorder_probe,node.autoconnect=(boolean)false\"",
1084 &perr);
1085
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 4 times.
4 if (!probe || perr) {
1086 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: quirk probe pipeline failed: %s\n",
1087 perr ? perr->message : "unknown");
1088 ✗ if (perr)
1089 ✗ g_error_free(perr);
1090 ✗ if (probe)
1091 ✗ gst_object_unref(probe);
1092 ✗ return false;
1093 }
1094
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_element_set_state(probe, GST_STATE_PLAYING);
1095
1096 // Poll pw-dump for the probe node's declared EnumFormat positions
1097 4 std::vector<std::string> positions;
1098
5/6
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 4 times.
✓ Branch 4 taken 4 times.
✓ Branch 5 taken 4 times.
8 for (int attempt = 0; attempt < 10 && positions.empty(); attempt++) {
1099
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 std::this_thread::sleep_for(std::chrono::milliseconds(300));
1100 // Bounded for the same reason as pw-metadata in GetPipeWireGraphRate().
1101
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 FILE* p = popen(FileExists("/usr/bin/timeout")
1102 ? "/usr/bin/timeout -k 1 3 /usr/bin/pw-dump 2>/dev/null"
1103 : "/usr/bin/pw-dump 2>/dev/null",
1104 "r");
1105
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if (!p)
1106 ✗ break;
1107 4 std::string out;
1108 char buf[8192];
1109 size_t r;
1110
3/4
✓ Branch 0 taken 72 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 68 times.
✓ Branch 3 taken 4 times.
72 while ((r = fread(buf, 1, sizeof(buf), p)) > 0)
1111
1/2
✓ Branch 0 taken 68 times.
✗ Branch 1 not taken.
68 out.append(buf, r);
1112
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 pclose(p);
1113
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 Json::Value dump;
1114
5/10
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 4 times.
✗ Branch 8 not taken.
✓ Branch 9 taken 4 times.
4 if (!LoadJsonFromString(out, dump) || !dump.isArray())
1115 ✗ continue;
1116
7/12
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 296 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 296 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 300 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 296 times.
✓ Branch 11 taken 4 times.
300 for (const auto& obj : dump) {
1117
2/4
✓ Branch 0 taken 296 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 296 times.
296 if (!obj.isObject())
1118 ✗ continue;
1119
6/10
✓ Branch 0 taken 296 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 296 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 296 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 296 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 271 times.
✓ Branch 9 taken 25 times.
296 if (obj.get("type", "").asString() != "PipeWire:Interface:Node")
1120 271 continue;
1121
8/14
✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 25 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 25 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 25 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 25 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 21 times.
✓ Branch 13 taken 4 times.
25 if (obj["info"]["props"].get("node.name", "").asString() != "fpp_chorder_probe")
1122 21 continue;
1123
8/18
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 4 times.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✓ Branch 14 taken 4 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 4 times.
✗ Branch 17 not taken.
4 for (const auto& fmt : obj["info"]["params"]["EnumFormat"]) {
1124
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 const Json::Value& pos = fmt["position"];
1125
5/10
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✗ Branch 9 not taken.
4 if (pos.isArray() && pos.size() == 8) {
1126
6/10
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 32 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 36 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 32 times.
✓ Branch 9 taken 4 times.
36 for (const auto& ch : pos)
1127
3/6
✓ Branch 0 taken 32 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 32 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 32 times.
✗ Branch 5 not taken.
32 positions.push_back(ch.asString());
1128 4 break;
1129 }
1130 }
1131 }
1132
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 }
1133 // pipewiresink can deadlock in its teardown (see Stop()); don't let the
1134 // probe hang the Start() that asked for it.
1135
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 4 times.
4 if (!SetPipelineToNullSupervised(probe, PIPELINE_TEARDOWN_TIMEOUT_MS)) {
1136 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: channel-order quirk probe did not stop, abandoning it\n");
1137 }
1138
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_object_unref(probe);
1139
1140
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if (positions.size() != 8) {
1141 // PipeWire down or dump unparsable — leave workaround off, don't cache
1142 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: channel-order quirk probe inconclusive; workaround disabled this run\n");
1143 ✗ return false;
1144 }
1145 4 int idxRL = -1, idxFC = -1;
1146
2/2
✓ Branch 0 taken 32 times.
✓ Branch 1 taken 4 times.
36 for (int i = 0; i < 8; i++) {
1147
3/4
✓ Branch 0 taken 32 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 28 times.
32 if (positions[i] == "RL")
1148 4 idxRL = i;
1149
3/4
✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 24 times.
28 else if (positions[i] == "FC")
1150 4 idxFC = i;
1151 }
1152
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 bool quirky = (idxRL >= 0 && idxFC >= 0 && idxRL < idxFC);
1153
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 cachedVerdict = quirky ? 1 : 0;
1154
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 LogWarn(VB_MEDIAOUT, "GStreamer: pipewire declares 7.1 positions [%s %s %s %s %s %s %s %s] — channel-order quirk %s\n",
1155 positions[0].c_str(), positions[1].c_str(), positions[2].c_str(), positions[3].c_str(),
1156 positions[4].c_str(), positions[5].c_str(), positions[6].c_str(), positions[7].c_str(),
1157 quirky ? "PRESENT (pre-permute workaround active)" : "absent");
1158
1159
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 Json::Value cache;
1160
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 cache["pluginSig"] = pluginSig;
1161
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 cache["channelOrderQuirky"] = quirky;
1162
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 cache["declaredOrder"] = Json::arrayValue;
1163
2/2
✓ Branch 0 taken 32 times.
✓ Branch 1 taken 4 times.
36 for (const auto& s : positions)
1164
3/6
✓ Branch 0 taken 32 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 32 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 32 times.
✗ Branch 5 not taken.
32 cache["declaredOrder"].append(s);
1165
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 SaveJsonToFile(cache, cachePath);
1166 4 return quirky;
1167 4 }
1168
1169 // Build the pre-permute fragment that reorders GStreamer/WAV canonical
1170 // channel order into the PulseAudio order the quirky plugin will declare,
1171 // so PipeWire's label-matched links route the right content. Returns the
1172 // extra caps to append to the rate capsfilter and the permute elements.
1173 4 static void BuildChannelOrderFix(int channels, std::string& extraCaps, std::string& permuteChain) {
1174 struct Layout {
1175 int channels;
1176 const char* mask;
1177 std::vector<int> srcIdx; // pulse-order slot -> wav-order source channel
1178 };
1179 static const Layout layouts[] = {
1180 { 5, "0x37", { 0, 1, 3, 4, 2 } }, // [FL FR RL RR FC] <- [FL FR FC RL RR]
1181 { 6, "0x3f", { 0, 1, 4, 5, 2, 3 } }, // [FL FR RL RR FC LFE] <- [FL FR FC LFE RL RR]
1182 { 8, "0xc3f", { 0, 1, 4, 5, 2, 3, 6, 7 } } // [FL FR RL RR FC LFE SL SR] <- [FL FR FC LFE RL RR SL SR]
1183
5/16
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
12 };
1184
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 for (const auto& l : layouts) {
1185
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 4 times.
8 if (l.channels != channels)
1186 4 continue;
1187
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 extraCaps = ",channels=" + std::to_string(channels) + ",channel-mask=(bitmask)" + l.mask;
1188
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 std::string matrix = "<";
1189
2/2
✓ Branch 0 taken 24 times.
✓ Branch 1 taken 4 times.
28 for (int row = 0; row < channels; row++) {
1190
3/4
✓ Branch 0 taken 20 times.
✓ Branch 1 taken 4 times.
✓ Branch 2 taken 24 times.
✗ Branch 3 not taken.
24 matrix += (row ? ",<" : "<");
1191
2/2
✓ Branch 0 taken 144 times.
✓ Branch 1 taken 24 times.
168 for (int col = 0; col < channels; col++) {
1192
3/4
✓ Branch 0 taken 120 times.
✓ Branch 1 taken 24 times.
✓ Branch 2 taken 144 times.
✗ Branch 3 not taken.
144 matrix += (col ? "," : "");
1193
3/4
✓ Branch 0 taken 24 times.
✓ Branch 1 taken 120 times.
✓ Branch 2 taken 144 times.
✗ Branch 3 not taken.
144 matrix += (l.srcIdx[row] == col) ? "(float)1.0" : "(float)0.0";
1194 }
1195
1/2
✓ Branch 0 taken 24 times.
✗ Branch 1 not taken.
24 matrix += ">";
1196 }
1197
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 matrix += ">";
1198
4/8
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
4 permuteChain = "audioconvert mix-matrix=\"" + matrix + "\" ! audio/x-raw" + extraCaps + " ! ";
1199 4 return;
1200 4 }
1201 ✗ extraCaps.clear();
1202 ✗ permuteChain.clear();
1203 }
1204
1205 1970 GStreamerOutput::GStreamerOutput(const std::string& mediaFilename, MediaOutputStatus* status, const std::string& videoOut, int streamSlot)
1206
2/4
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1970 times.
✗ Branch 3 not taken.
1970 : m_videoOut(videoOut), m_streamSlot(streamSlot) {
1207
1/2
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
1970 LogDebug(VB_MEDIAOUT, "GStreamer: CTOR enter (%s, videoOut=%s, slot=%d)\n", mediaFilename.c_str(), videoOut.c_str(), streamSlot);
1208
1/2
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
1970 m_mediaFilename = mediaFilename;
1209 1970 m_mediaOutputStatus = status;
1210
1/2
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
1970 m_allowSpeedAdjust = (getSettingInt("remoteIgnoreSync") == 0);
1211 // An audio-only track will play through the slot's persistent PipeWire
1212 // stream. Start bringing it up now: a MultiSync remote constructs this on
1213 // the master's media-open packet, well before the start packet arrives.
1214
7/8
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1840 times.
✓ Branch 3 taken 130 times.
✓ Branch 4 taken 1778 times.
✓ Branch 5 taken 62 times.
✓ Branch 6 taken 1908 times.
✓ Branch 7 taken 62 times.
1970 if (m_videoOut == "--Disabled--" || m_videoOut.empty()) {
1215
1/2
✓ Branch 0 taken 1908 times.
✗ Branch 1 not taken.
1908 PipeWireOutputStream::Prewarm(m_streamSlot, PipeWireOutputStream::LINGER_MS);
1216
2/4
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
76 } else if (m_videoOut.starts_with("HDMI") || m_videoOut.starts_with("--HDMI") ||
1217
5/6
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 55 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 7 times.
✓ Branch 4 taken 55 times.
✓ Branch 5 taken 7 times.
76 m_videoOut.starts_with("DSI-") || m_videoOut.starts_with("Composite-")) {
1218 // A video for a display, so for the slot's video group: build its
1219 // overlay and RTP members while the video's own pipeline starts,
1220 // rather than after.
1221
2/4
✓ Branch 0 taken 55 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 55 times.
✗ Branch 3 not taken.
55 VideoOutputManager::Instance().Prewarm(m_streamSlot, VideoOutputManager::LINGER_MS);
1222 }
1223
1/2
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
1970 EnsureGStreamerInit();
1224
1/2
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
1970 LogDebug(VB_MEDIAOUT, "GStreamer: CTOR done (%s)\n", mediaFilename.c_str());
1225 1970 }
1226
1227 4052 GStreamerOutput::~GStreamerOutput() {
1228 3816 Close();
1229 4052 }
1230
1231 1970 int GStreamerOutput::Start(int msTime) {
1232
1/2
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
1970 LogDebug(VB_MEDIAOUT, "GStreamer: Start(%d) enter - %s\n", msTime, m_mediaFilename.c_str());
1233
1/2
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
1970 std::lock_guard<std::recursive_mutex> opLock(m_opMutex);
1234
1235 // Off the slot table until SetActiveOutput() at the end, as in Close():
1236 // an output started again below is still registered from its last run,
1237 // and the slot manager's calls into it (SetVolume() from Set Slot Volume
1238 // on an HTTP thread) would meet the guard, m_volume and the pipeline
1239 // being rebuilt here without the guard lock. Waits out any such call in
1240 // progress. m_opMutex before the slot manager's lock, as documented on
1241 // m_opMutex. The manager's pump try-locks m_opMutex under its own lock,
1242 // but a Process() it runs can go on to Stop() (and so into the slot
1243 // manager again) on the same thread; no path does that while another
1244 // thread sits here, but it is the exception to the order.
1245
2/4
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1970 times.
✗ Branch 3 not taken.
1970 StreamSlotManager::Instance().DetachOutput(m_streamSlot, this);
1246
1247 // An output can be started again once its media has ended (a remote that
1248 // missed the stop and open packets is handed the same file again). That
1249 // run's pipeline, bus, guard and slot registration are all still here and
1250 // would be overwritten below, leaking the pipeline with its PipeWire node
1251 // and DRM resources and leaving its callbacks pointed at this object.
1252
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1970 times.
1970 if (m_pipeline) {
1253 ✗ LogDebug(VB_MEDIAOUT, "GStreamer: Start() on an output that already ran, closing the old pipeline first\n");
1254 ✗ Close();
1255 }
1256 // Per-run state the watchdogs and the preroll guards key off.
1257 1970 m_wallStartMs = 0;
1258 1970 m_playStartMs = 0;
1259 1970 m_lastPosition = -1;
1260 1970 m_stallStartMs = 0;
1261 1970 m_pendingSeekNs = -1;
1262 1970 m_stopRequested = false;
1263 1970 m_reportedFailure = false;
1264 1970 m_reportedNoAudio = false;
1265 1970 m_sawAudioPad = false;
1266 1970 m_dkmsConnectorNames.clear();
1267 // Before anything below writes the slot's status.
1268
2/4
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1970 times.
✗ Branch 3 not taken.
1970 StreamSlotManager::Instance().ClaimSlot(m_streamSlot, this);
1269
1270 // Flush PipeWire filter-chain delay ring-buffers EARLY in Start().
1271 // This runs as a fire-and-forget thread (pw-cli calls take ~200ms+).
1272 // By calling it here instead of right before set_state(PLAYING),
1273 // the flush has the entire pipeline-build time (~50-100ms) to complete
1274 // before audio actually starts flowing.
1275
1/2
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
1970 FlushPipeWireDelayBuffers();
1276
1277 // Fresh pipeline — allow Stop()'s teardown to run for this track
1278 1970 m_teardownComplete = false;
1279 1970 m_audible = false;
1280
1281 // Fresh lifetime guard for this pipeline's decodebin pad callbacks. Any
1282 // connection left over from a previous pipeline holds the previous guard,
1283 // which Close() already cleared, so it can never reach this object again.
1284
1/2
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
1970 auto cbGuard = std::make_shared<CallbackGuard>();
1285 1970 cbGuard->self = this;
1286
1/2
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
1970 m_cbGuard.store(cbGuard);
1287
1288 // Reset MultiSync rate-matching state for the new track
1289 1970 m_currentRate = 1.0f;
1290 1970 m_diffsSize = 0;
1291 1970 m_diffIdx = 0;
1292 1970 m_diffSum = 0;
1293 1970 m_rateSum = 0.0f;
1294 1970 m_lastDiff = -1;
1295 1970 m_rateDiff = 0;
1296 1970 m_lastRates.clear();
1297 1970 m_lastRatesSum = 0.0f;
1298
1299 // Build full path — check music dir, then video dir (mirrors SDLOutput)
1300
1/2
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
1970 std::string fullPath = m_mediaFilename;
1301
3/4
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1952 times.
✓ Branch 3 taken 18 times.
1970 if (!FileExists(fullPath)) {
1302
4/8
✓ Branch 0 taken 1952 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1952 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1952 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1952 times.
✗ Branch 7 not taken.
5818 fullPath = FPP_DIR_MUSIC("/" + m_mediaFilename);
1303 }
1304
3/4
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 65 times.
✓ Branch 3 taken 1905 times.
1970 if (!FileExists(fullPath)) {
1305
4/8
✓ Branch 0 taken 65 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 65 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 65 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 65 times.
✗ Branch 7 not taken.
195 fullPath = FPP_DIR_VIDEO("/" + m_mediaFilename);
1306 }
1307
3/4
✓ Branch 0 taken 1970 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 1968 times.
1970 if (!FileExists(fullPath)) {
1308
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogErr(VB_MEDIAOUT, "GStreamer: media file not found: %s\n", m_mediaFilename.c_str());
1309
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 WarningHolder::AddWarningTimeout(60, 30, "Media file not found: " + m_mediaFilename);
1310 2 return 0;
1311 }
1312
1/2
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
1968 m_fileGain = LookupNormalizeGain(fullPath);
1313
1314 // Pre-populate duration from file metadata so that
1315 // PlaylistEntryMedia::GetLengthInMS() returns a valid value immediately.
1316 // Without this, the playlist status polls GetElapsedMS() before GStreamer
1317 // has queried the duration, causing m_duration to track elapsed time
1318 // and seconds_remaining to always report 0.
1319 1968 int mediaChannels = 0; // used by the pipewire channel-order workaround below
1320 1968 int mediaRate = 0; // used by the decode-rate choice below
1321 {
1322
1/2
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
1968 MediaDetails details;
1323
1/2
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
1968 details.ParseMedia(fullPath.c_str());
1324 1968 mediaChannels = details.channels;
1325 1968 mediaRate = details.sampleRate;
1326
2/2
✓ Branch 0 taken 1903 times.
✓ Branch 1 taken 65 times.
1968 if (details.lengthMS > 0) {
1327 1903 int totalSecs = details.lengthMS / 1000;
1328
1/2
✓ Branch 0 taken 1903 times.
✗ Branch 1 not taken.
1903 std::lock_guard<std::mutex> lock(MediaOutputStatus::FieldsLock());
1329 1903 m_mediaOutputStatus->minutesTotal = totalSecs / 60;
1330 1903 m_mediaOutputStatus->secondsTotal = totalSecs % 60;
1331 1903 m_maxDuration = (gint64)details.lengthMS * GST_MSECOND;
1332
1/2
✓ Branch 0 taken 1903 times.
✗ Branch 1 not taken.
1903 LogDebug(VB_MEDIAOUT, "GStreamer: pre-set duration from metadata: %d:%02d (%d ms)\n",
1333 m_mediaOutputStatus->minutesTotal, m_mediaOutputStatus->secondsTotal, details.lengthMS);
1334 1903 }
1335 1968 }
1336
1337 // Read PipeWire video routing setting early — it affects whether we
1338 // still need a video pipeline even when the primary HDMI is disconnected.
1339 // Only honour it when PipeWire is actually the audio backend; in ALSA-only
1340 // mode PipeWire isn't running so pipewiresink would fail to connect and
1341 // block the pipeline (causing audio stall / playback abort).
1342
1/2
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
1968 bool usePipeWireBackendLocal = isPipeWireBackend();
1343
2/2
✓ Branch 0 taken 1965 times.
✓ Branch 1 taken 3 times.
1968 if (usePipeWireBackendLocal) {
1344
1/2
✓ Branch 0 taken 1965 times.
✗ Branch 1 not taken.
1965 m_pwVideoSinkName = getSetting("PipeWireVideoSinkName");
1345
2/2
✓ Branch 0 taken 1104 times.
✓ Branch 1 taken 861 times.
1965 if (m_streamSlot > 1) {
1346
1/2
✓ Branch 0 taken 1104 times.
✗ Branch 1 not taken.
1104 std::string slotSetting = "PipeWireVideoSinkName_" + std::to_string(m_streamSlot);
1347
1/2
✓ Branch 0 taken 1104 times.
✗ Branch 1 not taken.
1104 std::string slotVideoSinkName = getSetting(slotSetting.c_str());
1348
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1104 times.
1104 if (!slotVideoSinkName.empty()) {
1349 ✗ m_pwVideoSinkName = slotVideoSinkName;
1350 }
1351 1104 }
1352 } else {
1353 3 m_pwVideoSinkName.clear();
1354 }
1355
2/2
✓ Branch 0 taken 59 times.
✓ Branch 1 taken 1909 times.
1968 if (!m_pwVideoSinkName.empty()) {
1356
1/2
✓ Branch 0 taken 59 times.
✗ Branch 1 not taken.
59 LogDebug(VB_MEDIAOUT, "GStreamer: PipeWireVideoSinkName='%s' (slot %d) — video will route through PipeWire\n",
1357 m_pwVideoSinkName.c_str(), m_streamSlot);
1358 }
1359
1360 // Determine if we need a video overlay branch or HDMI output
1361 1968 bool wantVideo = false; // PixelOverlay mode
1362 1968 bool wantHDMI = false; // DRM/KMS HDMI mode
1363
1364
7/8
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1840 times.
✓ Branch 3 taken 128 times.
✓ Branch 4 taken 62 times.
✓ Branch 5 taken 1778 times.
✓ Branch 6 taken 62 times.
✓ Branch 7 taken 1906 times.
1968 if (m_videoOut != "--Disabled--" && !m_videoOut.empty()) {
1365 // Check if this is an HDMI/DRM output connector
1366
2/4
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
76 if (m_videoOut.starts_with("HDMI-") || m_videoOut.starts_with("DSI-") ||
1367 7 m_videoOut.starts_with("Composite-") ||
1368
10/16
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 55 times.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
✓ 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 not taken.
✓ Branch 10 taken 7 times.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✓ Branch 13 taken 7 times.
✓ Branch 14 taken 55 times.
✓ Branch 15 taken 7 times.
69 m_videoOut == "--HDMI--" || m_videoOut == "--hdmi--" || m_videoOut == "HDMI") {
1369 #ifdef PLATFORM_OSX
1370 // A Mac has no connectors to resolve: every display target is the
1371 // slot's video window. It stands in for the primary HDMI output,
1372 // as connector 0, so the rest of the HDMI path - its pacing, its
1373 // position queries, its synchronous teardown - applies unchanged.
1374 if (MacOSAppActive()) {
1375 m_wantHDMI = true;
1376 wantHDMI = true;
1377 m_macVideoWindow = true;
1378 m_hdmiConnectorId = 0;
1379 m_hdmiCardPath.clear();
1380 LogDebug(VB_MEDIAOUT, "GStreamer: %s -> macOS video window (slot %d)\n", m_videoOut.c_str(), m_streamSlot);
1381 } else {
1382 LogWarn(VB_MEDIAOUT, "GStreamer: no GUI session, so no video window for %s; playing audio only\n", m_videoOut.c_str());
1383 }
1384 #else
1385 // Resolve the connector name
1386
1/2
✓ Branch 0 taken 55 times.
✗ Branch 1 not taken.
55 std::string connectorName = m_videoOut;
1387
7/14
✓ Branch 0 taken 55 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 55 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 55 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 55 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 55 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✓ Branch 11 taken 55 times.
✗ Branch 12 not taken.
✓ Branch 13 taken 55 times.
55 if (connectorName == "--HDMI--" || connectorName == "--hdmi--" || connectorName == "HDMI") {
1388 ✗ connectorName = "HDMI-A-1";
1389 }
1390
1/2
✓ Branch 0 taken 55 times.
✗ Branch 1 not taken.
55 DrmConnectorInfo drmInfo = ResolveDrmConnector(connectorName);
1391
3/4
✓ Branch 0 taken 6 times.
✓ Branch 1 taken 49 times.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
55 if (drmInfo.connected && drmInfo.connectorId >= 0) {
1392
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 4 times.
12 if (!m_pwVideoSinkName.empty()) {
1393 // PipeWire video routing is active — VideoOutput is a legacy
1394 // setting that shouldn't drive a direct kmssink when PipeWire
1395 // groups define all outputs. Build the video pipeline (so the
1396 // pipewiresink receives decoded frames) but don't create a
1397 // primary kmssink; consumer kmssinks in VideoOutputManager
1398 // (one per group member) own every HDMI connector.
1399 2 wantHDMI = true;
1400
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogDebug(VB_MEDIAOUT, "GStreamer: PipeWire video routing active — ignoring VideoOutput=%s for direct kmssink, consumers handle HDMI\n",
1401 connectorName.c_str());
1402 } else {
1403 4 m_wantHDMI = true;
1404 4 wantHDMI = true;
1405 4 m_hdmiConnectorId = drmInfo.connectorId;
1406
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 m_hdmiCardPath = drmInfo.cardPath;
1407 4 m_hdmiDisplayWidth = drmInfo.displayWidth;
1408 4 m_hdmiDisplayHeight = drmInfo.displayHeight;
1409
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 LogDebug(VB_MEDIAOUT, "GStreamer HDMI output: connector=%s id=%d card=%s resolution=%dx%d\n",
1410 connectorName.c_str(), m_hdmiConnectorId, m_hdmiCardPath.c_str(),
1411 m_hdmiDisplayWidth, m_hdmiDisplayHeight);
1412 }
1413
1/2
✓ Branch 0 taken 49 times.
✗ Branch 1 not taken.
49 } else if (!drmInfo.connected) {
1414 // Configured connector not connected — do NOT fall back to
1415 // another connector. Each HDMI output is independently
1416 // configured; stealing a different connector would conflict
1417 // with whatever that connector is supposed to display.
1418
2/2
✓ Branch 0 taken 48 times.
✓ Branch 1 taken 1 times.
49 if (!m_pwVideoSinkName.empty()) {
1419 // PipeWire video routing is active — still build the
1420 // video pipeline (without kmssink) so consumers on
1421 // other connectors can display the stream.
1422 // Don't set m_hdmiDisplayWidth/Height — without a local
1423 // kmssink there's no need to software-scale in the main
1424 // pipeline. Consumer kmssinks do DRM hardware scaling.
1425 48 wantHDMI = true;
1426
1/2
✓ Branch 0 taken 48 times.
✗ Branch 1 not taken.
48 LogWarn(VB_MEDIAOUT, "GStreamer: %s is not connected — building video pipeline without kmssink (PipeWire consumers will handle output)\n", connectorName.c_str());
1427 } else {
1428
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT, "GStreamer: %s is not connected, disabling video\n", connectorName.c_str());
1429 }
1430 } else {
1431 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: could not resolve connector ID for %s\n", connectorName.c_str());
1432 }
1433 #endif
1434 55 } else {
1435 // PixelOverlay model name
1436 7 wantVideo = true;
1437 }
1438 }
1439
1440
2/2
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 1961 times.
1968 if (wantVideo) {
1441 // Register listener and get model
1442
2/4
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
7 PixelOverlayManager::INSTANCE.addModelListener(m_videoOut, OverlayListenerId(),
1443 7 [this](PixelOverlayModel* m) {
1444 ✗ std::lock_guard<std::mutex> lock(m_videoOverlayModelLock);
1445 ✗ m_videoOverlayModel = m;
1446 ✗ });
1447
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 m_videoOverlayModel = PixelOverlayManager::INSTANCE.getModel(m_videoOut);
1448
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 if (m_videoOverlayModel) {
1449 7 m_videoOverlayModel->getSize(m_videoOverlayWidth, m_videoOverlayHeight);
1450
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 LogDebug(VB_MEDIAOUT, "GStreamer video overlay: model=%s size=%dx%d\n",
1451 m_videoOut.c_str(), m_videoOverlayWidth, m_videoOverlayHeight);
1452 {
1453
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 std::lock_guard<std::mutex> lock(m_overlayOutputsLock);
1454
2/4
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
7 if (std::find(m_overlayOutputs.begin(), m_overlayOutputs.end(), this) == m_overlayOutputs.end()) {
1455
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 m_overlayOutputs.push_back(this);
1456 }
1457 7 }
1458 } else {
1459 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: PixelOverlay model '%s' not found, skipping video\n",
1460 m_videoOut.c_str());
1461 ✗ wantVideo = false;
1462 }
1463 }
1464
1465 // Build the pipeline
1466 // Audio: filesrc ! decodebin ! audioconvert ! audioresample ! tee name=t
1467 // t. ! queue ! volume ! pipewiresink
1468 // t. ! queue ! audioconvert ! F32LE,1ch ! appsink (WLED tap)
1469 // Video (when overlay model available):
1470 // decodebin pad-added -> videoconvert ! videoscale ! capsfilter(RGB,WxH) ! appsink
1471 //
1472 // When video is needed, we must use decodebin's pad-added signal for dynamic linking,
1473 // since decodebin creates pads on-the-fly for each stream type.
1474 // We still use gst_parse_launch for the audio chain and manually add the video chain.
1475
1476
1/2
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
1968 LogDebug(VB_MEDIAOUT, "GStreamer: Start() building pipeline...\n");
1477
1478 1968 bool usePipeWire = usePipeWireBackendLocal;
1479
1480 // Rate to hand the sink, or 0 to pass the file's own rate straight through
1481 // (see ChooseDecodeRate). Shared by all three pipeline shapes below.
1482
1/2
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
1968 int decodeRate = ChooseDecodeRate(mediaRate, usePipeWire);
1483
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1967 times.
1968 if (decodeRate) {
1484
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogDebug(VB_MEDIAOUT, "GStreamer: media rate %d Hz, resampling to the %d Hz graph rate\n",
1485 mediaRate, decodeRate);
1486 } else {
1487
1/2
✓ Branch 0 taken 1967 times.
✗ Branch 1 not taken.
1967 LogDebug(VB_MEDIAOUT, "GStreamer: media rate %d Hz passed through unconstrained\n", mediaRate);
1488 }
1489
1490 1968 std::string pipelineSinkName;
1491
2/2
✓ Branch 0 taken 1965 times.
✓ Branch 1 taken 3 times.
1968 if (usePipeWire) {
1492
1/2
✓ Branch 0 taken 1965 times.
✗ Branch 1 not taken.
1965 pipelineSinkName = PipeWireSinkNameForSlot(m_streamSlot);
1493 }
1494
3/4
✓ Branch 0 taken 1965 times.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 1968 times.
✗ Branch 3 not taken.
1968 LogDebug(VB_MEDIAOUT, "GStreamer: PipeWireSinkName='%s' (slot %d, backend=%s)\n",
1495 pipelineSinkName.c_str(), m_streamSlot, usePipeWire ? "pipewire" : "non-pipewire");
1496
1497 // Log PipeWire group delay for reference (handled natively by PipeWire
1498 // filter-chain delay nodes, not by GStreamer ts-offset).
1499
1/2
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
1968 int maxGroupDelayMs = GetMaxPipeWireGroupDelayMs();
1500
1/2
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
1968 LogDebug(VB_MEDIAOUT, "GStreamer: PipeWire maxGroupDelay=%dms (handled by PipeWire filter-chain)\n",
1501 maxGroupDelayMs);
1502
1503 // PipeWireVideoSinkName was already read above (before connector check)
1504
1505 // Stream identity: use slot number for PipeWire node naming
1506
1/2
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
1968 std::string streamNodeName = StreamSlotManager::GetNodeName(m_streamSlot);
1507
1/2
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
1968 std::string streamNodeDesc = StreamSlotManager::GetNodeDescription(m_streamSlot);
1508 // The slot's persistent output stream carries the same node.name, so a
1509 // pipewiresink of this pipeline's own is also tagged with an id of its
1510 // own, by which ApplyRate() finds its node.
1511 static std::atomic<uint64_t> nextStreamId{ 1 };
1512
1/2
✓ Branch 0 taken 1968 times.
✗ Branch 1 not taken.
1968 const std::string pwSinkStreamId = std::to_string(nextStreamId++);
1513 1968 m_pwSinkStreamId.clear();
1514
1515 1968 std::string sinkStr;
1516
2/2
✓ Branch 0 taken 1955 times.
✓ Branch 1 taken 13 times.
1968 if (!pipelineSinkName.empty()) {
1517 // stream-properties cannot be set inline in gst_parse_launch (GstStructure
1518 // values with spaces break the parser). Set it post-launch instead.
1519 // target-object is set post-launch too (below), never pasted in: it
1520 // comes from the PipeWireSinkName setting, and in the description a
1521 // crafted value added elements that read or wrote files as root.
1522
1/2
✓ Branch 0 taken 1955 times.
✗ Branch 1 not taken.
1955 sinkStr = "pipewiresink name=pwsink sync=true";
1523
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 3 times.
13 } else if (usePipeWire) {
1524 // PipeWire backend with no specific sink configured — use pipewiresink
1525 // and let PipeWire route to the default output.
1526
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 sinkStr = "pipewiresink name=pwsink sync=true";
1527 } else {
1528 // Prefer alsasink directly — autoaudiosink probes pulsesink first
1529 // which fails slowly in ALSA-only mode, causing startup delay.
1530 // buffer-time=500000 (500ms) gives USB audio devices adequate ring
1531 // buffer headroom to avoid underruns during initial isochronous
1532 // scheduling. Falls back to autoaudiosink if the gstreamer1.0-alsa
1533 // plugin is not installed (otherwise the pipeline fails to build).
1534
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 const char* alsaSink = GetAlsaDirectSinkName();
1535
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 if (strcmp(alsaSink, "alsasink") == 0) {
1536
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 sinkStr = "alsasink buffer-time=500000";
1537 } else {
1538 ✗ sinkStr = alsaSink;
1539 }
1540 }
1541
1542 1968 GError* error = nullptr;
1543
1544 // A video's sound (one or two channels) plays through the slot's
1545 // persistent output stream, as an audio file's does, not a pipewiresink of
1546 // its own: that per-track stream ran on the system clock, so the sound
1547 // drifted off the card's own rate; it lost the head of every track to the
1548 // stream coming up; it sat behind the picture and the lights by the
1549 // card's queue, which nothing corrected; and on a MultiSync remote each
1550 // rate change queued sound it then played late (~180 ms on a Pi 5). Run
1551 // on the stream's feed clock, the picture keeps time with the sound -- and
1552 // a remote steers both (AdjustFeedSpeed()). A video with no sound, or
1553 // more than two channels, keeps the old sink.
1554
4/4
✓ Branch 0 taken 1901 times.
✓ Branch 1 taken 64 times.
✓ Branch 2 taken 1878 times.
✓ Branch 3 taken 23 times.
1965 PipeWireOutputStream* videoPwStream = (usePipeWire && mediaChannels >= 1 && mediaChannels <= 2)
1555
3/4
✓ Branch 0 taken 1965 times.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 1878 times.
✗ Branch 3 not taken.
3933 ? PipeWireOutputStream::ForSlot(m_streamSlot)
1556 1968 : nullptr;
1557
1558
2/2
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 1961 times.
1968 if (wantVideo) {
1559 // Build pipeline with decodebin pad-added for dynamic audio+video linking
1560 std::string pipelineStr =
1561
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 "filesrc name=fsrc ! decodebin name=decoder";
1562
1563
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 LogDebug(VB_MEDIAOUT, "GStreamer pipeline (video): %s\n", pipelineStr.c_str());
1564
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 m_pipeline = gst_parse_launch(pipelineStr.c_str(), &error);
1565
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 7 times.
7 if (error) {
1566 ✗ LogErr(VB_MEDIAOUT, "GStreamer pipeline error: %s\n", error->message);
1567 ✗ g_error_free(error);
1568 ✗ return 0;
1569 }
1570
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 SetFileSourceLocation(m_pipeline, fullPath);
1571
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 GuardVideoConvertInputs(m_pipeline);
1572
1573 // Build audio sub-chain: audioconvert ! audioresample ! tee ! ...
1574
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 GstElement* audioconvert = gst_element_factory_make("audioconvert", "aconv");
1575
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 GstElement* audioresample = gst_element_factory_make("audioresample", "aresample");
1576
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 GstElement* tee = gst_element_factory_make("tee", "t");
1577
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 GstElement* queue1 = gst_element_factory_make("queue", "q1");
1578 // Generous buffer for PipeWire connection startup.
1579
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 g_object_set(queue1, "max-size-time", (guint64)(5 * GST_SECOND),
1580 "max-size-buffers", 0, "max-size-bytes", 0, NULL);
1581
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 m_volume = gst_element_factory_make("volume", "vol");
1582 7 GstElement* sink = nullptr;
1583
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 7 times.
7 if (videoPwStream) {
1584 ✗ sink = gst_element_factory_make("appsink", "pwfeed");
1585 ✗ if (sink) {
1586 ✗ g_object_set(sink, "sync", TRUE, "enable-last-sample", FALSE, NULL);
1587 }
1588
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 } else if (!pipelineSinkName.empty()) {
1589
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 sink = gst_element_factory_make("pipewiresink", "pwsink");
1590
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 g_object_set(sink, "target-object", pipelineSinkName.c_str(), NULL);
1591 // sync=TRUE: gate audio buffer delivery by PTS against
1592 // GstSystemClock (the pipeline clock we force above).
1593 // Both pipewiresink and kmssink sync against the same system
1594 // clock, so A/V stay aligned. No ts-offset needed — PipeWire
1595 // filter-chain delay nodes handle inter-member alignment,
1596 // and PipeWire quantum latency (~21ms) ≈ DRM vsync (~16ms).
1597
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 g_object_set(sink, "sync", TRUE, NULL);
1598 // Name the stream. It is left to PipeWire's channel mixer: with
1599 // stream.dont-remix a 5.1 file on a stereo sink lost its centre,
1600 // LFE and rear channels, and a stereo file on a mono sink its
1601 // right channel. Channel order on a surround sink is kept by
1602 // the pre-permute (IsGstPipeWireChannelOrderQuirky()).
1603
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 GstStructure* props = gst_structure_new("props",
1604 "node.name", G_TYPE_STRING, streamNodeName.c_str(),
1605 "node.description", G_TYPE_STRING, streamNodeDesc.c_str(),
1606 "fpp.stream.id", G_TYPE_STRING, pwSinkStreamId.c_str(),
1607 NULL);
1608
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 g_object_set(sink, "stream-properties", props, NULL);
1609
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 m_pwSinkStreamId = pwSinkStreamId;
1610
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 gst_structure_free(props);
1611 } else {
1612 // Prefer alsasink directly — avoids autoaudiosink probe delay.
1613 // Fall back to autoaudiosink if gstreamer1.0-alsa is missing.
1614 ✗ const char* alsaSinkName = GetAlsaDirectSinkName();
1615 ✗ sink = gst_element_factory_make(alsaSinkName, "audiosink");
1616 ✗ if (sink && strcmp(alsaSinkName, "alsasink") == 0) {
1617 ✗ g_object_set(sink, "buffer-time", (gint64)500000, NULL);
1618 }
1619 }
1620
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 GstElement* queue2 = gst_element_factory_make("queue", "q2");
1621
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 GstElement* audioconvert2 = gst_element_factory_make("audioconvert", "aconv2");
1622
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 GstElement* capsfilterAudio = gst_element_factory_make("capsfilter", "acapsf");
1623
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 m_appsink = gst_element_factory_make("appsink", "sampletap");
1624
1625 // Set audio tap caps: F32LE mono
1626
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 GstCaps* audioCaps = gst_caps_new_simple("audio/x-raw",
1627 "format", G_TYPE_STRING, "F32LE",
1628 "channels", G_TYPE_INT, 1, NULL);
1629
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 g_object_set(capsfilterAudio, "caps", audioCaps, NULL);
1630
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 gst_caps_unref(audioCaps);
1631
1632 // Configure queues
1633
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 g_object_set(queue2, "max-size-buffers", 3, "leaky", 2 /* downstream */, NULL);
1634
1635 // Configure audio appsink
1636
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 g_object_set(m_appsink, "emit-signals", TRUE, "sync", FALSE,
1637 "max-buffers", 3, "drop", TRUE, NULL);
1638
1639
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 GstElement* rateCapsfilter = gst_element_factory_make("capsfilter", "ratecaps");
1640 // Feeding the output stream, the sound must arrive in its one fixed
1641 // format (see PipeWireOutputStream::CapsFields()).
1642
2/14
✗ Branch 0 not taken.
✓ Branch 1 taken 7 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 7 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
21 GstCaps* rateCaps = videoPwStream ? gst_caps_from_string(("audio/x-raw" + PipeWireOutputStream::CapsFields()).c_str())
1643
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 7 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
7 : decodeRate ? gst_caps_new_simple("audio/x-raw", "rate", G_TYPE_INT, decodeRate, NULL)
1644
2/4
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 7 times.
14 : gst_caps_new_empty_simple("audio/x-raw");
1645
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 g_object_set(rateCapsfilter, "caps", rateCaps, NULL);
1646
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 gst_caps_unref(rateCaps);
1647
1648 // Add audio elements to pipeline
1649 // gst_bin_add_many() stops at the first NULL, taking it for the end
1650 // of the list: an element that couldn't be made (no pipewiresink, no
1651 // ALSA sink plugin) left the rest of the chain out of the pipeline,
1652 // floating, and every link after it failed.
1653 {
1654 7 GstElement* parts[] = { audioconvert, audioresample, rateCapsfilter, tee, queue1, m_volume, sink,
1655 7 queue2, audioconvert2, capsfilterAudio, m_appsink };
1656 7 bool missing = false;
1657
2/2
✓ Branch 0 taken 77 times.
✓ Branch 1 taken 7 times.
84 for (GstElement* e : parts) {
1658 77 missing |= (e == nullptr);
1659 }
1660
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 6 times.
7 if (missing) {
1661
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogErr(VB_MEDIAOUT, "GStreamer: could not create the audio chain (a GStreamer plugin is missing)\n");
1662
2/2
✓ Branch 0 taken 11 times.
✓ Branch 1 taken 1 times.
12 for (GstElement* e : parts) {
1663
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 1 times.
11 if (e) {
1664
2/4
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
10 gst_object_unref(gst_object_ref_sink(e));
1665 }
1666 }
1667 1 m_volume = nullptr;
1668 1 m_appsink = nullptr;
1669
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 gst_object_unref(m_pipeline);
1670 1 m_pipeline = nullptr;
1671 1 return 0;
1672 }
1673 }
1674
3/6
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 6 times.
✗ Branch 5 not taken.
6 gst_bin_add_many(GST_BIN(m_pipeline), audioconvert, audioresample, rateCapsfilter, tee,
1675 queue1, m_volume, sink,
1676 queue2, audioconvert2, capsfilterAudio, m_appsink, NULL);
1677
1678 // Take our own refs on elements we keep pointers to.
1679 // gst_bin_add sinks the floating ref; we need our own ref so Close() can safely unref.
1680
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 gst_object_ref(m_volume);
1681
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 gst_object_ref(m_appsink);
1682
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 6 times.
6 if (videoPwStream) {
1683 ✗ m_pwFeed = GST_ELEMENT(gst_object_ref(sink));
1684 ✗ m_pwStream = videoPwStream;
1685 }
1686
1687 // Link audio chain
1688
2/4
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 6 times.
6 if (!gst_element_link_many(audioconvert, audioresample, rateCapsfilter, tee, NULL)) {
1689 ✗ LogErr(VB_MEDIAOUT, "GStreamer: Failed to link audioconvert->audioresample->ratecaps->tee\n");
1690 }
1691
2/4
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 6 times.
6 if (!gst_element_link_many(queue1, m_volume, sink, NULL)) {
1692 ✗ LogErr(VB_MEDIAOUT, "GStreamer: Failed to link queue1->volume->sink\n");
1693 }
1694
2/4
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 6 times.
6 if (!gst_element_link_many(queue2, audioconvert2, capsfilterAudio, m_appsink, NULL)) {
1695 ✗ LogErr(VB_MEDIAOUT, "GStreamer: Failed to link queue2->audioconvert2->capsfilter->appsink\n");
1696 }
1697
1698 // Link tee to both queues
1699
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 GstPad* teeSrc1 = gst_element_request_pad_simple(tee, "src_%u");
1700
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 GstPad* q1Sink = gst_element_get_static_pad(queue1, "sink");
1701
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 gst_pad_link(teeSrc1, q1Sink);
1702
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 gst_object_unref(teeSrc1);
1703
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 gst_object_unref(q1Sink);
1704
1705
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 GstPad* teeSrc2 = gst_element_request_pad_simple(tee, "src_%u");
1706
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 GstPad* q2Sink = gst_element_get_static_pad(queue2, "sink");
1707
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 gst_pad_link(teeSrc2, q2Sink);
1708
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 gst_object_unref(teeSrc2);
1709
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 gst_object_unref(q2Sink);
1710
1711 // Remember the audio chain entry point for pad-added linkage
1712 6 m_audioChain = audioconvert;
1713
1714 // Attach AES67 zero-hop RTP branches if any send instances are active
1715 #ifdef HAS_AES67_GSTREAMER
1716
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 AttachAES67Branches(tee);
1717 #endif
1718
1719 // Build video sub-chain: videoconvert ! videoscale ! capsfilter(RGB,WxH) ! appsink
1720
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 GstElement* videoconvert = gst_element_factory_make("videoconvert", "vconv");
1721
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 GstElement* videoscale = gst_element_factory_make("videoscale", "vscale");
1722
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 GstElement* capsfilterVideo = gst_element_factory_make("capsfilter", "vcapsf");
1723
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 GstElement* videoQueue = gst_element_factory_make("queue", "vq");
1724
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 m_videoAppsink = gst_element_factory_make("appsink", "videosink");
1725
1726 // Set video caps: RGB at overlay model dimensions
1727
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 GstCaps* videoCaps = gst_caps_new_simple("video/x-raw",
1728 "format", G_TYPE_STRING, "RGB",
1729 "width", G_TYPE_INT, m_videoOverlayWidth,
1730 "height", G_TYPE_INT, m_videoOverlayHeight, NULL);
1731
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 g_object_set(capsfilterVideo, "caps", videoCaps, NULL);
1732
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 gst_caps_unref(videoCaps);
1733
1734 // Configure video appsink: emit signals, sync=TRUE to pace delivery at real-time speed.
1735 // Without sync=TRUE, all frames are consumed immediately and position jumps to EOF,
1736 // falsely triggering stall detection (especially for video-only files).
1737 // drop=TRUE ensures old frames are discarded if ProcessVideoOverlay can't keep up.
1738
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 g_object_set(m_videoAppsink, "emit-signals", TRUE, "sync", TRUE,
1739 "max-buffers", 2, "drop", TRUE, NULL);
1740
1741 // Add video elements to pipeline
1742
3/6
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 6 times.
✗ Branch 5 not taken.
6 gst_bin_add_many(GST_BIN(m_pipeline), videoQueue, videoconvert, videoscale,
1743 capsfilterVideo, m_videoAppsink, NULL);
1744
1745 // Take our own ref on the video appsink pointer
1746
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 gst_object_ref(m_videoAppsink);
1747
1748 // Link video chain
1749
2/4
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 6 times.
6 if (!gst_element_link_many(videoQueue, videoconvert, videoscale, capsfilterVideo,
1750 m_videoAppsink, NULL)) {
1751 ✗ LogErr(VB_MEDIAOUT, "GStreamer: Failed to link video chain\n");
1752 }
1753
1754 // Remember the video chain entry point for pad-added linkage
1755 6 m_videoChain = videoQueue;
1756
1757 // Connect decodebin pad-added signal for dynamic linking
1758
3/6
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 6 times.
✗ Branch 5 not taken.
6 GstElement* decoder = gst_bin_get_by_name(GST_BIN(m_pipeline), "decoder");
1759
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 ConnectPadSignals(decoder, true);
1760
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 gst_object_unref(decoder);
1761
1762
4/4
✓ Branch 0 taken 6 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 54 times.
✓ Branch 3 taken 1907 times.
1968 } else if (wantHDMI) {
1763 // ---------------------------------------------------------------
1764 // HDMI/DRM video output via kmssink (Phase 4)
1765 // Pipeline: filesrc ! decodebin name=decoder
1766 // Audio: pad-added -> audioconvert ! audioresample ! tee
1767 // tee -> queue ! volume ! pipewiresink
1768 // tee -> queue ! audioconvert ! F32LE,1ch ! appsink (WLED tap)
1769 // Video: pad-added -> queue ! videoconvert ! videoscale ! capsfilter ! kmssink
1770 //
1771 // decodebin auto-selects the best available decoder:
1772 // Pi 5: v4l2slh265dec for H.265, avdec_h264 for H.264
1773 // Pi 4: v4l2 stateless H.264 if kernel supports, else avdec_h264
1774 // Pi 3/Zero2: avdec_h264 software decode
1775 // All platforms: avdec_h264 from gstreamer1.0-libav as universal fallback
1776 // ---------------------------------------------------------------
1777 std::string pipelineStr =
1778
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 "filesrc name=fsrc ! decodebin name=decoder";
1779
1780
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 LogDebug(VB_MEDIAOUT, "GStreamer pipeline (HDMI): %s connector-id=%d card=%s\n",
1781 pipelineStr.c_str(), m_hdmiConnectorId, m_hdmiCardPath.c_str());
1782
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 m_pipeline = gst_parse_launch(pipelineStr.c_str(), &error);
1783
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 54 times.
54 if (error) {
1784 ✗ LogErr(VB_MEDIAOUT, "GStreamer HDMI pipeline error: %s\n", error->message);
1785 ✗ g_error_free(error);
1786 ✗ return 0;
1787 }
1788
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 SetFileSourceLocation(m_pipeline, fullPath);
1789
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GuardVideoConvertInputs(m_pipeline);
1790
1791 // Build audio sub-chain (same as overlay mode)
1792
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstElement* audioconvert = gst_element_factory_make("audioconvert", "aconv");
1793
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstElement* audioresample = gst_element_factory_make("audioresample", "aresample");
1794
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstElement* tee = gst_element_factory_make("tee", "t");
1795
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstElement* queue1 = gst_element_factory_make("queue", "q1");
1796 // Generous buffer for PipeWire connection startup.
1797
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 g_object_set(queue1, "max-size-time", (guint64)(5 * GST_SECOND),
1798 "max-size-buffers", 0, "max-size-bytes", 0, NULL);
1799
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 m_volume = gst_element_factory_make("volume", "vol");
1800 54 GstElement* sink = nullptr;
1801
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 53 times.
54 if (videoPwStream) {
1802
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 sink = gst_element_factory_make("appsink", "pwfeed");
1803
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (sink) {
1804
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 g_object_set(sink, "sync", TRUE, "enable-last-sample", FALSE, NULL);
1805 }
1806
1/2
✓ Branch 0 taken 53 times.
✗ Branch 1 not taken.
53 } else if (!pipelineSinkName.empty()) {
1807
1/2
✓ Branch 0 taken 53 times.
✗ Branch 1 not taken.
53 sink = gst_element_factory_make("pipewiresink", "pwsink");
1808
1/2
✓ Branch 0 taken 53 times.
✗ Branch 1 not taken.
53 g_object_set(sink, "target-object", pipelineSinkName.c_str(), NULL);
1809 // sync=TRUE: same rationale as the wantVideo branch above.
1810
1/2
✓ Branch 0 taken 53 times.
✗ Branch 1 not taken.
53 g_object_set(sink, "sync", TRUE, NULL);
1811 // Name the stream; remixing stays on (see the wantVideo branch).
1812
1/2
✓ Branch 0 taken 53 times.
✗ Branch 1 not taken.
53 GstStructure* props = gst_structure_new("props",
1813 "node.name", G_TYPE_STRING, streamNodeName.c_str(),
1814 "node.description", G_TYPE_STRING, streamNodeDesc.c_str(),
1815 "fpp.stream.id", G_TYPE_STRING, pwSinkStreamId.c_str(),
1816 NULL);
1817
1/2
✓ Branch 0 taken 53 times.
✗ Branch 1 not taken.
53 g_object_set(sink, "stream-properties", props, NULL);
1818
1/2
✓ Branch 0 taken 53 times.
✗ Branch 1 not taken.
53 m_pwSinkStreamId = pwSinkStreamId;
1819
1/2
✓ Branch 0 taken 53 times.
✗ Branch 1 not taken.
53 gst_structure_free(props);
1820 } else {
1821 // Prefer alsasink directly — avoids autoaudiosink probe delay.
1822 // Fall back to autoaudiosink if gstreamer1.0-alsa is missing.
1823 ✗ const char* alsaSinkName = GetAlsaDirectSinkName();
1824 ✗ sink = gst_element_factory_make(alsaSinkName, "audiosink");
1825 ✗ if (sink && strcmp(alsaSinkName, "alsasink") == 0) {
1826 ✗ g_object_set(sink, "buffer-time", (gint64)500000, NULL);
1827 }
1828 }
1829
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstElement* queue2 = gst_element_factory_make("queue", "q2");
1830
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstElement* audioconvert2 = gst_element_factory_make("audioconvert", "aconv2");
1831
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstElement* capsfilterAudio = gst_element_factory_make("capsfilter", "acapsf");
1832
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 m_appsink = gst_element_factory_make("appsink", "sampletap");
1833
1834 // Set audio tap caps: F32LE mono
1835
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstCaps* audioCaps = gst_caps_new_simple("audio/x-raw",
1836 "format", G_TYPE_STRING, "F32LE",
1837 "channels", G_TYPE_INT, 1, NULL);
1838
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 g_object_set(capsfilterAudio, "caps", audioCaps, NULL);
1839
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 gst_caps_unref(audioCaps);
1840
1841
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 g_object_set(queue2, "max-size-buffers", 3, "leaky", 2 /* downstream */, NULL);
1842
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 g_object_set(m_appsink, "emit-signals", TRUE, "sync", FALSE,
1843 "max-buffers", 3, "drop", TRUE, NULL);
1844
1845
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstElement* rateCapsfilter = gst_element_factory_make("capsfilter", "ratecaps");
1846 // Feeding the output stream, the sound must arrive in its one fixed
1847 // format (see PipeWireOutputStream::CapsFields()).
1848
7/14
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 53 times.
✓ 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 taken 53 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
161 GstCaps* rateCaps = videoPwStream ? gst_caps_from_string(("audio/x-raw" + PipeWireOutputStream::CapsFields()).c_str())
1849
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 53 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
53 : decodeRate ? gst_caps_new_simple("audio/x-raw", "rate", G_TYPE_INT, decodeRate, NULL)
1850
3/4
✓ Branch 0 taken 53 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 53 times.
107 : gst_caps_new_empty_simple("audio/x-raw");
1851
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 g_object_set(rateCapsfilter, "caps", rateCaps, NULL);
1852
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 gst_caps_unref(rateCaps);
1853
1854 // gst_bin_add_many() stops at the first NULL, taking it for the end
1855 // of the list: an element that couldn't be made (no pipewiresink, no
1856 // ALSA sink plugin) left the rest of the chain out of the pipeline,
1857 // floating, and every link after it failed.
1858 {
1859 54 GstElement* parts[] = { audioconvert, audioresample, rateCapsfilter, tee, queue1, m_volume, sink,
1860 54 queue2, audioconvert2, capsfilterAudio, m_appsink };
1861 54 bool missing = false;
1862
2/2
✓ Branch 0 taken 594 times.
✓ Branch 1 taken 54 times.
648 for (GstElement* e : parts) {
1863 594 missing |= (e == nullptr);
1864 }
1865
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 54 times.
54 if (missing) {
1866 ✗ LogErr(VB_MEDIAOUT, "GStreamer: could not create the audio chain (a GStreamer plugin is missing)\n");
1867 ✗ for (GstElement* e : parts) {
1868 ✗ if (e) {
1869 ✗ gst_object_unref(gst_object_ref_sink(e));
1870 }
1871 }
1872 ✗ m_volume = nullptr;
1873 ✗ m_appsink = nullptr;
1874 ✗ gst_object_unref(m_pipeline);
1875 ✗ m_pipeline = nullptr;
1876 ✗ return 0;
1877 }
1878 }
1879
3/6
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 54 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 54 times.
✗ Branch 5 not taken.
54 gst_bin_add_many(GST_BIN(m_pipeline), audioconvert, audioresample, rateCapsfilter, tee,
1880 queue1, m_volume, sink,
1881 queue2, audioconvert2, capsfilterAudio, m_appsink, NULL);
1882
1883
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 gst_object_ref(m_volume);
1884
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 gst_object_ref(m_appsink);
1885
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 53 times.
54 if (videoPwStream) {
1886
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 m_pwFeed = GST_ELEMENT(gst_object_ref(sink));
1887 1 m_pwStream = videoPwStream;
1888 }
1889
1890
2/4
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 54 times.
54 if (!gst_element_link_many(audioconvert, audioresample, rateCapsfilter, tee, NULL)) {
1891 ✗ LogErr(VB_MEDIAOUT, "GStreamer HDMI: Failed to link audioconvert->audioresample->ratecaps->tee\n");
1892 }
1893
2/4
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 54 times.
54 if (!gst_element_link_many(queue1, m_volume, sink, NULL)) {
1894 ✗ LogErr(VB_MEDIAOUT, "GStreamer HDMI: Failed to link queue1->volume->sink\n");
1895 }
1896
2/4
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 54 times.
54 if (!gst_element_link_many(queue2, audioconvert2, capsfilterAudio, m_appsink, NULL)) {
1897 ✗ LogErr(VB_MEDIAOUT, "GStreamer HDMI: Failed to link audio appsink chain\n");
1898 }
1899
1900
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstPad* teeSrc1 = gst_element_request_pad_simple(tee, "src_%u");
1901
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstPad* q1Sink = gst_element_get_static_pad(queue1, "sink");
1902
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 gst_pad_link(teeSrc1, q1Sink);
1903
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 gst_object_unref(teeSrc1);
1904
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 gst_object_unref(q1Sink);
1905
1906
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstPad* teeSrc2 = gst_element_request_pad_simple(tee, "src_%u");
1907
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstPad* q2Sink = gst_element_get_static_pad(queue2, "sink");
1908
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 gst_pad_link(teeSrc2, q2Sink);
1909
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 gst_object_unref(teeSrc2);
1910
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 gst_object_unref(q2Sink);
1911
1912 54 m_audioChain = audioconvert;
1913
1914 // Attach AES67 zero-hop RTP branches if any send instances are active
1915 #ifdef HAS_AES67_GSTREAMER
1916
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 AttachAES67Branches(tee);
1917 #endif
1918
1919 // Build video sub-chain: queue ! videoconvert ! videoscale ! capsfilter ! kmssink
1920 // When PipeWire video routing is active, a tee is inserted before kmssink
1921 // so a deferred pipewiresink can expose the stream in the PipeWire graph.
1922 // Each kmssink runs on its own DRM lease (DrmCards::AcquireLease())
1923 // so multiple HDMI outputs on the same card work simultaneously.
1924 54 bool haveHdmiConnector = (m_hdmiConnectorId >= 0);
1925
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstElement* videoQueue = gst_element_factory_make("queue", "vq");
1926
1927
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 50 times.
54 if (haveHdmiConnector) {
1928 #ifdef PLATFORM_OSX
1929 // The "kmssink" slot holds whatever drives the primary display;
1930 // here that is glimagesink drawing into the slot's window. The
1931 // view is fetched now, before the pipeline starts, and handed over
1932 // directly - see MacOSShowVideoWindow() for why it must not wait
1933 // for the sink's prepare-window-handle message.
1934 m_kmssink = gst_element_factory_make("glimagesink", "kmsvideosink");
1935 void* view = m_kmssink ? MacOSShowVideoWindow(m_streamSlot) : nullptr;
1936 if (view) {
1937 gst_video_overlay_set_window_handle(GST_VIDEO_OVERLAY(m_kmssink), (guintptr)view);
1938 } else {
1939 LogWarn(VB_MEDIAOUT, "GStreamer: could not open the macOS video window; video will open in its own window\n");
1940 }
1941 #else
1942
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 m_kmssink = gst_element_factory_make("kmssink", "kmsvideosink");
1943 #endif
1944
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if (!m_kmssink) {
1945 ✗ LogErr(VB_MEDIAOUT, "GStreamer: kmssink element not available — is gstreamer1.0-plugins-bad installed?\n");
1946 // Permanent: the element appears only with a package install and a restart.
1947 ✗ WarningHolder::AddWarning(31, "Video output unavailable: kmssink element missing (install gstreamer1.0-plugins-bad)");
1948 // Release the extra refs taken above (~827-828) before discarding
1949 // the pipeline. Close() only unrefs m_volume/m_appsink when
1950 // m_pipeline is still set, and we're about to null it out below,
1951 // so without this those two refs leak every time kmssink is
1952 // unavailable.
1953 ✗ if (m_volume) {
1954 ✗ gst_object_unref(m_volume);
1955 ✗ m_volume = nullptr;
1956 }
1957 ✗ if (m_appsink) {
1958 ✗ gst_object_unref(m_appsink);
1959 ✗ m_appsink = nullptr;
1960 }
1961 ✗ gst_object_unref(m_pipeline);
1962 ✗ m_pipeline = nullptr;
1963 ✗ return 0;
1964 }
1965 #ifndef PLATFORM_OSX
1966 // A persistent consumer on this connector (a camera group) stands
1967 // aside until the video is over. Then a lease on the connector,
1968 // so the KMS framebuffer outputs on the card keep their DRM
1969 // master (see DrmCards.h).
1970
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 VideoOutputManager::Instance().ClaimConnectorForMedia(m_hdmiConnectorId);
1971
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 m_mediaConnectors.push_back(m_hdmiConnectorId);
1972
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 DrmCards::Lease lease = DrmCards::AcquireLease(m_hdmiCardPath, m_hdmiConnectorId);
1973
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 if (lease.fd >= 0) {
1974
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 g_object_set(m_kmssink,
1975 "fd", lease.fd,
1976 "connector-id", m_hdmiConnectorId,
1977 "restore-crtc", TRUE,
1978 "skip-vsync", TRUE,
1979 NULL);
1980
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 if (lease.planeId >= 0) {
1981
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 g_object_set(m_kmssink, "plane-id", lease.planeId, NULL);
1982 }
1983
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 m_drmLeases.push_back(lease);
1984 } else {
1985 ✗ g_object_set(m_kmssink,
1986 "driver-name", "vc4",
1987 "connector-id", m_hdmiConnectorId,
1988 "restore-crtc", TRUE,
1989 "skip-vsync", TRUE,
1990 NULL);
1991 }
1992 #endif
1993 4 }
1994
1995 // kmssink handles format conversion + scaling in hardware.
1996 // No capsfilter needed — let sinks negotiate with the decoder directly.
1997
1998
2/2
✓ Branch 0 taken 50 times.
✓ Branch 1 taken 4 times.
54 if (!m_pwVideoSinkName.empty()) {
1999 // ── PipeWire video routing (tee + deferred pipewiresink) ──
2000 // When HDMI is connected, the primary pipeline drives it via kmssink.
2001 // When HDMI is not connected, vtee routes only to pipewiresink.
2002 // In both cases a tee exposes the stream in the PipeWire graph for
2003 // overlay consumers and secondary HDMI outputs.
2004 50 m_videoPipeWireRouting = true;
2005
2006
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 GstElement* vtee = gst_element_factory_make("tee", "vtee");
2007
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 g_object_set(vtee, "allow-not-linked", TRUE, NULL);
2008
2009 // Link videoQueue directly to vtee — skip videoconvert/videoscale.
2010 // kmssink accepts the HW decoder's native output (NV12 DMA buffers)
2011 // and handles format conversion + scaling in hardware. Putting
2012 // videoconvert/videoscale before the tee forces CPU-side conversion
2013 // of every frame (~100% CPU on Pi4). Paths that need specific
2014 // formats (e.g., pipewiresink) include their own converter.
2015
3/6
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
50 gst_bin_add_many(GST_BIN(m_pipeline), videoQueue, vtee, NULL);
2016
2/4
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 50 times.
50 if (!gst_element_link_many(videoQueue, vtee, NULL)) {
2017 ✗ LogErr(VB_MEDIAOUT, "GStreamer video: Failed to link vq → vtee\n");
2018 }
2019
2020
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 50 times.
50 if (haveHdmiConnector) {
2021 // Link tee → kmsQueue → kmssink via request pad
2022 ✗ GstElement* kmsQueue = gst_element_factory_make("queue", "vkmsq");
2023 // Passthrough for HW-decoded NV12/DMA; required for software
2024 // decode (I420 won't negotiate against the DRM plane).
2025 ✗ GstElement* teeKmsConvert = gst_element_factory_make("videoconvert", "vteekmsconv");
2026 // The primary display is a configured output like any other:
2027 // it can be one half of a split, and it has its own scaling
2028 // mode, both read from the same config the consumers use.
2029 ✗ std::vector<GstElement*> kmsChain = { kmsQueue, teeKmsConvert };
2030 ✗ for (GstElement* e : BuildHdmiScaleChain(
2031 ✗ VideoOutputManager::Instance().GetHdmiCropForConnector(m_streamSlot, m_hdmiConnectorId),
2032 ✗ VideoOutputManager::Instance().GetHdmiScalingForConnector(m_streamSlot, m_hdmiConnectorId),
2033 ✗ m_hdmiDisplayWidth, m_hdmiDisplayHeight, "vkms"))
2034 ✗ kmsChain.push_back(e);
2035 ✗ kmsChain.push_back(m_kmssink);
2036
2037 ✗ if (!AddAndLinkChain(m_pipeline, kmsChain)) {
2038 ✗ LogErr(VB_MEDIAOUT, "GStreamer: Failed to link primary kmssink chain\n");
2039 }
2040 ✗ GstPad* teeSrc = gst_element_request_pad_simple(vtee, "src_%u");
2041 ✗ GstPad* kmsQSink = gst_element_get_static_pad(kmsQueue, "sink");
2042 ✗ gst_pad_link(teeSrc, kmsQSink);
2043 ✗ gst_object_unref(teeSrc);
2044 ✗ gst_object_unref(kmsQSink);
2045 ✗ LogDebug(VB_MEDIAOUT, "GStreamer: video tee active — kmssink direct, pipewiresink deferred\n");
2046 ✗ } else {
2047 // No primary HDMI — vtee routes to deferred pipewiresink
2048 // and direct consumer kmssinks linked below.
2049
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 LogDebug(VB_MEDIAOUT, "GStreamer: video tee active — no primary kmssink (HDMI disconnected)\n");
2050 }
2051
2052 // ── Consumer direct kmssink branches ──
2053 // Added to pipeline BEFORE PLAYING so video renders from frame 0.
2054 // Previously these were in the deferred thread, but that caused
2055 // a ~500ms-1s gap where vtee dropped all frames (decoder raced
2056 // ahead and the first rendered frame was already ~1 second in).
2057 {
2058 50 std::set<int> excludeConnectors;
2059
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 50 times.
50 if (m_hdmiConnectorId >= 0)
2060 ✗ excludeConnectors.insert(m_hdmiConnectorId);
2061
2062
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 auto hdmiConsumers = VideoOutputManager::Instance().GetHdmiConsumers(
2063
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 m_streamSlot, excludeConnectors);
2064
2065
2/2
✓ Branch 0 taken 12 times.
✓ Branch 1 taken 50 times.
62 for (const auto& hc : hdmiConsumers) {
2066 // Resolve the DRM connector live from sysfs — the config
2067 // may have a stale cardPath (e.g. card1 when HDMI is card0).
2068 12 std::string resolvedCard;
2069 12 int resolvedConnId = hc.connectorId;
2070
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 10 times.
12 if (!hc.connector.empty()) {
2071
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 auto drmCheck = ResolveDrmConnector(hc.connector);
2072
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 if (!drmCheck.connected) {
2073 ✗ LogDebug(VB_MEDIAOUT, "GStreamer: skipping direct kmssink for connector %d (%s) — not connected\n",
2074 hc.connectorId, hc.connector.c_str());
2075 ✗ continue;
2076 }
2077
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 resolvedCard = drmCheck.cardPath;
2078
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 if (drmCheck.connectorId > 0)
2079 2 resolvedConnId = drmCheck.connectorId;
2080
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 }
2081
2082
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 std::string sinkName = "dkms_" + std::to_string(resolvedConnId);
2083
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 GstElement* dkmsSink = gst_element_factory_make("kmssink", sinkName.c_str());
2084
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 12 times.
12 if (!dkmsSink) {
2085 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: kmssink not available for connector %d\n", resolvedConnId);
2086 ✗ continue;
2087 }
2088
2/4
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
12 m_dkmsConnectorNames[resolvedConnId] = hc.connector;
2089
2090 // sync=TRUE: pace video rendering to PTS timestamps via
2091 // the PipeWire pipeline clock, keeping A/V in sync.
2092 // max-lateness=-1: NEVER drop frames as "too late".
2093 // On cold start the PipeWire clock can have a startup
2094 // offset (0.91x ratio in logs) causing frames to appear
2095 // late to kmssink. The default max-lateness (5ms) would
2096 // silently drop them → blank screen. With -1, late frames
2097 // are rendered immediately instead of dropped, while early
2098 // frames still wait for their PTS — correct pacing with
2099 // no blanking.
2100 // skip-vsync=TRUE: required for vc4 atomic modesetting
2101 // to avoid double-vsync wait (kmssink + kernel).
2102 // Prefer the live-resolved card path over the config value.
2103 22 std::string cardForDkms = !resolvedCard.empty() ? resolvedCard
2104
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 6 times.
10 : !hc.cardPath.empty() ? hc.cardPath
2105
3/4
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 10 times.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
22 : m_hdmiCardPath;
2106 12 DrmCards::Lease lease;
2107
2/2
✓ Branch 0 taken 6 times.
✓ Branch 1 taken 6 times.
12 if (!cardForDkms.empty()) {
2108
2/4
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
6 VideoOutputManager::Instance().ClaimConnectorForMedia(resolvedConnId);
2109
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 m_mediaConnectors.push_back(resolvedConnId);
2110
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 lease = DrmCards::AcquireLease(cardForDkms, resolvedConnId);
2111 }
2112 12 int drmFd = lease.fd;
2113
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 LogDebug(VB_MEDIAOUT, "GStreamer: dkms_%d cardPath='%s' fd=%d lease=%d\n",
2114 resolvedConnId, cardForDkms.c_str(), drmFd, lease.isLease);
2115
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 10 times.
12 if (drmFd >= 0) {
2116
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 m_drmLeases.push_back(lease);
2117
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 g_object_set(dkmsSink,
2118 "fd", drmFd,
2119 "connector-id", resolvedConnId,
2120 "sync", TRUE,
2121 "max-lateness", (gint64)-1,
2122 "skip-vsync", TRUE,
2123 NULL);
2124 2 int planeId = lease.planeId;
2125
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 if (planeId >= 0) {
2126
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 g_object_set(dkmsSink, "plane-id", planeId, NULL);
2127 }
2128 // Verify the fd was accepted by kmssink
2129 2 gint readbackFd = -1;
2130
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 g_object_get(dkmsSink, "fd", &readbackFd, NULL);
2131
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogDebug(VB_MEDIAOUT, "GStreamer: dkms_%d fd=%d plane=%d\n",
2132 resolvedConnId, readbackFd, planeId);
2133 } else {
2134
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 g_object_set(dkmsSink,
2135 "driver-name", "vc4",
2136 "connector-id", resolvedConnId,
2137 "sync", TRUE,
2138 "max-lateness", (gint64)-1,
2139 "skip-vsync", TRUE,
2140 NULL);
2141 }
2142
2143
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 GstElement* dQueue = gst_element_factory_make("queue", nullptr);
2144
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 g_object_set(dQueue,
2145 "max-size-buffers", 3,
2146 "max-size-bytes", 0,
2147 "max-size-time", (guint64)0,
2148 NULL);
2149
2150 // videoconvert negotiates to passthrough for a HW decoder's
2151 // native NV12/DMA (no CPU cost), but is required when the
2152 // decode happened in software -- I420 will not negotiate
2153 // against the DRM plane and the pipeline would fail with
2154 // "not-negotiated". See the direct-kmssink path below.
2155
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 GstElement* dConvert = gst_element_factory_make("videoconvert", nullptr);
2156
2157 // Crop and scaling for this output. With nothing but a
2158 // crop it sits last before the sink, so kmssink takes it as
2159 // the DRM plane's source rectangle (metadata only, no
2160 // per-frame copy) -- that is what lets one decode of a
2161 // double-wide file feed a different half to each display,
2162 // and what makes "fill" free. "stretch" adds a videoscale
2163 // behind it and does cost a copy.
2164 12 std::vector<GstElement*> dChain;
2165
2/4
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
12 if (GstElement* guard = MakeBranchErrorGuard())
2166
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 dChain.push_back(guard);
2167
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 dChain.push_back(dQueue);
2168
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 dChain.push_back(dConvert);
2169 12 for (GstElement* e : BuildHdmiScaleChain(
2170 12 hc.crop, hc.scaling, hc.width, hc.height,
2171
4/6
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 12 times.
26 "d_" + std::to_string(resolvedConnId)))
2172
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
14 dChain.push_back(e);
2173
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 dChain.push_back(dkmsSink);
2174
2175
2/4
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 12 times.
12 if (!AddAndLinkChain(m_pipeline, dChain)) {
2176 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: failed to link kmssink chain for connector %d\n",
2177 resolvedConnId);
2178 ✗ continue;
2179 }
2180
2181
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 GstPad* teeSrc = gst_element_request_pad_simple(vtee, "src_%u");
2182
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 GstPad* dQSink = gst_element_get_static_pad(dChain.front(), "sink");
2183
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 GstPadLinkReturn lr = gst_pad_link(teeSrc, dQSink);
2184
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 gst_object_unref(teeSrc);
2185
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 gst_object_unref(dQSink);
2186
2187
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 if (lr == GST_PAD_LINK_OK) {
2188
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 m_directConnectorIds.insert(resolvedConnId);
2189
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 LogDebug(VB_MEDIAOUT, "GStreamer: direct kmssink for connector %d (%dx%d) linked to vtee (pre-PLAYING)\n",
2190 resolvedConnId, hc.width, hc.height);
2191 } else {
2192 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: failed to link vtee → kmssink for connector %d (ret=%d)\n",
2193 resolvedConnId, lr);
2194 }
2195
5/10
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 12 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 12 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 12 times.
✗ Branch 9 not taken.
12 }
2196 50 }
2197
2198 // ── Slot channel for the other consumers ──
2199 // Overlay and RTP members of a playback group, and RTSP streams
2200 // aimed at this slot, read the slot's intervideo channel. Nothing
2201 // published it -- the pipewiresink branch below is only for the
2202 // PipeWire graph -- so they never got a frame. Only built when one
2203 // of them exists: HDMI members are fed directly above, and the
2204 // conversion costs CPU on every frame. Converting here, in this
2205 // pipeline, also keeps hardware-decoded frames (and their device
2206 // memory) out of the consumers' pipelines: this videoconvert has
2207 // the copy guard every converter in this pipeline gets.
2208
2/4
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
50 bool slotChannelWanted = VideoOutputManager::Instance().HasSlotChannelConsumers(m_streamSlot);
2209 #ifdef HAS_RTSP_OUTPUT_GSTREAMER
2210
5/6
✓ Branch 0 taken 11 times.
✓ Branch 1 taken 39 times.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 10 times.
50 slotChannelWanted = slotChannelWanted || RTSPOutputManager::INSTANCE.ServesStreamSlot(m_streamSlot);
2211 #endif
2212
2/2
✓ Branch 0 taken 40 times.
✓ Branch 1 taken 10 times.
50 if (slotChannelWanted) {
2213
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
40 std::string channel = StreamSlotManager::GetVideoNodeName(m_streamSlot);
2214
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
40 GstElement* chQueue = gst_element_factory_make("queue", "vchq");
2215
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
40 GstElement* chConvert = gst_element_factory_make("videoconvert", "vchconv");
2216
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
40 GstElement* chCaps = gst_element_factory_make("capsfilter", "vchcaps");
2217
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
40 GstElement* chSink = gst_element_factory_make("intervideosink", "vchsink");
2218
4/8
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 40 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 40 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 40 times.
✗ Branch 7 not taken.
40 if (chQueue && chConvert && chCaps && chSink) {
2219
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
40 g_object_set(chQueue, "leaky", 2, "max-size-buffers", 2,
2220 "max-size-bytes", 0, "max-size-time", (guint64)0, NULL);
2221
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
40 GstCaps* i420 = gst_caps_from_string("video/x-raw,format=I420");
2222
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
40 g_object_set(chCaps, "caps", i420, NULL);
2223
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
40 gst_caps_unref(i420);
2224 // sync: with no display there is no kmssink, and a file with
2225 // no audio track loses the audio sink too, so this may be the
2226 // only sink left to hold playback to the clock; without it the
2227 // file decodes as fast as it can and is over before a consumer
2228 // has started. The leaky queue keeps a waiting sink from ever
2229 // backing up the tee.
2230
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
40 g_object_set(chSink, "channel", channel.c_str(), "sync", TRUE, NULL);
2231
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
80 std::vector<GstElement*> chain = { chQueue, chConvert, chCaps, chSink };
2232 40 GstPad* teeSrc = nullptr;
2233 40 GstPad* qSink = nullptr;
2234
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
40 if (AddAndLinkChain(m_pipeline, chain) &&
2235
2/4
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 40 times.
✗ Branch 3 not taken.
40 (teeSrc = gst_element_request_pad_simple(vtee, "src_%u")) &&
2236
4/8
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 40 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 40 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 40 times.
✗ Branch 7 not taken.
120 (qSink = gst_element_get_static_pad(chQueue, "sink")) &&
2237
2/4
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 40 times.
✗ Branch 3 not taken.
40 gst_pad_link(teeSrc, qSink) == GST_PAD_LINK_OK) {
2238
1/2
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
40 LogDebug(VB_MEDIAOUT, "GStreamer: video published on channel %s\n", channel.c_str());
2239 } else {
2240 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: could not publish video on channel %s\n", channel.c_str());
2241 }
2242
2/4
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 40 times.
✗ Branch 3 not taken.
40 if (teeSrc) gst_object_unref(teeSrc);
2243
2/4
✓ Branch 0 taken 40 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 40 times.
✗ Branch 3 not taken.
40 if (qSink) gst_object_unref(qSink);
2244 40 } else {
2245 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: intervideosink unavailable, video groups' overlay/RTP/RTSP outputs get no video\n");
2246 ✗ for (GstElement* e : { chQueue, chConvert, chCaps, chSink }) {
2247 ✗ if (e) gst_object_unref(e);
2248 }
2249 }
2250 40 }
2251
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 } else if (haveHdmiConnector) {
2252 // ── Direct kmssink (no PipeWire video routing) ──
2253 // videoQueue → videoconvert → kmssink. A hardware decoder hands us
2254 // NV12/DMA that kmssink takes directly, and in that case videoconvert
2255 // negotiates to passthrough and costs nothing -- so this keeps the
2256 // "no CPU-side conversion" property that matters on a Pi4. But when
2257 // the decode is in SOFTWARE the format is typically I420, which the
2258 // DRM plane will not accept, and without a converter the whole
2259 // pipeline dies with "not-negotiated". That is not hypothetical: a
2260 // Pi5 has no hardware H.264 decoder at all, so every H.264 video
2261 // takes the software path and HDMI output failed outright.
2262
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 GstElement* kmsConvert = gst_element_factory_make("videoconvert", "vkmsconv");
2263 // The crop and scaling configured for this display still apply with
2264 // no PipeWire routing -- a single-output split (one half shown, the
2265 // other discarded) is legitimate, and neither setting should
2266 // silently do nothing just because nothing else is consuming the
2267 // stream.
2268
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
12 std::vector<GstElement*> kmsChain = { videoQueue, kmsConvert };
2269
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
8 for (GstElement* e : BuildHdmiScaleChain(
2270
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 VideoOutputManager::Instance().GetHdmiCropForConnector(m_streamSlot, m_hdmiConnectorId),
2271
1/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
8 VideoOutputManager::Instance().GetHdmiScalingForConnector(m_streamSlot, m_hdmiConnectorId),
2272
3/6
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 4 times.
16 m_hdmiDisplayWidth, m_hdmiDisplayHeight, "vkms"))
2273
0/2
✗ Branch 0 not taken.
✗ Branch 1 not taken.
4 kmsChain.push_back(e);
2274
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 kmsChain.push_back(m_kmssink);
2275
2276
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 4 times.
4 if (!AddAndLinkChain(m_pipeline, kmsChain)) {
2277 ✗ LogErr(VB_MEDIAOUT, "GStreamer HDMI: Failed to link video chain\n");
2278 }
2279 4 }
2280
2281 54 m_videoChain = videoQueue;
2282 54 m_hasVideoStream = true; // expect video stream from decodebin
2283
2284 // Connect decodebin pad-added signal for dynamic linking
2285
3/6
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 54 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 54 times.
✗ Branch 5 not taken.
54 GstElement* decoder = gst_bin_get_by_name(GST_BIN(m_pipeline), "decoder");
2286
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 ConnectPadSignals(decoder, true);
2287
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 gst_object_unref(decoder);
2288
2289
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 } else {
2290 // Audio-only pipeline (original gst_parse_launch approach)
2291
1/2
✓ Branch 0 taken 1907 times.
✗ Branch 1 not taken.
1907 LogDebug(VB_MEDIAOUT, "GStreamer: Building audio-only pipeline\n");
2292 // expose-all-streams=false + audio caps makes decodebin discard any
2293 // video stream instead of auto-plugging a decoder for it. Without
2294 // this, playing an mp4 audio-only still spins up the bcm2835 hardware
2295 // H.264 decoder for a video branch nothing consumes; tearing that down
2296 // leaves vb2 buffers active and sprays kernel WARN stack dumps
2297 // (videobuf2-core "driver bug: stop_streaming...") on every stop.
2298 // Multichannel + quirky gst-pipewire plugin: pin the layout and
2299 // pre-permute the samples into the (wrong) PulseAudio channel order
2300 // the plugin will declare, so the labels PipeWire routes by actually
2301 // match the content. Self-disabling: once a fixed plugin is
2302 // installed the probe returns false and no permute is inserted.
2303 1907 std::string chOrderCaps, chOrderPermute;
2304
4/6
✓ Branch 0 taken 1904 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1900 times.
✓ Branch 3 taken 4 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 1900 times.
1904 if (usePipeWire && (mediaChannels == 5 || mediaChannels == 6 || mediaChannels == 8)
2305
6/8
✓ Branch 0 taken 1904 times.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✓ Branch 7 taken 1903 times.
3811 && IsGstPipeWireChannelOrderQuirky()) {
2306
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 BuildChannelOrderFix(mediaChannels, chOrderCaps, chOrderPermute);
2307
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 LogWarn(VB_MEDIAOUT, "GStreamer: applying %dch channel-order workaround for quirky gst-pipewire plugin\n",
2308 mediaChannels);
2309 }
2310
8/14
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1906 times.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1906 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1906 times.
✓ Branch 7 taken 1 times.
✓ Branch 8 taken 1 times.
✓ Branch 9 taken 1906 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
5719 std::string rateCaps = decodeRate ? ",rate=" + std::to_string(decodeRate) : "";
2311 // On PipeWire, play through the slot's persistent output stream rather
2312 // than a pipewiresink of our own: a new PipeWire stream per track is
2313 // most of a second of silence on slow boards (see
2314 // PipeWireOutputStream.h). The appsink syncs, so this pipeline still
2315 // paces the audio.
2316 //
2317 // That stream's format is fixed for its lifetime (pipewiresink never
2318 // renegotiates a connected stream -- new caps are taken as a relabel,
2319 // so 16-bit samples would play as floats: full-scale noise), so this
2320 // pipeline converts to it. Files with more than two channels keep a
2321 // pipewiresink of their own, where the channel-order workaround above
2322 // applies.
2323
2/2
✓ Branch 0 taken 1881 times.
✓ Branch 1 taken 23 times.
1904 PipeWireOutputStream* pwStream = (usePipeWire && mediaChannels <= 2)
2324
3/4
✓ Branch 0 taken 1904 times.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 1881 times.
✗ Branch 3 not taken.
3811 ? PipeWireOutputStream::ForSlot(m_streamSlot)
2325 1907 : nullptr;
2326
2/2
✓ Branch 0 taken 1881 times.
✓ Branch 1 taken 26 times.
1907 if (pwStream) {
2327
1/2
✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
1881 rateCaps = PipeWireOutputStream::CapsFields();
2328 }
2329
6/10
✓ Branch 0 taken 1881 times.
✓ Branch 1 taken 26 times.
✓ Branch 2 taken 1881 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 26 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1881 times.
✓ Branch 7 taken 26 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
3788 std::string audioSinkStr = pwStream ? std::string("appsink name=pwfeed sync=true enable-last-sample=false") : sinkStr;
2330 std::string pipelineStr =
2331 "filesrc name=fsrc ! decodebin name=decoder expose-all-streams=false caps=\"audio/x-raw\" ! audioconvert ! audioresample ! "
2332
4/8
✓ Branch 0 taken 1907 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1907 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1907 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1907 times.
✗ Branch 7 not taken.
3814 "audio/x-raw" + rateCaps + chOrderCaps + " ! " + chOrderPermute +
2333 "tee name=t "
2334
2/4
✓ Branch 0 taken 1907 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1907 times.
✗ Branch 3 not taken.
3814 "t. ! queue ! volume name=vol ! " + audioSinkStr + " "
2335 "t. ! queue name=q2 max-size-buffers=3 leaky=downstream ! "
2336 "audioconvert ! audio/x-raw,format=F32LE,channels=1 ! "
2337
1/2
✓ Branch 0 taken 1907 times.
✗ Branch 1 not taken.
1907 "appsink name=sampletap emit-signals=true sync=false max-buffers=3 drop=true";
2338
2339
1/2
✓ Branch 0 taken 1907 times.
✗ Branch 1 not taken.
1907 LogDebug(VB_MEDIAOUT, "GStreamer pipeline: %s\n", pipelineStr.c_str());
2340
2341
1/2
✓ Branch 0 taken 1907 times.
✗ Branch 1 not taken.
1907 LogDebug(VB_MEDIAOUT, "GStreamer: Calling gst_parse_launch()...\n");
2342
1/2
✓ Branch 0 taken 1907 times.
✗ Branch 1 not taken.
1907 m_pipeline = gst_parse_launch(pipelineStr.c_str(), &error);
2343
1/2
✓ Branch 0 taken 1907 times.
✗ Branch 1 not taken.
1907 LogDebug(VB_MEDIAOUT, "GStreamer: gst_parse_launch() returned (pipeline=%p, error=%p)\n", m_pipeline, error);
2344
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1906 times.
1907 if (error) {
2345
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogErr(VB_MEDIAOUT, "GStreamer pipeline error: %s\n", error->message);
2346
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 g_error_free(error);
2347 1 return 0;
2348 }
2349
1/2
✓ Branch 0 taken 1906 times.
✗ Branch 1 not taken.
1906 SetFileSourceLocation(m_pipeline, fullPath);
2350
2351 // This pipeline's only sink is audio, so a file with nothing decodable
2352 // to feed it cannot play at all -- and the caps filter above turns that
2353 // into an autoplug failure decodebin reports as a missing plug-in, which
2354 // sends people off installing packages that were never the problem.
2355 // Watch the pads so the real reason can be given instead; the linking
2356 // itself stays with gst_parse_launch's own delayed-link handler.
2357 1906 m_audioOnlyPipeline = true;
2358
4/8
✓ Branch 0 taken 1906 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1906 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1906 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1906 times.
✗ Branch 7 not taken.
1906 if (GstElement* fbDecoder = gst_bin_get_by_name(GST_BIN(m_pipeline), "decoder")) {
2359
1/2
✓ Branch 0 taken 1906 times.
✗ Branch 1 not taken.
1906 ConnectPadSignals(fbDecoder, false);
2360
2/2
✓ Branch 0 taken 1880 times.
✓ Branch 1 taken 26 times.
1906 if (pwStream) {
2361
1/2
✓ Branch 0 taken 1880 times.
✗ Branch 1 not taken.
1880 g_signal_connect(fbDecoder, "autoplug-query", G_CALLBACK(PreferFloatDecode), nullptr);
2362 }
2363
1/2
✓ Branch 0 taken 1906 times.
✗ Branch 1 not taken.
1906 gst_object_unref(fbDecoder);
2364 }
2365
2366 // Get the volume element for later control
2367
3/6
✓ Branch 0 taken 1906 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1906 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1906 times.
✗ Branch 5 not taken.
1906 m_volume = gst_bin_get_by_name(GST_BIN(m_pipeline), "vol");
2368
2369 // Get the appsink
2370
3/6
✓ Branch 0 taken 1906 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1906 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1906 times.
✗ Branch 5 not taken.
1906 m_appsink = gst_bin_get_by_name(GST_BIN(m_pipeline), "sampletap");
2371
2372
2/2
✓ Branch 0 taken 1880 times.
✓ Branch 1 taken 26 times.
1906 if (pwStream) {
2373
3/6
✓ Branch 0 taken 1880 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1880 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1880 times.
✗ Branch 5 not taken.
1880 m_pwFeed = gst_bin_get_by_name(GST_BIN(m_pipeline), "pwfeed");
2374
1/2
✓ Branch 0 taken 1880 times.
✗ Branch 1 not taken.
1880 if (m_pwFeed) {
2375 1880 m_pwStream = pwStream;
2376 }
2377 }
2378
2379 // Set stream-properties on pipewiresink (must be done post-launch;
2380 // gst_parse_launch can't deserialize GstStructure with spaced values).
2381 // Always set when using PipeWire backend, to name the stream.
2382
5/6
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 1903 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
✓ Branch 4 taken 1903 times.
✓ Branch 5 taken 3 times.
1906 if (usePipeWire || !pipelineSinkName.empty()) {
2383
3/6
✓ Branch 0 taken 1903 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1903 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1903 times.
✗ Branch 5 not taken.
1903 GstElement* pwsink = gst_bin_get_by_name(GST_BIN(m_pipeline), "pwsink");
2384
2/2
✓ Branch 0 taken 23 times.
✓ Branch 1 taken 1880 times.
1903 if (pwsink) {
2385
2/2
✓ Branch 0 taken 22 times.
✓ Branch 1 taken 1 times.
23 if (!pipelineSinkName.empty()) {
2386
1/2
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
22 g_object_set(pwsink, "target-object", pipelineSinkName.c_str(), NULL);
2387 }
2388
1/2
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
23 GstStructure* props = gst_structure_new("props",
2389 "node.name", G_TYPE_STRING, streamNodeName.c_str(),
2390 "node.description", G_TYPE_STRING, streamNodeDesc.c_str(),
2391 "fpp.stream.id", G_TYPE_STRING, pwSinkStreamId.c_str(),
2392 NULL);
2393
1/2
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
23 g_object_set(pwsink, "stream-properties", props, NULL);
2394
1/2
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
23 m_pwSinkStreamId = pwSinkStreamId;
2395
1/2
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
23 gst_structure_free(props);
2396
1/2
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
23 gst_object_unref(pwsink);
2397 }
2398 }
2399
2400 // Attach AES67 zero-hop RTP branches to the audio tee
2401 #ifdef HAS_AES67_GSTREAMER
2402 {
2403
3/6
✓ Branch 0 taken 1906 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1906 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1906 times.
✗ Branch 5 not taken.
1906 GstElement* tee = gst_bin_get_by_name(GST_BIN(m_pipeline), "t");
2404
1/2
✓ Branch 0 taken 1906 times.
✗ Branch 1 not taken.
1906 if (tee) {
2405
1/2
✓ Branch 0 taken 1906 times.
✗ Branch 1 not taken.
1906 AttachAES67Branches(tee);
2406
1/2
✓ Branch 0 taken 1906 times.
✗ Branch 1 not taken.
1906 gst_object_unref(tee);
2407 }
2408 }
2409 #endif
2410
10/10
✓ Branch 0 taken 1906 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 1906 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 1906 times.
✓ Branch 5 taken 1 times.
✓ Branch 6 taken 1906 times.
✓ Branch 7 taken 1 times.
✓ Branch 8 taken 1906 times.
✓ Branch 9 taken 1 times.
1911 }
2411
2412
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1966 times.
1966 if (!m_pipeline) {
2413 ✗ LogErr(VB_MEDIAOUT, "Failed to create GStreamer pipeline\n");
2414 // Per track: the next one tries again.
2415 ✗ WarningHolder::AddWarningTimeout(60, 31, "Failed to create GStreamer media pipeline");
2416 ✗ return 0;
2417 }
2418
2419 // Connect audio appsink callback
2420 1966 m_shutdownFlag.store(false);
2421
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 if (m_appsink) {
2422
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 m_appsinkSignalId = g_signal_connect(m_appsink, "new-sample", G_CALLBACK(OnNewSample), this);
2423 // Only WLED's audio-reactive effects read the tap, and most shows run
2424 // none, yet the branch costs a thread, a downmix and a copy of every
2425 // buffer -- a few percent of a single-core board. Drop its buffers at
2426 // the branch's queue unless something has read recently. The sink
2427 // then may never see a buffer, so it must not hold up preroll.
2428
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 g_object_set(m_appsink, "async", FALSE, NULL);
2429
4/8
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1966 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1966 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1966 times.
✗ Branch 7 not taken.
1966 if (GstElement* tapQueue = gst_bin_get_by_name(GST_BIN(m_pipeline), "q2")) {
2430
2/4
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1966 times.
✗ Branch 3 not taken.
1966 if (GstPad* tapIn = gst_element_get_static_pad(tapQueue, "sink")) {
2431
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 gst_pad_add_probe(tapIn, (GstPadProbeType)(GST_PAD_PROBE_TYPE_BUFFER | GST_PAD_PROBE_TYPE_BUFFER_LIST),
2432 GateSampleTap, nullptr, nullptr);
2433
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 gst_object_unref(tapIn);
2434 }
2435
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 gst_object_unref(tapQueue);
2436 }
2437
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 LogDebug(VB_MEDIAOUT, "GStreamer audio sample tap connected\n");
2438 } else {
2439 ✗ m_appsinkSignalId = 0;
2440 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: could not find sampletap appsink element\n");
2441 }
2442
2443 // Connect video appsink callback
2444
2/2
✓ Branch 0 taken 6 times.
✓ Branch 1 taken 1960 times.
1966 if (m_videoAppsink) {
2445
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 m_videoAppsinkSignalId = g_signal_connect(m_videoAppsink, "new-sample",
2446 G_CALLBACK(OnNewVideoSample), this);
2447 6 m_hasVideoStream = true;
2448
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 LogDebug(VB_MEDIAOUT, "GStreamer video appsink connected\n");
2449 } else {
2450 1960 m_videoAppsinkSignalId = 0;
2451 }
2452
2453 // Compute the target volume (1.0 unless dB adjustment is set)
2454 1966 double targetVolume = m_fileGain;
2455
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1966 times.
1966 if (m_volumeAdjust != 0) {
2456 ✗ targetVolume *= pow(10.0, m_volumeAdjust / 2000.0); // volAdj is in 0.01dB units
2457 }
2458 // The volume element rejects anything over 10.0 (+20 dB) and keeps the
2459 // old value -- mid-ramp that would leave the song stuck at a fraction.
2460 1966 targetVolume = std::min(targetVolume, 10.0);
2461
2462 // Start muted — the background thread will ramp up to targetVolume
2463 // after the pipeline reaches PLAYING. This eliminates audible clicks
2464 // caused by USB/ALSA/PipeWire sink initialisation transients.
2465 //
2466 // Not when feeding the persistent output stream: it is already running,
2467 // so there is no sink start-up to hide -- and the ramp is timed by the
2468 // wall clock, not the audio, so it mutes whatever the pipeline prerolled.
2469 // A cold start prerolls while it waits for the stream, and that first
2470 // decoded buffer (~90 ms of the song) went out silent.
2471
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 if (m_volume) {
2472
3/4
✓ Branch 0 taken 1881 times.
✓ Branch 1 taken 85 times.
✓ Branch 2 taken 1966 times.
✗ Branch 3 not taken.
1966 g_object_set(m_volume, "volume", m_pwStream ? targetVolume : 0.0, NULL);
2473 }
2474
2475 // Get the bus for message handling
2476
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 LogDebug(VB_MEDIAOUT, "GStreamer: Getting bus and setting sync handler...\n");
2477
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 m_bus = gst_element_get_bus(m_pipeline);
2478
2479 // Install sync handler for autonomous bus message processing
2480 // This allows GStreamer playback to work without external Process() calls.
2481 // It runs on streaming threads, so it gets the lifetime guard rather than
2482 // `this`; the bus drops that reference once no call is in flight.
2483
2/6
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1966 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
1966 gst_bus_set_sync_handler(m_bus, BusSyncHandler,
2484
2/4
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1966 times.
✗ Branch 3 not taken.
1966 new std::shared_ptr<CallbackGuard>(m_cbGuard.load()),
2485 ReleaseBusCallbackGuard);
2486
2487 // Force the pipeline to use GstSystemClock instead of auto-selecting
2488 // the PipeWire clock. The PipeWire clock (provided by the audio
2489 // pipewiresink) is frozen at 0 on cold start — it does not begin
2490 // ticking until PipeWire's graph processes the first audio quantum,
2491 // which can be delayed hundreds of milliseconds. During that window
2492 // kmssink's sync=TRUE blocks forever in gst_clock_id_wait() because
2493 // every frame PTS is "in the future" relative to clock time 0.
2494 // Diagnostics confirmed: rendered=3 (preroll only) across 160 s of
2495 // playback, clock_time=0 at first Process(), dkms_43 state=PAUSED.
2496 //
2497 // With GstSystemClock the system monotonic clock drives sync:
2498 // - Video kmssink paces frames correctly from the first frame.
2499 // - Audio pipewiresink (sync=TRUE) delivers buffers at wall-clock
2500 // rate; PipeWire's internal quantum scheduling handles actual HW
2501 // playout timing, so audio stays correct.
2502 // - Any drift between the system clock and PipeWire's graph clock
2503 // is negligible over typical media durations (sub-ms per minute).
2504 //
2505 // Audio-only pipelines are excluded, because there the trade is all cost
2506 // and no benefit: with no kmssink there is nothing to wedge, and a running
2507 // wall clock costs the head of every file. base_time is latched when the
2508 // pipeline reaches PLAYING, but pipewiresink prerolls before its PipeWire
2509 // stream is actually streaming, so everything the sink renders during that
2510 // gap is already late and never reaches the graph. Measured on a BBB with
2511 // an I2S cape, playing a 10 s chirp and capturing the sink monitor: 240 ms
2512 // to 760 ms of the start missing, run to run. Leaving pipewiresink's own
2513 // clock in place -- it does not advance until the stream runs, so no buffer
2514 // is ever late -- brings that down to one quantum (~20 ms).
2515 //
2516 // A pipeline feeding the persistent output stream has no pipewiresink of
2517 // its own. It runs on the stream's card clock: a monotonic clock like the
2518 // system's, but held to the rate the card actually plays at, so the feed
2519 // delivers exactly what the card takes (the "sub-ms per minute" above is
2520 // not true of real cards -- see PipeWireOutputStream::DisciplineClock()).
2521 // Video in the same pipeline then keeps time with its own sound. (It is
2522 // the stream's feed clock: the card clock, unless a MultiSync remote
2523 // steers this track's speed -- see AdjustFeedSpeed().)
2524
2/2
✓ Branch 0 taken 1881 times.
✓ Branch 1 taken 85 times.
1966 if (m_pwStream) {
2525
1/2
✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
1881 GstClock* cardClock = m_pwStream->FeedClock();
2526
3/6
✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1881 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1881 times.
✗ Branch 5 not taken.
1881 gst_pipeline_use_clock(GST_PIPELINE(m_pipeline), cardClock);
2527
1/2
✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
1881 gst_object_unref(cardClock);
2528
1/2
✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
1881 LogDebug(VB_MEDIAOUT, "GStreamer: pipeline clock is the output stream's card clock\n");
2529
4/4
✓ Branch 0 taken 79 times.
✓ Branch 1 taken 6 times.
✓ Branch 2 taken 53 times.
✓ Branch 3 taken 26 times.
85 } else if (wantVideo || wantHDMI) {
2530 // A monotonic system clock of its own, not the process-wide one:
2531 // ApplyRate() recalibrates the pipeline's clock.
2532
5/10
✓ Branch 0 taken 59 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 59 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 59 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 59 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 59 times.
✗ Branch 9 not taken.
59 GstClock* sysClock = GST_CLOCK(gst_object_ref_sink(
2533 g_object_new(GST_TYPE_SYSTEM_CLOCK, "clock-type", GST_CLOCK_TYPE_MONOTONIC, NULL)));
2534
3/6
✓ Branch 0 taken 59 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 59 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 59 times.
✗ Branch 5 not taken.
59 gst_pipeline_use_clock(GST_PIPELINE(m_pipeline), sysClock);
2535
1/2
✓ Branch 0 taken 59 times.
✗ Branch 1 not taken.
59 gst_object_unref(sysClock);
2536
1/2
✓ Branch 0 taken 59 times.
✗ Branch 1 not taken.
59 LogDebug(VB_MEDIAOUT, "GStreamer: forced pipeline clock to GstSystemClock\n");
2537 }
2538
2539 // Flush AES67 send pipelines just before PLAYING so the drop probe
2540 // catches any PipeWire graph transition artifacts (combine-stream
2541 // gaining a new source). The Close()-time flush is consumed by
2542 // silence buffers during idle, so this Start()-time flush is the
2543 // one that actually protects the AES67 receivers.
2544 #ifdef HAS_AES67_GSTREAMER
2545
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 1962 times.
1966 if (AES67Manager::INSTANCE.IsActive()) {
2546
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 AES67Manager::INSTANCE.FlushSendPipelines();
2547 }
2548 #endif
2549
2550
2/2
✓ Branch 0 taken 1881 times.
✓ Branch 1 taken 85 times.
1966 if (m_pwStream) {
2551
1/2
✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
1881 m_pwStream->Acquire(pipelineSinkName);
2552
1/2
✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
1881 m_pwStream->AttachFeed(m_pwFeed);
2553 }
2554
2555 // Move the potentially-blocking state transition to a background thread.
2556 // pipewiresink's READY→PAUSED blocks in pw_thread_loop_wait if its
2557 // target PipeWire node doesn't exist yet, which would hang the calling
2558 // HTTP handler thread and make the entire fppd API unresponsive.
2559 1966 m_playing = true;
2560 // Anchor the preroll watchdog (Process()): if the pipeline never reaches a
2561 // playing position, this is the reference point for declaring it wedged.
2562
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 m_playStartMs = GetMonotonicTimeMS();
2563
2564 {
2565 1966 int seekMs = msTime;
2566 1966 bool videoPW = m_videoPipeWireRouting;
2567 1966 int streamSlot = m_streamSlot;
2568
2569 // The thread must NOT capture `this`: it may still be running (blocked in
2570 // gst_element_set_state / pipewiresink) when this GStreamerOutput is torn
2571 // down and freed, and any `this->member` access would be a use-after-free.
2572 // Capture owning refs on the GStreamer objects we touch (pipeline already
2573 // ref'd above; volume ref'd here) plus a shared cancellation token; Stop()
2574 // sets the token so a fast stop aborts the ramp/attach early.
2575
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 m_startThreadCancel = std::make_shared<std::atomic<bool>>(false);
2576 1966 std::shared_ptr<std::atomic<bool>> cancel = m_startThreadCancel;
2577
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 m_startSeekPending = std::make_shared<std::atomic<bool>>(seekMs > 0);
2578 1966 std::shared_ptr<std::atomic<bool>> seekPending = m_startSeekPending;
2579 1966 GstElement* pipeline = m_pipeline;
2580
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 gst_object_ref(pipeline);
2581
4/8
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1966 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1966 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1966 times.
✗ Branch 7 not taken.
1966 GstElement* volume = m_volume ? GST_ELEMENT(gst_object_ref(m_volume)) : nullptr;
2582 // A per-slot singleton that is never freed, so safe to capture.
2583 1966 PipeWireOutputStream* pwStream = m_pwStream;
2584 // See VideoOutputManager::ProducerGeneration(): consumers are started
2585 // from this thread, possibly after this output has been stopped.
2586
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 const uint64_t producerGeneration = VideoOutputManager::Instance().ProducerGeneration();
2587
0/4
✗ Branch 0 not taken.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
3932 std::thread([pipeline, volume, cancel, seekPending, seekMs, videoPW, targetVolume,
2588 streamSlot, pwStream,
2589 producerGeneration]() {
2590 // Releases our owning refs on exit no matter which path we return on.
2591 //
2592 // A pipeline must never lose its last ref above NULL: disposing it
2593 // with streaming threads still running frees decodebin's elements
2594 // under them. Stop() takes the pipeline to NULL and drops its own
2595 // refs, but nothing stops this thread from raising the state again
2596 // afterwards -- it checks `cancel` and then calls set_state(), and
2597 // Stop() can land in between. When that happens this ref is the
2598 // last one, so a cancelled start puts the pipeline back to NULL
2599 // itself before letting go.
2600 struct RefGuard {
2601 GstElement* pipeline;
2602 GstElement* volume;
2603 std::shared_ptr<std::atomic<bool>> cancel;
2604 1957 ~RefGuard() {
2605
4/4
✓ Branch 0 taken 1560 times.
✓ Branch 1 taken 397 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 1956 times.
3517 if (cancel->load() &&
2606
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1559 times.
1560 !SetPipelineToNullSupervised(pipeline, PIPELINE_TEARDOWN_TIMEOUT_MS)) {
2607 1 LogWarn(VB_MEDIAOUT, "GStreamer: cancelled start could not return its pipeline to NULL, abandoning it\n");
2608 }
2609
1/2
✓ Branch 0 taken 1957 times.
✗ Branch 1 not taken.
1957 if (volume) gst_object_unref(volume);
2610 1957 gst_object_unref(pipeline);
2611 1957 }
2612 1966 } refGuard{pipeline, volume, cancel};
2613
2614 // Starting part-way in (a playlist entry resuming after Insert
2615 // Playlist Immediate) needs the seek to land before playback
2616 // starts. A seek sent while the PLAYING transition is still
2617 // ASYNC is dropped: it travels upstream from the sinks, and the
2618 // audio chain is not linked to decodebin until its pad-added
2619 // fires, so gst_element_seek_simple() returns FALSE. The media
2620 // then played from 0 while the sequence resumed at the paused
2621 // frame, and the channel output servo, seeing the lights minutes
2622 // ahead of the audio, slowed output to a standstill until the
2623 // audio caught up. Preroll in PAUSED (decodebin is linked by
2624 // then), seek, and only then go to PLAYING.
2625
2/2
✓ Branch 0 taken 13 times.
✓ Branch 1 taken 1953 times.
1966 if (seekMs > 0) {
2626
1/2
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
13 LogDebug(VB_MEDIAOUT, "GStreamer: Prerolling to start at %dms...\n", seekMs);
2627
1/2
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
13 GstStateChangeReturn pret = gst_element_set_state(pipeline, GST_STATE_PAUSED);
2628
1/2
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
13 if (pret != GST_STATE_CHANGE_FAILURE) {
2629
1/2
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
13 gst_element_get_state(pipeline, nullptr, nullptr,
2630 (GstClockTime)PREROLL_TIMEOUT_MS * GST_MSECOND);
2631 }
2632
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 13 times.
13 if (cancel->load()) {
2633 ✗ seekPending->store(false);
2634 ✗ return;
2635 }
2636
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 13 times.
13 if (!gst_element_seek_simple(pipeline, GST_FORMAT_TIME,
2637 (GstSeekFlags)(GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_ACCURATE),
2638
1/2
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
13 (gint64)seekMs * GST_MSECOND)) {
2639 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: seek to %dms failed, media will play from the start\n", seekMs);
2640 }
2641 13 seekPending->store(false);
2642 }
2643
2644
2/2
✓ Branch 0 taken 1881 times.
✓ Branch 1 taken 85 times.
1966 if (pwStream) {
2645 // Preroll (decoder start-up) while the output stream comes up.
2646 // Nothing may go to PLAYING before the stream reaches the
2647 // card: PipeWire runs a freshly linked chain on a placeholder
2648 // driver while the ALSA node is still opening, and anything
2649 // played then is lost.
2650
1/2
✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
1881 gst_element_set_state(pipeline, GST_STATE_PAUSED);
2651
1/2
✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
1881 uint64_t waitStart = GetMonotonicTimeMS();
2652
3/4
✓ Branch 0 taken 1874 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✓ Branch 3 taken 1864 times.
1881 if (!pwStream->WaitReady(PW_STREAM_READY_TIMEOUT_MS)) {
2653
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 LogWarn(VB_MEDIAOUT, "GStreamer: PipeWire output stream %d not ready after %d ms, starting anyway\n",
2654 streamSlot, PW_STREAM_READY_TIMEOUT_MS);
2655
3/4
✓ Branch 0 taken 1864 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 127 times.
✓ Branch 3 taken 1737 times.
1864 } else if (GetMonotonicTimeMS() - waitStart > 5) {
2656
2/4
✓ Branch 0 taken 127 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 127 times.
✗ Branch 3 not taken.
127 LogDebug(VB_MEDIAOUT, "GStreamer: waited %d ms for PipeWire output stream %d\n",
2657 (int)(GetMonotonicTimeMS() - waitStart), streamSlot);
2658 }
2659 // A track starting right behind another queues behind what
2660 // that one left in the stream; start the clock only once it
2661 // has played, or the position leads the sound by that much.
2662
1/2
✓ Branch 0 taken 1874 times.
✗ Branch 1 not taken.
1874 pwStream->WaitDrained(PW_STREAM_DRAIN_MAX_MS);
2663
2/2
✓ Branch 0 taken 1550 times.
✓ Branch 1 taken 324 times.
1874 if (cancel->load()) {
2664 1550 return;
2665 }
2666 }
2667
2668
1/2
✓ Branch 0 taken 409 times.
✗ Branch 1 not taken.
409 LogDebug(VB_MEDIAOUT, "GStreamer: Setting pipeline to PLAYING...\n");
2669
1/2
✓ Branch 0 taken 409 times.
✗ Branch 1 not taken.
409 GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING);
2670
1/2
✓ Branch 0 taken 409 times.
✗ Branch 1 not taken.
409 LogDebug(VB_MEDIAOUT, "GStreamer: set_state returned %d\n", ret);
2671
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 408 times.
409 if (ret == GST_STATE_CHANGE_FAILURE) {
2672 // Don't touch the (possibly freed) object — failure is reported
2673 // to the rest of FPP via the GStreamer bus ERROR message
2674 // (ProcessMessages clears m_playing) and the stall watchdog.
2675
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogErr(VB_MEDIAOUT, "Failed to set GStreamer pipeline to PLAYING\n");
2676
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::AddWarningTimeout(60, 30, "Could not start media playback (pipeline failed to start)");
2677 1 return;
2678 }
2679
2680 // Fade volume from 0 to target over ~50ms to eliminate startup
2681 // clicks from sink initialisation transients. (Not needed, and
2682 // harmful, when feeding the persistent stream -- see Start().)
2683
7/8
✓ Branch 0 taken 408 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 84 times.
✓ Branch 3 taken 324 times.
✓ Branch 4 taken 83 times.
✓ Branch 5 taken 1 times.
✓ Branch 6 taken 83 times.
✓ Branch 7 taken 325 times.
408 if (volume && !pwStream && !cancel->load()) {
2684 83 constexpr int kRampSteps = 10;
2685 83 constexpr int kRampStepUs = 5000; // 5ms per step = 50ms total
2686
5/6
✓ Branch 0 taken 830 times.
✓ Branch 1 taken 83 times.
✓ Branch 2 taken 830 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 830 times.
✓ Branch 5 taken 83 times.
913 for (int i = 1; i <= kRampSteps && !cancel->load(); i++) {
2687 830 double v = targetVolume * ((double)i / kRampSteps);
2688
1/2
✓ Branch 0 taken 830 times.
✗ Branch 1 not taken.
830 g_object_set(volume, "volume", v, NULL);
2689
1/2
✓ Branch 0 taken 830 times.
✗ Branch 1 not taken.
830 std::this_thread::sleep_for(std::chrono::microseconds(kRampStepUs));
2690 }
2691
1/2
✓ Branch 0 taken 83 times.
✗ Branch 1 not taken.
83 if (!cancel->load())
2692
1/2
✓ Branch 0 taken 83 times.
✗ Branch 1 not taken.
83 g_object_set(volume, "volume", targetVolume, NULL);
2693 }
2694
2695 // Confirm the async transition actually completed. ASYNC only means
2696 // "in progress": if a sink never prerolls, the pipeline sits in
2697 // PAUSED forever, produces silence, and posts NOTHING on the bus, so
2698 // nothing above notices. That is exactly what a PipeWire daemon
2699 // restart under a live fppd leaves behind -- the GStreamer PipeWire
2700 // plugin caches its daemon connection process-wide, so every later
2701 // pipewiresink reuses a dead one. Process()'s preroll watchdog,
2702 // which the playlist and the slot pump (Play Media) both run, has
2703 // the same timeout and usually gets there first: it stops the
2704 // output, which cancels this wait, so it raises the same warning.
2705
2/2
✓ Branch 0 taken 407 times.
✓ Branch 1 taken 1 times.
408 if (!cancel->load()) {
2706 407 GstState startedState = GST_STATE_NULL;
2707
1/2
✓ Branch 0 taken 406 times.
✗ Branch 1 not taken.
407 GstStateChangeReturn scr = gst_element_get_state(pipeline, &startedState, nullptr,
2708 (GstClockTime)PREROLL_TIMEOUT_MS * GST_MSECOND);
2709
3/4
✓ Branch 0 taken 395 times.
✓ Branch 1 taken 11 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 395 times.
406 if (scr != GST_STATE_CHANGE_SUCCESS || startedState != GST_STATE_PLAYING) {
2710
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 9 times.
11 if (!cancel->load()) {
2711 // FAILURE and "still not there after the timeout" are
2712 // different faults and want different advice, but this
2713 // reported both as the stale-PipeWire-connection case.
2714 // An element that errors out returns FAILURE straight
2715 // away, so the message claimed a 15-second timeout it
2716 // had not waited for and blamed a PipeWire restart that
2717 // had not happened -- for any failure at all, including
2718 // a media file the pipeline simply could not play. The
2719 // bus error handler has already logged the real cause in
2720 // that case, so say nothing more than the outcome here.
2721
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 if (scr == GST_STATE_CHANGE_FAILURE) {
2722 ✗ LogErr(VB_MEDIAOUT,
2723 "GStreamer: pipeline failed to start (state=%s) — see the error logged above "
2724 "for the cause.\n",
2725 gst_element_state_get_name(startedState));
2726 } else {
2727
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 LogErr(VB_MEDIAOUT,
2728 "GStreamer: pipeline never reached PLAYING within %dms (state=%s) — output will "
2729 "be silent. A PipeWire restart under a running fppd does this; fppd must be "
2730 "restarted to reconnect.\n",
2731 PREROLL_TIMEOUT_MS, gst_element_state_get_name(startedState));
2732
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.
8 WarningHolder::AddWarningTimeout(60, 30, kMediaDidNotStartWarning);
2733 }
2734 }
2735 }
2736 }
2737
2738 // Deferred: attach pipewiresink to video tee and start consumer
2739 // pipelines. Pipewiresink must be added AFTER the pipeline has
2740 // fully reached PLAYING because gst_element_sync_state_with_parent
2741 // on a new element during a pending state transition can stall.
2742
2/2
✓ Branch 0 taken 48 times.
✓ Branch 1 taken 359 times.
407 if (videoPW) {
2743 GstState state;
2744
1/2
✓ Branch 0 taken 47 times.
✗ Branch 1 not taken.
48 gst_element_get_state(pipeline, &state, nullptr, 10 * GST_SECOND);
2745
5/6
✓ Branch 0 taken 45 times.
✓ Branch 1 taken 2 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 45 times.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 45 times.
47 if (state < GST_STATE_PLAYING || cancel->load()) {
2746
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogWarn(VB_MEDIAOUT, "GStreamer: Pipeline not PLAYING (state=%d), skipping pipewiresink\n", state);
2747 3 return;
2748 }
2749
2750 // The consumers read the slot's intervideo channel, which the
2751 // pipeline has published since before PLAYING; none of them
2752 // reads the PipeWire node attached below. Start them now:
2753 // waiting for that node (2 s for a sink that nothing links,
2754 // so never prerolls) left the first seconds of every video
2755 // out of the overlay and RTP members.
2756
1/2
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
45 std::string videoNodeName = StreamSlotManager::GetVideoNodeName(streamSlot);
2757
2/4
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 45 times.
✗ Branch 3 not taken.
45 VideoOutputManager::Instance().StartConsumers(videoNodeName, producerGeneration);
2758
2759
3/6
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 45 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 45 times.
✗ Branch 5 not taken.
45 GstElement* vtee = gst_bin_get_by_name(GST_BIN(pipeline), "vtee");
2760
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 44 times.
45 if (!vtee) {
2761
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT, "GStreamer: vtee not found, cannot attach pipewiresink\n");
2762 1 return;
2763 }
2764
2765
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 GstElement* pwvideosink = gst_element_factory_make("pipewiresink", "pwvideosink");
2766
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 44 times.
44 if (!pwvideosink) {
2767 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: pipewiresink not available\n");
2768 ✗ gst_object_unref(vtee);
2769 ✗ return;
2770 }
2771
2772
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 std::string videoNodeDesc = StreamSlotManager::GetVideoNodeDescription(streamSlot);
2773
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 GstStructure* vprops = gst_structure_new("props",
2774 "media.class", G_TYPE_STRING, "Stream/Output/Video",
2775 "node.name", G_TYPE_STRING, videoNodeName.c_str(),
2776 "node.description", G_TYPE_STRING, videoNodeDesc.c_str(),
2777 "node.autoconnect", G_TYPE_BOOLEAN, FALSE,
2778 "node.always-process", G_TYPE_BOOLEAN, TRUE,
2779 NULL);
2780
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 g_object_set(pwvideosink, "stream-properties", vprops, NULL);
2781
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 gst_structure_free(vprops);
2782
2783
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 g_object_set(pwvideosink, "async", FALSE, "sync", FALSE, NULL);
2784 // Nothing links this node, so it never negotiates a format and
2785 // never leaves READY. A bin waits for EOS from every element
2786 // flagged as a sink, and it never posts one: playback ran past
2787 // the end of the file until the "media reached end" watchdog
2788 // stopped it, 5-10 s later. It is a tap for the PipeWire
2789 // graph, not an output, so the pipeline doesn't count it.
2790 44 GST_OBJECT_FLAG_UNSET(pwvideosink, GST_ELEMENT_FLAG_SINK);
2791
2792
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 GstElement* pwQueue = gst_element_factory_make("queue", "vpwq");
2793
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 g_object_set(pwQueue,
2794 "leaky", 2,
2795 "max-size-buffers", 2,
2796 "max-size-bytes", 0,
2797 "max-size-time", (guint64)0,
2798 NULL);
2799
2800
3/6
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 44 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 44 times.
✗ Branch 5 not taken.
44 gst_bin_add_many(GST_BIN(pipeline), pwQueue, pwvideosink, NULL);
2801
2/4
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 44 times.
44 if (!gst_element_link(pwQueue, pwvideosink)) {
2802 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: failed to link vpwq → pwvideosink\n");
2803 }
2804 44 GstElement* pwHead = pwQueue;
2805
2/4
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 44 times.
✗ Branch 3 not taken.
44 if (GstElement* guard = MakeBranchErrorGuard()) {
2806
3/6
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 44 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 44 times.
✗ Branch 5 not taken.
44 gst_bin_add(GST_BIN(pipeline), guard);
2807
2/4
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 44 times.
✗ Branch 3 not taken.
44 if (gst_element_link(guard, pwQueue)) {
2808 44 pwHead = guard;
2809 } else {
2810 ✗ gst_bin_remove(GST_BIN(pipeline), guard);
2811 }
2812 }
2813
2814
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 if (pwHead != pwQueue) {
2815
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 gst_element_sync_state_with_parent(pwHead);
2816 }
2817
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 gboolean qSync = gst_element_sync_state_with_parent(pwQueue);
2818
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 gboolean pwSync = gst_element_sync_state_with_parent(pwvideosink);
2819
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 LogDebug(VB_MEDIAOUT, "GStreamer: deferred state sync — queue=%d pipewiresink=%d\n",
2820 qSync, pwSync);
2821
2822
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 GstPad* teeSrc = gst_element_request_pad_simple(vtee, "src_%u");
2823
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 GstPad* pwQSink = gst_element_get_static_pad(pwHead, "sink");
2824
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 GstPadLinkReturn linkRet = gst_pad_link(teeSrc, pwQSink);
2825
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 44 times.
44 if (linkRet != GST_PAD_LINK_OK) {
2826 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: vtee→vpwq pad link failed: %d\n", linkRet);
2827 }
2828
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 gst_object_unref(teeSrc);
2829
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 gst_object_unref(pwQSink);
2830
2831 GstState pwState, pwPending;
2832
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 gst_element_get_state(pwvideosink, &pwState, &pwPending, 2 * GST_SECOND);
2833
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 LogDebug(VB_MEDIAOUT, "GStreamer: pipewiresink state=%d pending=%d after attach\n",
2834 pwState, pwPending);
2835
2836
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 LogDebug(VB_MEDIAOUT, "GStreamer: pipewiresink attached to vtee (node=%s)\n",
2837 videoNodeName.c_str());
2838
2839
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 gst_object_unref(vtee);
2840
2/2
✓ Branch 0 taken 44 times.
✓ Branch 1 taken 1 times.
45 }
2841 // Owning refs on pipeline/volume released by refGuard on scope exit.
2842
4/6
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1966 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 403 times.
✓ Branch 5 taken 1554 times.
3923 }).detach();
2843 1966 }
2844
2845 // m_currentInstance owns the WLED audio-reactive tap. Slot 1 (primary)
2846 // always takes priority; secondary slots only claim it if no other
2847 // instance is active.
2848 {
2849
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 std::lock_guard<std::mutex> lock(s_sampleMutex);
2850
4/4
✓ Branch 0 taken 1104 times.
✓ Branch 1 taken 862 times.
✓ Branch 2 taken 316 times.
✓ Branch 3 taken 788 times.
1966 if (m_streamSlot == 1 || m_currentInstance == nullptr) {
2851
1/2
✓ Branch 0 taken 1178 times.
✗ Branch 1 not taken.
1178 SetSampleTapOwnerLocked(this);
2852 }
2853 1966 }
2854
2855 // Register with StreamSlotManager
2856
2/4
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1966 times.
✗ Branch 3 not taken.
1966 StreamSlotManager::Instance().SetActiveOutput(m_streamSlot, this);
2857
2858 #ifdef HAS_AES67_GSTREAMER
2859 // AES67 send pipelines run continuously (always sending to multicast).
2860 // No resume needed — filter-chain outputs silence when idle.
2861 #endif
2862
2863
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 if (m_mediaOutputStatus) {
2864 1966 m_mediaOutputStatus->status = MEDIAOUTPUTSTATUS_PLAYING;
2865 }
2866
2867 // Ensure channel output thread is running so ProcessVideoOverlay gets called
2868 // (same as SDLOutput behavior)
2869
2/2
✓ Branch 0 taken 6 times.
✓ Branch 1 taken 1960 times.
1966 if (m_videoOverlayModel) {
2870
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 StartChannelOutputThread();
2871 }
2872
2873
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 Starting();
2874
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 LogInfo(VB_MEDIAOUT, "GStreamer started playing: %s\n", m_mediaFilename.c_str());
2875 1966 return 1;
2876 1970 }
2877
2878 1953 int GStreamerOutput::Stop(void) {
2879
1/2
✓ Branch 0 taken 1953 times.
✗ Branch 1 not taken.
1953 LogDebug(VB_MEDIAOUT, "GStreamerOutput::Stop()\n");
2880
1/2
✓ Branch 0 taken 1953 times.
✗ Branch 1 not taken.
1953 std::unique_lock<std::recursive_mutex> opLock(m_opMutex);
2881 // Idempotency guard: Stop() is invoked both directly (e.g. from
2882 // CloseMediaOutput() when the media is still playing) and again via the
2883 // destructor's Close(). The teardown below includes a 250ms audio-silence
2884 // flush and a 150ms DRM-release sleep; running it twice adds ~400ms of
2885 // dead time to every clip transition, which on a multisync remote pushes
2886 // playback progressively behind the master. Run the teardown once only.
2887
4/4
✓ Branch 0 taken 1920 times.
✓ Branch 1 taken 33 times.
✓ Branch 2 taken 1906 times.
✓ Branch 3 taken 14 times.
1953 if (m_pipeline && !m_teardownComplete) {
2888 // Nothing below may race decodebin's no-more-pads handler, which
2889 // removes and unrefs m_appsink, m_volume and m_videoAppsink on a
2890 // streaming thread, or a bus handler still running for this pipeline.
2891
1/2
✓ Branch 0 taken 1906 times.
✗ Branch 1 not taken.
1906 DetachCallbacks();
2892
2893 // Detach AES67 zero-hop RTP branches BEFORE pipeline goes NULL —
2894 // this resumes the standalone send pipeline so AES67 keeps working
2895 // between tracks.
2896 #ifdef HAS_AES67_GSTREAMER
2897
1/2
✓ Branch 0 taken 1906 times.
✗ Branch 1 not taken.
1906 DetachAES67Branches();
2898 #endif
2899
2900 // Set shutdown flag to prevent appsink callbacks from doing work
2901 // during teardown — without this, the streaming thread can deadlock
2902 // with gst_element_set_state(NULL) due to malloc arena locks.
2903 1906 m_shutdownFlag.store(true);
2904
2905 // Tell the detached Start() PLAYING-transition thread (if still running)
2906 // to abort its volume ramp / deferred pipewiresink attach. It captures
2907 // only this token plus owning GStreamer refs, so it is safe even after
2908 // this object is freed.
2909
2/2
✓ Branch 0 taken 1905 times.
✓ Branch 1 taken 1 times.
1906 if (m_startThreadCancel) {
2910 1905 m_startThreadCancel->store(true);
2911 }
2912
2913 // Stop feeding the persistent output stream before anything else, so
2914 // nothing from this track's teardown reaches it. The stream stays open
2915 // for the next track.
2916 1906 bool fedPipeWireStream = m_pwStream != nullptr;
2917
1/2
✓ Branch 0 taken 1906 times.
✗ Branch 1 not taken.
1906 ReleasePipeWireStream();
2918
2919 // Disconnect appsink signals BEFORE state change to prevent
2920 // callbacks firing during pipeline teardown.
2921
2/2
✓ Branch 0 taken 1897 times.
✓ Branch 1 taken 9 times.
1906 if (m_appsink) {
2922
1/2
✓ Branch 0 taken 1897 times.
✗ Branch 1 not taken.
1897 if (m_appsinkSignalId > 0) {
2923
1/2
✓ Branch 0 taken 1897 times.
✗ Branch 1 not taken.
1897 g_signal_handler_disconnect(m_appsink, m_appsinkSignalId);
2924 1897 m_appsinkSignalId = 0;
2925 }
2926
1/2
✓ Branch 0 taken 1897 times.
✗ Branch 1 not taken.
1897 g_object_set(m_appsink, "emit-signals", FALSE, NULL);
2927 }
2928
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 1904 times.
1906 if (m_videoAppsink) {
2929
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 if (m_videoAppsinkSignalId > 0) {
2930
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 g_signal_handler_disconnect(m_videoAppsink, m_videoAppsinkSignalId);
2931 2 m_videoAppsinkSignalId = 0;
2932 }
2933
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 g_object_set(m_videoAppsink, "emit-signals", FALSE, NULL);
2934 }
2935
2936 // Remove bus sync handler before state change
2937
2/2
✓ Branch 0 taken 1905 times.
✓ Branch 1 taken 1 times.
1906 if (m_bus) {
2938
1/2
✓ Branch 0 taken 1905 times.
✗ Branch 1 not taken.
1905 gst_bus_set_sync_handler(m_bus, nullptr, nullptr, nullptr);
2939 }
2940
2941 // Stop video output consumers BEFORE tearing down the producer pipeline
2942 // to avoid pipewiresrc crash from disappearing PipeWire producer node
2943
2/2
✓ Branch 0 taken 43 times.
✓ Branch 1 taken 1863 times.
1906 if (m_videoPipeWireRouting) {
2944
2/4
✓ Branch 0 taken 43 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 43 times.
✗ Branch 3 not taken.
43 VideoOutputManager::Instance().StopConsumers();
2945 }
2946
2947 // NOTE: Do NOT pre-disconnect pipewiresink elements here.
2948 // Any operation that triggers gst_pipewire_sink_change_state or
2949 // gst_pipewire_sink_event will call pw_thread_loop_lock, which
2950 // deadlocks if the PipeWire callback thread is inside
2951 // on_param_changed. The 200ms delay before StartConsumers in
2952 // the deferred-attach thread ensures the buffer pool is active so
2953 // on_param_changed never blocks, and the pipeline NULL below can
2954 // proceed normally.
2955
2956 // For pipelines with no DRM/KMS involvement (audio-only), the rest of
2957 // the teardown - the 250ms silence flush and the pipeline state
2958 // change to NULL - has no ordering dependency on anything the next
2959 // track needs: PipeWire mixes concurrent streams, and the next
2960 // pipeline uses its own stream node. Run that slow tail on a
2961 // detached thread so a track transition (or a looping playlist wrap)
2962 // doesn't spend ~0.5s of lights-frozen time on it. Pipelines that
2963 // touch DRM (kmssink, direct connectors, reserved planes, shared DRM
2964 // fds) keep the fully synchronous path below: their kernel resources
2965 // must be released before the next pipeline may claim them.
2966
3/4
✓ Branch 0 taken 1858 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1857 times.
✓ Branch 3 taken 1 times.
5622 bool asyncTeardownEligible = !m_hasVideoStream && !m_kmssink &&
2967 1858 m_directConnectorIds.empty() &&
2968
2/4
✓ Branch 0 taken 1857 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1857 times.
✗ Branch 3 not taken.
3714 !m_videoPipeWireRouting &&
2969
3/4
✓ Branch 0 taken 1858 times.
✓ Branch 1 taken 48 times.
✓ Branch 2 taken 1857 times.
✗ Branch 3 not taken.
5621 m_drmLeases.empty() && m_mediaConnectors.empty();
2970
2/2
✓ Branch 0 taken 1857 times.
✓ Branch 1 taken 49 times.
1906 if (asyncTeardownEligible) {
2971 // These are what Close() would run inside its m_pipeline guard;
2972 // that guard will be skipped because we null the members here.
2973
1/2
✓ Branch 0 taken 1857 times.
✗ Branch 1 not taken.
1857 FlushPipeWireDelayBuffers();
2974 #ifdef HAS_AES67_GSTREAMER
2975
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 1854 times.
1857 if (AES67Manager::INSTANCE.IsActive()) {
2976
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 AES67Manager::INSTANCE.FlushSendPipelines();
2977 }
2978 #endif
2979
1/2
✓ Branch 0 taken 1857 times.
✗ Branch 1 not taken.
1857 Stopping();
2980
2981 // The detached thread takes over this object's refs; it must not
2982 // touch `this` (the object is typically destroyed right after).
2983 3714 std::thread([pipeline = m_pipeline, volume = m_volume, bus = m_bus,
2984 1857 appsink = m_appsink, videoAppsink = m_videoAppsink,
2985 1857 flushSilence = !fedPipeWireStream]() {
2986
2/4
✓ Branch 0 taken 1857 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1857 times.
✗ Branch 3 not taken.
1857 SetThreadName("FPP-GstTeardown");
2987 // A track that fed the persistent stream has nothing to flush:
2988 // its feed is already detached, and the stream plays silence
2989 // on its own once nothing feeds it.
2990
4/4
✓ Branch 0 taken 1856 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 21 times.
✓ Branch 3 taken 1835 times.
1857 if (volume && flushSilence) {
2991 // Silence flush: push zeros through the PipeWire chain so
2992 // stale buffer contents can't replay as a click when the
2993 // next track starts (see comment on the synchronous path).
2994 21 g_object_set(volume, "volume", 0.0, NULL);
2995
1/2
✓ Branch 0 taken 21 times.
✗ Branch 1 not taken.
21 std::this_thread::sleep_for(std::chrono::milliseconds(250));
2996 }
2997 1857 const bool hwDecoder = PipelineHasHwVideoDecoder(pipeline);
2998
2/2
✓ Branch 0 taken 9 times.
✓ Branch 1 taken 1847 times.
1857 if (!SetPipelineToNullSupervised(pipeline, PIPELINE_TEARDOWN_TIMEOUT_MS)) {
2999 // Reaper keeps its own refs to a wedged pipeline; drop
3000 // ours below either way (mirrors Close()'s behavior).
3001 9 RecordWedgeEventAndMaybeRestart("async pipeline teardown wedged, abandoning pipeline", hwDecoder);
3002 }
3003
2/2
✓ Branch 0 taken 1855 times.
✓ Branch 1 taken 1 times.
1856 if (appsink)
3004 1855 gst_object_unref(appsink);
3005
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1856 times.
1856 if (videoAppsink)
3006 ✗ gst_object_unref(videoAppsink);
3007
2/2
✓ Branch 0 taken 1855 times.
✓ Branch 1 taken 1 times.
1856 if (volume)
3008 1855 gst_object_unref(volume);
3009
2/2
✓ Branch 0 taken 1855 times.
✓ Branch 1 taken 1 times.
1856 if (bus)
3010 1855 gst_object_unref(bus);
3011 1856 gst_object_unref(pipeline);
3012
2/4
✓ Branch 0 taken 1857 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1857 times.
✗ Branch 3 not taken.
3713 }).detach();
3013
3014 1857 m_pipeline = nullptr;
3015 1857 m_volume = nullptr;
3016 1857 m_bus = nullptr;
3017 1857 m_appsink = nullptr;
3018 1857 m_videoAppsink = nullptr;
3019
3020 1857 m_playing = false;
3021
1/2
✓ Branch 0 taken 1857 times.
✗ Branch 1 not taken.
1857 if (m_mediaOutputStatus) {
3022 1857 m_mediaOutputStatus->status = MEDIAOUTPUTSTATUS_IDLE;
3023 }
3024 // Last touch of `this`: Stopped() may hand the object to a
3025 // deferred delete (Play Media's runningCommandMedia), so nothing
3026 // may be written after it -- the lock included.
3027 1857 m_teardownComplete = true;
3028
1/2
✓ Branch 0 taken 1857 times.
✗ Branch 1 not taken.
1857 opLock.unlock();
3029
1/2
✓ Branch 0 taken 1857 times.
✗ Branch 1 not taken.
1857 Stopped();
3030 1857 return 1;
3031 }
3032
3033 // Flush silence through the PipeWire graph before tearing down.
3034 // When the pipeline stops, PipeWire combine-stream and filter-chain
3035 // nodes go IDLE with whatever audio was last in their buffers. If a
3036 // new track starts before WirePlumber suspends those nodes (~5s),
3037 // the stale buffer contents replay as an audible click.
3038 // Setting GStreamer volume to 0 while the pipeline is still PLAYING
3039 // pushes silence through the entire chain (filter-chain delay buffers,
3040 // combine-stream mixers, ALSA ring buffers), overwriting stale data.
3041 // 250ms covers the longest configured delay (206ms) plus a few
3042 // PipeWire quanta for propagation.
3043
2/2
✓ Branch 0 taken 41 times.
✓ Branch 1 taken 8 times.
49 if (m_volume) {
3044
1/2
✓ Branch 0 taken 41 times.
✗ Branch 1 not taken.
41 g_object_set(m_volume, "volume", 0.0, NULL);
3045
1/2
✓ Branch 0 taken 41 times.
✗ Branch 1 not taken.
41 std::this_thread::sleep_for(std::chrono::milliseconds(250));
3046 }
3047
3048
1/2
✓ Branch 0 taken 49 times.
✗ Branch 1 not taken.
49 Stopping();
3049
1/2
✓ Branch 0 taken 49 times.
✗ Branch 1 not taken.
49 LogDebug(VB_MEDIAOUT, "GStreamerOutput::Stop() - setting pipeline to NULL\n");
3050 // Supervised: a wedged V4L2 decoder (issue #2695) makes this state
3051 // change block forever — on the 2026-07-03 soak it hung the playlist
3052 // thread for 2h25m and ended in SIGBUS. On timeout the pipeline is
3053 // abandoned (reaper thread keeps the ref) so playback can continue.
3054
1/2
✓ Branch 0 taken 49 times.
✗ Branch 1 not taken.
49 const bool hwDecoder = PipelineHasHwVideoDecoder(m_pipeline);
3055
3/4
✓ Branch 0 taken 49 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 48 times.
✓ Branch 3 taken 1 times.
49 if (SetPipelineToNullSupervised(m_pipeline, PIPELINE_TEARDOWN_TIMEOUT_MS)) {
3056
1/2
✓ Branch 0 taken 48 times.
✗ Branch 1 not taken.
48 LogDebug(VB_MEDIAOUT, "GStreamerOutput::Stop() - pipeline NULL complete\n");
3057 } else {
3058 1 m_teardownAbandoned = true;
3059
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 RecordWedgeEventAndMaybeRestart("pipeline teardown wedged, abandoning pipeline", hwDecoder);
3060 }
3061
3062 // On Raspberry Pi's vc4/v3d DRM driver, the kernel needs time to
3063 // fully release CRTC/plane resources after kmssink's DRM fd is
3064 // closed. Without this delay, a subsequent Start() may fail with
3065 // "general resource error" from kmssink because the connector is
3066 // still held by the kernel. 150ms covers the observed ~100-120ms
3067 // release window on Pi 5 hardware.
3068 // Only needed when kmssink is in the primary pipeline (not when
3069 // PipeWire routing sends HDMI through a consumer pipeline), and
3070 // pointless after an abandoned teardown (nothing was released).
3071
9/10
✓ Branch 0 taken 48 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 48 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 44 times.
✓ Branch 5 taken 4 times.
✓ Branch 6 taken 8 times.
✓ Branch 7 taken 36 times.
✓ Branch 8 taken 12 times.
✓ Branch 9 taken 37 times.
49 if (!m_teardownAbandoned && !m_macVideoWindow && (m_kmssink || !m_directConnectorIds.empty())) {
3072
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 std::this_thread::sleep_for(std::chrono::milliseconds(150));
3073
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 LogDebug(VB_MEDIAOUT, "GStreamerOutput::Stop() - DRM release delay complete\n");
3074 }
3075
3076 49 m_playing = false;
3077
1/2
✓ Branch 0 taken 49 times.
✗ Branch 1 not taken.
49 if (m_mediaOutputStatus) {
3078 49 m_mediaOutputStatus->status = MEDIAOUTPUTSTATUS_IDLE;
3079 }
3080 // Last touch of `this` - see the async path above.
3081 49 m_teardownComplete = true;
3082
1/2
✓ Branch 0 taken 49 times.
✗ Branch 1 not taken.
49 opLock.unlock();
3083
1/2
✓ Branch 0 taken 49 times.
✗ Branch 1 not taken.
49 Stopped();
3084 }
3085 96 return 1;
3086 1953 }
3087
3088 5604 void GStreamerOutput::DetachCallbacks() {
3089 // Start() hands an owning pipeline ref to a detached thread that calls
3090 // set_state(PLAYING), so decodebin can still be exposing pads after the
3091 // teardown nulls m_audioChain/m_videoChain and drops our pipeline ref --
3092 // and after ~GStreamerOutput frees the object outright. Clearing `self`
3093 // under the guard lock both blocks until any in-flight callback finishes
3094 // and makes every later one a no-op. The bus sync handler doesn't hold
3095 // the lock while it runs, so wait for it separately. Never call this from
3096 // a streaming thread: it waits for the handlers running on them.
3097
3/4
✓ Branch 0 taken 5604 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1909 times.
✓ Branch 3 taken 3695 times.
5604 if (auto guard = m_cbGuard.load()) {
3098 {
3099
1/2
✓ Branch 0 taken 1909 times.
✗ Branch 1 not taken.
1909 std::unique_lock<std::recursive_mutex> lock(guard->mtx);
3100 1909 guard->self = nullptr;
3101
1/2
✓ Branch 0 taken 1909 times.
✗ Branch 1 not taken.
3826 guard->busIdle.wait(lock, [&guard] { return guard->busInFlight == 0; });
3102 1909 }
3103
1/2
✓ Branch 0 taken 1909 times.
✗ Branch 1 not taken.
1909 m_cbGuard.store(nullptr);
3104 5604 }
3105 5604 }
3106
3107 // Per output: two outputs on one model (a Play Media restarted on it, whose
3108 // old output closes after the new one starts) used to share one id, and the
3109 // old one's Close() removed the new one's listener -- leaving it holding a
3110 // model pointer nothing would clear when the model was deleted.
3111 106 std::string GStreamerOutput::OverlayListenerId() const {
3112 char id[48];
3113 106 snprintf(id, sizeof(id), "GStreamerOut_%p", (const void*)this);
3114
1/2
✓ Branch 0 taken 106 times.
✗ Branch 1 not taken.
212 return id;
3115 }
3116
3117 4 bool GStreamerOutput::SetPipelineToNullBounded(GstElement* pipeline, int timeoutMs) {
3118 4 return SetPipelineToNullSupervised(pipeline, timeoutMs);
3119 }
3120
3121 // A video whose sound feeds the output stream shows each frame when the feed
3122 // clock reaches it; the sound of that moment leaves the card a card's queue
3123 // later. Hold the picture back by that queue (a sink's ts-offset), so picture,
3124 // sound and the lights, which follow the sound, all meet. Re-applied only as
3125 // the queue's average moves.
3126 158668 void GStreamerOutput::AlignPictureToSound(int64_t latNs) {
3127
4/4
✓ Branch 0 taken 128710 times.
✓ Branch 1 taken 29958 times.
✓ Branch 2 taken 127955 times.
✓ Branch 3 taken 755 times.
158668 if (latNs <= 0 || std::llabs(latNs - m_pictureDelayNs) < 2 * GST_MSECOND) {
3128 157913 return;
3129 }
3130 755 m_pictureDelayNs = latNs;
3131 755 std::vector<GstElement*> sinks;
3132 {
3133 // OnNoMorePads() can remove the kmssink on a streaming thread.
3134
1/2
✓ Branch 0 taken 755 times.
✗ Branch 1 not taken.
755 std::shared_ptr<CallbackGuard> guard = m_cbGuard.load();
3135 755 std::unique_lock<std::recursive_mutex> lock;
3136
1/2
✓ Branch 0 taken 755 times.
✗ Branch 1 not taken.
755 if (guard) {
3137
1/2
✓ Branch 0 taken 755 times.
✗ Branch 1 not taken.
755 lock = std::unique_lock<std::recursive_mutex>(guard->mtx);
3138 }
3139
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 755 times.
755 if (m_kmssink) {
3140 ✗ sinks.push_back(GST_ELEMENT(gst_object_ref(m_kmssink)));
3141 }
3142
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 755 times.
755 if (m_videoAppsink) {
3143 ✗ sinks.push_back(GST_ELEMENT(gst_object_ref(m_videoAppsink)));
3144 }
3145 755 }
3146
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 755 times.
755 for (int cid : m_directConnectorIds) {
3147 ✗ std::string name = "dkms_" + std::to_string(cid);
3148 ✗ if (GstElement* dkms = gst_bin_get_by_name(GST_BIN(m_pipeline), name.c_str())) {
3149 ✗ sinks.push_back(dkms);
3150 }
3151 ✗ }
3152
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 755 times.
755 for (GstElement* sink : sinks) {
3153 ✗ g_object_set(sink, "ts-offset", (gint64)latNs, NULL);
3154 ✗ gst_object_unref(sink);
3155 }
3156
1/2
✓ Branch 0 taken 755 times.
✗ Branch 1 not taken.
755 LogDebug(VB_MEDIAOUT, "GStreamer: picture held back %.1f ms to meet the sound\n", latNs / 1e6);
3157 755 }
3158
3159 1906 void GStreamerOutput::ReleasePipeWireStream() {
3160
2/2
✓ Branch 0 taken 1836 times.
✓ Branch 1 taken 70 times.
1906 if (m_pwStream) {
3161 1836 m_pwStream->Release(m_pwFeed);
3162 1836 m_pwStream = nullptr;
3163 }
3164
2/2
✓ Branch 0 taken 1836 times.
✓ Branch 1 taken 70 times.
1906 if (m_pwFeed) {
3165 1836 gst_object_unref(m_pwFeed);
3166 1836 m_pwFeed = nullptr;
3167 }
3168 1906 }
3169
3170 // ---------------------------------------------------------------------------
3171 // Audio sink latency
3172 //
3173 // gst_element_query_position() on pipewiresink reports what the sink has
3174 // rendered into the PipeWire graph, not what has left the card, and
3175 // pipewiresink adds nothing of its own to the GStreamer LATENCY query -- it
3176 // answers min = upstream + processing-deadline + render-delay with render-delay
3177 // at 0, identically no matter which node it targets, so it never asks PipeWire
3178 // how far the data still has to travel. Everything queued downstream is
3179 // therefore invisible to mediaSeconds, and the sequence -- which
3180 // CalculateNewChannelOutputDelay() servos to exactly that number -- runs ahead
3181 // of the sound by the whole downstream depth.
3182 //
3183 // Measured with the sequence's own frame index captured off the wire against a
3184 // sample-accurate audio reference: lights ahead by 22 ms on an AM62x I2S cape,
3185 // 56 ms on a Pi 5 I2S cape, and 160 ms on a BeagleBone with a full-speed USB
3186 // dongle. It is a per-platform constant, which is why it cannot be a setting.
3187 //
3188 // The USB figure is not fixed for all time: it is dominated by that card's
3189 // api.alsa.headroom, and a USB card that is the sole sink in its audio group is
3190 // now given a quarter of what it used to get (see kUsbHeadroomSoleSink in
3191 // FPPINIT_Audio.cpp), which took the same BeagleBone from 160 ms to ~80 ms.
3192 // Nothing below needs changing for that -- the queue depth is read live and
3193 // scaled by the card's own rate, so the correction simply gets smaller -- but do
3194 // not treat the numbers above as the values a current box will report.
3195 //
3196 // The card's own delay figure is the closest estimate obtainable without a new
3197 // library or a patched GStreamer plugin: /proc/asound/cardN/pcmMp/sub0/status
3198 // reports `delay` in frames -- the distance from PipeWire's write pointer to
3199 // the DAC. It matched the measured offset to within 3 ms on the two platforms
3200 // where both could be cross-checked (157 vs 160 ms, 21 vs 22 ms). PipeWire's
3201 // own declared SPA_PARAM_Latency was tried and is worse: it is the nominal
3202 // quantum + headroom + period, which under-reports the live queue by 30 ms on
3203 // the USB box.
3204 //
3205 // This corrects the *reported position*. It deliberately does not touch
3206 // mediaOffset, which stays a user knob applied on top in setMediaElapsed().
3207 // ---------------------------------------------------------------------------
3208
3209 64 static int AlsaCardNumber(const std::string& cardId) {
3210
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 64 times.
64 if (cardId.empty()) {
3211 ✗ return -1;
3212 }
3213
3/6
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 64 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 64 times.
✗ Branch 5 not taken.
64 std::istringstream iss(GetFileContents("/proc/asound/cards"));
3214 64 std::string line;
3215
3/6
✓ Branch 0 taken 192 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 192 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 192 times.
✗ Branch 5 not taken.
192 while (std::getline(iss, line)) {
3216 // " 2 [KulpLightsHIFI ]: simple-card - ..."
3217 192 std::size_t lb = line.find('[');
3218 192 std::size_t rb = line.find(']');
3219
4/6
✓ Branch 0 taken 128 times.
✓ Branch 1 taken 64 times.
✓ Branch 2 taken 128 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 128 times.
192 if (lb == std::string::npos || rb == std::string::npos || rb < lb) {
3220 64 continue;
3221 }
3222
1/2
✓ Branch 0 taken 128 times.
✗ Branch 1 not taken.
128 std::string id = line.substr(lb + 1, rb - lb - 1);
3223
5/6
✓ Branch 0 taken 608 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 480 times.
✓ Branch 3 taken 128 times.
✓ Branch 4 taken 480 times.
✓ Branch 5 taken 128 times.
608 while (!id.empty() && id.back() == ' ') {
3224 480 id.pop_back();
3225 }
3226
2/2
✓ Branch 0 taken 64 times.
✓ Branch 1 taken 64 times.
128 if (id == cardId) {
3227 64 return std::atoi(line.c_str());
3228 }
3229
2/2
✓ Branch 0 taken 64 times.
✓ Branch 1 taken 64 times.
128 }
3230 ✗ return -1;
3231 64 }
3232
3233 // Card the running graph actually feeds.
3234 //
3235 // Taken from the adapters FPP generated rather than trusting AudioOutput
3236 // outright -- the setting can name a card that has since been unplugged, and
3237 // the queue depth is only readable for a card the graph really holds. But the
3238 // conf declares an adapter for EVERY playback-capable card, emitted in
3239 // card-number order, so simply reading its first api.alsa.path yields card 0
3240 // whether or not that is the output. On a box with three USB cards and
3241 // AudioOutput=ICUSBAUDIO7D (card 2) that read card 0, found its PCM 'closed'
3242 // (idle cards suspend), and so applied no correction at all -- the sequence
3243 // stayed ahead of the sound, which is the whole defect this is here to remove.
3244 //
3245 // The order of preference is what the graph feeds, then what the setting says,
3246 // then anything at all:
3247 //
3248 // 1. a card the group config actually targets, matching AudioOutput
3249 // 2. any card the group config targets
3250 // 3. a declared adapter matching AudioOutput
3251 // 4. the first declared adapter
3252 //
3253 // Steps 1 and 2 exist because AudioOutput can legitimately disagree with the
3254 // running graph. The setting is flagged reboot-required, so between changing
3255 // the output card and rebooting, the setting names the new card while the graph
3256 // still feeds the old one -- reading the setting there picks a card that is not
3257 // playing, whose PCM is closed, and applies no correction at all. The group
3258 // config is what PipeWire actually loaded, so it is the better authority; this
3259 // is what the original code was reaching for by reading the conf rather than the
3260 // setting, before it settled on "first adapter in the file" and got card 0.
3261 //
3262 // Both generated confs have to be searched, not just the boot-time one. The
3263 // boot probe deliberately skips a card it cannot drive directly -- an
3264 // IEC958-only vc4-hdmi is the common one ("no standard PCM format") -- and the
3265 // group config then creates that card's adapter itself, through sysdefault:
3266 // rather than hw:. A Pi playing audio over HDMI therefore has its only sink
3267 // adapter in 97-fpp-audio-groups.conf and none in the 95 conf, so searching the
3268 // 95 conf alone finds either the wrong card or no card, and silently applies no
3269 // correction at all. That configuration is Simple mode's default on a board
3270 // with no other sound card, which is most of them.
3271 3330 static int AlsaSinkCardNumber() {
3272 // 95 first: where a card FPP can drive directly is declared. 97 second: the
3273 // group config's own adapters, for the cards 95 had to skip.
3274
2/4
✓ Branch 0 taken 3330 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3330 times.
✗ Branch 3 not taken.
3330 const std::string groupsConf = GetFileContents("/etc/pipewire/pipewire.conf.d/97-fpp-audio-groups.conf");
3275 const std::string confs[] = {
3276
1/2
✓ Branch 0 taken 3330 times.
✗ Branch 1 not taken.
3330 GetFileContents("/etc/pipewire/pipewire.conf.d/95-fpp-alsa-sink.conf"),
3277 groupsConf
3278
2/8
✓ Branch 0 taken 3330 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3330 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
16650 };
3279 // Reads the value of `key = "..."` at or after `from`, or "" if absent.
3280 168 auto quotedValue = [](const std::string& s, const std::string& key, std::size_t from) {
3281 168 std::size_t k = s.find(key, from);
3282
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 168 times.
168 if (k == std::string::npos) {
3283 ✗ return std::string();
3284 }
3285 168 std::size_t a = s.find('"', k + key.size());
3286
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 168 times.
168 if (a == std::string::npos) {
3287 ✗ return std::string();
3288 }
3289 168 std::size_t b = s.find('"', a + 1);
3290
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 168 times.
168 if (b == std::string::npos) {
3291 ✗ return std::string();
3292 }
3293 168 return s.substr(a + 1, b - a - 1);
3294 };
3295
3296 // Adapters the group config's filter chains actually play into. Only
3297 // fpp_alsa_* targets count: the combine streams carry node.target too, but
3298 // theirs name the fpp_fx_* filter chains rather than a card.
3299 3330 std::set<std::string> graphTargets;
3300
2/2
✓ Branch 0 taken 84 times.
✓ Branch 1 taken 3330 times.
3414 for (std::size_t t = groupsConf.find("node.target"); t != std::string::npos;
3301 84 t = groupsConf.find("node.target", t + 1)) {
3302
2/4
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 84 times.
✗ Branch 3 not taken.
168 std::string target = quotedValue(groupsConf, "node.target", t);
3303
4/6
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 84 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 42 times.
✓ Branch 5 taken 42 times.
168 if (startsWith(target, "fpp_alsa_")) {
3304
1/2
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
42 graphTargets.insert(target);
3305 }
3306 84 }
3307
3308 // AudioOutput is a stable ALSA card ID on current installs, a bare card
3309 // number on older ones; resolveAudioOutputCardNum() accepts both, so match
3310 // its handling here rather than assuming either spelling.
3311
1/2
✓ Branch 0 taken 3330 times.
✗ Branch 1 not taken.
3330 std::string wanted = GetEffectiveAudioOutput();
3312
1/2
✓ Branch 0 taken 3330 times.
✗ Branch 1 not taken.
3330 TrimWhiteSpace(wanted);
3313 3330 int wantedCard = -1;
3314
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3330 times.
3330 if (!wanted.empty()) {
3315 ✗ wantedCard = (wanted.find_first_not_of("0123456789") == std::string::npos)
3316 ✗ ? std::atoi(wanted.c_str())
3317 ✗ : AlsaCardNumber(wanted);
3318 }
3319
3320 3330 int targetedCard = -1; // first adapter the graph plays into
3321 3330 int settingCard = -1; // first adapter matching AudioOutput
3322 3330 int firstCard = -1; // first adapter of any kind
3323 // Sink adapters only -- the capture adapters FPP also declares are named
3324 // fpp_alsain_, which this needle does not match.
3325
1/2
✓ Branch 0 taken 3330 times.
✗ Branch 1 not taken.
3330 const std::string nameKey = "node.name = \"fpp_alsa_";
3326
2/2
✓ Branch 0 taken 6660 times.
✓ Branch 1 taken 3330 times.
9990 for (const std::string& conf : confs) {
3327
2/2
✓ Branch 0 taken 42 times.
✓ Branch 1 taken 6660 times.
6702 for (std::size_t n = conf.find(nameKey); n != std::string::npos; n = conf.find(nameKey, n + 1)) {
3328
2/4
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 42 times.
✗ Branch 3 not taken.
84 std::string nodeName = quotedValue(conf, "node.name", n);
3329 // "hw:CardId", or "sysdefault:CardId" for a card reached through the
3330 // ALSA plug layer -- the card ID is what follows the colon either way.
3331
2/4
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 42 times.
✗ Branch 3 not taken.
42 std::string path = quotedValue(conf, "api.alsa.path", n);
3332 42 std::size_t colon = path.find(':');
3333
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 42 times.
42 if (colon == std::string::npos) {
3334 ✗ continue;
3335 }
3336
2/4
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 42 times.
✗ Branch 3 not taken.
42 int card = AlsaCardNumber(path.substr(colon + 1));
3337
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 42 times.
42 if (card < 0) {
3338 ✗ continue;
3339 }
3340
1/2
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
42 const bool targeted = graphTargets.count(nodeName) > 0;
3341
2/4
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 42 times.
42 if (targeted && card == wantedCard) {
3342 ✗ return card; // the graph feeds it and the setting names it
3343 }
3344
2/4
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 42 times.
✗ Branch 3 not taken.
42 if (targeted && targetedCard < 0) {
3345 42 targetedCard = card;
3346 }
3347
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 42 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
42 if (card == wantedCard && settingCard < 0) {
3348 ✗ settingCard = card;
3349 }
3350
1/2
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
42 if (firstCard < 0) {
3351 42 firstCard = card;
3352 }
3353
2/6
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 42 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
42 }
3354 }
3355
2/2
✓ Branch 0 taken 42 times.
✓ Branch 1 taken 3288 times.
3330 if (targetedCard >= 0) {
3356 42 return targetedCard;
3357 }
3358
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3288 times.
3288 if (settingCard >= 0) {
3359 ✗ return settingCard;
3360 }
3361 3288 return firstCard;
3362
2/4
✓ Branch 0 taken 6660 times.
✓ Branch 1 taken 3330 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
16650 }
3363
3364 // First playback substream of the card; the device index is not always 0.
3365 64 static std::string AlsaPlaybackStatusPath(int card) {
3366
1/2
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
64 std::string base = "/proc/asound/card" + std::to_string(card);
3367
1/2
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
64 DIR* d = opendir(base.c_str());
3368
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 64 times.
64 if (!d) {
3369 ✗ return "";
3370 }
3371 64 std::vector<std::string> devs;
3372
3/4
✓ Branch 0 taken 320 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 256 times.
✓ Branch 3 taken 64 times.
320 while (struct dirent* e = readdir(d)) {
3373
1/2
✓ Branch 0 taken 256 times.
✗ Branch 1 not taken.
512 std::string n = e->d_name;
3374
7/10
✓ Branch 0 taken 64 times.
✓ Branch 1 taken 192 times.
✓ Branch 2 taken 64 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 64 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 64 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 64 times.
✓ Branch 9 taken 192 times.
256 if (n.size() > 4 && n.compare(0, 3, "pcm") == 0 && n.back() == 'p') {
3375
1/2
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
64 devs.push_back(n);
3376 }
3377 256 }
3378
1/2
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
64 closedir(d);
3379
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 64 times.
64 if (devs.empty()) {
3380 ✗ return "";
3381 }
3382
1/2
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
64 std::sort(devs.begin(), devs.end());
3383
3/6
✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 64 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 64 times.
✗ Branch 5 not taken.
64 return base + "/" + devs[0] + "/sub0/status";
3384 64 }
3385
3386 // Value of every `key = "..."` in [from, to) of `s`.
3387 154 static std::vector<std::string> QuotedValues(const std::string& s, const std::string& key,
3388 std::size_t from, std::size_t to) {
3389 154 std::vector<std::string> out;
3390
4/4
✓ Branch 0 taken 253 times.
✓ Branch 1 taken 110 times.
✓ Branch 2 taken 209 times.
✓ Branch 3 taken 44 times.
363 for (std::size_t k = s.find(key, from); k != std::string::npos && k < to; k = s.find(key, k + key.size())) {
3391 209 std::size_t a = s.find('"', k + key.size());
3392 209 std::size_t eol = s.find('\n', k);
3393
2/4
✓ Branch 0 taken 209 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 209 times.
209 if (a == std::string::npos || a > eol) {
3394 ✗ continue;
3395 }
3396 209 std::size_t b = s.find('"', a + 1);
3397
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 209 times.
209 if (b == std::string::npos) {
3398 ✗ break;
3399 }
3400
2/4
✓ Branch 0 taken 209 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 209 times.
✗ Branch 3 not taken.
209 out.push_back(s.substr(a + 1, b - a - 1));
3401 }
3402 154 return out;
3403 ✗ }
3404
3405 // Cards a PipeWire sink ultimately plays into, following FPP's generated
3406 // configs: an input mix bus (96) targets an output group, the group's
3407 // combine-stream (97) targets one filter chain per member, and each filter
3408 // chain targets that member's sink -- an fpp_alsa_* adapter for a card, or a
3409 // network sender (AES67, Opus RTP) for anything else. Within one module block
3410 // every node.name is treated as feeding every node.target, which is exactly
3411 // right for all of these block shapes. A sink that reaches no adapter, e.g. a
3412 // group whose only member is an AES67 sender, yields no cards.
3413 152 static std::vector<int> AlsaCardsFedBy(const std::string& sinkName) {
3414
2/4
✓ Branch 0 taken 152 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 152 times.
✗ Branch 3 not taken.
304 const std::string conf95 = GetFileContents("/etc/pipewire/pipewire.conf.d/95-fpp-alsa-sink.conf");
3415
2/4
✓ Branch 0 taken 152 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 152 times.
✗ Branch 3 not taken.
304 const std::string conf97 = GetFileContents("/etc/pipewire/pipewire.conf.d/97-fpp-audio-groups.conf");
3416
2/4
✓ Branch 0 taken 152 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 152 times.
✗ Branch 3 not taken.
152 const std::string conf96 = GetFileContents("/etc/pipewire/pipewire.conf.d/96-fpp-input-groups.conf");
3417
3418 152 std::map<std::string, std::set<std::string>> edges;
3419
1/2
✓ Branch 0 taken 152 times.
✗ Branch 1 not taken.
152 const std::string blockKey = "name = libpipewire-module-";
3420
2/2
✓ Branch 0 taken 304 times.
✓ Branch 1 taken 152 times.
456 for (const std::string* conf : { &conf96, &conf97 }) {
3421 304 std::vector<std::size_t> starts;
3422
2/2
✓ Branch 0 taken 55 times.
✓ Branch 1 taken 304 times.
359 for (std::size_t b = conf->find(blockKey); b != std::string::npos; b = conf->find(blockKey, b + 1)) {
3423
1/2
✓ Branch 0 taken 55 times.
✗ Branch 1 not taken.
55 starts.push_back(b);
3424 }
3425
2/2
✓ Branch 0 taken 55 times.
✓ Branch 1 taken 304 times.
359 for (std::size_t i = 0; i < starts.size(); i++) {
3426
2/2
✓ Branch 0 taken 22 times.
✓ Branch 1 taken 33 times.
55 std::size_t end = (i + 1 < starts.size()) ? starts[i + 1] : conf->size();
3427
2/4
✓ Branch 0 taken 55 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 55 times.
✗ Branch 3 not taken.
110 std::vector<std::string> names = QuotedValues(*conf, "node.name", starts[i], end);
3428
2/4
✓ Branch 0 taken 55 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 55 times.
✗ Branch 3 not taken.
55 std::vector<std::string> targets = QuotedValues(*conf, "node.target", starts[i], end);
3429
2/2
✓ Branch 0 taken 110 times.
✓ Branch 1 taken 55 times.
165 for (const auto& n : names) {
3430
2/4
✓ Branch 0 taken 110 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 110 times.
✗ Branch 3 not taken.
110 edges[n].insert(targets.begin(), targets.end());
3431 }
3432 55 }
3433 304 }
3434
3435 // fpp_alsa_* adapter -> card, from whichever conf declared it.
3436 152 std::map<std::string, int> adapterCard;
3437
2/2
✓ Branch 0 taken 304 times.
✓ Branch 1 taken 152 times.
456 for (const std::string* conf : { &conf95, &conf97 }) {
3438
1/2
✓ Branch 0 taken 304 times.
✗ Branch 1 not taken.
304 const std::string nameKey = "node.name = \"fpp_alsa_";
3439
2/2
✓ Branch 0 taken 22 times.
✓ Branch 1 taken 304 times.
326 for (std::size_t n = conf->find(nameKey); n != std::string::npos; n = conf->find(nameKey, n + 1)) {
3440 22 std::size_t next = conf->find(nameKey, n + 1);
3441
2/4
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 22 times.
✗ Branch 3 not taken.
44 std::vector<std::string> name = QuotedValues(*conf, "node.name", n, n + nameKey.size() + 128);
3442
3/6
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 22 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 22 times.
✗ Branch 5 not taken.
22 std::vector<std::string> path = QuotedValues(*conf, "api.alsa.path", n, next == std::string::npos ? conf->size() : next);
3443
3/6
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 22 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 22 times.
22 if (name.empty() || path.empty()) {
3444 ✗ continue;
3445 }
3446 22 std::size_t colon = path[0].find(':');
3447
4/10
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 22 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 22 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 22 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
22 int card = colon == std::string::npos ? -1 : AlsaCardNumber(path[0].substr(colon + 1));
3448
4/8
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 22 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 22 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 22 times.
✗ Branch 7 not taken.
22 if (card >= 0 && !adapterCard.count(name[0])) {
3449
1/2
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
22 adapterCard[name[0]] = card;
3450 }
3451
2/4
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 22 times.
✗ Branch 3 not taken.
22 }
3452 304 }
3453
3454 152 std::vector<int> cards;
3455 152 std::set<std::string> seen;
3456
3/6
✓ Branch 0 taken 152 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 152 times.
✓ Branch 3 taken 152 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
456 std::vector<std::string> todo{ sinkName };
3457
2/2
✓ Branch 0 taken 218 times.
✓ Branch 1 taken 152 times.
370 while (!todo.empty()) {
3458
1/2
✓ Branch 0 taken 218 times.
✗ Branch 1 not taken.
218 std::string node = todo.back();
3459 218 todo.pop_back();
3460
3/4
✓ Branch 0 taken 218 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 22 times.
✓ Branch 3 taken 196 times.
218 if (!seen.insert(node).second) {
3461 22 continue;
3462 }
3463
1/2
✓ Branch 0 taken 196 times.
✗ Branch 1 not taken.
196 auto ac = adapterCard.find(node);
3464
2/2
✓ Branch 0 taken 22 times.
✓ Branch 1 taken 174 times.
196 if (ac != adapterCard.end()) {
3465
2/4
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 22 times.
✗ Branch 3 not taken.
22 if (std::find(cards.begin(), cards.end(), ac->second) == cards.end()) {
3466
1/2
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
22 cards.push_back(ac->second);
3467 }
3468 22 continue;
3469 }
3470
1/2
✓ Branch 0 taken 174 times.
✗ Branch 1 not taken.
174 auto e = edges.find(node);
3471
2/2
✓ Branch 0 taken 44 times.
✓ Branch 1 taken 130 times.
174 if (e != edges.end()) {
3472
1/2
✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
44 todo.insert(todo.end(), e->second.begin(), e->second.end());
3473 }
3474
2/2
✓ Branch 0 taken 174 times.
✓ Branch 1 taken 44 times.
218 }
3475 152 return cards;
3476
1/6
✓ Branch 0 taken 152 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
304 }
3477
3478 6005 int GStreamerOutput::PipeWireGraphRate() {
3479 6005 int rate = GetPipeWireGraphRate();
3480
2/2
✓ Branch 0 taken 5875 times.
✓ Branch 1 taken 130 times.
6005 return rate > 0 ? rate : 48000;
3481 }
3482
3483 203 std::string GStreamerOutput::AlsaSinkStatusPath(const std::string& sinkName) {
3484
2/2
✓ Branch 0 taken 51 times.
✓ Branch 1 taken 152 times.
203 if (sinkName.empty()) {
3485 // PipeWire's default sink: nothing in FPP's configs says which card.
3486 51 return std::string();
3487 }
3488
1/2
✓ Branch 0 taken 152 times.
✗ Branch 1 not taken.
152 std::vector<int> cards = AlsaCardsFedBy(sinkName);
3489
3/4
✓ Branch 0 taken 130 times.
✓ Branch 1 taken 22 times.
✓ Branch 2 taken 22 times.
✗ Branch 3 not taken.
152 return cards.empty() ? std::string() : AlsaPlaybackStatusPath(cards[0]);
3490 152 }
3491
3492 6706 static int ReadIntField(const std::string& text, const char* key) {
3493 6706 std::size_t k = text.find(key);
3494
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 6706 times.
6706 if (k == std::string::npos) {
3495 ✗ return -1;
3496 }
3497 6706 std::size_t c = text.find(':', k);
3498
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 6706 times.
6706 if (c == std::string::npos) {
3499 ✗ return -1;
3500 }
3501 6706 return std::atoi(text.c_str() + c + 1);
3502 }
3503
3504 // Say once, per media, that the position is going out uncorrected and why.
3505 // Deliberately not a WarningHolder banner: on a board with no usable sink this
3506 // would fire on every track forever, and the condition is a tuning shortfall
3507 // rather than a fault the user must act on immediately.
3508 3288 void GStreamerOutput::NoteNoSinkLatency(const std::string& why) {
3509
2/2
✓ Branch 0 taken 2497 times.
✓ Branch 1 taken 791 times.
3288 if (m_warnedNoSinkLatency) {
3510 2497 return;
3511 }
3512 791 m_warnedNoSinkLatency = true;
3513 791 LogWarn(VB_MEDIAOUT,
3514 "GStreamer: no audio sink latency available (%s) — reported position is not "
3515 "corrected for the card's queue, so the sequence may lead the audio by tens of ms\n",
3516 why.c_str());
3517 }
3518
3519 162609 int64_t GStreamerOutput::AudioSinkLatencyNs() {
3520 // The queue depth is essentially constant while a track plays; re-reading
3521 // it on every Process() would put a /proc read in the main loop at frame
3522 // rate for no benefit.
3523
1/2
✓ Branch 0 taken 162609 times.
✗ Branch 1 not taken.
162609 uint64_t now = GetMonotonicTimeMS();
3524
4/4
✓ Branch 0 taken 161776 times.
✓ Branch 1 taken 833 times.
✓ Branch 2 taken 152657 times.
✓ Branch 3 taken 9119 times.
162609 if (m_sinkLatencyCheckedMs != 0 && (now - m_sinkLatencyCheckedMs) < 250) {
3525 152657 return m_sinkLatencyNs;
3526 }
3527 9952 m_sinkLatencyCheckedMs = now;
3528
3529
2/2
✓ Branch 0 taken 3330 times.
✓ Branch 1 taken 6622 times.
9952 if (m_alsaStatusPath.empty()) {
3530
1/2
✓ Branch 0 taken 3330 times.
✗ Branch 1 not taken.
3330 int card = AlsaSinkCardNumber();
3531
2/2
✓ Branch 0 taken 3288 times.
✓ Branch 1 taken 42 times.
3330 if (card < 0) {
3532 // Nothing to read means no correction, which is not a small thing:
3533 // uncorrected, the sequence leads the sound by the whole ALSA queue
3534 // depth -- 22 to 160 ms on the boards this was measured on. That is
3535 // audible and looks exactly like a misconfigured show, so say so
3536 // once rather than leaving the user to find it by ear.
3537
2/4
✓ Branch 0 taken 3288 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3288 times.
✗ Branch 3 not taken.
3288 NoteNoSinkLatency("no FPP ALSA sink adapter matches the configured audio output");
3538 3288 m_sinkLatencyNs = 0;
3539 3288 return 0;
3540 }
3541
1/2
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
42 m_alsaStatusPath = AlsaPlaybackStatusPath(card);
3542
1/2
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
42 m_alsaHwParamsPath = m_alsaStatusPath;
3543
1/2
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
42 std::size_t s = m_alsaHwParamsPath.rfind("/status");
3544
1/2
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
42 if (s != std::string::npos) {
3545
1/2
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
42 m_alsaHwParamsPath.replace(s, 7, "/hw_params");
3546 }
3547
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 42 times.
42 if (m_alsaStatusPath.empty()) {
3548 ✗ NoteNoSinkLatency("card " + std::to_string(card) + " exposes no playback substream status");
3549 ✗ m_sinkLatencyNs = 0;
3550 ✗ return 0;
3551 }
3552 }
3553
3554
1/2
✓ Branch 0 taken 6664 times.
✗ Branch 1 not taken.
6664 std::string status = GetFileContents(m_alsaStatusPath);
3555
4/8
✓ Branch 0 taken 6664 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6664 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 6664 times.
✗ Branch 6 not taken.
✓ Branch 7 taken 6664 times.
6664 if (status.empty() || status.compare(0, 6, "closed") == 0) {
3556 // Suspended between tracks: keep the last good value rather than
3557 // snapping the reported position by tens of ms at every gap.
3558 ✗ return m_sinkLatencyNs;
3559 }
3560 6664 int delayFrames = ReadIntField(status, "delay");
3561
2/2
✓ Branch 0 taken 9 times.
✓ Branch 1 taken 6655 times.
6664 if (delayFrames <= 0) {
3562 9 return m_sinkLatencyNs;
3563 }
3564
2/2
✓ Branch 0 taken 42 times.
✓ Branch 1 taken 6613 times.
6655 if (m_alsaRate <= 0) {
3565
1/2
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
42 m_alsaRate = ReadIntField(GetFileContents(m_alsaHwParamsPath), "rate");
3566 }
3567
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 6655 times.
6655 if (m_alsaRate <= 0) {
3568 ✗ NoteNoSinkLatency("could not read the card's rate from hw_params");
3569 ✗ m_sinkLatencyNs = 0;
3570 ✗ return 0;
3571 }
3572 6655 int64_t ns = (int64_t)delayFrames * 1000000000LL / m_alsaRate;
3573 // A sane sink is a few ms to a few hundred; anything past a second means
3574 // the field was misread (dmix, for one, leaves appl_ptr at 0 and reports a
3575 // hugely negative delay) and is not worth trusting.
3576
2/4
✓ Branch 0 taken 6655 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 6655 times.
6655 if (ns < 0 || ns > 1000000000LL) {
3577 ✗ NoteNoSinkLatency("card reports an implausible queue depth (" +
3578 ✗ std::to_string(delayFrames) + " frames at " +
3579 ✗ std::to_string(m_alsaRate) + " Hz)");
3580 ✗ m_sinkLatencyNs = 0;
3581 ✗ return 0;
3582 }
3583 // The queue saw-tooths through a whole graph quantum (~21 ms) as PipeWire
3584 // refills it, and each reading lands anywhere on the tooth. What the
3585 // sound is behind by on average is the mean, so average the readings
3586 // (~2 s at one every 250 ms): taken raw, they moved the position, and the
3587 // lights with it, by up to a quantum every quarter second.
3588
2/2
✓ Branch 0 taken 6613 times.
✓ Branch 1 taken 42 times.
6655 m_sinkLatencyNs = m_sinkLatencyNs > 0 ? m_sinkLatencyNs + (ns - m_sinkLatencyNs) / 8 : ns;
3589 6655 return m_sinkLatencyNs;
3590 6664 }
3591
3592 169310 int GStreamerOutput::Process(void) {
3593 // Busy being started or stopped on another thread: skip this tick rather
3594 // than wait for it (see m_opMutex).
3595 169310 std::unique_lock<std::recursive_mutex> opLock(m_opMutex, std::try_to_lock);
3596
2/2
✓ Branch 0 taken 84 times.
✓ Branch 1 taken 169226 times.
169310 if (!opLock.owns_lock()) {
3597 84 return 1;
3598 }
3599
6/8
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 169225 times.
✓ 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 taken 169225 times.
169226 if (m_stopRequested.exchange(false) && m_pipeline && !m_teardownComplete) {
3600
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "GStreamer: stopping %s on request\n", m_mediaFilename.c_str());
3601
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 opLock.unlock();
3602
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 Stop();
3603 1 return 1;
3604 }
3605
3/4
✓ Branch 0 taken 169201 times.
✓ Branch 1 taken 24 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 169201 times.
169225 if (!m_pipeline || !m_bus) {
3606 24 return 0;
3607 }
3608
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 169201 times.
169201 if (m_pwFeedOrphaned.exchange(false)) {
3609 ✗ LogDebug(VB_MEDIAOUT, "GStreamer: %s has no sound; releasing its output stream\n", m_mediaFilename.c_str());
3610 ✗ ReleasePipeWireStream();
3611 }
3612
5/6
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 169199 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 169199 times.
169203 if (m_haveFailedBranches.load(std::memory_order_acquire) && !m_teardownComplete &&
3613
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 !m_shutdownFlag.load(std::memory_order_acquire)) {
3614
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 SetMainLoopPhase("GStreamer remove failed branch");
3615
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 RemoveFailedBranches();
3616 }
3617 // Sub-phases for the main-loop stall watchdog (issue #2727): everything
3618 // below runs on the main loop and calls into GStreamer, which takes both
3619 // element locks and (via pipewiresink) PipeWire's thread-loop lock, so a
3620 // sink that is stuck in preroll can park the main loop right here.
3621
1/2
✓ Branch 0 taken 169201 times.
✗ Branch 1 not taken.
169201 SetMainLoopPhase("GStreamer ProcessMessages");
3622
1/2
✓ Branch 0 taken 169201 times.
✗ Branch 1 not taken.
169201 ProcessMessages();
3623
3624 // Update position
3625
2/2
✓ Branch 0 taken 169144 times.
✓ Branch 1 taken 57 times.
169201 if (m_playing) {
3626 169144 gint64 pos = 0, dur = 0;
3627 // Query position from a sync'd sink, NOT the pipeline.
3628 // Pipeline-level queries return the demuxer's read-ahead position,
3629 // which races far ahead when sinks use leaky queues or async=FALSE.
3630 // Prefer kmssink (sync=TRUE, video playout position), then a
3631 // consumer direct-kmssink (sync=TRUE), then audio pipewiresink
3632 // (sync=TRUE), then the pipeline as fallback.
3633 169144 GstElement* posSource = nullptr;
3634 169144 bool ownPosSource = false;
3635 {
3636 // OnNoMorePads() removes the kmssink of a file that turned out to
3637 // have no video, on a streaming thread, under the callback guard;
3638 // the bin's was the only reference, so take our own under it.
3639
1/2
✓ Branch 0 taken 169144 times.
✗ Branch 1 not taken.
169144 std::shared_ptr<CallbackGuard> guard = m_cbGuard.load();
3640 169144 std::unique_lock<std::recursive_mutex> lock;
3641
1/2
✓ Branch 0 taken 169144 times.
✗ Branch 1 not taken.
169144 if (guard) {
3642
1/2
✓ Branch 0 taken 169144 times.
✗ Branch 1 not taken.
169144 lock = std::unique_lock<std::recursive_mutex>(guard->mtx);
3643 }
3644 // A video whose sound feeds the output stream follows the sound
3645 // (below), and its picture is held back to match (see
3646 // AlignPictureToSound()).
3647
3/4
✓ Branch 0 taken 599 times.
✓ Branch 1 taken 168545 times.
✓ Branch 2 taken 599 times.
✗ Branch 3 not taken.
169144 if (m_kmssink && !m_pwFeed) {
3648
3/6
✓ Branch 0 taken 599 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 599 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 599 times.
✗ Branch 5 not taken.
599 posSource = GST_ELEMENT(gst_object_ref(m_kmssink));
3649 599 ownPosSource = true;
3650 }
3651 169144 }
3652
8/8
✓ Branch 0 taken 168545 times.
✓ Branch 1 taken 599 times.
✓ Branch 2 taken 6421 times.
✓ Branch 3 taken 162124 times.
✓ Branch 4 taken 1363 times.
✓ Branch 5 taken 5058 times.
✓ Branch 6 taken 1363 times.
✓ Branch 7 taken 167781 times.
169144 if (!posSource && !m_pwFeed && !m_directConnectorIds.empty()) {
3653
1/2
✓ Branch 0 taken 1363 times.
✗ Branch 1 not taken.
1363 std::string name = "dkms_" + std::to_string(*m_directConnectorIds.begin());
3654
3/6
✓ Branch 0 taken 1363 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1363 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1363 times.
✗ Branch 5 not taken.
1363 posSource = gst_bin_get_by_name(GST_BIN(m_pipeline), name.c_str());
3655
2/2
✓ Branch 0 taken 1241 times.
✓ Branch 1 taken 122 times.
1363 if (posSource) ownPosSource = true;
3656 1363 }
3657 // Only the audio sink's position gets the ALSA-queue correction below;
3658 // a kmssink is reporting video playout, which is a different path.
3659 169144 bool posFromAudioSink = false;
3660
4/4
✓ Branch 0 taken 167304 times.
✓ Branch 1 taken 1840 times.
✓ Branch 2 taken 162124 times.
✓ Branch 3 taken 5180 times.
169144 if (!posSource && m_pwFeed) {
3661 // Feeding the persistent output stream: the feed is the synced
3662 // sink, and what it has rendered has only just entered the stream.
3663
3/6
✓ Branch 0 taken 162124 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 162124 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 162124 times.
✗ Branch 5 not taken.
162124 posSource = GST_ELEMENT(gst_object_ref(m_pwFeed));
3664 162124 ownPosSource = true;
3665 162124 posFromAudioSink = true;
3666 }
3667
2/2
✓ Branch 0 taken 5180 times.
✓ Branch 1 taken 163964 times.
169144 if (!posSource) {
3668
1/2
✓ Branch 0 taken 5180 times.
✗ Branch 1 not taken.
5180 SetMainLoopPhase("GStreamer get pwsink");
3669
3/6
✓ Branch 0 taken 5180 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5180 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 5180 times.
✗ Branch 5 not taken.
5180 posSource = gst_bin_get_by_name(GST_BIN(m_pipeline), "pwsink");
3670
2/2
✓ Branch 0 taken 4764 times.
✓ Branch 1 taken 416 times.
5180 if (posSource) {
3671 4764 ownPosSource = true;
3672 4764 posFromAudioSink = true;
3673 }
3674 }
3675
2/2
✓ Branch 0 taken 416 times.
✓ Branch 1 taken 168728 times.
169144 if (!posSource) posSource = m_pipeline;
3676
1/2
✓ Branch 0 taken 169144 times.
✗ Branch 1 not taken.
169144 SetMainLoopPhase("GStreamer query position");
3677
1/2
✓ Branch 0 taken 169144 times.
✗ Branch 1 not taken.
169144 bool havePos = gst_element_query_position(posSource, GST_FORMAT_TIME, &pos);
3678
5/8
✓ Branch 0 taken 163846 times.
✓ Branch 1 taken 5298 times.
✓ Branch 2 taken 163846 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 163846 times.
✗ Branch 6 not taken.
✓ Branch 7 taken 169144 times.
169144 if (havePos && m_startSeekPending && m_startSeekPending->load()) {
3679 // Prerolled at 0 and not yet seeked to the start position (see
3680 // Start()). Reporting 0 now would drag the sequence back to it.
3681 ✗ havePos = false;
3682 }
3683 // The duration only moves as a VBR estimate is refined, but a
3684 // pipeline-wide query is anything but cheap: the bin asks every sink,
3685 // and each one walks back through the tee and the decoder to the
3686 // parser, which converts filesrc's size (an fstat apiece). Once a
3687 // second is plenty; at the main loop's 100 Hz it was most of its
3688 // CPU on a single-core board.
3689 169144 bool haveDur = false;
3690
1/2
✓ Branch 0 taken 169144 times.
✗ Branch 1 not taken.
169144 uint64_t nowMs = GetMonotonicTimeMS();
3691
4/4
✓ Branch 0 taken 166852 times.
✓ Branch 1 taken 2292 times.
✓ Branch 2 taken 3173 times.
✓ Branch 3 taken 163679 times.
169144 if (m_maxDuration <= 0 || nowMs - m_durationQueriedMs >= 1000) {
3692 5465 m_durationQueriedMs = nowMs;
3693
1/2
✓ Branch 0 taken 5465 times.
✗ Branch 1 not taken.
5465 SetMainLoopPhase("GStreamer query duration");
3694
1/2
✓ Branch 0 taken 5465 times.
✗ Branch 1 not taken.
5465 haveDur = gst_element_query_duration(m_pipeline, GST_FORMAT_TIME, &dur);
3695 }
3696
1/2
✓ Branch 0 taken 169144 times.
✗ Branch 1 not taken.
169144 SetMainLoopPhase("GStreamer position post-processing");
3697
3/4
✓ Branch 0 taken 168728 times.
✓ Branch 1 taken 416 times.
✓ Branch 2 taken 168728 times.
✗ Branch 3 not taken.
169144 if (ownPosSource) gst_object_unref(posSource);
3698
3699 // One-shot: log pipeline clock and sink sync state on first position update
3700
4/4
✓ Branch 0 taken 163846 times.
✓ Branch 1 taken 5298 times.
✓ Branch 2 taken 854 times.
✓ Branch 3 taken 162992 times.
169144 if (havePos && m_wallStartMs == 0) {
3701
1/2
✓ Branch 0 taken 854 times.
✗ Branch 1 not taken.
854 m_wallStartMs = GetMonotonicTimeMS(); // record wall time at first pos (also used by preroll watchdog)
3702 // A valid position means the pipeline prerolled and the decoder
3703 // is producing — clear the consecutive-wedge escalation counter.
3704 854 s_consecutiveWedgeEvents = 0;
3705
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 853 times.
854 if (m_pendingSeekNs >= 0) {
3706 1 gint64 seekNs = m_pendingSeekNs;
3707 1 m_pendingSeekNs = -1;
3708
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogDebug(VB_MEDIAOUT, "GStreamer: prerolled, applying held seek to %0.3fs\n",
3709 (double)seekNs / GST_SECOND);
3710
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
1 if (!gst_element_seek(m_pipeline, 1.0, GST_FORMAT_TIME,
3711 (GstSeekFlags)(GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_ACCURATE),
3712 GST_SEEK_TYPE_SET, seekNs, GST_SEEK_TYPE_NONE, 0)) {
3713 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: held seek to %0.3fs failed\n", (double)seekNs / GST_SECOND);
3714 }
3715 }
3716 // The remaining pipeline/sink introspection here exists purely to
3717 // populate the debug log — skip the GStreamer queries entirely when
3718 // MediaOut isn't at DEBUG (or more verbose).
3719
3/4
✓ Branch 0 taken 854 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✓ Branch 3 taken 845 times.
854 if (WillLog(LOG_DEBUG, VB_MEDIAOUT)) {
3720
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 GstClock* clock = gst_pipeline_get_clock(GST_PIPELINE(m_pipeline));
3721
2/4
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
9 LogDebug(VB_MEDIAOUT, "GStreamer: pipeline clock=%s pos=%.1fs\n",
3722 clock ? GST_OBJECT_NAME(clock) : "(none)",
3723 (float)pos / GST_SECOND);
3724
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 if (clock) {
3725
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 GstClockTime ct = gst_clock_get_time(clock);
3726
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 GstClockTime bt = gst_element_get_base_time(m_pipeline);
3727
9/18
✓ 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.
✓ Branch 8 taken 9 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 9 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 9 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 9 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 9 times.
✗ Branch 17 not taken.
9 LogDebug(VB_MEDIAOUT, "GStreamer: clock_time=%" GST_TIME_FORMAT " base_time=%" GST_TIME_FORMAT "\n",
3728 GST_TIME_ARGS(ct), GST_TIME_ARGS(bt));
3729
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 gst_object_unref(clock);
3730 }
3731 // Log consumer kmssink stats (rendered/dropped frame counts)
3732
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 9 times.
9 for (int cid : m_directConnectorIds) {
3733 ✗ std::string name = "dkms_" + std::to_string(cid);
3734 ✗ GstElement* dkms = gst_bin_get_by_name(GST_BIN(m_pipeline), name.c_str());
3735 ✗ if (dkms) {
3736 ✗ GstStructure* stats = nullptr;
3737 ✗ g_object_get(dkms, "stats", &stats, NULL);
3738 ✗ if (stats) {
3739 ✗ guint64 rendered = 0, dropped = 0;
3740 ✗ gst_structure_get_uint64(stats, "rendered", &rendered);
3741 ✗ gst_structure_get_uint64(stats, "dropped", &dropped);
3742 ✗ LogDebug(VB_MEDIAOUT, "GStreamer: %s stats: rendered=%" G_GUINT64_FORMAT " dropped=%" G_GUINT64_FORMAT "\n",
3743 name.c_str(), rendered, dropped);
3744 ✗ gst_structure_free(stats);
3745 }
3746 GstState st, pend;
3747 ✗ gst_element_get_state(dkms, &st, &pend, 0);
3748 ✗ LogDebug(VB_MEDIAOUT, "GStreamer: %s state=%d pending=%d\n", name.c_str(), st, pend);
3749 ✗ gst_object_unref(dkms);
3750 }
3751 ✗ }
3752 // Check sync on sinks
3753
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(m_pipeline), "pwsink");
3754
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 7 times.
9 if (pwsink) {
3755
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gboolean s; g_object_get(pwsink, "sync", &s, NULL);
3756
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogDebug(VB_MEDIAOUT, "GStreamer: pwsink sync=%d\n", s);
3757
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_object_unref(pwsink);
3758 }
3759
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* kms = gst_bin_get_by_name(GST_BIN(m_pipeline), "kmsvideosink");
3760
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 7 times.
9 if (kms) {
3761
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gboolean s; g_object_get(kms, "sync", &s, NULL);
3762
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogDebug(VB_MEDIAOUT, "GStreamer: kmssink sync=%d\n", s);
3763
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_object_unref(kms);
3764 }
3765 }
3766 }
3767
3768 // Periodic wall-clock vs stream-position diagnostic (DEBUG only — the
3769 // stall/preroll watchdogs below run independently of this block).
3770
8/10
✓ Branch 0 taken 163846 times.
✓ Branch 1 taken 5298 times.
✓ Branch 2 taken 163846 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 163846 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3497 times.
✓ Branch 7 taken 160349 times.
✓ Branch 8 taken 3497 times.
✓ Branch 9 taken 165647 times.
169144 if (havePos && m_wallStartMs > 0 && WillLog(LOG_DEBUG, VB_MEDIAOUT)) {
3771
1/2
✓ Branch 0 taken 3497 times.
✗ Branch 1 not taken.
3497 uint64_t wallMs = GetMonotonicTimeMS() - m_wallStartMs;
3772 3497 float wallSec = wallMs / 1000.0f;
3773 3497 float streamSec = (float)pos / GST_SECOND;
3774
2/2
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 3489 times.
3497 if (wallMs - m_lastWallLogMs > 5000) {
3775 8 m_lastWallLogMs = wallMs;
3776
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 float ratio = (wallSec > 0.1f) ? streamSec / wallSec : 0.0f;
3777
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 LogDebug(VB_MEDIAOUT, "GStreamer: wall=%.1fs stream=%.1fs ratio=%.2fx\n",
3778 wallSec, streamSec, ratio);
3779 // Log pipeline clock time and consumer kmssink stats at each 5s checkpoint
3780
3/6
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
8 GstClock* chkClock = gst_pipeline_get_clock(GST_PIPELINE(m_pipeline));
3781
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 if (chkClock) {
3782
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 GstClockTime ct = gst_clock_get_time(chkClock);
3783
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 GstClockTime bt = gst_element_get_base_time(m_pipeline);
3784
21/42
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 8 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 8 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 8 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 8 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 8 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 8 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 8 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 8 times.
✗ Branch 21 not taken.
✓ Branch 22 taken 8 times.
✗ Branch 23 not taken.
✓ Branch 24 taken 8 times.
✗ Branch 25 not taken.
✓ Branch 26 taken 8 times.
✗ Branch 27 not taken.
✓ Branch 28 taken 8 times.
✗ Branch 29 not taken.
✓ Branch 30 taken 8 times.
✗ Branch 31 not taken.
✓ Branch 32 taken 8 times.
✗ Branch 33 not taken.
✓ Branch 34 taken 8 times.
✗ Branch 35 not taken.
✓ Branch 36 taken 8 times.
✗ Branch 37 not taken.
✓ Branch 38 taken 8 times.
✗ Branch 39 not taken.
✓ Branch 40 taken 8 times.
✗ Branch 41 not taken.
8 LogDebug(VB_MEDIAOUT, "GStreamer: clock_time=%" GST_TIME_FORMAT " base_time=%" GST_TIME_FORMAT " running=%" GST_TIME_FORMAT "\n",
3785 GST_TIME_ARGS(ct), GST_TIME_ARGS(bt),
3786 GST_TIME_ARGS(ct >= bt ? ct - bt : 0));
3787
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 gst_object_unref(chkClock);
3788 }
3789
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 8 times.
8 for (int cid : m_directConnectorIds) {
3790 ✗ std::string name = "dkms_" + std::to_string(cid);
3791 ✗ GstElement* dkms = gst_bin_get_by_name(GST_BIN(m_pipeline), name.c_str());
3792 ✗ if (dkms) {
3793 GstState st, pend;
3794 ✗ gst_element_get_state(dkms, &st, &pend, 0);
3795 ✗ GstStructure* stats = nullptr;
3796 ✗ g_object_get(dkms, "stats", &stats, NULL);
3797 ✗ if (stats) {
3798 ✗ guint64 rendered = 0, dropped = 0;
3799 ✗ gst_structure_get_uint64(stats, "rendered", &rendered);
3800 ✗ gst_structure_get_uint64(stats, "dropped", &dropped);
3801 ✗ LogDebug(VB_MEDIAOUT, "GStreamer: %s rendered=%" G_GUINT64_FORMAT " dropped=%" G_GUINT64_FORMAT " state=%d pending=%d\n",
3802 name.c_str(), rendered, dropped, st, pend);
3803 ✗ gst_structure_free(stats);
3804 }
3805 ✗ gst_object_unref(dkms);
3806 }
3807 ✗ }
3808 }
3809 }
3810
3811
2/2
✓ Branch 0 taken 163846 times.
✓ Branch 1 taken 5298 times.
169144 if (havePos) {
3812 // Track the maximum observed duration — VBR MP3 files can report
3813 // fluctuating durations as GStreamer revises its estimate during
3814 // decoding. Using the max prevents time-remaining from jumping
3815 // backwards or going negative.
3816
4/4
✓ Branch 0 taken 3063 times.
✓ Branch 1 taken 160783 times.
✓ Branch 2 taken 81 times.
✓ Branch 3 taken 2982 times.
163846 if (haveDur && dur > m_maxDuration) {
3817 81 m_maxDuration = dur;
3818 }
3819 163846 gint64 effectiveDur = m_maxDuration;
3820
3821 163846 float elapsed = (float)pos / GST_SECOND;
3822
2/2
✓ Branch 0 taken 163416 times.
✓ Branch 1 taken 430 times.
163846 float remaining = (effectiveDur > pos) ? (float)(effectiveDur - pos) / GST_SECOND : 0.0f;
3823
3824 // Back the position up to what is actually leaving the DAC, so the
3825 // sequence servo and the MultiSync packets both follow the sound
3826 // rather than the graph's write pointer. See AudioSinkLatencyNs().
3827
2/2
✓ Branch 0 taken 161600 times.
✓ Branch 1 taken 2246 times.
163846 if (posFromAudioSink) {
3828 // Feeding the persistent stream adds nothing to this: the feed
3829 // hands each buffer over a graph cycle early (FeedLeadNs()),
3830 // which is the cycle the stream takes to pass it to the graph.
3831 // Adding that cycle as well put the lights a quantum (21 ms)
3832 // behind the sound on a Pi 5 once media sync stopped hiding
3833 // anything under a frame.
3834
1/2
✓ Branch 0 taken 161600 times.
✗ Branch 1 not taken.
161600 int64_t latNs = AudioSinkLatencyNs();
3835
2/2
✓ Branch 0 taken 158668 times.
✓ Branch 1 taken 2932 times.
161600 if (m_pwFeed) {
3836
1/2
✓ Branch 0 taken 158668 times.
✗ Branch 1 not taken.
158668 AlignPictureToSound(latNs);
3837 }
3838 161600 float latSec = (float)latNs / 1000000000.0f;
3839
2/2
✓ Branch 0 taken 128710 times.
✓ Branch 1 taken 32890 times.
161600 if (latSec > 0.0f) {
3840 128710 elapsed -= latSec;
3841
2/2
✓ Branch 0 taken 469 times.
✓ Branch 1 taken 128241 times.
128710 if (elapsed < 0.0f) {
3842 469 elapsed = 0.0f;
3843 }
3844 128710 remaining += latSec;
3845
2/2
✓ Branch 0 taken 42 times.
✓ Branch 1 taken 128668 times.
128710 if (!m_loggedSinkLatency) {
3846 42 m_loggedSinkLatency = true;
3847
1/2
✓ Branch 0 taken 42 times.
✗ Branch 1 not taken.
42 LogDebug(VB_MEDIAOUT, "GStreamer: correcting reported position by %.1f ms of audio sink latency\n",
3848 latSec * 1000.0f);
3849 }
3850 }
3851 }
3852
2/2
✓ Branch 0 taken 161189 times.
✓ Branch 1 taken 2657 times.
163846 if (elapsed > 0.0f) {
3853 161189 m_audible = true;
3854 }
3855
1/2
✓ Branch 0 taken 163846 times.
✗ Branch 1 not taken.
163846 setMediaElapsed(elapsed, remaining);
3856
3857 // Only the primary slot represents the show's synced position;
3858 // companion streams (slot > 1, e.g. extraMedia) play locally on
3859 // each device and must not broadcast themselves as the master.
3860
8/8
✓ Branch 0 taken 160055 times.
✓ Branch 1 taken 3791 times.
✓ Branch 2 taken 152444 times.
✓ Branch 3 taken 7611 times.
✓ Branch 4 taken 2393 times.
✓ Branch 5 taken 150051 times.
✓ Branch 6 taken 2393 times.
✓ Branch 7 taken 161453 times.
163846 if (m_streamSlot == 1 && m_drivesShowClock && multiSync->isMultiSyncEnabled()) {
3861
1/2
✓ Branch 0 taken 2393 times.
✗ Branch 1 not taken.
2393 multiSync->SendMediaSyncPacket(m_mediaFilename, m_mediaOutputStatus->mediaSeconds);
3862 }
3863 // Only the show's own media paces the sequence. Media started
3864 // beside it (Play Media on another slot, a companion) followed the
3865 // same servo, so the sequence slewed toward whichever clock
3866 // reported last.
3867
2/2
✓ Branch 0 taken 152444 times.
✓ Branch 1 taken 11402 times.
163846 if (m_drivesShowClock) {
3868 // The position is in card time when it comes from the output
3869 // stream; frames go out at the same pace.
3870
2/2
✓ Branch 0 taken 150747 times.
✓ Branch 1 taken 1697 times.
152444 if (m_pwStream) {
3871
1/2
✓ Branch 0 taken 150747 times.
✗ Branch 1 not taken.
150747 SetMediaClockRate(m_pwStream->ClockRate());
3872 }
3873
1/2
✓ Branch 0 taken 152444 times.
✗ Branch 1 not taken.
152444 CalculateNewChannelOutputDelay(m_mediaOutputStatus->mediaSeconds);
3874 }
3875
3876 // Always update total duration — it may be refined for VBR media
3877
2/2
✓ Branch 0 taken 163450 times.
✓ Branch 1 taken 396 times.
163846 if (effectiveDur > 0) {
3878 163450 int totalSecs = (int)(effectiveDur / GST_SECOND);
3879 163450 int newMin = totalSecs / 60;
3880 163450 int newSec = totalSecs % 60;
3881
1/2
✓ Branch 0 taken 163450 times.
✗ Branch 1 not taken.
163450 std::lock_guard<std::mutex> lock(MediaOutputStatus::FieldsLock());
3882
1/2
✓ Branch 0 taken 163450 times.
✗ Branch 1 not taken.
163450 if (newMin != m_mediaOutputStatus->minutesTotal ||
3883
2/2
✓ Branch 0 taken 71 times.
✓ Branch 1 taken 163379 times.
163450 newSec != m_mediaOutputStatus->secondsTotal) {
3884 71 m_mediaOutputStatus->minutesTotal = newMin;
3885 71 m_mediaOutputStatus->secondsTotal = newSec;
3886
1/2
✓ Branch 0 taken 71 times.
✗ Branch 1 not taken.
71 LogDebug(VB_MEDIAOUT, "GStreamer duration: %d:%02d\n", newMin, newSec);
3887 }
3888 163450 }
3889
3890 // Stall watchdog: detect when PipeWire stops consuming data
3891 // (e.g. HDMI sink unplugged causing combine-stream to block)
3892 // Skip watchdog near end of media — position naturally stops advancing
3893
4/4
✓ Branch 0 taken 163450 times.
✓ Branch 1 taken 396 times.
✓ Branch 2 taken 8647 times.
✓ Branch 3 taken 154803 times.
163846 bool nearEnd = (effectiveDur > 0 && (effectiveDur - pos) < GST_SECOND);
3894
3895
2/2
✓ Branch 0 taken 161909 times.
✓ Branch 1 taken 1937 times.
163846 if (pos != m_lastPosition) {
3896 161909 m_lastPosition = pos;
3897 161909 m_stallStartMs = 0; // position advancing, clear stall timer
3898
2/2
✓ Branch 0 taken 215 times.
✓ Branch 1 taken 1722 times.
1937 } else if (nearEnd) {
3899 // Position at/near end of media — this is natural completion, not a stall.
3900 // Wait for EOS from the pipeline, or handle it ourselves after a grace period.
3901
1/2
✓ Branch 0 taken 215 times.
✗ Branch 1 not taken.
215 uint64_t now = GetMonotonicTimeMS();
3902
2/2
✓ Branch 0 taken 62 times.
✓ Branch 1 taken 153 times.
215 if (m_stallStartMs == 0) {
3903 62 m_stallStartMs = now;
3904
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 153 times.
153 } else if ((now - m_stallStartMs) > (STALL_TIMEOUT_MS * 2)) {
3905 // EOS should have arrived by now but didn't — force end
3906 ✗ LogInfo(VB_MEDIAOUT, "GStreamer: media reached end (%.1fs/%.1fs), forcing stop\n",
3907 elapsed, (float)effectiveDur / GST_SECOND);
3908 ✗ m_playing = false;
3909 ✗ if (m_mediaOutputStatus) {
3910 ✗ m_mediaOutputStatus->status = MEDIAOUTPUTSTATUS_IDLE;
3911 }
3912 ✗ Stopping();
3913 ✗ Stopped();
3914 6 return 0;
3915 }
3916 } else {
3917 // Position unchanged — start or continue stall timer
3918
1/2
✓ Branch 0 taken 1722 times.
✗ Branch 1 not taken.
1722 uint64_t now = GetMonotonicTimeMS();
3919
2/2
✓ Branch 0 taken 207 times.
✓ Branch 1 taken 1515 times.
1722 if (m_stallStartMs == 0) {
3920 207 m_stallStartMs = now;
3921
1/2
✓ Branch 0 taken 207 times.
✗ Branch 1 not taken.
207 LogDebug(VB_MEDIAOUT, "GStreamer: position stalled at %.1fs, starting watchdog\n", elapsed);
3922
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1515 times.
1515 } else if ((now - m_stallStartMs) > STALL_TIMEOUT_MS) {
3923 ✗ LogWarn(VB_MEDIAOUT, "GStreamer pipeline stalled for %dms at position %.1fs — "
3924 "audio sink may be blocked (HDMI unplugged?). Stopping playback.\n",
3925 STALL_TIMEOUT_MS, elapsed);
3926 // The cause, when it showed itself, already has its banner.
3927 ✗ if (!m_reportedFailure.exchange(true, std::memory_order_acq_rel)) {
3928 ✗ WarningHolder::AddWarningTimeout(MEDIA_FAILURE_WARNING_SECONDS, 30,
3929 ✗ "Playback of " + m_mediaFilename + " stalled and was stopped");
3930 }
3931 ✗ m_playing = false;
3932 ✗ if (m_mediaOutputStatus) {
3933 ✗ m_mediaOutputStatus->status = MEDIAOUTPUTSTATUS_IDLE;
3934 }
3935 ✗ Stopping();
3936 ✗ Stopped();
3937 ✗ return 0;
3938 }
3939 }
3940 } else {
3941 // Preroll watchdog: position query keeps failing because the
3942 // pipeline never reached PLAYING. Normal startup clears this within
3943 // a fraction of a second (m_wallStartMs gets set on the first valid
3944 // position). If we're still here after PREROLL_TIMEOUT_MS the
3945 // pipeline is wedged in preroll — e.g. the V4L2 HW decoder stopped
3946 // producing frames after a long video-only loop (issue #2695) so the
3947 // video sink never prerolls and the whole pipeline stays PAUSED.
3948 // The regular stall watchdog above can't catch this (it lives inside
3949 // the havePos branch), so without this check m_playing stays true
3950 // forever, the playlist never advances, and only a reboot recovers.
3951 // Force a stop so FinishPlay()/CloseMediaOutput() tears the pipeline
3952 // down and the playlist can move on to the next item.
3953
5/6
✓ Branch 0 taken 5296 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 5296 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 6 times.
✓ Branch 5 taken 5292 times.
10594 if (m_wallStartMs == 0 && m_playStartMs > 0 &&
3954
3/4
✓ Branch 0 taken 5296 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✓ Branch 3 taken 5290 times.
5296 (GetMonotonicTimeMS() - m_playStartMs) > PREROLL_TIMEOUT_MS) {
3955
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 LogWarn(VB_MEDIAOUT, "GStreamer pipeline failed to preroll within %dms "
3956 "(stuck in PAUSED — HW decoder may have wedged, see issue #2695). "
3957 "Forcing stop so the playlist advances.\n", PREROLL_TIMEOUT_MS);
3958
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 bool hwDecoder = PipelineHasHwVideoDecoder(m_pipeline);
3959
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 RecordWedgeEventAndMaybeRestart("pipeline failed to preroll", hwDecoder);
3960 // Stopping cancels the start thread's wait for PLAYING, which
3961 // was the only place this reached the UI: tell the user here.
3962 // A wedged hardware decoder has its own restart and reboot
3963 // rungs above.
3964
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 2 times.
6 if (!hwDecoder) {
3965
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.
16 WarningHolder::AddWarningTimeout(60, 30, kMediaDidNotStartWarning);
3966 }
3967 6 m_playing = false;
3968
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 if (m_mediaOutputStatus) {
3969 6 m_mediaOutputStatus->status = MEDIAOUTPUTSTATUS_IDLE;
3970 }
3971
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 Stopping();
3972
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 Stopped();
3973 6 return 0;
3974 }
3975
1/2
✓ Branch 0 taken 5292 times.
✗ Branch 1 not taken.
5292 LogExcess(VB_MEDIAOUT, "GStreamer position query pending (pipeline not yet PLAYING)\n");
3976 }
3977 }
3978
3979
2/2
✓ Branch 0 taken 169138 times.
✓ Branch 1 taken 57 times.
169195 return m_playing ? 1 : 0;
3980 169310 }
3981
3982 169201 void GStreamerOutput::ProcessMessages() {
3983
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 169201 times.
169201 if (!m_bus)
3984 ✗ return;
3985
3986 GstMessage* msg;
3987
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 169201 times.
169201 while ((msg = gst_bus_pop(m_bus)) != nullptr) {
3988 ✗ switch (GST_MESSAGE_TYPE(msg)) {
3989 ✗ case GST_MESSAGE_EOS:
3990 ✗ LogDebug(VB_MEDIAOUT, "GStreamer: End of stream\n");
3991 ✗ if (m_loopCount > 0 || m_loopCount == -1) {
3992 // Loop: seek back to beginning
3993 ✗ if (m_loopCount > 0)
3994 ✗ m_loopCount--;
3995 ✗ gst_element_seek_simple(m_pipeline, GST_FORMAT_TIME,
3996 (GstSeekFlags)(GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_KEY_UNIT),
3997 0);
3998 ✗ LogDebug(VB_MEDIAOUT, "GStreamer: Looping (remaining: %d)\n", m_loopCount);
3999 } else {
4000 ✗ m_playing = false;
4001 ✗ if (m_mediaOutputStatus) {
4002 ✗ m_mediaOutputStatus->status = MEDIAOUTPUTSTATUS_IDLE;
4003 }
4004 ✗ Stopping();
4005 ✗ Stopped();
4006 }
4007 ✗ break;
4008
4009 ✗ case GST_MESSAGE_ERROR: {
4010 GError* err;
4011 gchar* debug;
4012 ✗ gst_message_parse_error(msg, &err, &debug);
4013 ✗ LogErr(VB_MEDIAOUT, "GStreamer error: %s\n", err->message);
4014 ✗ LogDebug(VB_MEDIAOUT, "GStreamer debug: %s\n", debug ? debug : "(none)");
4015 ✗ g_error_free(err);
4016 ✗ g_free(debug);
4017 ✗ m_playing = false;
4018 ✗ if (m_mediaOutputStatus) {
4019 ✗ m_mediaOutputStatus->status = MEDIAOUTPUTSTATUS_IDLE;
4020 }
4021 ✗ Stopping();
4022 ✗ Stopped();
4023 ✗ break;
4024 }
4025
4026 ✗ case GST_MESSAGE_STATE_CHANGED: {
4027 ✗ if (GST_MESSAGE_SRC(msg) == GST_OBJECT(m_pipeline)) {
4028 GstState oldState, newState, pending;
4029 ✗ gst_message_parse_state_changed(msg, &oldState, &newState, &pending);
4030 ✗ LogDebug(VB_MEDIAOUT, "GStreamer state: %s -> %s\n",
4031 gst_element_state_get_name(oldState),
4032 gst_element_state_get_name(newState));
4033 ✗ if (newState == GST_STATE_PLAYING) {
4034 ✗ Playing();
4035 }
4036 }
4037 ✗ break;
4038 }
4039
4040 ✗ default:
4041 ✗ break;
4042 }
4043 ✗ gst_message_unref(msg);
4044 }
4045 }
4046
4047 // What a playback error says is wrong with the file, or nullptr when it isn't
4048 // the file's fault. STREAM_ERROR_FAILED ("Internal data stream error") is left
4049 // out: a demuxer posts it when anything downstream fails, a sink included.
4050 9 static const char* MediaFileFault(GstMessage* msg, const GError* err, bool audioOnly) {
4051
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 GstElement* src = GST_IS_ELEMENT(GST_MESSAGE_SRC(msg)) ? GST_ELEMENT(GST_MESSAGE_SRC(msg)) : nullptr;
4052
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 GstElementFactory* f = src ? gst_element_get_factory(src) : nullptr;
4053
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 const char* factory = f ? GST_OBJECT_NAME(f) : "";
4054
2/2
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 4 times.
9 if (err->domain == GST_STREAM_ERROR) {
4055
2/4
✗ Branch 0 not taken.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✓ Branch 3 taken 2 times.
5 switch (err->code) {
4056 ✗ case GST_STREAM_ERROR_CODEC_NOT_FOUND:
4057 ✗ return "it uses a format this device can't decode";
4058 ✗ case GST_STREAM_ERROR_DECODE: {
4059 // A hardware decoder's own fault (issue #2695) is not the file's.
4060 ✗ const char* klass = f ? gst_element_factory_get_metadata(f, GST_ELEMENT_METADATA_KLASS) : nullptr;
4061 ✗ if (strncmp(factory, "v4l2", 4) == 0 || (klass && strstr(klass, "Hardware"))) {
4062 ✗ return nullptr;
4063 }
4064 ✗ return "the file looks damaged or is not a media file";
4065 }
4066 3 case GST_STREAM_ERROR_TYPE_NOT_FOUND:
4067 case GST_STREAM_ERROR_WRONG_TYPE:
4068 case GST_STREAM_ERROR_DEMUX:
4069 case GST_STREAM_ERROR_FORMAT:
4070 3 return "the file looks damaged or is not a media file";
4071 2 default:
4072 2 return nullptr;
4073 }
4074 }
4075 // The audio-only pipeline's missing plug-in is the no-audio case, not this.
4076
6/8
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 1 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 1 times.
✗ Branch 6 not taken.
✓ Branch 7 taken 4 times.
4 if (err->domain == GST_CORE_ERROR && err->code == GST_CORE_ERROR_MISSING_PLUGIN && !audioOnly) {
4077 ✗ return "it uses a format this device can't decode";
4078 }
4079
4/6
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 2 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 4 times.
4 if (err->domain == GST_RESOURCE_ERROR && strcmp(factory, "filesrc") == 0) {
4080 ✗ return "the file could not be read";
4081 }
4082 4 return nullptr;
4083 }
4084
4085 // A sink's message is often just "general resource error"; the reason is the
4086 // last line of its debug text ("drmModeSetPlane failed: No space left on device (-28)").
4087 3 static std::string ErrorReason(const GError* err, const gchar* debug) {
4088
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 std::string d = debug ? debug : "";
4089
3/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 taken 3 times.
3 while (!d.empty() && isspace((unsigned char)d.back())) {
4090 ✗ d.pop_back();
4091 }
4092 3 size_t nl = d.find_last_of('\n');
4093
5/6
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 1 times.
3 if (nl != std::string::npos && nl + 1 < d.size()) {
4094
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 return d.substr(nl + 1);
4095 }
4096
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
2 return err->message ? err->message : "unknown error";
4097 3 }
4098
4099 3 void GStreamerOutput::ReportFileFailure(const char* what, const std::string& reason) {
4100
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 2 times.
3 if (m_reportedFailure.exchange(true, std::memory_order_acq_rel)) {
4101 1 return;
4102 }
4103
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
8 WarningHolder::AddWarningTimeout(MEDIA_FAILURE_WARNING_SECONDS, 30,
4104
6/12
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 2 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 2 times.
✗ Branch 11 not taken.
4 "Can't play " + m_mediaFilename + ": " + what + " (" + reason + ")");
4105 }
4106
4107 // Keyed on the display and the reason, not the file, so a playlist of videos
4108 // failing the same way shares one banner.
4109 3 void GStreamerOutput::ReportVideoOutputFailure(const char* sinkName, const std::string& reason) {
4110
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 2 times.
3 if (m_reportedFailure.exchange(true, std::memory_order_acq_rel)) {
4111 1 return;
4112 }
4113 2 std::string display;
4114 #ifdef PLATFORM_OSX
4115 display = "the video window";
4116 #else
4117
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 if (strncmp(sinkName, "dkms_", 5) == 0) {
4118 2 int connId = atoi(sinkName + 5);
4119
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 auto it = m_dkmsConnectorNames.find(connId);
4120
3/8
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 2 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✗ Branch 7 not taken.
6 display = (it != m_dkmsConnectorNames.end() && !it->second.empty()) ? it->second
4121
1/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
6 : "display connector " + std::to_string(connId);
4122 } else {
4123 ✗ display = m_videoOut;
4124 ✗ if (display == "--HDMI--" || display == "--hdmi--" || display == "HDMI") {
4125 ✗ display = "HDMI-A-1";
4126 }
4127 }
4128 #endif
4129
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
6 WarningHolder::AddWarningTimeout(MEDIA_FAILURE_WARNING_SECONDS, 31,
4130
4/8
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✗ Branch 7 not taken.
4 "Video output to " + display + " failed (" + reason + ")");
4131 2 }
4132
4133 97860 GstBusSyncReply GStreamerOutput::BusSyncHandler(GstBus* bus, GstMessage* msg, gpointer userData) {
4134 // Everything this handler doesn't act on is dropped without touching the
4135 // output. Passing it on queued it on the bus for Process() to pop, and
4136 // nothing calls Process() for Play Media with no playlist running: every
4137 // tag, QoS, state-change and stream-status message piled up there for the
4138 // whole of the media, a looping video's indefinitely. Of the state
4139 // changes, only the pipeline's own matter -- it is the one source on its
4140 // bus with no parent.
4141
3/3
✓ Branch 0 taken 178 times.
✓ Branch 1 taken 69132 times.
✓ Branch 2 taken 28550 times.
97860 switch (GST_MESSAGE_TYPE(msg)) {
4142 178 case GST_MESSAGE_EOS:
4143 case GST_MESSAGE_ERROR:
4144 178 break;
4145 69132 case GST_MESSAGE_STATE_CHANGED:
4146
3/4
✓ Branch 0 taken 69132 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 64982 times.
✓ Branch 3 taken 4150 times.
69132 if (!GST_MESSAGE_SRC(msg) || GST_OBJECT_PARENT(GST_MESSAGE_SRC(msg))) {
4147
1/2
✓ Branch 0 taken 64982 times.
✗ Branch 1 not taken.
64982 gst_message_unref(msg);
4148 64982 return GST_BUS_DROP;
4149 }
4150 4150 break;
4151 28550 default:
4152
1/2
✓ Branch 0 taken 28550 times.
✗ Branch 1 not taken.
28550 gst_message_unref(msg);
4153 28550 return GST_BUS_DROP;
4154 }
4155
4156 // An EOS or error marks the output idle below, and the playlist can then
4157 // destroy it from the main thread while this call is still running. The
4158 // scope holds Close() off until the call returns.
4159
1/2
✓ Branch 0 taken 4328 times.
✗ Branch 1 not taken.
8656 BusCallbackScope scope(userData);
4160 4328 GStreamerOutput* self = scope.self;
4161
2/2
✓ Branch 0 taken 50 times.
✓ Branch 1 taken 4278 times.
4328 if (!self)
4162 50 return GST_BUS_PASS;
4163
4164
3/4
✓ Branch 0 taken 169 times.
✓ Branch 1 taken 9 times.
✓ Branch 2 taken 4100 times.
✗ Branch 3 not taken.
4278 switch (GST_MESSAGE_TYPE(msg)) {
4165 169 case GST_MESSAGE_EOS:
4166
1/2
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
169 LogInfo(VB_MEDIAOUT, "GStreamer sync: End of stream\n");
4167
3/4
✓ Branch 0 taken 167 times.
✓ Branch 1 taken 2 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 167 times.
169 if (self->m_loopCount > 0 || self->m_loopCount == -1) {
4168
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 if (self->m_loopCount > 0)
4169 2 self->m_loopCount--;
4170
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 ScheduleLoopSeek(scope.guard);
4171
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogDebug(VB_MEDIAOUT, "GStreamer sync: Looping (remaining: %d)\n", self->m_loopCount);
4172 } else {
4173 // Detach AES67 inline branches so the standalone pipeline
4174 // resumes sending — EOS means no more data will flow through
4175 // the tee, so the inline branch is useless.
4176 #ifdef HAS_AES67_GSTREAMER
4177
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 self->DetachAES67Branches();
4178 #endif
4179 167 self->m_playing = false;
4180
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 if (self->m_mediaOutputStatus) {
4181 167 self->m_mediaOutputStatus->status = MEDIAOUTPUTSTATUS_IDLE;
4182 }
4183
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 self->Stopping();
4184
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 self->Stopped();
4185 }
4186
1/2
✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
169 gst_message_unref(msg);
4187 169 return GST_BUS_DROP;
4188
4189 9 case GST_MESSAGE_ERROR: {
4190 GError* err;
4191 gchar* debug;
4192
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 gst_message_parse_error(msg, &err, &debug);
4193
4194 // Identify which element produced the error
4195
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 const gchar* srcName = GST_MESSAGE_SRC(msg) ?
4196 9 GST_OBJECT_NAME(GST_MESSAGE_SRC(msg)) : "unknown";
4197
4198 // AES67 inline RTP branch elements are named "aes67_*".
4199 // Errors from these branches (e.g. network issues, PipeWire
4200 // disconnects) should NOT stop media playback.
4201 // Video PipeWire sink errors ("pwvideosink") are always non-fatal
4202 // because kmssink is the primary video output — the pipewiresink
4203 // is a secondary tee branch for PipeWire graph visibility only.
4204 9 bool isAES67Branch = (strncmp(srcName, "aes67_", 6) == 0);
4205 9 bool isVideoPWSink = (strcmp(srcName, "pwvideosink") == 0);
4206 // Direct kmssink branches (dkms_*) may fail if a display is
4207 // disconnected during playback — treat as non-fatal.
4208 9 bool isDirectKmsSink = (strncmp(srcName, "dkms_", 5) == 0);
4209
4210 // A file with nothing decodable to feed the audio-only pipeline fails
4211 // here, and the raw GStreamer wording is actively misleading: decodebin
4212 // reports its autoplug failure as "your installation is missing a
4213 // plug-in" (nothing is missing -- there is simply no audio to plug), and
4214 // the demuxer then follows with a generic "Internal data stream error".
4215 // A video-only clip played on an item whose video output is disabled or
4216 // whose display is unplugged lands exactly here, so say what actually
4217 // happened and what to do about it. Everything else keeps the raw
4218 // message -- this only claims the case it can prove, which is that
4219 // decodebin never produced an audio pad.
4220 // "No audio pad yet" is not on its own evidence that the file has no
4221 // audio: anything that fails before decodebin gets that far leaves the
4222 // flag clear too. Stopping PipeWire under a running fppd and playing a
4223 // plain WAV does exactly that -- pwsink errors first, and blaming the
4224 // file for an audio-backend outage is a worse lie than the one being
4225 // fixed. So the source has to be the decode chain as well.
4226 9 bool errorFromDecoder = false;
4227
2/2
✓ Branch 0 taken 17 times.
✓ Branch 1 taken 3 times.
20 for (GstObject* o = GST_MESSAGE_SRC(msg); o; o = GST_OBJECT_PARENT(o)) {
4228 17 const gchar* n = GST_OBJECT_NAME(o);
4229
3/4
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✓ Branch 3 taken 11 times.
17 if (n && strcmp(n, "decoder") == 0) {
4230 6 errorFromDecoder = true;
4231 6 break;
4232 }
4233 }
4234 // A damaged file is not one with no audio, nor is the follow-on error after it.
4235
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 const char* fileFault = MediaFileFault(msg, err, self->m_audioOnlyPipeline);
4236
3/4
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✓ Branch 3 taken 3 times.
6 if (self->m_audioOnlyPipeline && errorFromDecoder && !fileFault &&
4237
6/6
✓ Branch 0 taken 6 times.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 7 times.
17 !self->m_reportedFailure.load(std::memory_order_acquire) &&
4238
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 !self->m_sawAudioPad.load(std::memory_order_acquire)) {
4239
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 if (!self->m_reportedNoAudio.exchange(true, std::memory_order_acq_rel)) {
4240
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogErr(VB_MEDIAOUT,
4241 "GStreamer: '%s' has no playable audio stream, and no video output is "
4242 "configured for it, so there is nothing to play. If this is a video-only "
4243 "file, set the item's Video Output to a connected display; FPP falls back to "
4244 "audio-only whenever the chosen display is disabled or unplugged.\n",
4245 self->m_mediaFilename.c_str());
4246
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
3 WarningHolder::AddWarningTimeout(60, 30,
4247
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
2 "No playable audio in " + self->m_mediaFilename +
4248
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 " and no video output — nothing to play");
4249
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogDebug(VB_MEDIAOUT, "GStreamer no-audio underlying error (src=%s): %s\n",
4250 srcName, err->message);
4251 #ifdef HAS_AES67_GSTREAMER
4252
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 self->DetachAES67Branches();
4253 #endif
4254 1 self->m_playing = false;
4255
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (self->m_mediaOutputStatus) {
4256 1 self->m_mediaOutputStatus->status = MEDIAOUTPUTSTATUS_IDLE;
4257 }
4258
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 self->Stopping();
4259
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 self->Stopped();
4260 } else {
4261 // The demuxer's follow-on error describes the same event; logging
4262 // it again reads as a second, unrelated failure.
4263
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogDebug(VB_MEDIAOUT, "GStreamer no-audio follow-on error (src=%s): %s\n",
4264 srcName, err->message);
4265 }
4266
4/6
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✓ Branch 5 taken 4 times.
7 } else if (isAES67Branch || isVideoPWSink || isDirectKmsSink) {
4267
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 std::string reason = ErrorReason(err, debug);
4268
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 LogWarn(VB_MEDIAOUT, "GStreamer non-fatal error (src=%s) playing %s: %s\n",
4269 srcName, self->m_mediaFilename.c_str(), reason.c_str());
4270
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 if (isDirectKmsSink) {
4271
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 self->ReportVideoOutputFailure(srcName, reason);
4272 }
4273 // Logging it as non-fatal isn't enough: the sink is about to
4274 // return GST_FLOW_ERROR, which travels back through its queue to
4275 // the tee and the demuxer, and that ends the media. This runs on
4276 // the sink's streaming thread before it returns, so cutting the
4277 // branch off at the tee here gets in ahead of that.
4278
9/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 not taken.
✓ Branch 7 taken 3 times.
✓ Branch 8 taken 3 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✓ Branch 11 taken 3 times.
✓ Branch 12 taken 3 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 3 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 3 times.
✗ Branch 17 not taken.
3 if ((isVideoPWSink || isDirectKmsSink) && GST_IS_ELEMENT(GST_MESSAGE_SRC(msg))) {
4279
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.
3 self->CutFailedBranch(GST_ELEMENT(GST_MESSAGE_SRC(msg)));
4280 }
4281
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 LogDebug(VB_MEDIAOUT, "GStreamer AES67 branch debug: %s\n",
4282 debug ? debug : "(none)");
4283 3 } else {
4284 4 bool primaryVideoSink = strcmp(srcName, "kmsvideosink") == 0;
4285
4/6
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 3 times.
4 std::string reason = (primaryVideoSink || (fileFault && err->domain == GST_CORE_ERROR))
4286 4 ? ErrorReason(err, debug)
4287
4/12
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
8 : std::string(err->message ? err->message : "unknown error");
4288
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 LogErr(VB_MEDIAOUT, "GStreamer sync error (src=%s): %s\n", srcName, reason.c_str());
4289
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 LogDebug(VB_MEDIAOUT, "GStreamer sync debug: %s\n", debug ? debug : "(none)");
4290
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 1 times.
4 if (fileFault) {
4291
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 self->ReportFileFailure(fileFault, reason);
4292
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 } else if (primaryVideoSink) {
4293 ✗ self->ReportVideoOutputFailure(srcName, reason);
4294 }
4295 #ifdef HAS_AES67_GSTREAMER
4296
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 self->DetachAES67Branches();
4297 #endif
4298 4 self->m_playing = false;
4299
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 if (self->m_mediaOutputStatus) {
4300 4 self->m_mediaOutputStatus->status = MEDIAOUTPUTSTATUS_IDLE;
4301 }
4302
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 self->Stopping();
4303
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 self->Stopped();
4304 4 }
4305
4306
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 g_error_free(err);
4307
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 g_free(debug);
4308
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 gst_message_unref(msg);
4309 9 return GST_BUS_DROP;
4310 }
4311
4312 4100 case GST_MESSAGE_STATE_CHANGED: {
4313
3/6
✓ Branch 0 taken 4100 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4100 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4100 times.
✗ Branch 5 not taken.
4100 if (GST_MESSAGE_SRC(msg) == GST_OBJECT(self->m_pipeline)) {
4314 GstState oldState, newState, pending;
4315
1/2
✓ Branch 0 taken 4100 times.
✗ Branch 1 not taken.
4100 gst_message_parse_state_changed(msg, &oldState, &newState, &pending);
4316
3/6
✓ Branch 0 taken 4100 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4100 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4100 times.
✗ Branch 5 not taken.
4100 LogDebug(VB_MEDIAOUT, "GStreamer sync state: %s -> %s\n",
4317 gst_element_state_get_name(oldState),
4318 gst_element_state_get_name(newState));
4319
2/2
✓ Branch 0 taken 413 times.
✓ Branch 1 taken 3687 times.
4100 if (newState == GST_STATE_PLAYING) {
4320
1/2
✓ Branch 0 taken 413 times.
✗ Branch 1 not taken.
413 self->Playing();
4321 }
4322 }
4323
1/2
✓ Branch 0 taken 4100 times.
✗ Branch 1 not taken.
4100 gst_message_unref(msg);
4324 4100 return GST_BUS_DROP;
4325 }
4326
4327 ✗ default:
4328 ✗ gst_message_unref(msg);
4329 ✗ return GST_BUS_DROP;
4330 }
4331 }
4332
4333 // Read a single "<Key>: <N> kB" line from /proc/meminfo. Returns -1 if the
4334 // key isn't present. Small and self-contained on purpose — mirrors the
4335 // CmaFree check VideoOutputManager::StartConsumer does before launching an
4336 // HDMI consumer, generalized to whichever key is asked for.
4337 7396 static long ReadMeminfoKB(const char* key) {
4338
1/2
✓ Branch 0 taken 7396 times.
✗ Branch 1 not taken.
7396 FILE* f = fopen("/proc/meminfo", "r");
4339
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 7396 times.
7396 if (!f)
4340 ✗ return -1;
4341 char line[256];
4342 7396 long kb = -1;
4343 7396 size_t keyLen = strlen(key);
4344
2/4
✓ Branch 0 taken 343544 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 343544 times.
✗ Branch 3 not taken.
343544 while (fgets(line, sizeof(line), f)) {
4345
3/4
✓ Branch 0 taken 7396 times.
✓ Branch 1 taken 336148 times.
✓ Branch 2 taken 7396 times.
✗ Branch 3 not taken.
343544 if (strncmp(line, key, keyLen) == 0 && line[keyLen] == ':') {
4346 7396 sscanf(line + keyLen + 1, "%ld", &kb);
4347 7396 break;
4348 }
4349 }
4350
1/2
✓ Branch 0 taken 7396 times.
✗ Branch 1 not taken.
7396 fclose(f);
4351 7396 return kb;
4352 }
4353
4354 3698 int GStreamerOutput::Close(void) {
4355
1/2
✓ Branch 0 taken 3698 times.
✗ Branch 1 not taken.
3698 LogDebug(VB_MEDIAOUT, "GStreamerOutput::Close()\n");
4356
4357 // Leave the slot table before anything is torn down. The slot manager
4358 // calls Process(), SeekTo() and SetVolume() through its pointer under its
4359 // own lock, so this waits out any such call and makes it the last: from
4360 // here on nothing can reach this output that way while it is dismantled
4361 // or freed. The slot itself (status, KMS gate) is cleared at the end.
4362
2/4
✓ Branch 0 taken 3698 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3698 times.
✗ Branch 3 not taken.
3698 StreamSlotManager::Instance().DetachOutput(m_streamSlot, this);
4363
4364
1/2
✓ Branch 0 taken 3698 times.
✗ Branch 1 not taken.
3698 std::lock_guard<std::recursive_mutex> opLock(m_opMutex);
4365
1/2
✓ Branch 0 taken 3698 times.
✗ Branch 1 not taken.
3698 DetachCallbacks();
4366
4367
2/2
✓ Branch 0 taken 231 times.
✓ Branch 1 taken 3467 times.
3698 if (m_pipeline) {
4368 // Flush PipeWire filter-chain delay buffers: a delayed group
4369 // member's chain still holds the end of this track (see
4370 // FlushPipeWireDelayBuffers()). Detached, so Close() doesn't wait.
4371
1/2
✓ Branch 0 taken 231 times.
✗ Branch 1 not taken.
231 FlushPipeWireDelayBuffers();
4372
4373 // Flush AES67 send pipelines — drop a few buffers to discard
4374 // tail-end audio from the ending track. The Start()-time flush
4375 // is the primary protection; this Close() flush is supplementary
4376 // for cases where Close→Start is very fast (back-to-back tracks).
4377 #ifdef HAS_AES67_GSTREAMER
4378
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 228 times.
231 if (AES67Manager::INSTANCE.IsActive()) {
4379
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 AES67Manager::INSTANCE.FlushSendPipelines();
4380 }
4381 #endif
4382
4383 // Stop() handles shutdown flag, appsink/bus cleanup, AES67 detach,
4384 // and the pipeline state change.
4385
1/2
✓ Branch 0 taken 231 times.
✗ Branch 1 not taken.
231 Stop();
4386
4387 // Restore overlay model state if we enabled it
4388
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 229 times.
231 if (m_wasOverlayDisabled) {
4389
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 std::lock_guard<std::mutex> lock(m_videoOverlayModelLock);
4390
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 if (m_videoOverlayModel) {
4391
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 m_videoOverlayModel->setState(PixelOverlayState(PixelOverlayState::Disabled));
4392 }
4393 2 m_wasOverlayDisabled = false;
4394 2 }
4395
4396
2/2
✓ Branch 0 taken 41 times.
✓ Branch 1 taken 190 times.
231 if (m_appsink) {
4397
1/2
✓ Branch 0 taken 41 times.
✗ Branch 1 not taken.
41 gst_object_unref(m_appsink);
4398 41 m_appsink = nullptr;
4399 }
4400
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 229 times.
231 if (m_videoAppsink) {
4401
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_object_unref(m_videoAppsink);
4402 2 m_videoAppsink = nullptr;
4403 }
4404
2/2
✓ Branch 0 taken 41 times.
✓ Branch 1 taken 190 times.
231 if (m_volume) {
4405
1/2
✓ Branch 0 taken 41 times.
✗ Branch 1 not taken.
41 gst_object_unref(m_volume);
4406 41 m_volume = nullptr;
4407 }
4408
2/2
✓ Branch 0 taken 49 times.
✓ Branch 1 taken 182 times.
231 if (m_bus) {
4409
1/2
✓ Branch 0 taken 49 times.
✗ Branch 1 not taken.
49 gst_object_unref(m_bus);
4410 49 m_bus = nullptr;
4411 }
4412 // Stop()'s asynchronous teardown has already handed the pipeline off.
4413
2/2
✓ Branch 0 taken 49 times.
✓ Branch 1 taken 182 times.
231 if (m_pipeline) {
4414
1/2
✓ Branch 0 taken 49 times.
✗ Branch 1 not taken.
49 gst_object_unref(m_pipeline);
4415 49 m_pipeline = nullptr;
4416 }
4417 }
4418 // Branches cut off but not yet removed go down with the pipeline; only
4419 // our refs on them are left to drop, before the planes are returned below.
4420
1/2
✓ Branch 0 taken 3698 times.
✗ Branch 1 not taken.
3698 ReleaseFailedBranches();
4421
4422 // Hand back the DRM leases this pipeline's kmssinks ran on, which also
4423 // returns their planes. Must run after gst_object_unref(m_pipeline)
4424 // above: the kmssinks are destroyed by that unref, and closing a lease
4425 // under a live one is undefined. Closing it is also what frees any GEM
4426 // handles or framebuffers a kmssink failed to release. Runs outside the
4427 // `if (m_pipeline)` guard so a Start() that failed after taking a lease
4428 // still gives it back.
4429 //
4430 // EXCEPT after an abandoned (wedged) teardown: the pipeline and its
4431 // kmssinks are still alive on the reaper thread and still using these.
4432 // Leak them with the pipeline; the restart once teardowns pile up
4433 // (STUCK_TEARDOWN_RESTART_LIMIT) bounds the loss.
4434
2/2
✓ Branch 0 taken 3697 times.
✓ Branch 1 taken 1 times.
3698 if (!m_teardownAbandoned) {
4435
2/2
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 3697 times.
3702 for (DrmCards::Lease& lease : m_drmLeases) {
4436
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 DrmCards::ReleaseLease(lease);
4437 }
4438 // The connectors are free again: what stood aside for this video
4439 // (a camera group on the same display) comes back.
4440
2/2
✓ Branch 0 taken 9 times.
✓ Branch 1 taken 3697 times.
3706 for (int connectorId : m_mediaConnectors) {
4441
2/4
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
9 VideoOutputManager::Instance().ReleaseConnectorFromMedia(connectorId);
4442 }
4443 }
4444 3698 m_drmLeases.clear();
4445 3698 m_mediaConnectors.clear();
4446
4447 // Clean up video overlay state
4448
3/4
✓ Branch 0 taken 3696 times.
✓ Branch 1 taken 2 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 3696 times.
3698 if (m_videoFramesReceived > 0 || m_videoFramesDelivered > 0) {
4449
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogDebug(VB_MEDIAOUT, "GStreamer video overlay stats: %lu frames received, %lu delivered\n",
4450 (unsigned long)m_videoFramesReceived, (unsigned long)m_videoFramesDelivered);
4451 }
4452 3698 m_hasVideoStream = false;
4453 3698 m_videoFrameReady = false;
4454 3698 m_videoFramesReceived = 0;
4455 3698 m_videoFramesDelivered = 0;
4456 3698 m_audioChain = nullptr;
4457 3698 m_videoChain = nullptr;
4458 3698 m_kmssink = nullptr; // owned by pipeline bin, already freed
4459 3698 m_wantHDMI = false;
4460 #ifdef PLATFORM_OSX
4461 if (m_macVideoWindow) {
4462 // Deferred, and cancelled if the next item opens it again.
4463 MacOSHideVideoWindowSoon(m_streamSlot);
4464 }
4465 #endif
4466 3698 m_macVideoWindow = false;
4467
4468 // Consumers already stopped before pipeline NULL; just clear state
4469 3698 m_videoPipeWireRouting = false;
4470 3698 m_pwVideoSinkName.clear();
4471
4472 // Remove model listener
4473
7/8
✓ Branch 0 taken 216 times.
✓ Branch 1 taken 3482 times.
✓ Branch 2 taken 216 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 99 times.
✓ Branch 5 taken 117 times.
✓ Branch 6 taken 99 times.
✓ Branch 7 taken 3599 times.
3698 if (!m_videoOut.empty() && m_videoOut != "--Disabled--") {
4474
2/4
✓ Branch 0 taken 99 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 99 times.
✗ Branch 3 not taken.
99 PixelOverlayManager::INSTANCE.removeModelListener(m_videoOut, OverlayListenerId());
4475 }
4476 // Deregister before taking m_videoOverlayModelLock: ProcessVideoOverlay()
4477 // walks the registry and then takes that lock, so acquiring them in the
4478 // other order here would be a lock inversion.
4479 {
4480
1/2
✓ Branch 0 taken 3698 times.
✗ Branch 1 not taken.
3698 std::lock_guard<std::mutex> lock(m_overlayOutputsLock);
4481
2/4
✓ Branch 0 taken 3698 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3698 times.
✗ Branch 3 not taken.
3734 m_overlayOutputs.erase(std::remove(m_overlayOutputs.begin(), m_overlayOutputs.end(), this),
4482 3698 m_overlayOutputs.end());
4483 3698 }
4484 {
4485
1/2
✓ Branch 0 taken 3698 times.
✗ Branch 1 not taken.
3698 std::lock_guard<std::mutex> lock(m_videoOverlayModelLock);
4486 3698 m_videoOverlayModel = nullptr;
4487 3698 }
4488
4489 {
4490 // If we're closing the WLED tap owner, hand it to another active slot
4491 // (prefer slot 1). The candidate is found before taking the tap lock
4492 // so the slot manager's lock is never taken under it.
4493 3698 GStreamerOutput* next = nullptr;
4494
2/2
✓ Branch 0 taken 13062 times.
✓ Branch 1 taken 2035 times.
15097 for (int s = 1; s <= StreamSlotManager::MAX_SLOTS; s++) {
4495
2/2
✓ Branch 0 taken 2729 times.
✓ Branch 1 taken 10333 times.
13062 if (s == m_streamSlot) continue;
4496
2/4
✓ Branch 0 taken 10333 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10333 times.
✗ Branch 3 not taken.
10333 GStreamerOutput* other = StreamSlotManager::Instance().GetActiveOutput(s);
4497
7/8
✓ Branch 0 taken 2115 times.
✓ Branch 1 taken 8218 times.
✓ Branch 2 taken 2115 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1663 times.
✓ Branch 5 taken 452 times.
✓ Branch 6 taken 1663 times.
✓ Branch 7 taken 8670 times.
10333 if (other && other != this && other->m_playing) {
4498 1663 next = other;
4499 1663 break;
4500 }
4501 }
4502
1/2
✓ Branch 0 taken 3698 times.
✗ Branch 1 not taken.
3698 std::lock_guard<std::mutex> lock(s_sampleMutex);
4503
2/2
✓ Branch 0 taken 931 times.
✓ Branch 1 taken 2767 times.
3698 if (m_currentInstance == this) {
4504
1/2
✓ Branch 0 taken 931 times.
✗ Branch 1 not taken.
931 SetSampleTapOwnerLocked(next);
4505 }
4506 3698 }
4507
4508 // Deregister from StreamSlotManager (only if we still own the slot --
4509 // see ClearSlot()'s ownership check).
4510
2/4
✓ Branch 0 taken 3698 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3698 times.
✗ Branch 3 not taken.
3698 StreamSlotManager::Instance().ClearSlot(m_streamSlot, this);
4511
4512 // Per-track CMA trend line — logged unconditionally (not just when
4513 // m_pipeline was set) so 24h soak-test logs show CMA after every Close(),
4514 // including failed-Start cycles, letting a slow leak be correlated with
4515 // build/teardown count instead of only track count.
4516 {
4517
1/2
✓ Branch 0 taken 3698 times.
✗ Branch 1 not taken.
3698 long cmaFreeKB = ReadMeminfoKB("CmaFree");
4518
1/2
✓ Branch 0 taken 3698 times.
✗ Branch 1 not taken.
3698 long cmaTotalKB = ReadMeminfoKB("CmaTotal");
4519
1/2
✓ Branch 0 taken 3698 times.
✗ Branch 1 not taken.
3698 LogInfo(VB_MEDIAOUT, "GStreamer: post-close CmaFree=%ld kB / CmaTotal=%ld kB\n", cmaFreeKB, cmaTotalKB);
4520 }
4521
4522 3698 return 1;
4523 3698 }
4524
4525 145720 int GStreamerOutput::IsPlaying(void) {
4526
2/2
✓ Branch 0 taken 145647 times.
✓ Branch 1 taken 73 times.
145720 return m_playing ? 1 : 0;
4527 }
4528
4529 1110 int GStreamerOutput::AdjustSpeed(float masterMediaPosition) {
4530 1110 std::unique_lock<std::recursive_mutex> opLock(m_opMutex, std::try_to_lock);
4531
4/8
✓ Branch 0 taken 1110 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1110 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 1110 times.
✗ Branch 6 not taken.
✓ Branch 7 taken 1110 times.
1110 if (!opLock.owns_lock() || !m_pipeline || !m_allowSpeedAdjust)
4532 ✗ return 1;
4533
4534 // Nothing below may touch a pipeline that has never produced a position:
4535 // both branches that act on a large diff issue a *synchronous* seek, and a
4536 // seek into a pipeline still stuck in preroll blocks the calling thread
4537 // forever inside the wedged sink. This runs on the main loop (MultiSync
4538 // ProcessControlPacket -> UpdateMasterMediaPosition) while holding
4539 // mediaOutputLock, so that block takes the whole player down: no sync
4540 // packets, no status, no `fpp` commands, and "Restart FPPD" never even gets
4541 // read (issue #2727 -- confirmed 2026-07-29 on FPPv4-3, main thread parked
4542 // on a PipeWire mutex with the pipeline still ASYNC). Sitting out the
4543 // sync until preroll completes is free: the preroll watchdog in Process()
4544 // tears a genuinely wedged pipeline down and the playlist advances.
4545
2/2
✓ Branch 0 taken 11 times.
✓ Branch 1 taken 1099 times.
1110 if (m_wallStartMs == 0) {
4546
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 LogDebug(VB_MEDIAOUT, "GStreamer: skipping speed adjust, pipeline has not prerolled yet (master %0.3f)\n",
4547 masterMediaPosition);
4548 11 return 1;
4549 }
4550
4551 // Can't adjust speed if not playing yet
4552
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 1096 times.
1099 if (m_mediaOutputStatus->mediaSeconds < 0.01f) {
4553
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 LogDebug(VB_MEDIAOUT, "GStreamer: Can't adjust speed if not playing yet (%0.3f/%0.3f)\n",
4554 masterMediaPosition, m_mediaOutputStatus->mediaSeconds);
4555 3 return 1;
4556 }
4557
4/6
✓ Branch 0 taken 1061 times.
✓ Branch 1 taken 35 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 1061 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 1096 times.
1096 if (m_mediaOutputStatus->mediaSeconds > 1 && m_mediaOutputStatus->status == MEDIAOUTPUTSTATUS_IDLE) {
4558 ✗ LogDebug(VB_MEDIAOUT, "GStreamer: Can't adjust speed if beyond end of media (%0.3f/%0.3f)\n",
4559 masterMediaPosition, m_mediaOutputStatus->mediaSeconds);
4560 ✗ return 1;
4561 }
4562
4563
3/4
✓ Branch 0 taken 1009 times.
✓ Branch 1 taken 87 times.
✓ Branch 2 taken 1009 times.
✗ Branch 3 not taken.
1096 if (m_pwStream && m_pwFeed) {
4564
1/2
✓ Branch 0 taken 1009 times.
✗ Branch 1 not taken.
1009 return AdjustFeedSpeed(masterMediaPosition);
4565 }
4566
4567 87 float rate = m_currentRate;
4568
4569
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 86 times.
87 if (m_lastRates.empty()) {
4570 // Preload rate list with normal (1.0) rate
4571
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 m_lastRates.push_back(1.0f);
4572 1 m_lastRatesSum = 1.0f;
4573 }
4574
4575 87 int rawdiff = (int)(m_mediaOutputStatus->mediaSeconds * 1000) - (int)(masterMediaPosition * 1000);
4576 87 int diff = rawdiff;
4577 87 int sign = 1;
4578
1/2
✓ Branch 0 taken 87 times.
✗ Branch 1 not taken.
87 if (diff < 0) {
4579 87 sign = -1;
4580 87 diff = -diff;
4581 }
4582
4583
3/4
✓ Branch 0 taken 85 times.
✓ Branch 1 taken 2 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 85 times.
87 if ((m_mediaOutputStatus->mediaSeconds < 1) || (diff > 3000)) {
4584
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogDebug(VB_MEDIAOUT, "GStreamer Diff: %d\tMaster: %0.3f Local: %0.3f Rate: %0.3f\n",
4585 rawdiff, masterMediaPosition, m_mediaOutputStatus->mediaSeconds, m_currentRate);
4586 } else {
4587
1/2
✓ Branch 0 taken 85 times.
✗ Branch 1 not taken.
85 LogExcess(VB_MEDIAOUT, "GStreamer Diff: %d\tMaster: %0.3f Local: %0.3f Rate: %0.3f\n",
4588 rawdiff, masterMediaPosition, m_mediaOutputStatus->mediaSeconds, m_currentRate);
4589 }
4590
4591
1/2
✓ Branch 0 taken 87 times.
✗ Branch 1 not taken.
87 pushDiff(rawdiff, m_currentRate);
4592
4593 // Sign-flip detection: if diff sign flipped and we're not at normal speed,
4594 // reset to 1.0 to avoid oscillation
4595
1/2
✓ Branch 0 taken 87 times.
✗ Branch 1 not taken.
87 int oldSign = m_lastDiff < 0 ? -1 : 1;
4596
1/6
✗ Branch 0 not taken.
✓ Branch 1 taken 87 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
87 if ((oldSign != sign) && (m_lastDiff != 0) && (m_currentRate != 1.0f)) {
4597 ✗ LogDebug(VB_MEDIAOUT, "GStreamer Diff: %d\tFlipped, reset speed to normal\t(%0.3f)\n", rawdiff, 1.0f);
4598 ✗ ApplyRate(1.0f);
4599 // Reset rate average list to 1.0
4600 ✗ m_lastRates.clear();
4601 ✗ m_lastRates.push_back(1.0f);
4602 ✗ m_lastRatesSum = 1.0f;
4603 ✗ m_currentRate = 1.0f;
4604 ✗ m_rateDiff = 0;
4605 ✗ m_lastDiff = rawdiff;
4606 ✗ return 1;
4607 }
4608
4609
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 87 times.
87 if (diff < 30) {
4610 // Close enough — return to normal rate
4611 ✗ if (m_currentRate != 1.0f) {
4612 ✗ rate = 1.0f;
4613 ✗ LogDebug(VB_MEDIAOUT, "GStreamer Diff: %d\tVery close, use normal rate\t(%0.3f)\n", rawdiff, rate);
4614 ✗ ApplyRate(rate);
4615 ✗ m_lastRates.push_back(rate);
4616 ✗ m_lastRatesSum += rate;
4617 ✗ while ((int)m_lastRates.size() > RATE_AVERAGE_COUNT) {
4618 ✗ m_lastRatesSum -= m_lastRates.front();
4619 ✗ m_lastRates.pop_front();
4620 }
4621 ✗ m_currentRate = rate;
4622 ✗ m_rateDiff = 0;
4623 ✗ m_lastDiff = rawdiff;
4624 }
4625 ✗ return 1;
4626
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 87 times.
87 } else if (diff > 10000) {
4627 // More than 10 seconds off — jump to the master position
4628 ✗ gint64 pos_ns = (gint64)(masterMediaPosition * GST_SECOND);
4629 ✗ LogDebug(VB_MEDIAOUT, "GStreamer Diff: %d\tVery far, jumping to: %0.3f\t(currently at %0.3f)\n",
4630 rawdiff, masterMediaPosition, m_mediaOutputStatus->mediaSeconds);
4631 ✗ if (!m_pwSinkStreamId.empty() && m_currentRate != 1.0f) {
4632 // The seek's rate of 1 doesn't reach the clock or the stream.
4633 ✗ ApplyRate(1.0f);
4634 }
4635 ✗ gst_element_seek(m_pipeline, 1.0, GST_FORMAT_TIME,
4636 (GstSeekFlags)(GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_ACCURATE),
4637 GST_SEEK_TYPE_SET, pos_ns, GST_SEEK_TYPE_NONE, 0);
4638 ✗ m_lastRates.clear();
4639 ✗ m_lastRates.push_back(1.0f);
4640 ✗ m_lastRatesSum = 1.0f;
4641 ✗ m_currentRate = 1.0f;
4642 ✗ m_rateDiff = 0;
4643 ✗ m_lastDiff = -1; // after seeking, assume slightly behind master
4644 ✗ return 1;
4645
2/2
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 79 times.
87 } else if (diff < 100) {
4646 // Very close — could be transient; delay one cycle
4647
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 8 times.
8 if (!m_lastDiff) {
4648 ✗ LogDebug(VB_MEDIAOUT, "GStreamer Diff: %d\tVery close but could be transient, wait till next time\n", rawdiff);
4649 ✗ m_lastDiff = rawdiff;
4650 ✗ return 1;
4651 }
4652 }
4653
4654 // Calculate proportional rate adjustment
4655 87 float rateDiffF = (float)diff;
4656
2/2
✓ Branch 0 taken 68 times.
✓ Branch 1 taken 19 times.
87 if (m_mediaOutputStatus->mediaSeconds > 10) {
4657 68 rateDiffF /= 100.0f;
4658
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 68 times.
68 if (rateDiffF > 10.0f)
4659 ✗ rateDiffF = 10.0f;
4660 } else {
4661 // In first 10 seconds, use larger rate changes to sync faster
4662 19 rateDiffF /= 50.0f;
4663
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 19 times.
19 if (rateDiffF > 20.0f)
4664 ✗ rateDiffF = 20.0f;
4665 }
4666
4667 87 rateDiffF *= sign;
4668 87 int rateDiffI = (int)std::round(rateDiffF);
4669
4670
1/2
✓ Branch 0 taken 87 times.
✗ Branch 1 not taken.
87 LogExcess(VB_MEDIAOUT, "GStreamer Diff: %d\trateDiffI: %d m_rateDiff: %d\n", rawdiff, rateDiffI, m_rateDiff);
4671
4672
2/2
✓ Branch 0 taken 16 times.
✓ Branch 1 taken 71 times.
87 if (rateDiffI < m_rateDiff) {
4673
2/2
✓ Branch 0 taken 22 times.
✓ Branch 1 taken 16 times.
38 for (int r = rateDiffI; r < m_rateDiff; r++) {
4674 22 rate = rate * 1.02f;
4675 }
4676
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 LogDebug(VB_MEDIAOUT, "GStreamer Diff: %d\tSpeedUp %0.3f/%0.3f [goal/current]\n", rawdiff, rate, m_currentRate);
4677
2/2
✓ Branch 0 taken 20 times.
✓ Branch 1 taken 51 times.
71 } else if (rateDiffI > m_rateDiff) {
4678
2/2
✓ Branch 0 taken 20 times.
✓ Branch 1 taken 20 times.
40 for (int r = rateDiffI; r > m_rateDiff; r--) {
4679 20 rate = rate * 0.98f;
4680 }
4681
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 LogDebug(VB_MEDIAOUT, "GStreamer Diff: %d\tSlowDown %0.3f/%0.3f [goal/current]\n", rawdiff, rate, m_currentRate);
4682 } else {
4683 // No rate change needed
4684
1/2
✓ Branch 0 taken 51 times.
✗ Branch 1 not taken.
51 LogExcess(VB_MEDIAOUT, "GStreamer Diff: %d\tno rate change\n", rawdiff);
4685 51 return 1;
4686 }
4687
4688 // Add to rate history for running average
4689
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 m_lastRates.push_back(rate);
4690 36 m_lastRatesSum += rate;
4691
2/2
✓ Branch 0 taken 17 times.
✓ Branch 1 taken 19 times.
36 if ((int)m_lastRates.size() > RATE_AVERAGE_COUNT) {
4692 17 m_lastRatesSum -= m_lastRates.front();
4693 17 m_lastRates.pop_front();
4694 }
4695
4696 // Cross-unity check: if rate crossed 1.0, reset to 1.0
4697
3/8
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 36 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 36 times.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
36 if (((rate > 1.0f) && (m_currentRate < 1.0f)) || ((rate < 1.0f) && (m_currentRate > 1.0f))) {
4698 ✗ rate = 1.0f;
4699 ✗ m_rateDiff = 0;
4700 }
4701
4702
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 LogExcess(VB_MEDIAOUT, "GStreamer Diff: %d\toldDiff: %d\tnewRate: %0.3f oldRate: %0.3f avgRate: %0.3f rateSum: %0.3f/%d\n",
4703 rawdiff, m_lastDiff, m_lastRates.back(), m_currentRate, rate, m_lastRatesSum, (int)m_lastRates.size());
4704
4705 // Clamp rate to safe range
4706
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 36 times.
36 if (rate > 2.0f)
4707 ✗ rate = 2.0f;
4708
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 36 times.
36 if (rate < 0.5f)
4709 ✗ rate = 0.5f;
4710
4711 // Only apply if rate changed by > 0.001
4712
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 if ((int)(rate * 1000) != (int)(m_currentRate * 1000)) {
4713
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 LogDebug(VB_MEDIAOUT, "GStreamer Diff: %d\tApplyRate\t(%0.3f)\n", rawdiff, rate);
4714
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 ApplyRate(rate);
4715 36 m_currentRate = rate;
4716
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 36 times.
36 if (rate == 1.0f) {
4717 ✗ m_rateDiff = 0;
4718 } else {
4719 36 m_rateDiff = rateDiffI;
4720 }
4721 }
4722
4723 36 m_lastDiff = rawdiff;
4724 36 return 1;
4725 1110 }
4726
4727 // A remote holding its copy of the show audio to the master, when that audio
4728 // plays through the slot's persistent output stream.
4729 //
4730 // AdjustSpeed()'s rate seeks never moved the sound there. The stream plays at
4731 // the card's fixed rate, so a pipeline sped up 2% only handed it audio faster
4732 // than the card took it: the surplus queued where neither the position nor
4733 // the card's delay shows it, and the remote settled ~0.6 s behind the master
4734 // while reporting itself in sync (slowing down starved the stream instead).
4735 // And mp3 and FLAC -- most shows -- refuse instant rate changes outright
4736 // (GstBaseParse), so every adjustment was a flushing seek.
4737 //
4738 // Here the speed is the stream's feed speed: the feed clock runs that much
4739 // faster or slower and the stream resamples to the card's rate, so the media
4740 // really plays faster or slower (PipeWireOutputStream::SetFeedSpeed()). A
4741 // PI servo on the error holds it, within +-0.5% (under 9 cents of pitch;
4742 // card-to-card drift needs well under 0.1%). An error too big to slew away
4743 // in a few seconds -- a remote starting late, a missed sync -- is one
4744 // accurate seek to where the master will be once the seek is heard, aimed
4745 // using what earlier seeks took.
4746 //
4747 // Every decision is made on the lowest reading of the last two seconds, not
4748 // the latest (FeedSyncWindow): a late sync packet can only make this remote
4749 // look ahead, so one alone never seeks, never moves the servo, and never
4750 // teaches the next seek its aim. An error the window agrees on is acted on
4751 // as before -- behind at once, ahead once two seconds of packets say so.
4752 1009 int GStreamerOutput::AdjustFeedSpeed(float masterMediaPosition) {
4753 1009 constexpr double kResyncS = 0.050; // beyond this, seek rather than slew
4754 1009 constexpr double kMaxSpeedOffset = 0.005; // +-0.5%
4755 1009 constexpr double kKp = 0.1; // per second of error
4756 1009 constexpr double kKi = kKp * kKp / 4; // critically damped
4757 1009 constexpr uint64_t kResyncSettleMs = 2000;
4758
4759
1/2
✓ Branch 0 taken 1009 times.
✗ Branch 1 not taken.
1009 uint64_t now = GetMonotonicTimeMS();
4760 // Where the sound is now, as Process() reports it (what the feed has
4761 // handed over, less the card's queue) -- but read now: the last
4762 // Process() can be tens of ms old on a remote, which made every error
4763 // reading tens of ms off.
4764 1009 double local = m_mediaOutputStatus->mediaSeconds;
4765 1009 gint64 pos = 0;
4766
4/8
✓ Branch 0 taken 1009 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1009 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1009 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1009 times.
✗ Branch 7 not taken.
1009 if (gst_element_query_position(m_pwFeed, GST_FORMAT_TIME, &pos) && pos >= 0) {
4767
1/2
✓ Branch 0 taken 1009 times.
✗ Branch 1 not taken.
1009 local = std::max(0.0, (double)(pos - AudioSinkLatencyNs()) / GST_SECOND - mediaOffset);
4768 }
4769 1009 double raw = local - masterMediaPosition; // > 0: this remote is ahead
4770
4771
4/4
✓ Branch 0 taken 979 times.
✓ Branch 1 taken 30 times.
✓ Branch 2 taken 41 times.
✓ Branch 3 taken 938 times.
1009 if (m_feedResyncMs && now - m_feedResyncMs < kResyncSettleMs) {
4772 41 return 1; // still landing
4773 }
4774 // The lowest of the last two seconds (FeedSyncWindow). Until a second
4775 // reading agrees, nothing is seeked or learnt on it.
4776
1/2
✓ Branch 0 taken 968 times.
✗ Branch 1 not taken.
968 double err = m_feedErrWindow.Add(now, raw, masterMediaPosition);
4777 968 bool agreed = m_feedErrWindow.Agreed();
4778
4/4
✓ Branch 0 taken 11 times.
✓ Branch 1 taken 957 times.
✓ Branch 2 taken 5 times.
✓ Branch 3 taken 6 times.
968 if (m_feedResyncLanding && agreed) {
4779 // How far the last seek landed off is how much more (or less) lead
4780 // the next one needs.
4781 5 m_feedResyncLanding = false;
4782
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 if (std::fabs(err) < 1.0) {
4783
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 s_feedResyncLeadS = std::clamp(s_feedResyncLeadS - 0.8 * err, 0.0, 2.0);
4784 }
4785
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 LogDebug(VB_MEDIAOUT, "GStreamer feed sync: resync landed %+.1f ms from the master; seeks now aim %.0f ms ahead\n",
4786 err * 1000.0, s_feedResyncLeadS * 1000.0);
4787 }
4788
4/4
✓ Branch 0 taken 947 times.
✓ Branch 1 taken 21 times.
✓ Branch 2 taken 16 times.
✓ Branch 3 taken 931 times.
968 if (agreed && std::fabs(err) > kResyncS) {
4789 16 double target = masterMediaPosition + s_feedResyncLeadS;
4790
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 LogInfo(VB_MEDIAOUT, "GStreamer feed sync: %+.0f ms from the master (%.3f vs %.3f), seeking to %.3f\n",
4791 err * 1000.0, local, masterMediaPosition, target);
4792
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 m_pwStream->SetFeedSpeed(m_pwFeed, 1.0);
4793 16 m_feedSpeedErrInt = 0.0;
4794 16 m_feedSpeedLastMs = 0;
4795 16 m_feedResyncMs = now;
4796 16 m_feedResyncLanding = true;
4797 16 m_feedErrWindow.Clear(); // the seek starts a new timeline
4798 16 gst_element_seek(m_pipeline, 1.0, GST_FORMAT_TIME,
4799 (GstSeekFlags)(GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_ACCURATE),
4800
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 GST_SEEK_TYPE_SET, (gint64)(target * GST_SECOND), GST_SEEK_TYPE_NONE, 0);
4801
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 m_pwStream->FlushFeed(m_pwFeed);
4802 16 return 1;
4803 }
4804
4805
3/4
✓ Branch 0 taken 931 times.
✓ Branch 1 taken 21 times.
✓ Branch 2 taken 931 times.
✗ Branch 3 not taken.
952 double dt = m_feedSpeedLastMs ? std::clamp((double)(now - m_feedSpeedLastMs) / 1000.0, 0.0, 1.0) : 0.0;
4806 952 m_feedSpeedLastMs = now;
4807
1/2
✓ Branch 0 taken 952 times.
✗ Branch 1 not taken.
952 m_feedSpeedErrInt = std::clamp(m_feedSpeedErrInt + err * dt, -kMaxSpeedOffset / kKi, kMaxSpeedOffset / kKi);
4808
1/2
✓ Branch 0 taken 952 times.
✗ Branch 1 not taken.
952 double speed = 1.0 - std::clamp(kKp * err + kKi * m_feedSpeedErrInt, -kMaxSpeedOffset, kMaxSpeedOffset);
4809
1/2
✓ Branch 0 taken 952 times.
✗ Branch 1 not taken.
952 m_pwStream->SetFeedSpeed(m_pwFeed, speed);
4810 // `error` is what the servo steered by; `this packet` its own reading,
4811 // larger when it arrived late.
4812
1/2
✓ Branch 0 taken 952 times.
✗ Branch 1 not taken.
952 LogDebug(VB_MEDIAOUT, "GStreamer feed sync: master %.3f local %.3f error %+.1f ms, speed %+.0f ppm (this packet %+.1f ms)\n",
4813 masterMediaPosition, local, err * 1000.0, (speed - 1.0) * 1e6, raw * 1000.0);
4814 952 return 1;
4815 }
4816
4817 double GStreamerOutput::s_feedResyncLeadS = 0.06;
4818
4819 87 void GStreamerOutput::pushDiff(int diff, float rate) {
4820 87 m_diffSum += diff;
4821 87 m_rateSum += rate;
4822
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 77 times.
87 if (m_diffsSize < MAX_DIFFS) {
4823 10 m_diffIdx = m_diffsSize;
4824 10 m_diffsSize++;
4825 } else {
4826 77 m_diffIdx++;
4827
2/2
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 69 times.
77 if (m_diffIdx == MAX_DIFFS) {
4828 8 m_diffIdx = 0;
4829 }
4830 77 m_diffSum -= m_diffs[m_diffIdx].first;
4831 77 m_rateSum -= m_diffs[m_diffIdx].second;
4832 }
4833 87 m_diffs[m_diffIdx].first = diff;
4834 87 m_diffs[m_diffIdx].second = rate;
4835 87 }
4836
4837 1 bool GStreamerOutput::SeekTo(float seconds) {
4838 1 std::unique_lock<std::recursive_mutex> opLock(m_opMutex, std::try_to_lock);
4839
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 1 times.
1 if (!opLock.owns_lock() || !m_pipeline)
4840 ✗ return false;
4841 1 gint64 pos_ns = (gint64)(seconds * GST_SECOND);
4842 // Same rule as AdjustSpeed(): a flushing seek into a pipeline that has
4843 // never produced a position blocks the caller for good if its preroll is
4844 // wedged (#2727), and callers hold StreamSlotManager's lock on the main
4845 // loop. The usual case is a companion that was just started for a
4846 // resumed entry, so keep the position and let Process() seek once the
4847 // pipeline is running.
4848
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (m_wallStartMs == 0) {
4849
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogDebug(VB_MEDIAOUT, "GStreamer: holding seek to %0.3fs until the pipeline has prerolled\n", seconds);
4850 1 m_pendingSeekNs = pos_ns;
4851 1 return true;
4852 }
4853 ✗ return gst_element_seek(m_pipeline, 1.0, GST_FORMAT_TIME,
4854 (GstSeekFlags)(GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_ACCURATE),
4855 GST_SEEK_TYPE_SET, pos_ns, GST_SEEK_TYPE_NONE, 0)
4856 ? true
4857 ✗ : false;
4858 1 }
4859
4860 36 void GStreamerOutput::ApplyRate(float rate) {
4861
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 36 times.
36 if (!m_pipeline)
4862 ✗ return;
4863
4864 // A pipewiresink of the pipeline's own is drained by PipeWire at the
4865 // graph's rate whatever the pipeline's, and the rate seek below changed
4866 // nothing that could be heard or seen: a remote playing more than two
4867 // channels fell behind a fast master at the full difference. Two things
4868 // move it instead. The stream's adapter resamples by its rate prop, so
4869 // it takes the media that much faster or slower; but a sink that syncs
4870 // hands buffers over only as its clock reaches them, so on its own that
4871 // starves the stream rather than speeding it up. So the pipeline's
4872 // clock is run at the same rate, as the persistent stream's feed clock is
4873 // (AdjustFeedSpeed()): the sink delivers at the speed the stream takes,
4874 // and the position -- read off that clock -- moves with the sound.
4875
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 if (!m_pwSinkStreamId.empty()) {
4876
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 if (GstClock* clock = gst_pipeline_get_clock(GST_PIPELINE(m_pipeline))) {
4877 GstClockTime cInt, cExt, num, den;
4878
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 gst_clock_get_calibration(clock, &cInt, &cExt, &num, &den);
4879
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 GstClockTime internal = gst_clock_get_internal_time(clock);
4880
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 GstClockTime external = gst_clock_adjust_with_calibration(clock, internal, cInt, cExt, num, den);
4881
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 gst_clock_set_calibration(clock, internal, external, (GstClockTime)llround(rate * 1e9), 1000000000ULL);
4882
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 gst_object_unref(clock);
4883 }
4884 36 SetPipeWireStreamRate(m_pwSinkStreamId, rate);
4885 36 return;
4886 }
4887
4888 // Use instant-rate-change (GStreamer >= 1.18) for glitch-free rate adjustment.
4889 // Falls back to a flush seek if instant-rate-change fails.
4890 ✗ gboolean ok = gst_element_seek(m_pipeline, (gdouble)rate, GST_FORMAT_TIME,
4891 GST_SEEK_FLAG_INSTANT_RATE_CHANGE,
4892 GST_SEEK_TYPE_NONE, 0, GST_SEEK_TYPE_NONE, 0);
4893 ✗ if (!ok) {
4894 // Fallback: flush seek to current position with new rate
4895 ✗ gint64 pos = 0;
4896 ✗ if (gst_element_query_position(m_pipeline, GST_FORMAT_TIME, &pos)) {
4897 ✗ LogDebug(VB_MEDIAOUT, "GStreamer: instant-rate-change failed, falling back to flush seek at %" GST_TIME_FORMAT "\n",
4898 GST_TIME_ARGS(pos));
4899 ✗ gst_element_seek(m_pipeline, (gdouble)rate, GST_FORMAT_TIME,
4900 (GstSeekFlags)(GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_ACCURATE),
4901 GST_SEEK_TYPE_SET, pos, GST_SEEK_TYPE_NONE, 0);
4902 } else {
4903 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: ApplyRate(%0.3f) failed — could not query position\n", rate);
4904 }
4905 }
4906 }
4907
4908 // A reference to the volume element, or null. OnNoMorePads() removes the
4909 // audio chain from a video-only pipeline and drops m_volume -- freeing the
4910 // element -- on a streaming thread, under the callback guard, while the
4911 // volume setters run on the playlist or an HTTP thread (a slot's saved volume
4912 // is restored while the pipeline is still prerolling, which is exactly when
4913 // no-more-pads fires). Taken under the same guard, the reference keeps the
4914 // element alive for the caller's g_object_set().
4915 //
4916 // Stop() races this too: an output stays on its slot until Close(), so Set
4917 // Slot Volume reaches one that Play Media is replacing or Stop Media Slot is
4918 // stopping on another HTTP thread. DetachCallbacks() clears `self` under the
4919 // guard lock before Stop() hands m_volume to the teardown thread and before
4920 // Close() unrefs it, so with `self` gone m_volume is not ours to read, and the
4921 // guard pointer itself is read atomically because DetachCallbacks() drops it.
4922 // `self` being set says only that no teardown has begun: Start() builds the
4923 // pipeline, m_volume included, without the guard lock, and is kept safe by
4924 // taking the output off its slot first, not by anything here.
4925 // Not m_opMutex: SetSlotVolume() calls in holding StreamSlotManager's lock,
4926 // which m_opMutex holders take, and Process() holds m_opMutex every tick.
4927 63155 GstElement* GStreamerOutput::RefVolumeElement() {
4928
1/2
✓ Branch 0 taken 63155 times.
✗ Branch 1 not taken.
63155 std::shared_ptr<CallbackGuard> guard = m_cbGuard.load();
4929
2/2
✓ Branch 0 taken 24 times.
✓ Branch 1 taken 63131 times.
63155 if (!guard) {
4930 24 return nullptr;
4931 }
4932
1/2
✓ Branch 0 taken 63131 times.
✗ Branch 1 not taken.
63131 std::unique_lock<std::recursive_mutex> lock(guard->mtx);
4933
5/6
✓ Branch 0 taken 63131 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✓ Branch 3 taken 63126 times.
✓ Branch 4 taken 5 times.
✓ Branch 5 taken 63126 times.
63131 if (!guard->self || !m_volume) {
4934 5 return nullptr;
4935 }
4936
3/6
✓ Branch 0 taken 63126 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63126 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 63126 times.
✗ Branch 5 not taken.
63126 return GST_ELEMENT(gst_object_ref(m_volume));
4937 63155 }
4938
4939 113 void GStreamerOutput::SetVolume(int volume) {
4940 113 TrySetVolume(volume);
4941 113 }
4942
4943 63155 bool GStreamerOutput::TrySetVolume(int volume) {
4944 63155 GstElement* vol = RefVolumeElement();
4945
2/2
✓ Branch 0 taken 63126 times.
✓ Branch 1 taken 29 times.
63155 if (vol) {
4946 // volume parameter is 0-100 percentage
4947 63126 double linearVol = volume / 100.0 * m_fileGain;
4948 63126 g_object_set(vol, "volume", linearVol, NULL);
4949 63126 gst_object_unref(vol);
4950 63126 LogDebug(VB_MEDIAOUT, "GStreamer volume set to %d%% (%.2f)\n", volume, linearVol);
4951 }
4952 63155 return vol != nullptr;
4953 }
4954
4955 56 GstElement* GStreamerOutput::MakeBranchErrorGuard() {
4956 56 GstElement* guard = gst_element_factory_make("errorignore", nullptr);
4957
1/2
✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
56 if (guard) {
4958 // Only a failing sink is in scope: a branch that can't negotiate
4959 // still fails the media, as it always has, rather than sitting dead.
4960 56 g_object_set(guard,
4961 "ignore-error", TRUE,
4962 "ignore-notnegotiated", FALSE,
4963 "convert-to", GST_FLOW_OK,
4964 NULL);
4965 }
4966 56 return guard;
4967 }
4968
4969 3 bool GStreamerOutput::CutFailedBranch(GstElement* sink) {
4970 // Walk upstream from the sink to the tee feeding its branch.
4971 3 FailedBranch b;
4972
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.
3 GstElement* el = GST_ELEMENT(gst_object_ref(sink));
4973
2/4
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
12 for (int depth = 0; depth < 16 && el; depth++) {
4974
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 b.elements.push_back(el);
4975
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 GstPad* sinkPad = gst_element_get_static_pad(el, "sink");
4976
2/4
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
12 GstPad* peer = sinkPad ? gst_pad_get_peer(sinkPad) : nullptr;
4977
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 if (sinkPad) {
4978
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 gst_object_unref(sinkPad);
4979 }
4980
2/4
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
12 GstElement* up = peer ? gst_pad_get_parent_element(peer) : nullptr;
4981
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 12 times.
12 if (!up) {
4982 ✗ if (peer) {
4983 ✗ gst_object_unref(peer);
4984 }
4985 ✗ break;
4986 }
4987
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 GstElementFactory* f = gst_element_get_factory(up);
4988
3/4
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✓ Branch 3 taken 9 times.
12 if (f && strcmp(GST_OBJECT_NAME(f), "tee") == 0) {
4989 3 b.tee = up;
4990 3 b.teePad = peer;
4991 3 break;
4992 }
4993
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 gst_object_unref(peer);
4994 9 el = up;
4995 }
4996 1 auto release = [](FailedBranch& fb) {
4997
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 1 times.
5 for (GstElement* e : fb.elements) {
4998
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_object_unref(e);
4999 }
5000
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (fb.teePad) {
5001 1 gst_object_unref(fb.teePad);
5002 }
5003
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (fb.tee) {
5004 1 gst_object_unref(fb.tee);
5005 }
5006 1 };
5007
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
3 if (!b.teePad) {
5008 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: no tee above failed sink %s; it cannot be cut off\n",
5009 GST_OBJECT_NAME(sink));
5010 ✗ release(b);
5011 ✗ return false;
5012 }
5013
5014
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 std::lock_guard<std::mutex> lock(m_failedBranchLock);
5015
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 2 times.
3 for (const auto& fb : m_failedBranches) {
5016
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (fb.teePad == b.teePad) {
5017
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 release(b); // a second error from a branch already cut off
5018 1 return true;
5019 }
5020 }
5021 // Everything the tee sends this way is dropped and reported as
5022 // delivered, so the failed sink's error never makes it upstream.
5023
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_pad_add_probe(b.teePad, GST_PAD_PROBE_TYPE_DATA_DOWNSTREAM,
5024 49 [](GstPad*, GstPadProbeInfo*, gpointer) { return GST_PAD_PROBE_DROP; },
5025 nullptr, nullptr);
5026
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogWarn(VB_MEDIAOUT, "GStreamer: %s failed; cut its branch off so %s keeps playing\n",
5027 GST_OBJECT_NAME(sink), m_mediaFilename.c_str());
5028
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 m_failedBranches.push_back(std::move(b));
5029 2 m_haveFailedBranches.store(true, std::memory_order_release);
5030 2 return true;
5031 3 }
5032
5033 2 void GStreamerOutput::RemoveFailedBranches() {
5034 // The cut branch would still count as a sink that has to preroll after a
5035 // seek and post EOS at the end -- it can do neither, so a seek would wait
5036 // forever and the media would never finish. Take it out of the pipeline.
5037 2 std::vector<FailedBranch> branches;
5038 {
5039
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 std::lock_guard<std::mutex> lock(m_failedBranchLock);
5040 2 branches.swap(m_failedBranches);
5041 2 m_haveFailedBranches.store(false, std::memory_order_release);
5042 2 }
5043
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
4 for (auto& b : branches) {
5044
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 if (GstPad* qSink = gst_pad_get_peer(b.teePad)) {
5045
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_pad_unlink(b.teePad, qSink);
5046
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_object_unref(qSink);
5047 }
5048
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_element_release_request_pad(b.tee, b.teePad);
5049 // Upstream first, so nothing is pushing into an element going down.
5050
2/2
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 2 times.
10 for (auto it = b.elements.rbegin(); it != b.elements.rend(); ++it) {
5051 8 GstElement* e = *it;
5052
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 gst_element_set_state(e, GST_STATE_NULL);
5053
5/10
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 8 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 8 times.
✗ Branch 9 not taken.
8 if (m_pipeline && GST_OBJECT_PARENT(e) == GST_OBJECT(m_pipeline)) {
5054
3/6
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
8 gst_bin_remove(GST_BIN(m_pipeline), e);
5055 }
5056 }
5057
2/4
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 LogInfo(VB_MEDIAOUT, "GStreamer: removed failed branch %s\n",
5058 b.elements.empty() ? "?" : GST_OBJECT_NAME(b.elements.front()));
5059
2/2
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 2 times.
10 for (GstElement* e : b.elements) {
5060
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 gst_object_unref(e);
5061 }
5062
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_object_unref(b.teePad);
5063
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_object_unref(b.tee);
5064 }
5065 2 }
5066
5067 3698 void GStreamerOutput::ReleaseFailedBranches() {
5068 3698 std::vector<FailedBranch> branches;
5069 {
5070
1/2
✓ Branch 0 taken 3698 times.
✗ Branch 1 not taken.
3698 std::lock_guard<std::mutex> lock(m_failedBranchLock);
5071 3698 branches.swap(m_failedBranches);
5072 3698 m_haveFailedBranches.store(false, std::memory_order_release);
5073 3698 }
5074
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3698 times.
3698 for (auto& b : branches) {
5075 ✗ for (GstElement* e : b.elements) {
5076 ✗ gst_object_unref(e);
5077 }
5078 ✗ gst_object_unref(b.teePad);
5079 ✗ gst_object_unref(b.tee);
5080 }
5081 3698 }
5082
5083 1837 void GStreamerOutput::SetVolumeAdjustment(int volAdj) {
5084 1837 m_volumeAdjust = volAdj;
5085
1/2
✓ Branch 0 taken 1837 times.
✗ Branch 1 not taken.
1837 if (!m_playing) {
5086 1837 return;
5087 }
5088 ✗ if (GstElement* vol = RefVolumeElement()) {
5089 ✗ double linearVol = std::min(m_fileGain * pow(10.0, volAdj / 2000.0), 10.0);
5090 ✗ g_object_set(vol, "volume", linearVol, NULL);
5091 ✗ gst_object_unref(vol);
5092 }
5093 }
5094
5095 // Static video overlay methods — called from Sequence.cpp and channeloutputthread.cpp
5096 // These walk every output driving a PixelOverlay model rather than the single
5097 // m_currentInstance they used to. m_currentInstance is claimed by slot 1
5098 // whenever slot 1 is playing, so a companion stream pointed at a model would
5099 // register the model, receive frames, and then have them go nowhere -- the
5100 // model simply never updated. m_currentInstance still selects the WLED audio
5101 // tap, which genuinely is a single-source thing.
5102 310632 bool GStreamerOutput::IsOverlayingVideo() {
5103
1/2
✓ Branch 0 taken 310632 times.
✗ Branch 1 not taken.
310632 std::lock_guard<std::mutex> lock(m_overlayOutputsLock);
5104
2/2
✓ Branch 0 taken 109 times.
✓ Branch 1 taken 310528 times.
310637 for (GStreamerOutput* o : m_overlayOutputs) {
5105
6/8
✓ Branch 0 taken 109 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 104 times.
✓ Branch 3 taken 5 times.
✓ Branch 4 taken 104 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 104 times.
✓ Branch 7 taken 5 times.
109 if (o->m_hasVideoStream && o->m_playing && o->m_videoOverlayModel)
5106 104 return true;
5107 }
5108 310528 return false;
5109 310632 }
5110
5111 96 bool GStreamerOutput::ProcessVideoOverlay(unsigned int msTimestamp) {
5112
1/2
✓ Branch 0 taken 96 times.
✗ Branch 1 not taken.
96 std::lock_guard<std::mutex> lock(m_overlayOutputsLock);
5113
2/2
✓ Branch 0 taken 100 times.
✓ Branch 1 taken 96 times.
196 for (GStreamerOutput* o : m_overlayOutputs) {
5114
1/2
✓ Branch 0 taken 100 times.
✗ Branch 1 not taken.
100 o->PushVideoOverlayFrame();
5115 }
5116 96 return false;
5117 96 }
5118
5119 100 bool GStreamerOutput::PushVideoOverlayFrame() {
5120 100 GStreamerOutput* self = this;
5121
3/6
✓ Branch 0 taken 100 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 100 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 100 times.
100 if (!self->m_playing || !self->m_hasVideoStream)
5122 ✗ return false;
5123
5124 // Copy the latest video frame data under lock
5125 100 std::vector<uint8_t> frameData;
5126 int frameW, frameH;
5127 {
5128
1/2
✓ Branch 0 taken 100 times.
✗ Branch 1 not taken.
100 std::lock_guard<std::mutex> lock(self->m_videoFrameMutex);
5129
2/2
✓ Branch 0 taken 55 times.
✓ Branch 1 taken 45 times.
100 if (!self->m_videoFrameReady)
5130 55 return false;
5131
1/2
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
45 frameData = self->m_videoFrameData;
5132 45 frameW = self->m_videoFrameWidth;
5133 45 frameH = self->m_videoFrameHeight;
5134 45 self->m_videoFrameReady = false;
5135
2/2
✓ Branch 0 taken 45 times.
✓ Branch 1 taken 55 times.
100 }
5136
5137 // Push the RGB frame to the PixelOverlayModel. blitData(), not
5138 // setData(): setData() reads width*height*bytesPerPixel from the buffer,
5139 // and an RGBW model has 4 bytes per pixel to this frame's 3 -- it read a
5140 // third past the end of every frame. blitData() converts to the model's
5141 // format and clips, which also covers a model replaced by one of another
5142 // size mid-play.
5143
1/2
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
45 std::lock_guard<std::mutex> lock(self->m_videoOverlayModelLock);
5144
3/6
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 45 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 45 times.
✗ Branch 5 not taken.
90 if (self->m_videoOverlayModel && !frameData.empty() &&
5145
1/2
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
45 frameData.size() >= (size_t)frameW * frameH * 3) {
5146
1/2
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
45 self->m_videoOverlayModel->blitData(frameData.data(), frameW, frameH, 3, 0, 0,
5147 45 PixelOverlayState(PixelOverlayState::Enabled));
5148 45 self->m_videoFramesDelivered++;
5149
5150
3/4
✓ Branch 0 taken 41 times.
✓ Branch 1 taken 4 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 41 times.
45 if (self->m_videoFramesDelivered == 1 || (self->m_videoFramesDelivered % 100) == 0) {
5151
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 LogExcess(VB_MEDIAOUT, "GStreamer: video frame %lu delivered to overlay (%zu bytes)\n",
5152 (unsigned long)self->m_videoFramesDelivered, frameData.size());
5153 }
5154
5155 // Auto-enable model if it was disabled (same as SDLOutput behavior)
5156
3/4
✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 41 times.
45 if (self->m_videoOverlayModel->getState() == PixelOverlayState::Disabled) {
5157 4 self->m_wasOverlayDisabled = true;
5158
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 self->m_videoOverlayModel->setState(PixelOverlayState(PixelOverlayState::Enabled));
5159 }
5160 }
5161 45 return false;
5162 100 }
5163
5164 61 GstFlowReturn GStreamerOutput::OnNewVideoSample(GstAppSink* appsink, gpointer userData) {
5165 61 GStreamerOutput* self = static_cast<GStreamerOutput*>(userData);
5166
3/6
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 61 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 61 times.
61 if (!self || self->m_shutdownFlag.load(std::memory_order_acquire))
5167 ✗ return GST_FLOW_EOS;
5168
5169
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 GstSample* sample = gst_app_sink_pull_sample(appsink);
5170
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 61 times.
61 if (!sample)
5171 ✗ return GST_FLOW_OK;
5172
5173
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 GstBuffer* buffer = gst_sample_get_buffer(sample);
5174 // The frame's own size, from its caps: the appsink caps pin it to the
5175 // overlay model's size at Start(), but this is what the buffer holds.
5176 61 int width = self->m_videoOverlayWidth;
5177 61 int height = self->m_videoOverlayHeight;
5178
2/4
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 61 times.
✗ Branch 3 not taken.
61 if (GstCaps* caps = gst_sample_get_caps(sample)) {
5179
2/4
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 61 times.
✗ Branch 3 not taken.
61 if (const GstStructure* st = gst_caps_get_structure(caps, 0)) {
5180
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 gst_structure_get_int(st, "width", &width);
5181
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 gst_structure_get_int(st, "height", &height);
5182 }
5183 }
5184 GstMapInfo map;
5185
5/10
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 61 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 61 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 61 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 61 times.
✗ Branch 9 not taken.
61 if (width > 0 && height > 0 && gst_buffer_map(buffer, &map, GST_MAP_READ)) {
5186 61 const size_t rowBytes = (size_t)width * 3; // RGB = 3 bytes/pixel
5187 61 const size_t expectedSize = rowBytes * height;
5188 // Packed RGB rows are padded to 4 bytes unless the buffer is exactly
5189 // the tightly packed size.
5190
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 61 times.
61 const size_t stride = (map.size == expectedSize) ? rowBytes : ((rowBytes + 3) & ~(size_t)3);
5191
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 61 times.
61 if (map.size < stride * (height - 1) + rowBytes) {
5192 // Shorter than the frame its caps describe: drop it rather than
5193 // read past the buffer.
5194 ✗ gst_buffer_unmap(buffer, &map);
5195 ✗ gst_sample_unref(sample);
5196 ✗ return GST_FLOW_OK;
5197 }
5198
5199
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 std::lock_guard<std::mutex> lock(self->m_videoFrameMutex);
5200
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 if (stride == rowBytes) {
5201 // No padding — direct copy
5202
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 self->m_videoFrameData.assign(map.data, map.data + expectedSize);
5203 } else {
5204 // GStreamer pads rows to 4-byte alignment — strip padding
5205 ✗ self->m_videoFrameData.resize(expectedSize);
5206 ✗ for (int y = 0; y < height; y++) {
5207 ✗ memcpy(&self->m_videoFrameData[(size_t)y * rowBytes], map.data + (size_t)y * stride, rowBytes);
5208 }
5209 }
5210 61 self->m_videoFrameWidth = width;
5211 61 self->m_videoFrameHeight = height;
5212 61 self->m_videoFrameReady = true;
5213 61 self->m_videoFramesReceived++;
5214
3/4
✓ Branch 0 taken 55 times.
✓ Branch 1 taken 6 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 55 times.
61 if (self->m_videoFramesReceived == 1 || (self->m_videoFramesReceived % 100) == 0) {
5215
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 LogExcess(VB_MEDIAOUT, "GStreamer: video frame %lu received (%zu bytes, stride=%zu, rowBytes=%zu)\n",
5216 (unsigned long)self->m_videoFramesReceived, map.size, stride, rowBytes);
5217 }
5218
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 gst_buffer_unmap(buffer, &map);
5219 61 }
5220
5221
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 gst_sample_unref(sample);
5222 61 return GST_FLOW_OK;
5223 }
5224
5225 1935 void GStreamerOutput::ReleaseCallbackGuard(gpointer data, GClosure* closure) {
5226
1/2
✓ Branch 0 taken 1935 times.
✗ Branch 1 not taken.
1935 delete static_cast<std::shared_ptr<CallbackGuard>*>(data);
5227 1935 }
5228
5229 1905 void GStreamerOutput::ReleaseBusCallbackGuard(gpointer data) {
5230
1/2
✓ Branch 0 taken 1905 times.
✗ Branch 1 not taken.
1905 delete static_cast<std::shared_ptr<CallbackGuard>*>(data);
5231 1905 }
5232
5233 4328 GStreamerOutput::BusCallbackScope::BusCallbackScope(gpointer userData) {
5234 4328 auto* held = static_cast<std::shared_ptr<CallbackGuard>*>(userData);
5235
3/6
✓ Branch 0 taken 4328 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 4328 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 4328 times.
4328 if (!held || !*held)
5236 50 return;
5237
1/2
✓ Branch 0 taken 4328 times.
✗ Branch 1 not taken.
4328 std::lock_guard<std::recursive_mutex> lock((*held)->mtx);
5238
2/2
✓ Branch 0 taken 50 times.
✓ Branch 1 taken 4278 times.
4328 if (!(*held)->self)
5239 50 return;
5240 4278 guard = *held;
5241 4278 self = guard->self;
5242 4278 guard->busInFlight++;
5243
2/2
✓ Branch 0 taken 4278 times.
✓ Branch 1 taken 50 times.
4328 }
5244
5245 2 void GStreamerOutput::ScheduleLoopSeek(const std::shared_ptr<CallbackGuard>& guard) {
5246 static std::atomic<unsigned> seq{ 0 };
5247 2 std::shared_ptr<CallbackGuard> g = guard;
5248
4/8
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✗ Branch 7 not taken.
2 Timers::INSTANCE.addTimer("GStreamerLoop" + std::to_string(seq++), GetTimeMS(), [g]() {
5249 // The guard says whether the output still exists; Close() clears
5250 // `self` under `mtx`. Seek on our own pipeline ref, outside the lock,
5251 // so a Close() on another thread never waits on the seek.
5252 2 GstElement* pipeline = nullptr;
5253 {
5254
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 std::lock_guard<std::recursive_mutex> lock(g->mtx);
5255 2 GStreamerOutput* self = g->self;
5256
4/8
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ 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 2 times.
2 if (!self || self->m_shutdownFlag.load() || !self->m_pipeline)
5257 ✗ return;
5258
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 pipeline = GST_ELEMENT(gst_object_ref(self->m_pipeline));
5259
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 }
5260
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 if (!gst_element_seek_simple(pipeline, GST_FORMAT_TIME,
5261 (GstSeekFlags)(GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_KEY_UNIT),
5262 0)) {
5263 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: seek back to the start for the next loop failed\n");
5264 }
5265 2 gst_object_unref(pipeline);
5266 });
5267 2 }
5268
5269 8606 GStreamerOutput::BusCallbackScope::~BusCallbackScope() {
5270
2/2
✓ Branch 0 taken 50 times.
✓ Branch 1 taken 4278 times.
4328 if (!guard)
5271 50 return;
5272 4278 std::lock_guard<std::recursive_mutex> lock(guard->mtx);
5273
1/2
✓ Branch 0 taken 4278 times.
✗ Branch 1 not taken.
4278 if (--guard->busInFlight == 0)
5274 4278 guard->busIdle.notify_all();
5275
2/2
✓ Branch 0 taken 4278 times.
✓ Branch 1 taken 50 times.
4328 }
5276
5277 1966 void GStreamerOutput::ConnectPadSignals(GstElement* decoder, bool wantNoMorePads) {
5278
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 std::shared_ptr<CallbackGuard> guard = m_cbGuard.load();
5279
3/6
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1966 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 1966 times.
1966 if (!decoder || !guard)
5280 ✗ return;
5281 // The closure owns its own shared_ptr to the guard, released by
5282 // ReleaseCallbackGuard when the signal connection dies with the decoder --
5283 // which can be well after this output is gone.
5284
2/6
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1966 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
1966 g_signal_connect_data(decoder, "pad-added", G_CALLBACK(OnPadAdded),
5285
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 new std::shared_ptr<CallbackGuard>(guard),
5286 ReleaseCallbackGuard, (GConnectFlags)0);
5287
2/2
✓ Branch 0 taken 60 times.
✓ Branch 1 taken 1906 times.
1966 if (wantNoMorePads) {
5288
2/6
✓ Branch 0 taken 60 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 60 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
60 g_signal_connect_data(decoder, "no-more-pads", G_CALLBACK(OnNoMorePads),
5289
1/2
✓ Branch 0 taken 60 times.
✗ Branch 1 not taken.
60 new std::shared_ptr<CallbackGuard>(guard),
5290 ReleaseCallbackGuard, (GConnectFlags)0);
5291 }
5292
1/2
✓ Branch 0 taken 1966 times.
✗ Branch 1 not taken.
1966 }
5293
5294 1920 GStreamerOutput* GStreamerOutput::LockCallbackGuard(gpointer userData,
5295 std::shared_ptr<CallbackGuard>& guard,
5296 std::unique_lock<std::recursive_mutex>& lock) {
5297 1920 auto* held = static_cast<std::shared_ptr<CallbackGuard>*>(userData);
5298
3/6
✓ Branch 0 taken 1920 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1920 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 1920 times.
1920 if (!held || !*held)
5299 ✗ return nullptr;
5300 1920 guard = *held;
5301
1/2
✓ Branch 0 taken 1920 times.
✗ Branch 1 not taken.
1920 lock = std::unique_lock<std::recursive_mutex>(guard->mtx);
5302
2/2
✓ Branch 0 taken 44 times.
✓ Branch 1 taken 1876 times.
1920 if (!guard->self) {
5303 // Close() already ran: the pipeline this callback belongs to is being
5304 // (or has been) torn down and every member below is stale.
5305 44 lock.unlock();
5306 44 return nullptr;
5307 }
5308 1876 return guard->self;
5309 }
5310
5311 1864 void GStreamerOutput::OnPadAdded(GstElement* element, GstPad* pad, gpointer userData) {
5312 1864 std::shared_ptr<CallbackGuard> guard;
5313 1864 std::unique_lock<std::recursive_mutex> guardLock;
5314
1/2
✓ Branch 0 taken 1864 times.
✗ Branch 1 not taken.
1864 GStreamerOutput* self = LockCallbackGuard(userData, guard, guardLock);
5315
2/2
✓ Branch 0 taken 43 times.
✓ Branch 1 taken 1821 times.
1864 if (!self)
5316 43 return;
5317
5318
1/2
✓ Branch 0 taken 1821 times.
✗ Branch 1 not taken.
1821 GstCaps* caps = gst_pad_get_current_caps(pad);
5319
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1821 times.
1821 if (!caps)
5320 ✗ caps = gst_pad_query_caps(pad, nullptr);
5321
5322
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1821 times.
1821 if (!caps)
5323 ✗ return;
5324
5325 // Log all structures in the caps for debugging
5326
3/4
✓ Branch 0 taken 3642 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1821 times.
✓ Branch 3 taken 1821 times.
3642 for (guint i = 0; i < gst_caps_get_size(caps); i++) {
5327
2/4
✓ Branch 0 taken 1821 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1821 times.
✗ Branch 3 not taken.
1821 const gchar* sname = gst_structure_get_name(gst_caps_get_structure(caps, i));
5328
1/2
✓ Branch 0 taken 1821 times.
✗ Branch 1 not taken.
1821 LogDebug(VB_MEDIAOUT, "GStreamer decodebin pad-added caps[%u]: %s\n", i, sname);
5329 }
5330
5331
2/4
✓ Branch 0 taken 1821 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1821 times.
✗ Branch 3 not taken.
1821 const gchar* name = gst_structure_get_name(gst_caps_get_structure(caps, 0));
5332
1/2
✓ Branch 0 taken 1821 times.
✗ Branch 1 not taken.
1821 LogDebug(VB_MEDIAOUT, "GStreamer decodebin pad-added: %s\n", name);
5333
5334 // Record that the file yielded decodable audio, independently of whether
5335 // this pipeline has an audio chain to link it into. The audio-only fallback
5336 // links its pads through gst_parse_launch rather than here, so m_audioChain
5337 // is null there and the branch below is skipped -- but that pipeline is
5338 // exactly the one that needs to know, so the flag is set before the check.
5339
4/8
✗ Branch 0 not taken.
✓ Branch 1 taken 1821 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✓ Branch 4 taken 1821 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1767 times.
✓ Branch 7 taken 54 times.
1821 if (g_str_has_prefix(name, "audio/")) {
5340 1767 self->m_sawAudioPad.store(true, std::memory_order_release);
5341 }
5342
5343
8/12
✗ Branch 0 not taken.
✓ Branch 1 taken 1821 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✓ Branch 4 taken 1821 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1767 times.
✓ Branch 7 taken 54 times.
✓ Branch 8 taken 47 times.
✓ Branch 9 taken 1720 times.
✓ Branch 10 taken 47 times.
✓ Branch 11 taken 1774 times.
1821 if (g_str_has_prefix(name, "audio/") && self->m_audioChain) {
5344
1/2
✓ Branch 0 taken 47 times.
✗ Branch 1 not taken.
47 GstPad* sinkPad = gst_element_get_static_pad(self->m_audioChain, "sink");
5345
4/8
✓ Branch 0 taken 47 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 47 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 47 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 47 times.
✗ Branch 7 not taken.
47 if (sinkPad && !gst_pad_is_linked(sinkPad)) {
5346
1/2
✓ Branch 0 taken 47 times.
✗ Branch 1 not taken.
47 GstPadLinkReturn ret = gst_pad_link(pad, sinkPad);
5347
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 47 times.
47 if (GST_PAD_LINK_FAILED(ret)) {
5348 ✗ LogErr(VB_MEDIAOUT, "GStreamer: Failed to link audio pad: %d\n", ret);
5349 } else {
5350
1/2
✓ Branch 0 taken 47 times.
✗ Branch 1 not taken.
47 LogDebug(VB_MEDIAOUT, "GStreamer: Linked audio pad successfully\n");
5351 47 self->m_audioLinked = true;
5352 }
5353 } else {
5354 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: Audio pad already linked or sink pad unavailable\n");
5355 }
5356
1/2
✓ Branch 0 taken 47 times.
✗ Branch 1 not taken.
47 if (sinkPad)
5357
1/2
✓ Branch 0 taken 47 times.
✗ Branch 1 not taken.
47 gst_object_unref(sinkPad);
5358
7/12
✗ Branch 0 not taken.
✓ Branch 1 taken 1774 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✓ Branch 4 taken 1774 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 54 times.
✓ Branch 7 taken 1720 times.
✓ Branch 8 taken 54 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 54 times.
✓ Branch 11 taken 1720 times.
1774 } else if (g_str_has_prefix(name, "video/") && self->m_videoChain) {
5359
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstPad* sinkPad = gst_element_get_static_pad(self->m_videoChain, "sink");
5360
4/8
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 54 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 54 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 54 times.
✗ Branch 7 not taken.
54 if (sinkPad && !gst_pad_is_linked(sinkPad)) {
5361
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 GstPadLinkReturn ret = gst_pad_link(pad, sinkPad);
5362
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 54 times.
54 if (GST_PAD_LINK_FAILED(ret)) {
5363 ✗ LogErr(VB_MEDIAOUT, "GStreamer: Failed to link video pad: %d\n", ret);
5364 } else {
5365
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 LogDebug(VB_MEDIAOUT, "GStreamer: Linked video pad successfully\n");
5366 54 self->m_videoLinked = true;
5367 }
5368 } else {
5369 ✗ LogWarn(VB_MEDIAOUT, "GStreamer: Video pad already linked or sink pad unavailable\n");
5370 }
5371
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 if (sinkPad)
5372
1/2
✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
54 gst_object_unref(sinkPad);
5373 } else {
5374
1/2
✓ Branch 0 taken 1720 times.
✗ Branch 1 not taken.
1720 LogDebug(VB_MEDIAOUT, "GStreamer: Ignoring pad with caps: %s\n", name);
5375 }
5376
5377
1/2
✓ Branch 0 taken 1821 times.
✗ Branch 1 not taken.
1821 gst_caps_unref(caps);
5378
4/4
✓ Branch 0 taken 1821 times.
✓ Branch 1 taken 43 times.
✓ Branch 2 taken 1821 times.
✓ Branch 3 taken 43 times.
1907 }
5379
5380 56 void GStreamerOutput::OnNoMorePads(GstElement* element, gpointer userData) {
5381 56 std::shared_ptr<CallbackGuard> guard;
5382 56 std::unique_lock<std::recursive_mutex> guardLock;
5383
1/2
✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
56 GStreamerOutput* self = LockCallbackGuard(userData, guard, guardLock);
5384
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 55 times.
56 if (!self)
5385 1 return;
5386
5387
5/6
✓ Branch 0 taken 54 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 47 times.
✓ Branch 3 taken 8 times.
✓ Branch 4 taken 55 times.
✗ Branch 5 not taken.
55 LogDebug(VB_MEDIAOUT, "GStreamer: no-more-pads (audio=%s, video=%s)\n",
5388 self->m_audioLinked ? "linked" : "not linked",
5389 self->m_videoLinked ? "linked" : "not linked");
5390
5391 // If audio chain was never connected, remove orphaned audio elements from the bin.
5392 // Without this, the pipeline will never reach EOS because the audio sinks
5393 // never receive data and never post their individual EOS events.
5394
4/6
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 47 times.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
55 if (!self->m_audioLinked && self->m_audioChain && self->m_pipeline) {
5395
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 LogDebug(VB_MEDIAOUT, "GStreamer: Removing unconnected audio chain (video-only media)\n");
5396
5397 // Get all audio elements by name and remove them
5398 8 const char* audioNames[] = {"aconv", "aresample", "t", "q1", "vol", "pwsink", "audiosink", "pwfeed",
5399 "q2", "aconv2", "acapsf", "sampletap", nullptr};
5400
2/2
✓ Branch 0 taken 96 times.
✓ Branch 1 taken 8 times.
104 for (int i = 0; audioNames[i]; i++) {
5401
3/6
✓ Branch 0 taken 96 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 96 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 96 times.
✗ Branch 5 not taken.
96 GstElement* el = gst_bin_get_by_name(GST_BIN(self->m_pipeline), audioNames[i]);
5402
2/2
✓ Branch 0 taken 80 times.
✓ Branch 1 taken 16 times.
96 if (el) {
5403
1/2
✓ Branch 0 taken 80 times.
✗ Branch 1 not taken.
80 gst_element_set_state(el, GST_STATE_NULL);
5404
3/6
✓ Branch 0 taken 80 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 80 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 80 times.
✗ Branch 5 not taken.
80 gst_bin_remove(GST_BIN(self->m_pipeline), el);
5405
1/2
✓ Branch 0 taken 80 times.
✗ Branch 1 not taken.
80 gst_object_unref(el);
5406 }
5407 }
5408
5409 // Release our refs and clear audio pointers
5410
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 if (self->m_appsink) {
5411
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 gst_object_unref(self->m_appsink);
5412 8 self->m_appsink = nullptr;
5413 }
5414
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 if (self->m_volume) {
5415
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 gst_object_unref(self->m_volume);
5416 8 self->m_volume = nullptr;
5417 }
5418 8 self->m_audioChain = nullptr;
5419 8 self->m_appsinkSignalId = 0;
5420 // A video that turned out to have no sound fed the output stream
5421 // nothing: Process() hands the stream back (on the main loop, where
5422 // the feed is used).
5423
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 8 times.
8 if (self->m_pwFeed) {
5424 ✗ self->m_pwFeedOrphaned = true;
5425 }
5426 }
5427
5428 // If video chain was never connected, remove orphaned video elements.
5429
4/6
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 54 times.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
55 if (!self->m_videoLinked && self->m_videoChain && self->m_pipeline) {
5430
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogDebug(VB_MEDIAOUT, "GStreamer: Removing unconnected video chain (audio-only media)\n");
5431
5432 1 const char* videoNames[] = {"vq", "vconv", "vscale", "vcapsf", "vtee", "vkmsq", "vpwq", "videosink", "kmsvideosink", "pwvideosink", nullptr};
5433
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 1 times.
11 for (int i = 0; videoNames[i]; i++) {
5434
3/6
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 10 times.
✗ Branch 5 not taken.
10 GstElement* el = gst_bin_get_by_name(GST_BIN(self->m_pipeline), videoNames[i]);
5435
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 8 times.
10 if (el) {
5436
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_element_set_state(el, GST_STATE_NULL);
5437
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 gst_bin_remove(GST_BIN(self->m_pipeline), el);
5438
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 gst_object_unref(el);
5439 }
5440 }
5441
5442 // The consumers' direct kmssink chains hang off vtee, gone now, and
5443 // their elements are unnamed but for the sink. Left in, a sink that
5444 // never negotiates never posts EOS, and the bin waits for one from
5445 // every sink. Walk each up from its sink until the link runs out.
5446 // m_directConnectorIds stays: Close() releases their DRM leases.
5447
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 for (int cid : self->m_directConnectorIds) {
5448
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::string name = "dkms_" + std::to_string(cid);
5449
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 GstElement* el = gst_bin_get_by_name(GST_BIN(self->m_pipeline), name.c_str());
5450
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 1 times.
5 while (el) {
5451 4 GstElement* up = nullptr;
5452
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 if (GstPad* sp = gst_element_get_static_pad(el, "sink")) {
5453
3/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✓ Branch 3 taken 1 times.
4 if (GstPad* peer = gst_pad_get_peer(sp)) {
5454
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 up = gst_pad_get_parent_element(peer);
5455
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 gst_object_unref(peer);
5456 }
5457
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_object_unref(sp);
5458 }
5459
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_element_set_state(el, GST_STATE_NULL);
5460
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 gst_bin_remove(GST_BIN(self->m_pipeline), el);
5461
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_object_unref(el);
5462 4 el = up;
5463 }
5464 1 }
5465
5466 // Release our ref and clear video pointers
5467
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (self->m_videoAppsink) {
5468 ✗ gst_object_unref(self->m_videoAppsink);
5469 ✗ self->m_videoAppsink = nullptr;
5470 }
5471 1 self->m_videoChain = nullptr;
5472 1 self->m_videoAppsinkSignalId = 0;
5473 1 self->m_hasVideoStream = false;
5474 1 self->m_kmssink = nullptr; // owned by pipeline bin, already removed above
5475 }
5476
4/4
✓ Branch 0 taken 55 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 55 times.
✓ Branch 3 taken 1 times.
57 }
5477
5478 716 GstFlowReturn GStreamerOutput::OnNewSample(GstAppSink* appsink, gpointer userData) {
5479 716 GStreamerOutput* self = static_cast<GStreamerOutput*>(userData);
5480
3/6
✓ Branch 0 taken 716 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 716 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 716 times.
716 if (!self || self->m_shutdownFlag.load(std::memory_order_acquire))
5481 ✗ return GST_FLOW_EOS;
5482
5483
1/2
✓ Branch 0 taken 716 times.
✗ Branch 1 not taken.
716 GstSample* sample = gst_app_sink_pull_sample(appsink);
5484
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 716 times.
716 if (!sample)
5485 ✗ return GST_FLOW_OK;
5486
5487
1/2
✓ Branch 0 taken 716 times.
✗ Branch 1 not taken.
716 GstBuffer* buffer = gst_sample_get_buffer(sample);
5488
1/2
✓ Branch 0 taken 716 times.
✗ Branch 1 not taken.
716 GstCaps* caps = gst_sample_get_caps(sample);
5489
5490 716 int rate = 0;
5491
1/2
✓ Branch 0 taken 716 times.
✗ Branch 1 not taken.
716 if (caps) {
5492
1/2
✓ Branch 0 taken 716 times.
✗ Branch 1 not taken.
716 GstStructure* s = gst_caps_get_structure(caps, 0);
5493
1/2
✓ Branch 0 taken 716 times.
✗ Branch 1 not taken.
716 gst_structure_get_int(s, "rate", &rate);
5494 }
5495
5496 GstMapInfo map;
5497
2/4
✓ Branch 0 taken 716 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 716 times.
✗ Branch 3 not taken.
716 if (gst_buffer_map(buffer, &map, GST_MAP_READ)) {
5498 716 int numFloats = map.size / sizeof(float);
5499 716 const float* src = reinterpret_cast<const float*>(map.data);
5500
5501
1/2
✓ Branch 0 taken 716 times.
✗ Branch 1 not taken.
716 std::lock_guard<std::mutex> lock(s_sampleMutex);
5502 // Every playing slot has a tap; only the owner's audio goes in the
5503 // ring, or two slots interleave into noise.
5504
2/2
✓ Branch 0 taken 602 times.
✓ Branch 1 taken 114 times.
716 if (self == m_currentInstance) {
5505
1/2
✓ Branch 0 taken 602 times.
✗ Branch 1 not taken.
602 if (rate > 0) {
5506 602 s_sampleRate = rate;
5507 }
5508
2/2
✓ Branch 0 taken 1180672 times.
✓ Branch 1 taken 602 times.
1181274 for (int i = 0; i < numFloats; i++) {
5509 1180672 s_sampleBuffer[s_sampleWritePos] = src[i];
5510 1180672 s_sampleWritePos = (s_sampleWritePos + 1) % SAMPLE_BUFFER_SIZE;
5511 }
5512
1/2
✓ Branch 0 taken 602 times.
✗ Branch 1 not taken.
602 s_sampleLastWriteMs = GetMonotonicTimeMS();
5513 }
5514
5515
1/2
✓ Branch 0 taken 716 times.
✗ Branch 1 not taken.
716 gst_buffer_unmap(buffer, &map);
5516 716 }
5517
5518
1/2
✓ Branch 0 taken 716 times.
✗ Branch 1 not taken.
716 gst_sample_unref(sample);
5519 716 return GST_FLOW_OK;
5520 }
5521
5522 93511 GstPadProbeReturn GStreamerOutput::GateSampleTap(GstPad* pad, GstPadProbeInfo* info, gpointer userData) {
5523 93511 uint64_t lastRead = s_sampleTapReadMs.load(std::memory_order_relaxed);
5524
5/6
✓ Branch 0 taken 1003 times.
✓ Branch 1 taken 92508 times.
✓ Branch 2 taken 1003 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1003 times.
✓ Branch 5 taken 92508 times.
93511 if (lastRead && (uint64_t)GetMonotonicTimeMS() - lastRead < SAMPLE_TAP_IDLE_MS) {
5525 1003 return GST_PAD_PROBE_OK;
5526 }
5527 92508 return GST_PAD_PROBE_DROP;
5528 }
5529
5530 2109 void GStreamerOutput::SetSampleTapOwnerLocked(GStreamerOutput* o) {
5531 2109 m_currentInstance = o;
5532
1/2
✓ Branch 0 taken 2109 times.
✗ Branch 1 not taken.
2109 s_sampleBuffer.fill(0.0f);
5533 2109 s_sampleWritePos = 0;
5534 2109 s_sampleRate = 0;
5535 2109 s_sampleLastWriteMs = 0;
5536 2109 }
5537
5538 3351 bool GStreamerOutput::GetAudioSamples(float* samples, int numSamples, int& sampleRate) {
5539
2/4
✓ Branch 0 taken 3351 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3351 times.
3351 if (numSamples <= 0 || numSamples > SAMPLE_BUFFER_SIZE)
5540 ✗ return false; // the read position below would leave the ring
5541
1/2
✓ Branch 0 taken 3351 times.
✗ Branch 1 not taken.
3351 std::lock_guard<std::mutex> lock(s_sampleMutex);
5542
2/2
✓ Branch 0 taken 1151 times.
✓ Branch 1 taken 2200 times.
3351 if (!m_currentInstance)
5543 1151 return false;
5544
5545
1/2
✓ Branch 0 taken 2200 times.
✗ Branch 1 not taken.
2200 uint64_t now = GetMonotonicTimeMS();
5546 2200 uint64_t lastRead = s_sampleTapReadMs.exchange(now, std::memory_order_relaxed);
5547
5548
3/4
✓ Branch 0 taken 2197 times.
✓ Branch 1 taken 3 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 2197 times.
2200 if (!lastRead || now - lastRead >= SAMPLE_TAP_IDLE_MS) {
5549 // The tap has been closed, so what the buffer holds is from whenever
5550 // it last ran. Its audio starts flowing again from here.
5551
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 s_sampleBuffer.fill(0.0f);
5552 3 s_sampleWritePos = 0;
5553 3 return false;
5554 }
5555
3/4
✓ Branch 0 taken 2187 times.
✓ Branch 1 taken 10 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 2187 times.
2197 if (s_sampleRate == 0 || now - s_sampleLastWriteMs > SAMPLE_STALE_MS)
5556 10 return false;
5557
5558 2187 sampleRate = s_sampleRate;
5559
5560 // Read the most recent numSamples from the circular buffer
5561 2187 int readPos = (s_sampleWritePos - numSamples + SAMPLE_BUFFER_SIZE) % SAMPLE_BUFFER_SIZE;
5562
2/2
✓ Branch 0 taken 2239488 times.
✓ Branch 1 taken 2187 times.
2241675 for (int i = 0; i < numSamples; i++) {
5563 2239488 samples[i] = s_sampleBuffer[readPos];
5564 2239488 readPos = (readPos + 1) % SAMPLE_BUFFER_SIZE;
5565 }
5566 2187 return true;
5567 3351 }
5568
5569 // ──────────────────────────────────────────────────────────────────────────────
5570 // AES67 zero-hop RTP branch helpers (Phase 7.9)
5571 // ──────────────────────────────────────────────────────────────────────────────
5572 #ifdef HAS_AES67_GSTREAMER
5573 1966 void GStreamerOutput::AttachAES67Branches(GstElement* tee) {
5574 // Inline zero-hop branches are disabled. They create a second RTP stream
5575 // (different SSRC) alongside the standalone pipewiresrc→udpsink pipeline,
5576 // which confuses AES67 receivers causing repeated/out-of-time audio.
5577 // The standalone pipeline with sync=false already provides low latency.
5578 (void)tee;
5579 1966 return;
5580 }
5581
5582 2078 void GStreamerOutput::DetachAES67Branches() {
5583 // No-op: inline branches are disabled.
5584 2078 }
5585 #endif // HAS_AES67_GSTREAMER
5586
5587 // ──────────────────────────────────────────────────────────────────────────────
5588 // PipeWire filter-chain delay buffer flush
5589 // ──────────────────────────────────────────────────────────────────────────────
5590 // A delayed group member plays through a filter-chain of builtin delay nodes,
5591 // and their ring buffers hold the last delaySec of whatever went in. When a
5592 // track stops, that tail would play on for the length of the delay, and
5593 // after an idle spell a stalled chain plays it ahead of the next track.
5594 //
5595 // Nothing in PipeWire empties a delay node's ring buffer: it has no reset,
5596 // and changing its "Delay (s)" only moves the read point, so zeroing the
5597 // delay and restoring it reads the same old samples back. Instead the
5598 // chain's output (<fx>_out, a stream FPP itself never mutes -- a member's
5599 // mute is on the chain's sink) is muted for the length of the delay: by then
5600 // everything in the buffer came in after the flush, and the next track,
5601 // itself delayed that long, has not come through yet.
5602
5603 static const char* const kPipeWireEnv =
5604 "PIPEWIRE_RUNTIME_DIR=/run/pipewire-fpp XDG_RUNTIME_DIR=/run/pipewire-fpp";
5605
5606 // The id of the newest PipeWire node whose property `key` is `value`, or -1.
5607 // From pw-dump: pw-cli ls lists only a few properties of each node, and a
5608 // grep -B1 on its node.name line found the "=" of the node.description line
5609 // above it. Newest by object.serial: a pipeline still closing can hold a
5610 // node of the same name.
5611 39 int GStreamerOutput::FindPipeWireNodeId(const std::string& key, const std::string& value) {
5612
2/4
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 39 times.
✗ Branch 3 not taken.
39 std::string cmd = std::string(kPipeWireEnv) + " timeout 3 pw-dump 2>/dev/null";
5613
1/2
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
39 FILE* fp = popen(cmd.c_str(), "r");
5614
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 39 times.
39 if (!fp) {
5615 ✗ return -1;
5616 }
5617 39 std::string out;
5618 char buf[8192];
5619 size_t r;
5620
3/4
✓ Branch 0 taken 530 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 491 times.
✓ Branch 3 taken 39 times.
530 while ((r = fread(buf, 1, sizeof(buf), fp)) > 0) {
5621
1/2
✓ Branch 0 taken 491 times.
✗ Branch 1 not taken.
491 out.append(buf, r);
5622 }
5623
1/2
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
39 pclose(fp);
5624
1/2
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
39 Json::Value dump;
5625
5/10
✓ 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 not taken.
✓ Branch 7 taken 39 times.
✗ Branch 8 not taken.
✓ Branch 9 taken 39 times.
39 if (!LoadJsonFromString(out, dump) || !dump.isArray()) {
5626 ✗ return -1;
5627 }
5628 39 int best = -1;
5629 39 Json::Int64 bestSerial = -1;
5630
7/12
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 39 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2278 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2278 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 2317 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 2278 times.
✓ Branch 11 taken 39 times.
2317 for (const auto& obj : dump) {
5631
13/28
✓ Branch 0 taken 2278 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2278 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2278 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2278 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 2278 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 2278 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 2073 times.
✓ Branch 13 taken 205 times.
✓ Branch 14 taken 2278 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 2278 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 2278 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 2073 times.
✓ Branch 21 taken 205 times.
✗ Branch 22 not taken.
✗ Branch 23 not taken.
✗ Branch 24 not taken.
✗ Branch 25 not taken.
✗ Branch 26 not taken.
✗ Branch 27 not taken.
2278 if (!obj.isObject() || obj.get("type", "").asString() != "PipeWire:Interface:Node") {
5632 2073 continue;
5633 }
5634
2/4
✓ Branch 0 taken 205 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 205 times.
✗ Branch 3 not taken.
205 const Json::Value& props = obj["info"]["props"];
5635
13/20
✓ Branch 0 taken 205 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 205 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 205 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 56 times.
✓ Branch 7 taken 149 times.
✓ Branch 8 taken 56 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 56 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 17 times.
✓ Branch 13 taken 39 times.
✓ Branch 14 taken 56 times.
✓ Branch 15 taken 149 times.
✓ Branch 16 taken 166 times.
✓ Branch 17 taken 39 times.
✗ Branch 18 not taken.
✗ Branch 19 not taken.
205 if (!props.isObject() || !props.isMember(key) || props[key].asString() != value) {
5636 166 continue;
5637 }
5638
1/2
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
39 const Json::Value& serial = props["object.serial"];
5639
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 Json::Int64 n = serial.isIntegral() ? serial.asInt64() : 0;
5640
5/10
✓ 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.
39 if (n >= bestSerial && obj["id"].isIntegral()) {
5641
2/4
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 39 times.
✗ Branch 3 not taken.
39 best = obj["id"].asInt();
5642 39 bestSerial = n;
5643 }
5644 }
5645 39 return best;
5646 39 }
5647
5648 // No result: fppd reaps its children itself (SA_NOCLDWAIT), so system()
5649 // returns -1 whatever pw-cli did.
5650 41 static void SetPipeWireNodeProps(int nodeId, const std::string& props) {
5651
3/6
✓ Branch 0 taken 41 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 41 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 41 times.
✗ Branch 5 not taken.
164 std::string cmd = std::string(kPipeWireEnv) + " timeout 3 pw-cli set-param " + std::to_string(nodeId) +
5652
3/6
✓ Branch 0 taken 41 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 41 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 41 times.
✗ Branch 5 not taken.
41 " Props '" + props + "' >/dev/null 2>&1";
5653
1/2
✓ Branch 0 taken 41 times.
✗ Branch 1 not taken.
41 system(cmd.c_str());
5654 41 }
5655
5656 // Sets the adapter rate of the stream tagged fpp.stream.id = `streamId`.
5657 // Detached, since AdjustSpeed() runs on the main loop with mediaOutputLock
5658 // held, and serialized: of the requests queued behind one in flight only the
5659 // newest is sent.
5660 36 void GStreamerOutput::SetPipeWireStreamRate(const std::string& streamId, float rate) {
5661 static std::mutex lock;
5662 static std::mutex sendLock;
5663
3/4
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 35 times.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
36 static std::map<std::string, uint64_t> latest;
5664 uint64_t seq;
5665 {
5666
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 std::lock_guard<std::mutex> l(lock);
5667
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 seq = ++latest[streamId];
5668 36 }
5669
1/4
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
108 std::thread([streamId, rate, seq]() {
5670
2/4
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 36 times.
✗ Branch 3 not taken.
36 SetThreadName("FPP-PWRate");
5671
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 std::lock_guard<std::mutex> sending(sendLock);
5672 {
5673
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 std::lock_guard<std::mutex> l(lock);
5674
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 auto it = latest.find(streamId);
5675
3/6
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 36 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 36 times.
36 if (it == latest.end() || it->second != seq) {
5676 ✗ return;
5677 }
5678
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 }
5679
2/4
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 36 times.
✗ Branch 3 not taken.
72 int nodeId = FindPipeWireNodeId("fpp.stream.id", streamId);
5680
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 36 times.
36 if (nodeId < 0) {
5681 ✗ LogDebug(VB_MEDIAOUT, "GStreamer: no PipeWire node for stream %s, rate %0.3f not set\n",
5682 streamId.c_str(), rate);
5683 ✗ std::lock_guard<std::mutex> l(lock);
5684 ✗ auto it = latest.find(streamId);
5685 ✗ if (it != latest.end() && it->second == seq) {
5686 ✗ latest.erase(it);
5687 }
5688 ✗ return;
5689 ✗ }
5690 char props[64];
5691 36 snprintf(props, sizeof(props), "{ rate = %.6f }", rate);
5692
2/4
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 36 times.
✗ Branch 3 not taken.
36 SetPipeWireNodeProps(nodeId, props);
5693
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 LogDebug(VB_MEDIAOUT, "GStreamer: PipeWire stream rate %0.3f (node %d)\n", rate, nodeId);
5694
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 std::lock_guard<std::mutex> l(lock);
5695
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 auto it = latest.find(streamId);
5696
3/6
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 36 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 36 times.
✗ Branch 5 not taken.
36 if (it != latest.end() && it->second == seq) {
5697
1/2
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
36 latest.erase(it); // one id per track: don't keep them all
5698 }
5699
3/6
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 36 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 36 times.
✗ Branch 5 not taken.
72 }).detach();
5700 36 }
5701
5702 4058 void GStreamerOutput::FlushPipeWireDelayBuffers() {
5703 // Read audio groups config to find delay values
5704
3/6
✓ Branch 0 taken 4058 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4058 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4058 times.
✗ Branch 5 not taken.
4058 std::string configPath = FPP_DIR_CONFIG("/pipewire-audio-groups.json");
5705
3/4
✓ Branch 0 taken 4058 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4053 times.
✓ Branch 3 taken 5 times.
4058 if (!FileExists(configPath)) {
5706 4053 return;
5707 }
5708
5709
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 Json::Value root;
5710
7/10
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 4 times.
✓ Branch 8 taken 1 times.
✓ Branch 9 taken 4 times.
5 if (!LoadJsonFromFile(configPath, root, JsonRoot::Object) || !root.isMember("groups")) {
5711 1 return;
5712 }
5713
5714 struct DelayInfo {
5715 std::string fxOutName; // e.g. fpp_fx_g1_s3_out
5716 double delaySec;
5717 };
5718 4 std::vector<DelayInfo> delays;
5719
5720 // Values of the wrong JSON type threw from here, on media start, to
5721 // std::terminate; flush what could be read.
5722 try {
5723
8/14
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 3 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 7 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 4 times.
✓ Branch 13 taken 3 times.
7 for (const auto& group : root["groups"]) {
5724
4/6
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✓ Branch 5 taken 1 times.
6 int groupId = group.get("id", 0).asInt();
5725
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
3 if (!group.isMember("members"))
5726 ✗ continue;
5727
8/14
✓ 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 taken 6 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 3 times.
✓ Branch 13 taken 3 times.
6 for (const auto& member : group["members"]) {
5728
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.
3 std::string cardId = member.get("cardId", "").asString();
5729
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.
3 double delayMs = member.get("delayMs", 0.0).asDouble();
5730
3/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 taken 3 times.
3 if (cardId.empty() || delayMs <= 0)
5731 ✗ continue; // no delay buffer to flush
5732
5733 // Normalize card ID to match PHP: preg_replace('/[^a-zA-Z0-9_]/', '_', strtolower($cardId))
5734 3 std::string cardIdNorm;
5735
2/2
✓ Branch 0 taken 24 times.
✓ Branch 1 taken 3 times.
27 for (char c : cardId) {
5736
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 24 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
24 if (std::isalnum(c) || c == '_')
5737
1/2
✓ Branch 0 taken 24 times.
✗ Branch 1 not taken.
24 cardIdNorm += std::tolower(c);
5738 else
5739 ✗ cardIdNorm += '_';
5740 }
5741
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.
3 std::string fxNodeName = "fpp_fx_g" + std::to_string(groupId) + "_" + cardIdNorm;
5742 3 delays.push_back({ fxNodeName + "_out", delayMs / 1000.0 });
5743
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 }
5744 }
5745
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 } catch (const std::exception& e) {
5746
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-audio-groups.json", e.what());
5747 1 }
5748
5749
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 3 times.
4 if (delays.empty())
5750 1 return;
5751
5752 // Start() and Close() both flush, often within a few ms of each other:
5753 // only the newest flush of a chain unmutes it.
5754 static std::mutex flushLock;
5755
3/4
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
3 static std::map<std::string, uint64_t> flushGeneration;
5756
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 3 times.
6 for (const auto& d : delays) {
5757 uint64_t generation;
5758 {
5759
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 std::lock_guard<std::mutex> lock(flushLock);
5760
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 generation = ++flushGeneration[d.fxOutName];
5761 3 }
5762 // Detached: pw-cli takes ~100 ms, and this runs from Start() and
5763 // Close() with media locks held.
5764
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 uint64_t unmuteAt = GetMonotonicTimeMS() + (uint64_t)(d.delaySec * 1000.0);
5765
1/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
9 std::thread([d, generation, unmuteAt]() {
5766
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 SetThreadName("FPP-PWFlush");
5767
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
6 int nodeId = FindPipeWireNodeId("node.name", d.fxOutName);
5768
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
3 if (nodeId < 0) {
5769 ✗ LogDebug(VB_MEDIAOUT, "PipeWire delay flush: no node %s\n", d.fxOutName.c_str());
5770 ✗ return;
5771 }
5772
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 SetPipeWireNodeProps(nodeId, "{ mute = true }");
5773 3 LogDebug(VB_MEDIAOUT, "PipeWire delay flush: muted %s (node %d)\n", d.fxOutName.c_str(), nodeId);
5774 3 uint64_t now = GetMonotonicTimeMS();
5775
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 if (unmuteAt > now) {
5776
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 std::this_thread::sleep_for(std::chrono::milliseconds(unmuteAt - now));
5777 }
5778 {
5779
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 std::lock_guard<std::mutex> lock(flushLock);
5780
3/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 2 times.
3 if (flushGeneration[d.fxOutName] != generation) {
5781 1 return; // a later flush holds the mute now
5782 }
5783
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 1 times.
3 }
5784
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 SetPipeWireNodeProps(nodeId, "{ mute = false }");
5785 2 LogDebug(VB_MEDIAOUT, "PipeWire delay buffer of %s flushed (%.0f ms)\n", d.fxOutName.c_str(),
5786 d.delaySec * 1000.0);
5787
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 }).detach();
5788 }
5789
8/12
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✓ Branch 5 taken 1 times.
✓ Branch 6 taken 3 times.
✓ Branch 7 taken 2 times.
✓ Branch 8 taken 3 times.
✓ Branch 9 taken 4055 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
4064 }
5790
5791 #endif // HAS_GSTREAMER
5792