GCC Code Coverage Report


Directory: ./
File: mediaoutput/GStreamerOut.h
Date: 2026-10-29 08:11:58
Exec Total Coverage
Lines: 9 9 100.0%
Functions: 9 9 100.0%
Branches: 0 0 -%

Line Branch Exec Source
1 #pragma once
2 /*
3 * This file is part of the Falcon Player (FPP) and is Copyright (C)
4 * 2013-2025 by the Falcon Player Developers.
5 *
6 * The Falcon Player (FPP) is free software, and is covered under
7 * multiple Open Source licenses. Please see the included 'LICENSES'
8 * file for descriptions of what files are covered by each license.
9 *
10 * This source file is covered under the LGPL v2.1 as described in the
11 * included LICENSE.LGPL file.
12 */
13
14 #include "MediaOutputBase.h"
15
16 #if __has_include(<gst/gst.h>)
17 #define HAS_GSTREAMER
18
19 #include <gst/gst.h>
20 #include <gst/app/gstappsink.h>
21 #include <array>
22 #include <atomic>
23 #include <condition_variable>
24 #include <list>
25 #include <map>
26 #include <memory>
27 #include <mutex>
28 #include <set>
29 #include <vector>
30
31 #include "AES67Manager.h"
32 #include "AtomicSharedPtr.h"
33 #include "DrmCards.h"
34 #include "FeedSyncWindow.h"
35 #include "OpusRTPManager.h"
36
37 class PixelOverlayModel;
38 class PipeWireOutputStream;
39
40 class GStreamerOutput : public MediaOutputBase {
41 public:
42 GStreamerOutput(const std::string& mediaFilename, MediaOutputStatus* status, const std::string& videoOut = "", int streamSlot = 1);
43 virtual ~GStreamerOutput();
44
45 virtual int Start(int msTime = 0) override;
46 virtual int Stop(void) override;
47 virtual int Process(void) override;
48 virtual int Close(void) override;
49 virtual int IsPlaying(void) override;
50 virtual int AdjustSpeed(float masterPos) override;
51 // True once the reported position -- already backed up to what is leaving
52 // the card -- has moved past the start of the media.
53 686 virtual bool IsAudible(void) override { return m_audible; }
54
55 /// Flush-seek to an absolute position in seconds. Audible/visible glitch,
56 /// so this is for repairing a stream that has genuinely left the show
57 /// timeline, not for routine correction. Returns false if the seek failed.
58 bool SeekTo(float seconds);
59 virtual void SetVolume(int volume) override;
60 /// SetVolume() that says whether the output took the volume: false when
61 /// it has no volume element to set -- before Start(), once its teardown
62 /// (Stop() or Close()) has begun, and for media with no sound.
63 bool TrySetVolume(int volume);
64
65 /// Ask the output's owner to stop it, from a thread that doesn't own it
66 /// (a command, or the slot manager stopping a playlist's background
67 /// slots). Only the owner may Stop()/Close()/delete an output: the base
68 /// version flags it for the next Process() on the owner's thread; Play
69 /// Media's outputs override it to stop through their own registry.
70 1 virtual void RequestStop() { m_stopRequested = true; }
71
72 /// Whether StreamSlotManager must call Process() for this output. False
73 /// for outputs whose owner already does (a playlist entry, the remote's
74 /// synced media); Play Media's outputs have no other pump.
75 48676 virtual bool NeedsSlotPump() const { return false; }
76
77 // Static methods matching SDLOutput interface
78 static bool IsOverlayingVideo();
79 static bool ProcessVideoOverlay(unsigned int msTimestamp);
80 static bool GetAudioSamples(float* samples, int numSamples, int& sampleRate);
81
82 // One-time GStreamer + PipeWire env initialization (safe to call repeatedly)
83 static void EnsureGStreamerInit();
84 // Take `pipeline` to NULL on a reaper thread, waiting at most timeoutMs.
85 // False if it wedged: the reaper keeps its own ref and the pipeline (with
86 // whatever it holds) is left to it. The caller's ref is untouched.
87 static bool SetPipelineToNullBounded(GstElement* pipeline, int timeoutMs);
88 // Set the environment now, on the calling thread, and run gst_init() in the
89 // background so the first play doesn't pay for it (~1 s on an AM335x, and
90 // ~20 s the very first time, when GStreamer builds its plugin registry).
91 static void PreloadAsync();
92 // Make every videoconvert in `pipeline` -- including ones added later --
93 // read decoded frames from padded system memory rather than straight out
94 // of a hardware decoder's buffers. See the comment at the definition.
95 // Also has each decodebin note a hardware video decoder it chooses, for
96 // the wedge ladder.
97 static void GuardVideoConvertInputs(GstElement* pipeline);
98
99 // The PipeWire sink a slot's audio is routed to ("" = PipeWire's default).
100 static std::string PipeWireSinkNameForSlot(int slot);
101 // The rate the PipeWire graph runs at (48000 if it can't be determined).
102 static int PipeWireGraphRate();
103 // /proc/asound status file of a card the PipeWire sink `sinkName` (a group
104 // or an fpp_alsa_* adapter) plays into, or "" if it reaches no card -- a
105 // group of network senders only, or PipeWire's unnamed default sink.
106 static std::string AlsaSinkStatusPath(const std::string& sinkName);
107
108 // Resolve connector name (e.g., "HDMI-A-1") to DRM card path, connector ID,
109 // and display resolution by scanning sysfs. Works on all Pi models.
110 struct DrmConnectorInfo {
111 std::string cardPath; // e.g. "/dev/dri/card1"
112 int connectorId = -1; // integer ID for kmssink
113 bool connected = false;
114 int displayWidth = 0;
115 int displayHeight = 0;
116 int primaryPlaneId = -1; // DRM primary plane for this connector's CRTC
117 };
118 static DrmConnectorInfo ResolveDrmConnector(const std::string& connectorName);
119
120
121 // GStreamer-specific
122 1837 void SetLoopCount(int loops) { m_loopCount = loops; }
123 // Whether this output's position paces the running sequence (the channel
124 // output servo). True for the show's own media -- a playlist entry's, or
125 // a remote's synced media -- and false for media played alongside it.
126 1837 void SetDrivesShowClock(bool drives) { m_drivesShowClock = drives; }
127 void SetVolumeAdjustment(int volAdj);
128
129 // Event callbacks (matching VLCOutput pattern).
130 //
131 // Playing(), Stopping() and Stopped() are called from Stop() and also from
132 // BusSyncHandler, on the GStreamer streaming thread that delivered the
133 // message, while that thread still holds the sink's internal locks. An override must only
134 // record state or hand work to the main loop (Timers): anything that
135 // seeks, changes pipeline state, stops or closes an output -- including
136 // running a command preset, which can do all of those -- deadlocks there.
137 1966 virtual void Starting() {}
138 413 virtual void Playing() {}
139 2084 virtual void Stopping() {}
140 1961 virtual void Stopped() {}
141
142 private:
143 // Serializes Start(), Stop() and Close() against each other and against
144 // Process(), SeekTo() and AdjustSpeed(), which a slot's pump and MultiSync
145 // run on the main loop while a command may be stopping the same output on
146 // an HTTP thread. The pump side only ever try-locks and skips a tick when
147 // the output is busy, so it never waits here; holders may take
148 // StreamSlotManager's lock, never the other way round.
149 std::recursive_mutex m_opMutex;
150 std::atomic<bool> m_stopRequested{false};
151
152 // An optional video branch -- a secondary display's kmssink, or the
153 // PipeWire video tap -- whose sink failed. Its GST_FLOW_ERROR would
154 // otherwise reach the tee and fail the whole pipeline, so the bus sync
155 // handler cuts it off at the tee (CutFailedBranch, on the failing sink's
156 // streaming thread) and Process() takes it out of the pipeline
157 // (RemoveFailedBranches, op lock held). Holds a ref on every object.
158 struct FailedBranch {
159 GstElement* tee = nullptr;
160 GstPad* teePad = nullptr;
161 std::vector<GstElement*> elements; // sink first, back to the queue
162 };
163 std::mutex m_failedBranchLock;
164 std::vector<FailedBranch> m_failedBranches;
165 std::atomic<bool> m_haveFailedBranches{false};
166 bool CutFailedBranch(GstElement* sink);
167 // The head of an optional branch, between the tee and the branch's
168 // queue: turns a sink's GST_FLOW_ERROR into OK, so even a buffer already
169 // waiting on the branch's full queue can't carry it back to the tee.
170 // nullptr where errorignore isn't installed.
171 static GstElement* MakeBranchErrorGuard();
172 void RemoveFailedBranches();
173 void ReleaseFailedBranches();
174 bool m_drivesShowClock = true;
175 // Cuts the decodebin and bus callbacks off from this object and waits for
176 // any that are running. See CallbackGuard.
177 void DetachCallbacks();
178 // This output's id for its PixelOverlayManager model listener.
179 std::string OverlayListenerId() const;
180
181 GstElement* m_pipeline = nullptr;
182 GstElement* m_volume = nullptr;
183 GstElement* m_appsink = nullptr; // audio sample tap
184 GstElement* m_videoAppsink = nullptr; // video frame sink for PixelOverlay
185 GstBus* m_bus = nullptr;
186 std::string m_videoOut;
187 int m_loopCount = 0;
188 int m_volumeAdjust = 0;
189 // Per-file loudness gain from the File Manager's Normalize (linear, 1.0
190 // = as recorded), looked up in media_gain.json when the song starts and
191 // multiplied into every value set on m_volume. See LookupNormalizeGain().
192 double m_fileGain = 1.0;
193 std::atomic<bool> m_playing{false}; // written from GStreamer thread (BusSyncHandler), read from main loop
194 std::atomic<bool> m_shutdownFlag{false}; // guards callbacks during teardown
195 bool m_teardownComplete = false; // makes Stop() idempotent — the expensive pipeline teardown (silence flush + DRM release sleeps) must run only once even when Stop() is called both directly and via Close()/dtor
196 // Set when the supervised pipeline-NULL transition timed out and the
197 // pipeline was abandoned to a reaper thread (wedged V4L2 decoder, issue
198 // #2695). Close() must then leak the pipeline's DRM planes and shared
199 // DRM fd refs — the still-alive kmssinks own them in the kernel.
200 bool m_teardownAbandoned = false;
201 // Cancellation token for the detached Start() PLAYING-transition thread.
202 // Shared (shared_ptr) so the thread can safely outlive this object: Stop()
203 // sets it to abort the volume ramp / deferred attach early, and the thread
204 // captures only this token (never `this`), eliminating use-after-free if it
205 // is still running when the object is torn down.
206 std::shared_ptr<std::atomic<bool>> m_startThreadCancel;
207 // Set while the Start() thread has yet to seek to a non-zero start
208 // position. The pipeline prerolls at 0 first, and Process() must not
209 // report that 0 as the media position (see Start()). Shared for the same
210 // reason as m_startThreadCancel.
211 std::shared_ptr<std::atomic<bool>> m_startSeekPending;
212 gulong m_appsinkSignalId = 0; // audio appsink signal handler ID
213 gulong m_videoAppsinkSignalId = 0; // video appsink signal handler ID
214
215 // Stall watchdog — detects when PipeWire sink stops consuming data.
216 // The *Ms stamps here are GetMonotonicTimeMS(): on the wall clock a step
217 // either way made the watchdogs stop playing media as stalled.
218 gint64 m_lastPosition = -1;
219 gint64 m_maxDuration = 0; // highest observed duration (handles VBR fluctuations)
220 uint64_t m_durationQueriedMs = 0;
221 uint64_t m_stallStartMs = 0;
222 uint64_t m_wallStartMs = 0;
223 // A SeekTo() that arrived before the pipeline prerolled, applied by
224 // Process() at the first position. -1 when none is waiting.
225 gint64 m_pendingSeekNs = -1;
226 uint64_t m_lastWallLogMs = 0;
227
228 // Audio still queued between pipewiresink's reported position and the DAC.
229 // Cached: the depth barely moves while a track plays, and Process() runs at
230 // frame rate.
231 int64_t AudioSinkLatencyNs();
232 // One-shot note that the position is going out uncorrected, and why.
233 void NoteNoSinkLatency(const std::string& why);
234 int64_t m_sinkLatencyNs = 0;
235 uint64_t m_sinkLatencyCheckedMs = 0;
236 std::string m_alsaStatusPath;
237 std::string m_alsaHwParamsPath;
238 int m_alsaRate = 0;
239 bool m_loggedSinkLatency = false;
240 bool m_warnedNoSinkLatency = false;
241 uint64_t m_playStartMs = 0; // when Start() set m_playing=true (GetMonotonicTimeMS()) — anchors the preroll watchdog
242 static constexpr int STALL_TIMEOUT_MS = 5000; // 5 seconds before declaring stall
243 // Preroll watchdog: if the pipeline is set to PLAYING but never produces a
244 // valid position (never actually starts), it is wedged in preroll/PAUSED —
245 // e.g. the Pi's V4L2 hardware decoder can stop producing frames after a very
246 // long video-only loop (issue #2695), so the video sink never prerolls and
247 // the pipeline stays PAUSED forever. The regular stall watchdog can't catch
248 // this because it only runs once a position is available. Generous timeout:
249 // normal preroll completes in well under 1s even on slow HW decoders.
250 static constexpr int PREROLL_TIMEOUT_MS = 15000;
251
252 void ProcessMessages();
253 static GstBusSyncReply BusSyncHandler(GstBus* bus, GstMessage* msg, gpointer userData);
254
255 // Audio sample tap for WLED audio-reactive effects
256 static constexpr int SAMPLE_BUFFER_SIZE = 4096; // circular buffer size
257 static std::array<float, SAMPLE_BUFFER_SIZE> s_sampleBuffer;
258 static int s_sampleWritePos;
259 static int s_sampleRate;
260 // Guards the ring above and m_currentInstance, the one output allowed to
261 // write it.
262 static std::mutex s_sampleMutex;
263 // When the tap owner last wrote the ring. A paused or stalled owner
264 // stops writing, and its last half second of audio must not read as
265 // live.
266 static uint64_t s_sampleLastWriteMs;
267 static constexpr int SAMPLE_STALE_MS = 500;
268 // Make `o` the tap owner and empty the ring. s_sampleMutex held.
269 static void SetSampleTapOwnerLocked(GStreamerOutput* o);
270 static GstFlowReturn OnNewSample(GstAppSink* appsink, gpointer userData);
271 // The tap's branch only carries audio while something is reading it.
272 static constexpr int SAMPLE_TAP_IDLE_MS = 1000;
273 static std::atomic<uint64_t> s_sampleTapReadMs;
274 static GstPadProbeReturn GateSampleTap(GstPad* pad, GstPadProbeInfo* info, gpointer userData);
275
276 // Video overlay for PixelOverlayModel (Phase 3)
277 PixelOverlayModel* m_videoOverlayModel = nullptr;
278 std::mutex m_videoOverlayModelLock;
279 int m_videoOverlayWidth = 0;
280 int m_videoOverlayHeight = 0;
281 // Read by the channel output thread through the overlay registry
282 // (IsOverlayingVideo(), ProcessVideoOverlay()) while Close() and a removed
283 // video branch clear it, before the output leaves the registry.
284 std::atomic<bool> m_hasVideoStream{false};
285 bool m_wasOverlayDisabled = false;
286
287 // Latest video frame buffer — written by OnNewVideoSample, read by ProcessVideoOverlay
288 std::vector<uint8_t> m_videoFrameData;
289 int m_videoFrameWidth = 0; // dimensions of m_videoFrameData (packed RGB)
290 int m_videoFrameHeight = 0;
291 std::mutex m_videoFrameMutex;
292 bool m_videoFrameReady = false;
293 uint64_t m_videoFramesReceived = 0; // diagnostic counter
294 uint64_t m_videoFramesDelivered = 0; // diagnostic counter
295
296 static GstFlowReturn OnNewVideoSample(GstAppSink* appsink, gpointer userData);
297
298 // HDMI/DRM video output via kmssink (Phase 4)
299 bool m_wantHDMI = false; // true when outputting to HDMI via kmssink
300 bool m_macVideoWindow = false; // macOS: the "HDMI" output is this slot's video window
301 GstElement* m_kmssink = nullptr; // kmssink element (owned by pipeline bin)
302 int m_hdmiConnectorId = -1; // DRM connector ID from sysfs
303 std::string m_hdmiCardPath; // e.g. "/dev/dri/card1"
304 // The DRM lease of each of this pipeline's kmssinks (the primary one and
305 // any dkms_* branches). Released in Close(), strictly after the pipeline
306 // (and therefore every kmssink) has been unreffed.
307 std::vector<DrmCards::Lease> m_drmLeases;
308 // HDMI connectors this pipeline claimed from the video output manager's
309 // persistent consumers (ClaimConnectorForMedia); released in Close()
310 // after the leases, which brings those consumers back.
311 std::vector<int> m_mediaConnectors;
312 int m_hdmiDisplayWidth = 0; // display resolution
313 int m_hdmiDisplayHeight = 0;
314
315 // PipeWire video routing — when PipeWireVideoSinkName is set, video is
316 // tee'd to both kmssink (HDMI) and pipewiresink (PipeWire graph node).
317 std::string m_pwVideoSinkName; // target-object for video pipewiresink
318 bool m_videoPipeWireRouting = false; // true when video goes through PipeWire
319 std::set<int> m_directConnectorIds; // HDMI connectors driven by direct kmssink on vtee
320
321 // Dynamic pad linking for decodebin (video requires pad-added signal)
322 //
323 // decodebin emits these from its own streaming thread, on a pipeline that
324 // can outlive this GStreamerOutput: Start() hands an owning pipeline ref to
325 // a detached state-change thread, so Close()/~GStreamerOutput can complete
326 // while decodebin is still exposing pads. The callbacks therefore must not
327 // dereference `this` directly. Each Start() allocates a fresh guard whose
328 // `self` Close() clears while holding `mtx`; a callback holding a shared_ptr
329 // to it either runs to completion before that clear or sees nullptr and
330 // returns. Old pipelines keep their own (already cleared) guard, so a
331 // restarted output is never reached through a stale connection.
332 //
333 // The bus sync handler runs on streaming threads too, and gets the same
334 // guard -- but it does not hold `mtx` while it works. It registers itself
335 // in `busInFlight` instead, and Close() waits for that to drain after
336 // clearing `self`. Holding the lock across a handler would let one
337 // streaming thread block another inside GStreamer. `mtx` is recursive
338 // because OnNoMorePads changes element state while holding it, and the
339 // resulting bus messages reach BusSyncHandler on that same thread.
340 struct CallbackGuard {
341 std::recursive_mutex mtx;
342 GStreamerOutput* self = nullptr;
343 int busInFlight = 0;
344 std::condition_variable_any busIdle;
345 };
346 // Written only under m_opMutex (Start(), DetachCallbacks()), but read
347 // without it by RefVolumeElement() and from streaming threads.
348 AtomicSharedPtr<CallbackGuard> m_cbGuard;
349 GstElement* RefVolumeElement();
350 // Connects pad-added (and optionally no-more-pads) on `decoder`, passing a
351 // guard reference as the closure data. Takes no ref on `decoder`.
352 void ConnectPadSignals(GstElement* decoder, bool wantNoMorePads);
353 static void ReleaseCallbackGuard(gpointer data, GClosure* closure);
354 // GDestroyNotify for the guard reference handed to gst_bus_set_sync_handler.
355 static void ReleaseBusCallbackGuard(gpointer data);
356 // Restarts the output from the top for its next loop. Called from
357 // BusSyncHandler on EOS, it does the seek on the main loop: a flushing
358 // seek issued from the thread that delivered the EOS deadlocks, because
359 // that thread still holds the sink's preroll lock and the flush needs it.
360 static void ScheduleLoopSeek(const std::shared_ptr<CallbackGuard>& guard);
361 // Resolves the closure data back to a live output. Returns nullptr (with
362 // `lock` left unlocked) once the owning Close() has run.
363 static GStreamerOutput* LockCallbackGuard(gpointer userData,
364 std::shared_ptr<CallbackGuard>& guard,
365 std::unique_lock<std::recursive_mutex>& lock);
366 // Keeps the output alive for one BusSyncHandler call. `self` is nullptr
367 // once Close() has run; otherwise Close() blocks until this is destroyed.
368 struct BusCallbackScope {
369 explicit BusCallbackScope(gpointer userData);
370 ~BusCallbackScope();
371 std::shared_ptr<CallbackGuard> guard;
372 GStreamerOutput* self = nullptr;
373 };
374 static void OnPadAdded(GstElement* element, GstPad* pad, gpointer userData);
375 static void OnNoMorePads(GstElement* element, gpointer userData);
376 GstElement* m_audioChain = nullptr; // audio sub-bin for pad linking
377 GstElement* m_videoChain = nullptr; // video sub-bin for pad linking
378 bool m_audioLinked = false; // true when audio pad was connected
379 bool m_videoLinked = false; // true when video pad was connected
380
381 // True when Start() built the audio-only fallback (no video output for this
382 // item), which is the one pipeline shape that cannot survive a file with no
383 // playable audio -- see the no-audio handling in BusSyncHandler().
384 bool m_audioOnlyPipeline = false;
385 // Whether decodebin ever exposed a decoded audio pad. Written on the
386 // streaming thread from OnPadAdded, read on the bus thread.
387 std::atomic<bool> m_sawAudioPad{false};
388 // Set once the "file has no playable audio" case has been reported, so the
389 // demuxer's follow-on "Internal data stream error" is not logged as a second,
390 // unrelated-looking failure.
391 std::atomic<bool> m_reportedNoAudio{false};
392
393 // A banner is up for this run's failure. The first one wins: the errors
394 // after it (a demuxer's follow-on) describe the same event.
395 std::atomic<bool> m_reportedFailure{false};
396 void ReportFileFailure(const char* what, const std::string& reason);
397 void ReportVideoOutputFailure(const char* sinkName, const std::string& reason);
398 // dkms_<id> -> its connector's name, for that banner. Built before the pipeline runs.
399 std::map<int, std::string> m_dkmsConnectorNames;
400
401 #ifdef HAS_AES67_GSTREAMER
402 // Zero-hop AES67 RTP branches attached to the audio tee (Phase 7.9)
403 std::vector<AES67Manager::InlineRTPBranch> m_aes67Branches;
404 void AttachAES67Branches(GstElement* tee);
405 void DetachAES67Branches();
406 #endif
407
408 // Flush PipeWire filter-chain delay buffers between songs
409 static void FlushPipeWireDelayBuffers();
410 static int FindPipeWireNodeId(const std::string& key, const std::string& value);
411 static void SetPipeWireStreamRate(const std::string& streamId, float rate);
412
413 // ── Phase 5: MultiSync rate adjustment (ported from VLCOut) ──
414 bool m_allowSpeedAdjust = true;
415 float m_currentRate = 1.0f;
416
417 // Circular buffer of last MAX_DIFFS diff/rate pairs for trend detection
418 static constexpr int MAX_DIFFS = 10;
419 std::array<std::pair<int, float>, MAX_DIFFS> m_diffs{};
420 int m_diffsSize = 0;
421 int m_diffIdx = 0;
422 int m_diffSum = 0;
423 float m_rateSum = 0.0f;
424
425 void pushDiff(int diff, float rate);
426
427 int m_lastDiff = -1; // init at -1 so speedup/slowdown logic initially assumes slightly behind master
428 int m_rateDiff = 0;
429
430 // Running rate average (smooths jitter)
431 static constexpr int RATE_AVERAGE_COUNT = 20;
432 std::list<float> m_lastRates;
433 float m_lastRatesSum = 0.0f;
434
435 // Apply playback rate change via GStreamer instant-rate-change seek
436 void ApplyRate(float rate);
437 // fpp.stream.id of this pipeline's own pipewiresink, if it has one
438 std::string m_pwSinkStreamId;
439
440 // A video whose sound feeds the output stream: its picture is held back by
441 // the card's queue (AlignPictureToSound()), and a video that turns out to
442 // have no sound hands the stream back from the main loop.
443 void AlignPictureToSound(int64_t latNs);
444 int64_t m_pictureDelayNs = 0;
445 std::atomic<bool> m_pwFeedOrphaned{ false };
446
447 // A MultiSync remote's media through the persistent output stream: held to
448 // the master by the speed of the stream's feed (see AdjustFeedSpeed()).
449 int AdjustFeedSpeed(float masterMediaPosition);
450 double m_feedSpeedErrInt = 0.0; // integral of the error, s*s
451 uint64_t m_feedSpeedLastMs = 0; // when the servo last ran
452 uint64_t m_feedResyncMs = 0; // when the last resync seek was made
453 bool m_feedResyncLanding = false; // the next error is how far it landed off
454 FeedSyncWindow m_feedErrWindow; // the last two seconds of error readings
455 // How far ahead of the master a resync seek aims: what a flushing seek
456 // takes to be heard again. Learnt from each landing, for the whole run.
457 static double s_feedResyncLeadS;
458
459 int m_streamSlot = 1; // stream slot number (1-5), determines PipeWire node name
460
461 // Audio-only PipeWire playback feeds the slot's persistent output stream
462 // through this appsink instead of owning a pipewiresink (see
463 // PipeWireOutputStream.h). Both null on every other path.
464 PipeWireOutputStream* m_pwStream = nullptr;
465 GstElement* m_pwFeed = nullptr;
466 void ReleasePipeWireStream();
467
468 std::atomic<bool> m_audible{false};
469
470 // The output whose audio feeds the WLED tap. Only read or written
471 // under s_sampleMutex, and never dereferenced by the tap's reader: the
472 // owner can be closing on another thread.
473 static GStreamerOutput* m_currentInstance;
474
475 // Every output currently driving a PixelOverlay model. The overlay push
476 // is per-output, unlike the WLED audio tap, so it cannot hang off
477 // m_currentInstance -- slot 1 always claims that, which left a companion
478 // stream's model receiving nothing.
479 static std::mutex m_overlayOutputsLock;
480 static std::vector<GStreamerOutput*> m_overlayOutputs;
481 bool PushVideoOverlayFrame();
482 };
483
484 #endif
485