| 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(¶ms); |
| 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, ¶ms); |
| 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 |