| 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 | // OpusRTPManager -- GStreamer-based Opus RTP audio send/receive | ||
| 15 | // | ||
| 16 | // Provides WiFi-friendly compressed audio streaming using the Opus codec | ||
| 17 | // over RTP, as an alternative to AES67 (uncompressed L24) which requires | ||
| 18 | // wired Ethernet. Supports both unicast and multicast destinations. | ||
| 19 | // | ||
| 20 | // Features: | ||
| 21 | // - Send: pipewiresrc -> audioconvert -> opusenc -> rtpopuspay -> udpsink | ||
| 22 | // - Receive: udpsrc -> rtpjitterbuffer -> rtpopusdepay -> opusdec -> audioconvert -> pipewiresink | ||
| 23 | // - Configurable bitrate, DTX, in-band FEC | ||
| 24 | // - Unicast or multicast destination | ||
| 25 | // - Config format: pipewire-opus-rtp-instances.json | ||
| 26 | |||
| 27 | #if __has_include(<gst/gst.h>) | ||
| 28 | #define HAS_OPUS_RTP_GSTREAMER | ||
| 29 | |||
| 30 | #include <gst/gst.h> | ||
| 31 | |||
| 32 | #include "fpphttp_types.h" | ||
| 33 | |||
| 34 | #include <atomic> | ||
| 35 | #include <chrono> | ||
| 36 | #include <cstdint> | ||
| 37 | #include <map> | ||
| 38 | #include <mutex> | ||
| 39 | #include <string> | ||
| 40 | #include <thread> | ||
| 41 | #include <vector> | ||
| 42 | |||
| 43 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 44 | // Opus RTP Protocol Constants | ||
| 45 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 46 | namespace OpusRTP { | ||
| 47 | |||
| 48 | constexpr int RTP_PAYLOAD_TYPE = 96; | ||
| 49 | constexpr int AUDIO_RATE = 48000; // Opus native rate | ||
| 50 | constexpr int DEFAULT_PORT = 5005; // avoid AES67's 5004 | ||
| 51 | constexpr int DEFAULT_CHANNELS = 2; | ||
| 52 | constexpr int DEFAULT_LATENCY_MS = 50; // higher default for WiFi | ||
| 53 | constexpr int DEFAULT_BITRATE = 128000; // 128 kbps | ||
| 54 | constexpr int DEFAULT_PACKET_LOSS = 5; // 5% expected loss | ||
| 55 | constexpr int MULTICAST_TTL = 4; | ||
| 56 | |||
| 57 | // DSCP codepoint for the RTP audio stream (udpsink qos-dscp). AF41, the same | ||
| 58 | // marking AES67 audio uses. It matters more here than there: this is the WiFi | ||
| 59 | // transport, and WiFi maps DSCP onto WMM access categories (AF41 -> AC_VI), so | ||
| 60 | // the marking decides how the stream contends on a busy AP. | ||
| 61 | constexpr int AUDIO_DSCP = 34; // AF41 | ||
| 62 | |||
| 63 | constexpr const char* DEFAULT_DEST_IP = "239.69.1.1"; | ||
| 64 | |||
| 65 | // Valid bitrates (bps) | ||
| 66 | 96 | inline bool IsValidBitrate(int br) { | |
| 67 |
2/4✓ Branch 0 taken 96 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 96 times.
✗ Branch 3 not taken.
|
96 | return br >= 6000 && br <= 512000; |
| 68 | } | ||
| 69 | |||
| 70 | } // namespace OpusRTP | ||
| 71 | |||
| 72 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 73 | // Instance configuration -- mirrors the JSON schema | ||
| 74 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 75 | struct OpusRTPInstance { | ||
| 76 | int id = 0; | ||
| 77 | std::string name; | ||
| 78 | bool enabled = true; | ||
| 79 | std::string mode = "send"; // "send" | "receive" | "both" | ||
| 80 | std::string destIP = OpusRTP::DEFAULT_DEST_IP; // unicast or multicast | ||
| 81 | int port = OpusRTP::DEFAULT_PORT; | ||
| 82 | int channels = OpusRTP::DEFAULT_CHANNELS; | ||
| 83 | std::string interface; // network interface (e.g., "wlan0") | ||
| 84 | int latency = OpusRTP::DEFAULT_LATENCY_MS; | ||
| 85 | int bitrate = OpusRTP::DEFAULT_BITRATE; | ||
| 86 | bool dtx = false; // Discontinuous Transmission | ||
| 87 | bool fec = true; // In-band Forward Error Correction | ||
| 88 | int packetLoss = OpusRTP::DEFAULT_PACKET_LOSS; // expected packet loss % | ||
| 89 | }; | ||
| 90 | |||
| 91 | struct OpusRTPConfig { | ||
| 92 | std::vector<OpusRTPInstance> instances; | ||
| 93 | |||
| 94 | // Hold a sender idle until something in the audio graph actually feeds it. | ||
| 95 | // | ||
| 96 | // A send instance is a PipeWire *sink*: its pipewiresrc is created with | ||
| 97 | // node.autoconnect=false, so nothing links into it unless an Audio Output | ||
| 98 | // Group member names it as node.target. With nothing linked the pipeline | ||
| 99 | // cannot preroll, and gst_element_set_state() sits there for 30 seconds | ||
| 100 | // before returning FAILURE -- per instance, on every Apply. That is the | ||
| 101 | // chicken and egg a new instance walks straight into: it cannot be added | ||
| 102 | // to a group until it has been saved, and saving it costs half a minute of | ||
| 103 | // dead UI and a "stream failed to start" warning for a stream the user has | ||
| 104 | // not finished configuring yet. | ||
| 105 | // | ||
| 106 | // So when nothing feeds it, do not start it at all: the instance stays | ||
| 107 | // configured, ApplyConfig() returns promptly, GetStatus() reports it as | ||
| 108 | // waiting rather than failed, and the next apply -- which the Audio Output | ||
| 109 | // Groups page performs anyway, by restarting the stack -- starts it for | ||
| 110 | // real. See PipeWireGraphFeedsNode(). | ||
| 111 | // | ||
| 112 | // Set false in pipewire-opus-rtp-instances.json for a graph FPP did not | ||
| 113 | // generate (a hand-written PipeWire conf, or nodes linked by hand with | ||
| 114 | // pw-link), where the group config cannot answer the question. | ||
| 115 | bool requireGroupSource = true; | ||
| 116 | }; | ||
| 117 | |||
| 118 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 119 | // Pipeline wrapper -- one per send or receive direction of an instance | ||
| 120 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 121 | struct OpusRTPPipeline { | ||
| 122 | int instanceId = 0; | ||
| 123 | bool isSend = true; | ||
| 124 | GstElement* pipeline = nullptr; | ||
| 125 | GstBus* bus = nullptr; | ||
| 126 | |||
| 127 | bool running = false; | ||
| 128 | std::string errorMessage; | ||
| 129 | |||
| 130 | // Buffer-drop probe for flush between tracks | ||
| 131 | std::atomic<int> dropCounter = 0; | ||
| 132 | GstPad* probePad = nullptr; | ||
| 133 | gulong probeId = 0; | ||
| 134 | |||
| 135 | // Zombie detection | ||
| 136 | uint64_t lastByteCount = 0; | ||
| 137 | int stallCount = 0; | ||
| 138 | }; | ||
| 139 | |||
| 140 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 141 | // OpusRTPManager -- singleton managing all Opus RTP GStreamer pipelines | ||
| 142 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 143 | class OpusRTPManager { | ||
| 144 | public: | ||
| 145 | static OpusRTPManager& INSTANCE; | ||
| 146 | |||
| 147 | // HTTP API endpoint (registered at /opusrtp) | ||
| 148 | HttpResponsePtr render_GET(const HttpRequestPtr& req); | ||
| 149 | |||
| 150 | // Lifecycle | ||
| 151 | bool Init(); | ||
| 152 | void Shutdown(); | ||
| 153 | |||
| 154 | // Apply configuration -- reads JSON and rebuilds pipelines | ||
| 155 | bool ApplyConfig(); | ||
| 156 | |||
| 157 | // Cleanup -- stop all pipelines | ||
| 158 | void Cleanup(); | ||
| 159 | |||
| 160 | // Status query | ||
| 161 | struct Status { | ||
| 162 | struct PipelineStatus { | ||
| 163 | int instanceId; | ||
| 164 | std::string name; | ||
| 165 | std::string mode; | ||
| 166 | bool running; | ||
| 167 | std::string error; | ||
| 168 | // Configured and enabled, but deliberately not started because | ||
| 169 | // nothing in the audio graph feeds it -- see | ||
| 170 | // OpusRTPConfig::requireGroupSource. Not an error: `note` carries | ||
| 171 | // the reason for the UI to render as guidance rather than a | ||
| 172 | // failure. | ||
| 173 | bool waitingForSource = false; | ||
| 174 | std::string note; | ||
| 175 | }; | ||
| 176 | std::vector<PipelineStatus> pipelines; | ||
| 177 | }; | ||
| 178 | Status GetStatus(); | ||
| 179 | |||
| 180 | // Check if manager is active | ||
| 181 | bool IsActive() const { return m_active.load(); } | ||
| 182 | |||
| 183 | // Flush send pipelines (media playback integration) | ||
| 184 | void FlushSendPipelines(); | ||
| 185 | |||
| 186 | // Apply the master volume to every send pipeline (see | ||
| 187 | // AES67Manager::SetMasterVolume()). | ||
| 188 | void SetMasterVolume(int volume); | ||
| 189 | |||
| 190 | OpusRTPManager(); | ||
| 191 | virtual ~OpusRTPManager(); | ||
| 192 | |||
| 193 | private: | ||
| 194 | // Config | ||
| 195 | OpusRTPConfig m_config; | ||
| 196 | std::string m_configPath; | ||
| 197 | bool LoadConfig(); | ||
| 198 | |||
| 199 | // Guards every access to m_config. | ||
| 200 | // | ||
| 201 | // LoadConfig() clears and refills m_config.instances, which reallocates | ||
| 202 | // the vector and reassigns its std::strings. ApplyConfig() is safe (it | ||
| 203 | // holds m_applyMutex and joins the watchdog first), but GetStatus() runs | ||
| 204 | // on an HTTP thread and iterates the same vector -- and UIs poll status, | ||
| 205 | // so a reload landing mid-poll is a use-after-free. | ||
| 206 | // | ||
| 207 | // Held only for the duration of a read or the final swap in LoadConfig(), | ||
| 208 | // and NEVER together with m_pipelineMutex -- GetStatus() snapshots the | ||
| 209 | // config, releases, then takes the pipeline lock. The two locks are | ||
| 210 | // therefore never held at once and cannot deadlock against each other. | ||
| 211 | std::mutex m_configMutex; | ||
| 212 | OpusRTPConfig GetConfigSnapshot(); | ||
| 213 | |||
| 214 | // Pipeline watchdog -- polls bus messages and recovers crashed pipelines | ||
| 215 | bool PollPipelinesWatchdog(); | ||
| 216 | // Streams whose pipeline failed to build or start in the last apply | ||
| 217 | // (PipeWire not up yet, say). Nothing else would try them again, so the | ||
| 218 | // watchdog rebuilds for them, waiting 30s, 60s, ... up to 8 minutes | ||
| 219 | // between tries: a rebuild restarts every stream. m_failedStarts is set | ||
| 220 | // by ApplyConfig() under m_pipelineMutex; the rest is watchdog thread only. | ||
| 221 | int m_failedStarts = 0; | ||
| 222 | int m_failedStartRebuilds = 0; | ||
| 223 | std::chrono::steady_clock::time_point m_lastFailedStartRebuild{}; | ||
| 224 | |||
| 225 | // Watchdog thread | ||
| 226 | std::thread m_watchdogThread; | ||
| 227 | std::atomic<bool> m_watchdogRunning{false}; | ||
| 228 | void WatchdogLoop(); | ||
| 229 | |||
| 230 | // Deferred pipeline-rebuild thread -- spawned by WatchdogLoop()'s rebuild | ||
| 231 | // path to call ApplyConfig() from a thread other than the one | ||
| 232 | // ApplyConfig() needs to join (m_watchdogThread). Tracked as a member | ||
| 233 | // (rather than detached) so Shutdown() can join it before tearing | ||
| 234 | // anything else down -- a detached thread could otherwise call | ||
| 235 | // ApplyConfig() and resurrect pipelines after Shutdown() has already run, | ||
| 236 | // and a detached thread racing Shutdown()'s own join of m_watchdogThread | ||
| 237 | // is undefined behavior. | ||
| 238 | std::thread m_rebuildThread; | ||
| 239 | |||
| 240 | // Rebuild bookkeeping. m_rebuildMutex guards m_rebuildThread. A rebuild | ||
| 241 | // is scheduled with the apply generation it was asked for and runs only | ||
| 242 | // if nothing has applied, cleaned up or shut down since (see | ||
| 243 | // ScheduleRebuild()); m_stopping is set once by Shutdown() and keeps | ||
| 244 | // ApplyConfig() from bringing anything back after it. | ||
| 245 | std::mutex m_rebuildMutex; | ||
| 246 | std::atomic<bool> m_rebuildRunning{false}; | ||
| 247 | std::atomic<bool> m_stopping{false}; | ||
| 248 | std::atomic<uint64_t> m_applyGeneration{0}; | ||
| 249 | void ScheduleRebuild(); | ||
| 250 | bool ApplyConfigLocked(); | ||
| 251 | |||
| 252 | // Serializes the whole tear-down/rebuild sequence in ApplyConfig(), | ||
| 253 | // Shutdown() and Cleanup(). ApplyConfig() joins the watchdog thread at | ||
| 254 | // the top and re-creates it ~50 lines later, after loading config and | ||
| 255 | // building pipelines; that window is wide, and ApplyConfig() has three | ||
| 256 | // independent callers (the boot sequence, OpusRTPApplyCommand::run on a | ||
| 257 | // command thread, and m_rebuildThread from the watchdog). Two overlapping calls both pass the join, then both assign | ||
| 258 | // m_watchdogThread -- and assigning over a still-joinable std::thread is | ||
| 259 | // an unconditional std::terminate(). | ||
| 260 | // | ||
| 261 | // Shutdown() must join m_rebuildThread BEFORE taking this lock: that | ||
| 262 | // thread calls ApplyConfig(), so holding the lock across the join would | ||
| 263 | // deadlock. | ||
| 264 | std::mutex m_applyMutex; | ||
| 265 | |||
| 266 | // Pipeline management | ||
| 267 | std::map<int, OpusRTPPipeline> m_sendPipelines; | ||
| 268 | std::map<int, OpusRTPPipeline> m_recvPipelines; | ||
| 269 | std::mutex m_pipelineMutex; | ||
| 270 | |||
| 271 | // Send instances ApplyConfig() chose not to start because nothing feeds | ||
| 272 | // them -- see OpusRTPConfig::requireGroupSource. Instance ID to the | ||
| 273 | // reason, which is shown to the user, so phrase it as guidance rather than | ||
| 274 | // as an error. Guarded by m_pipelineMutex alongside the pipeline maps, so | ||
| 275 | // a status reader sees the whole picture -- running, failed and waiting -- | ||
| 276 | // under one lock. | ||
| 277 | std::map<int, std::string> m_deferredSenders; | ||
| 278 | |||
| 279 | bool CreateSendPipeline(const OpusRTPInstance& inst); | ||
| 280 | bool CreateRecvPipeline(const OpusRTPInstance& inst); | ||
| 281 | void StopPipeline(OpusRTPPipeline& p); | ||
| 282 | void StopAllPipelines(); | ||
| 283 | |||
| 284 | // Get source IP for the network interface | ||
| 285 | std::string GetInterfaceIP(const std::string& iface); | ||
| 286 | |||
| 287 | // Check if destination IP is multicast | ||
| 288 | static bool IsMulticast(const std::string& ip); | ||
| 289 | |||
| 290 | // Safe node name | ||
| 291 | static std::string SafeNodeName(const std::string& name); | ||
| 292 | |||
| 293 | // State | ||
| 294 | std::atomic<bool> m_active{false}; | ||
| 295 | std::atomic<bool> m_initialized{false}; | ||
| 296 | }; | ||
| 297 | |||
| 298 | #endif // HAS_OPUS_RTP_GSTREAMER | ||
| 299 |