OSSIA
Open Scenario System for Interactive Application
Loading...
Searching...
No Matches
pipewire_protocol.hpp
1#pragma once
2#include <ossia/detail/config.hpp>
3
4#if defined(OSSIA_ENABLE_PIPEWIRE)
5#if __has_include(<libremidi/backends/linux/pipewire/context.hpp>) \
6 && __has_include(<pipewire/filter.h>) \
7 && __has_include(<spa/param/latency-utils.h>)
8#define OSSIA_AUDIO_PIPEWIRE 1
9
10#include <ossia/audio/audio_engine.hpp>
11#include <ossia/audio/pipewire_quantum.hpp>
13#include <ossia/detail/pod_vector.hpp>
14#include <ossia/detail/thread.hpp>
15
16#include <libremidi/backends/linux/pipewire/context.hpp>
17#include <libremidi/backends/linux/pipewire/filter.hpp>
18#include <libremidi/backends/linux/pipewire/loader.hpp>
19#include <libremidi/backends/linux/pipewire/subscription.hpp>
20#include <libremidi/backends/linux/pipewire/types.hpp>
21
22#include <pipewire/filter.h>
23#include <pipewire/keys.h>
24#include <pipewire/properties.h>
25#include <spa/param/latency-utils.h>
26#include <spa/utils/result.h>
27
28#include <fmt/format.h>
29
30#include <atomic>
31#include <chrono>
32#include <cstdint>
33#include <cstring>
34#include <memory>
35#include <stdexcept>
36#include <string>
37#include <thread>
38#include <vector>
39
40namespace ossia
41{
42
43struct audio_setup
44{
45 std::string name;
46 std::string card_in;
47 std::string card_out;
48
49 std::vector<std::string> inputs;
50 std::vector<std::string> outputs;
51
52 int rate{};
53 int buffer_size{};
54};
55
56// Validated against the PipeWire sources at 0.3.48 (Ubuntu 22.04),
57// 0.3.65 (Debian 12), 1.0.5 (Ubuntu 24.04), 1.2.8, 1.4.2/1.4.9
58// (Debian 13), 1.6.2 (Ubuntu 26.04) and 1.6.8. Every mechanism this file
59// relies on holds across that range; version differences that matter:
60// - 0.3.48/0.3.65 round a forced quantum down to a power of two
61// (clock.power-of-two-quantum defaults true there and the forced path
62// is not exempt) and let any follower's max-latency shrink it, so on
63// those servers the granted quantum diverges from the request even
64// more often — the per-cycle adaptation below is what absorbs it.
65// - 0.3.48/0.3.65 have no clock.target_duration: a quantum change is
66// visible only as clock.duration differing between two cycles, with
67// no advance notice. Never assume the previous cycle's size.
68// - DSP buffers are 8192 floats fixed before 1.0.5, and sized from the
69// *client* context's clock.quantum-limit from 1.0.5 on; no version
70// checks n_samples against the capacity, so the clamp in
71// fetch_cycle_buffers is required everywhere.
72// - pw_filter_disconnect/connect reuse is legal on all versions;
73// 1.0.5+ adds -EBUSY guards, which the watchdog's return-code
74// handling covers.
75class pipewire_audio_protocol : public audio_engine
76{
77public:
78 struct port
79 {
80 };
81
82 std::shared_ptr<libremidi::pipewire::context> loop;
83 pw_filter* filter{};
84 std::vector<pw_proxy*> links;
85
86 std::vector<port*> input_ports;
87 std::vector<port*> output_ports;
88
89 bool activated{};
90
91 explicit pipewire_audio_protocol(
92 std::shared_ptr<libremidi::pipewire::context> ctx,
93 const audio_setup& setup)
94 : loop{std::move(ctx)}
95 {
96 if (!loop || !loop->ok())
97 return;
98
99 auto& pw = libremidi::pipewire::load();
100 if (!pw.filter_available)
101 return;
102
103 if (setup.buffer_size <= 0 || setup.rate <= 0)
104 throw std::runtime_error("PipeWire: invalid buffer size or sample rate");
105
106 // Everything the process callback reads must be in place before
107 // filter_connect: cycles start arriving while this constructor is
108 // still waiting on the sync loops below.
109 this->effective_buffer_size = setup.buffer_size;
110 this->effective_sample_rate = setup.rate;
111 this->effective_inputs = setup.inputs.size();
112 this->effective_outputs = setup.outputs.size();
113 m_quantum.expected = setup.buffer_size;
114 m_rate.expected = setup.rate;
115 m_silence.assign(setup.buffer_size, 0.f);
116 m_scratch.assign(setup.buffer_size, 0.f);
117 m_cycle_in.resize(setup.inputs.size());
118 m_cycle_out.resize(setup.outputs.size());
119 m_chunk_in.resize(setup.inputs.size());
120 m_chunk_out.resize(setup.outputs.size());
121 m_buf_in.resize(setup.inputs.size());
122 m_buf_out.resize(setup.outputs.size());
123
124 // static: pw_filter_new_simple stores the pointer, not the table.
125 static constexpr const struct pw_filter_events filter_events = {
126 .version = PW_VERSION_FILTER_EVENTS,
127 .destroy = {},
128 .state_changed = {},
129 .io_changed = {},
130 .param_changed = {},
131 .add_buffer = {},
132 .remove_buffer = {},
133 .process = &on_process,
134 .drained = {},
135#if PW_VERSION_CORE > 3
136 .command = {},
137#endif
138 };
139
140 std::string default_sink_name = loop->default_audio_sink_name();
141 ossia::logger().info(
142 "PipeWire filter: default sink name = '{}'", default_sink_name);
143
144 bool created = false;
145 loop->with_lock([&] {
146 auto* filter_props = pw.properties_new(
147 PW_KEY_MEDIA_TYPE, "Audio",
148 PW_KEY_MEDIA_CATEGORY, "Duplex",
149 PW_KEY_MEDIA_ROLE, "DSP",
150 PW_KEY_MEDIA_NAME, setup.name.c_str(),
151 PW_KEY_NODE_NAME, setup.name.c_str(),
152 PW_KEY_NODE_GROUP, "group.dsp.0",
153 PW_KEY_NODE_DESCRIPTION, "ossia score",
154 // NODE_LATENCY + NODE_TRANSPORT_SYNC: wireplumber routing
155 // hints; without them the filter stays unconnected.
156 PW_KEY_NODE_LATENCY,
157 fmt::format("{}/{}", setup.buffer_size, setup.rate).c_str(),
158 PW_KEY_NODE_FORCE_QUANTUM,
159 fmt::format("{}", setup.buffer_size).c_str(),
160 PW_KEY_NODE_FORCE_RATE, fmt::format("{}", setup.rate).c_str(),
161 // Note: node.lock-rate / node.lock-quantum would be inert here —
162 // the driver cancels the lock whenever any follower forces the
163 // value (pipewire context.c), and this node always forces both.
164 // force-* is last-write-wins between clients and loses to the
165 // global clock.force-* settings, so the process callback must
166 // (and does) cope with any quantum or rate.
167 PW_KEY_NODE_LOCK_RATE, "true",
168 PW_KEY_NODE_TRANSPORT_SYNC, "true",
169 PW_KEY_NODE_ALWAYS_PROCESS, "true",
170 PW_KEY_NODE_PAUSE_ON_IDLE, "false",
171 PW_KEY_NODE_SUSPEND_ON_IDLE, "false",
172 nullptr);
173 if (!filter_props)
174 return;
175 if (!default_sink_name.empty())
176 {
177 pw.properties_set(
178 filter_props, PW_KEY_TARGET_OBJECT, default_sink_name.c_str());
179 }
180
181 this->filter = pw.filter_new_simple(
182 loop->bare_loop(), setup.name.c_str(), filter_props,
183 &filter_events, this);
184 // filter_props ownership taken by pw_filter_new_simple.
185 if (!this->filter)
186 return;
187
188 for (const auto& name : setup.inputs)
189 {
190 auto* p = static_cast<port*>(pw.filter_add_port(
191 this->filter, PW_DIRECTION_INPUT,
192 PW_FILTER_PORT_FLAG_MAP_BUFFERS, sizeof(struct port),
193 pw.properties_new(
194 PW_KEY_FORMAT_DSP, "32 bit float mono audio",
195 PW_KEY_PORT_NAME, name.c_str(), nullptr),
196 nullptr, 0));
197 input_ports.push_back(p);
198 }
199
200 for (const auto& name : setup.outputs)
201 {
202 auto* p = static_cast<port*>(pw.filter_add_port(
203 this->filter, PW_DIRECTION_OUTPUT,
204 PW_FILTER_PORT_FLAG_MAP_BUFFERS, sizeof(struct port),
205 pw.properties_new(
206 PW_KEY_FORMAT_DSP, "32 bit float mono audio",
207 PW_KEY_PORT_NAME, name.c_str(), nullptr),
208 nullptr, 0));
209 output_ports.push_back(p);
210 }
211
212 created = (pw.filter_connect(
213 this->filter, PW_FILTER_FLAG_RT_PROCESS, nullptr, 0)
214 >= 0);
215 });
216
217 if (!this->filter)
218 throw std::runtime_error("PipeWire: could not create filter instance");
219 if (!created)
220 {
221 // The destructor does not run when the constructor throws.
222 loop->with_lock([&] {
223 pw.filter_destroy(this->filter);
224 this->filter = nullptr;
225 });
226 throw std::runtime_error("PipeWire: cannot connect");
227 }
228
229 if (!loop->synchronize())
230 {
231 ossia::logger().error(
232 "PipeWire: synchronize() failed after filter_connect — engine inactive");
233 return;
234 }
235 {
236 int k = 0;
237 auto node_id = filter_node_id();
238 while (node_id == 0xFFFFFFFFu)
239 {
240 if (!loop->synchronize())
241 {
242 ossia::logger().error(
243 "PipeWire: synchronize() failed while waiting for node id");
244 return;
245 }
246 node_id = filter_node_id();
247 if (k++ > 100)
248 return;
249 }
250
251 // Registry broadcasts trail core_sync done by one round trip.
252 const auto num_in = input_ports.size();
253 const auto num_out = output_ports.size();
254 bool have_ports = false;
255 for (int j = 0; j < 200; ++j)
256 {
257 auto snap = loop->snapshot();
258 if (const auto* self = snap.find_by_id(node_id))
259 {
260 if (self->inputs.size() >= num_in
261 && self->outputs.size() >= num_out)
262 {
263 have_ports = true;
264 break;
265 }
266 }
267 if (!loop->synchronize())
268 {
269 ossia::logger().error(
270 "PipeWire: synchronize() failed while waiting for ports");
271 return;
272 }
273 }
274 if (!have_ports)
275 {
276 ossia::logger().error(
277 "PipeWire: ports never appeared in graph — engine inactive");
278 return;
279 }
280 }
281
282 // The graph may refuse our rate (global clock.force-rate, or a
283 // competing client's newer force-rate stamp): the DSP ports then carry
284 // audio at the graph rate, not ours. Report the real rate so the host
285 // resamples its material for what will actually be played.
286 {
287 std::uint32_t seen = 0;
288 for (int j = 0; j < 100; ++j)
289 {
290 seen = m_observed_rate.load(std::memory_order_relaxed);
291 if (seen != 0)
292 break;
293 std::this_thread::sleep_for(std::chrono::milliseconds(10));
294 }
295 if (seen != 0 && seen != static_cast<std::uint32_t>(setup.rate))
296 {
297 ossia::logger().warn(
298 "PipeWire: the graph runs at {} Hz, not the requested {} Hz; "
299 "the engine will use {} Hz",
300 seen, setup.rate, seen);
301 this->effective_sample_rate = seen;
302 }
303 }
304
305 activated = true;
306
307 // A node can end up scheduled by nothing, silently: if no active
308 // driver with priority exists (Dummy-Driver missing or a session
309 // manager race), pw_context_recalc_graph's unassigned-node pass calls
310 // remove_from_driver and the node just stops — no error reaches the
311 // client, playback simply never starts. Watch for the absence of
312 // process cycles and re-export the node, which re-runs activation and
313 // driver assignment.
314 m_watchdog = std::thread{[this] { watchdog_main(); }};
315 }
316
317 std::uint32_t filter_node_id() const noexcept
318 {
319 if (!this->filter)
320 return 0xFFFFFFFFu;
321 auto& pw = libremidi::pipewire::load();
322 if (!pw.filter_get_node_id)
323 return 0xFFFFFFFFu;
324 return pw.filter_get_node_id(this->filter);
325 }
326
327 // Modern PipeWire/WirePlumber does NOT set port.physical=true on
328 // ALSA hw ports — match the node's media.class (Audio/Source,
329 // Audio/Sink) rather than n.physical.
330 void autoconnect()
331 {
332 const auto our_node = filter_node_id();
333 if (our_node == 0xFFFFFFFFu)
334 return;
335
336 const std::string default_sink = loop->default_audio_sink_name();
337 const std::string default_source = loop->default_audio_source_name();
338 ossia::logger().info(
339 "PipeWire autoconnect: defaults src='{}' sink='{}'",
340 default_source, default_sink);
341
342 std::vector<std::uint32_t> source_outputs;
343 std::vector<std::uint32_t> sink_inputs;
344 std::vector<std::uint32_t> self_in_ids, self_out_ids;
345 bool have_self = false;
346
347 for (int attempt = 0; attempt < 50; ++attempt)
348 {
349 auto snap = loop->snapshot();
350
351 self_in_ids.clear();
352 self_out_ids.clear();
353 have_self = false;
354 if (const auto* self_node = snap.find_by_id(our_node))
355 {
356 have_self = true;
357 for (const auto& p : self_node->inputs)
358 self_in_ids.push_back(p.id);
359 for (const auto& p : self_node->outputs)
360 self_out_ids.push_back(p.id);
361 }
362
363 source_outputs.clear();
364 sink_inputs.clear();
365 if (!default_source.empty())
366 {
367 if (const auto* n = snap.find_by_name(default_source))
368 {
369 for (const auto& p : n->outputs)
370 source_outputs.push_back(p.id);
371 }
372 }
373 if (!default_sink.empty())
374 {
375 if (const auto* n = snap.find_by_name(default_sink))
376 {
377 for (const auto& p : n->inputs)
378 sink_inputs.push_back(p.id);
379 }
380 }
381
382 // A suspended sink legitimately has no ports — target.object
383 // on the filter wakes it during activation.
384 if (have_self && !source_outputs.empty() && !sink_inputs.empty())
385 break;
386 if (!loop->synchronize())
387 break;
388 }
389
390 if (!have_self)
391 return;
392
393 ossia::logger().info(
394 "PipeWire autoconnect: src_ports={}, sink_ports={}, "
395 "self_in={}, self_out={}",
396 source_outputs.size(), sink_inputs.size(),
397 self_in_ids.size(), self_out_ids.size());
398
399 {
400 auto snap = loop->snapshot();
401 ossia::logger().info(
402 "PipeWire autoconnect: snapshot has {} nodes", snap.nodes.size());
403 for (const auto& n : snap.nodes)
404 {
405 ossia::logger().info(
406 "PipeWire autoconnect: snap id={} name='{}' class='{}' "
407 "inputs={} outputs={}",
408 n.id, n.name, n.media_class_str, n.inputs.size(),
409 n.outputs.size());
410 }
411 }
412
413 for (std::size_t i = 0;
414 i < self_in_ids.size() && i < source_outputs.size(); ++i)
415 {
416 if (auto* link = libremidi::pipewire::link_ports(
417 *loop, source_outputs[i], self_in_ids[i]))
418 links.push_back(link);
419 }
420 for (std::size_t i = 0;
421 i < self_out_ids.size() && i < sink_inputs.size(); ++i)
422 {
423 if (auto* link = libremidi::pipewire::link_ports(
424 *loop, self_out_ids[i], sink_inputs[i]))
425 links.push_back(link);
426 }
427 }
428
429 void wait(int ms) override
430 {
431 if (ms > 0)
432 std::this_thread::sleep_for(std::chrono::milliseconds(ms));
433 }
434
435 bool running() const override { return loop && activated; }
436
437 // Must not be called from the pipewire loop thread: joining the
438 // watchdog while it waits for the loop lock held by the caller would
439 // deadlock.
440 void stop() override
441 {
442 audio_engine::stop();
443
444 // The watchdog reconnects the filter from its own thread; it must be
445 // gone before the teardown below starts destroying what it touches.
446 m_watchdog_quit.store(true, std::memory_order_release);
447 if (m_watchdog.joinable())
448 m_watchdog.join();
449 // Tear down whenever a filter exists, not just when fully activated:
450 // the constructor's failure paths after a successful filter_connect
451 // leave a connected filter whose process callback keeps firing, and
452 // destroying this object without disconnecting it first would let the
453 // RT thread run over freed members.
454 if (!loop)
455 return;
456
457 auto& pw = libremidi::pipewire::load();
458
459 // Tear-down order: disconnect → sync → drop links → destroy → sync.
460 // pw_filter_* / pw_proxy_destroy must run under the thread_loop lock.
461 if (this->filter)
462 {
463 loop->with_lock([&] {
464 if (int res = pw.filter_disconnect(this->filter); res < 0)
465 {
466 ossia::logger().warn(
467 "PipeWire: filter_disconnect failed: {}", spa_strerror(res));
468 }
469 });
470 (void)loop->synchronize();
471 }
472
473 for (auto* link : this->links)
474 libremidi::pipewire::unlink_ports(*loop, link);
475 this->links.clear();
476
477 if (this->filter)
478 {
479 loop->with_lock([&] {
480 pw.filter_destroy(this->filter);
481 this->filter = nullptr;
482 });
483 }
484
485 (void)loop->synchronize();
486 activated = false;
487 }
488
489 ~pipewire_audio_protocol() override { stop(); }
490
491 // RT thread: no locks, no allocation, no logging.
492 //
493 // The buffers are mmapped with a capacity taken from *this client's*
494 // clock.quantum-limit while clock.duration is bounded by the *daemon's*
495 // clock.quantum-limit; the two can be configured apart, and
496 // pw_filter_get_dsp_buffer would then stamp and let us write past
497 // maxsize. So dequeue ourselves, take the capacity into account, and
498 // requeue — exactly once per port per cycle, since a second dequeue in
499 // the same cycle hands out a different buffer.
500
501 // Dequeues one port buffer; caps `safe` to its capacity. The buffer is
502 // NOT requeued yet: outputs are stamped with the final safe count first.
503 static pw_buffer* dequeue_cycle_buffer(
504 const auto& pw, void* port, float*& data, std::uint32_t& safe) noexcept
505 {
506 pw_buffer* b = pw.filter_dequeue_buffer(port);
507 if (!b || !b->buffer || b->buffer->n_datas < 1
508 || !b->buffer->datas[0].data)
509 {
510 data = nullptr;
511 return b;
512 }
513 auto& d = b->buffer->datas[0];
514 data = static_cast<float*>(d.data);
515 const std::uint32_t cap = d.maxsize / sizeof(float);
516 if (cap < safe)
517 safe = cap;
518 return b;
519 }
520
521 static void requeue_cycle_buffer(
522 const auto& pw, void* port, pw_buffer* b, bool output,
523 std::uint32_t frames) noexcept
524 {
525 if (!b)
526 return;
527 if (output && b->buffer && b->buffer->n_datas >= 1)
528 {
529 if (auto* chunk = b->buffer->datas[0].chunk)
530 {
531 chunk->offset = 0;
532 chunk->size = frames * sizeof(float);
533 chunk->stride = sizeof(float);
534 chunk->flags = 0;
535 }
536 }
537 pw.filter_queue_buffer(port, b);
538 }
539
540 static bool can_dequeue(const auto& pw) noexcept
541 {
542 return pw.filter_dequeue_buffer && pw.filter_queue_buffer;
543 }
544
545 // Fetches every port's buffer for the cycle into m_cycle_in/out and
546 // returns the frame count that is safe to read and write everywhere.
547 std::uint32_t fetch_cycle_buffers(const auto& pw, std::uint32_t nframes)
548 {
549 const auto inputs = input_ports.size();
550 const auto outputs = output_ports.size();
551
552 if (!can_dequeue(pw))
553 {
554 // Old libpipewire without the dequeue API: keep the historical
555 // behaviour (no capacity check).
556 for (std::size_t i = 0; i < inputs; i++)
557 m_cycle_in[i] = static_cast<float*>(
558 pw.filter_get_dsp_buffer(input_ports[i], nframes));
559 for (std::size_t i = 0; i < outputs; i++)
560 m_cycle_out[i] = static_cast<float*>(
561 pw.filter_get_dsp_buffer(output_ports[i], nframes));
562 return nframes;
563 }
564
565 std::uint32_t safe = nframes;
566 for (std::size_t i = 0; i < inputs; i++)
567 m_buf_in[i] = dequeue_cycle_buffer(pw, input_ports[i], m_cycle_in[i], safe);
568 for (std::size_t i = 0; i < outputs; i++)
569 m_buf_out[i] = dequeue_cycle_buffer(pw, output_ports[i], m_cycle_out[i], safe);
570
571 for (std::size_t i = 0; i < inputs; i++)
572 requeue_cycle_buffer(pw, input_ports[i], m_buf_in[i], false, safe);
573 for (std::size_t i = 0; i < outputs; i++)
574 requeue_cycle_buffer(pw, output_ports[i], m_buf_out[i], true, safe);
575 return safe;
576 }
577
578 static void
579 clear_buffers(pipewire_audio_protocol& self, std::uint32_t nframes,
580 std::size_t outputs)
581 {
582 auto& pw = libremidi::pipewire::load();
583 if (!can_dequeue(pw))
584 {
585 for (std::size_t i = 0; i < outputs; i++)
586 {
587 auto* chan = static_cast<float*>(
588 pw.filter_get_dsp_buffer(self.output_ports[i], nframes));
589 if (chan)
590 for (std::size_t j = 0; j < nframes; j++)
591 chan[j] = 0.f;
592 }
593 return;
594 }
595
596 for (std::size_t i = 0; i < outputs; i++)
597 {
598 float* data{};
599 std::uint32_t safe = nframes;
600 auto* b = dequeue_cycle_buffer(pw, self.output_ports[i], data, safe);
601 if (data)
602 for (std::size_t j = 0; j < safe; j++)
603 data[j] = 0.f;
604 requeue_cycle_buffer(pw, self.output_ports[i], b, true, data ? safe : 0);
605 }
606 }
607
608 void do_process(std::uint32_t nframes, double secs, double rate)
609 {
610 auto& pw = libremidi::pipewire::load();
611
612 tick_start();
613
614 const auto inputs = input_ports.size();
615 const auto outputs = output_ports.size();
616 if (stop_processing)
617 {
618 tick_clear();
619 clear_buffers(*this, nframes, outputs);
620 return;
621 }
622
623 const std::uint32_t frames = fetch_cycle_buffers(pw, nframes);
624
625 bool missing_input = false;
626 for (std::size_t i = 0; i < inputs; i++)
627 missing_input |= !m_cycle_in[i];
628 if (missing_input)
629 std::memset(m_silence.data(), 0, m_silence.size() * sizeof(float));
630
631 // The graph quantum tracks what we forced only eventually (and not at
632 // all under a global clock.force-quantum or a competing client); the
633 // engine's buffers are sized for effective_buffer_size, so process any
634 // larger cycle in slices of it rather than skipping the cycle, which
635 // would leave the outputs in NEED_DATA — i.e. permanent silence.
636 const auto block = static_cast<std::uint32_t>(effective_buffer_size);
637 ossia::pipewire::for_each_chunk(
638 frames, block, [&](std::uint32_t offset, std::uint32_t n) {
639 ossia::pipewire::assign_chunk_pointers(
640 m_cycle_in.data(), m_chunk_in.data(), inputs, offset,
641 m_silence.data());
642 ossia::pipewire::assign_chunk_pointers(
643 m_cycle_out.data(), m_chunk_out.data(), outputs, offset,
644 m_scratch.data());
645
646 ossia::audio_tick_state ts{
647 m_chunk_in.data(), m_chunk_out.data(), (int)inputs, (int)outputs,
648 n, secs + offset / rate};
649 audio_tick(ts);
650 });
651 tick_end();
652 }
653
654 static void on_process(void* userdata, struct spa_io_position* position)
655 {
656 [[maybe_unused]] static const thread_local auto _ = [] {
657 ossia::set_thread_name("ossia audio 0");
658 ossia::set_thread_pinned(thread_type::Audio, 0);
659 return 0;
660 }();
661
662 if (!userdata || !position)
663 return;
664
665 auto& self = *static_cast<pipewire_audio_protocol*>(userdata);
666 self.m_cycles.fetch_add(1, std::memory_order_relaxed);
667 const std::uint32_t nframes = position->clock.duration;
668 const std::uint32_t rate = position->clock.rate.denom;
669 const double current_time = position->clock.nsec * 1e-9;
670
671 // Logging from the process callback is not realtime-safe; these fire
672 // only when the graph reconfigures (rare), which beats both per-cycle
673 // spam and silent misbehaviour — and a pathologically flapping graph
674 // is cut off after a few transitions. A zero duration/rate is an idle
675 // or reconfiguring cycle, not a value: it must not disturb the
676 // trackers (observe(0) would reset the first-cycle sentinel and eat
677 // the next 'restored' notification).
678 using event = ossia::pipewire::quantum_tracker::event;
679 if (nframes != 0)
680 {
681 switch (self.m_quantum.observe(nframes))
682 {
683 case event::mismatch:
684 if (may_log(self.m_quantum_logs))
685 ossia::logger().warn(
686 "PipeWire: graph quantum is {} but {} was requested; "
687 "adapting by processing in chunks",
688 nframes, self.effective_buffer_size);
689 break;
690 case event::recovered:
691 if (may_log(self.m_quantum_logs))
692 ossia::logger().info(
693 "PipeWire: graph quantum restored to {}", nframes);
694 break;
695 default:
696 break;
697 }
698 }
699 if (rate != 0)
700 {
701 switch (self.m_rate.observe(rate))
702 {
703 case event::mismatch:
704 if (may_log(self.m_rate_logs))
705 ossia::logger().warn(
706 "PipeWire: graph sample rate is {} but {} was requested; "
707 "audio will play at the wrong speed until it is restored",
708 rate, self.m_rate.expected);
709 break;
710 case event::recovered:
711 if (may_log(self.m_rate_logs))
712 ossia::logger().info(
713 "PipeWire: graph sample rate restored to {}", rate);
714 break;
715 default:
716 break;
717 }
718 }
719
720 if (rate != 0)
721 self.m_observed_rate.store(rate, std::memory_order_relaxed);
722
723 if (nframes == 0)
724 return;
725
726 // Chunk offsets are in graph-rate samples; use the cycle's actual rate
727 // for the time math (m_rate.expected as a fallback for a zero clock).
728 self.do_process(
729 nframes, current_time, rate != 0 ? rate : self.m_rate.expected);
730 }
731
732 // Stall watchdog state, public so hosts and tests can observe it.
733 // stall_timeout_ms may be lowered at runtime (tests) or raised by hosts
734 // that expect long scheduling gaps.
735 std::atomic<int> stall_timeout_ms{3000};
736 std::atomic<std::uint32_t> stalls_detected{};
737 std::atomic<std::uint32_t> recover_attempts{};
738 static constexpr std::uint32_t max_recover_attempts = 5;
739
740private:
741 void watchdog_main()
742 {
743 ossia::set_thread_name("ossia pw wdog");
744 auto& pw = libremidi::pipewire::load();
745
746 std::uint64_t last = m_cycles.load(std::memory_order_relaxed);
747 auto last_progress = std::chrono::steady_clock::now();
748 while (!m_watchdog_quit.load(std::memory_order_acquire))
749 {
750 std::this_thread::sleep_for(std::chrono::milliseconds(100));
751 const auto now = std::chrono::steady_clock::now();
752 const auto cur = m_cycles.load(std::memory_order_relaxed);
753 if (cur != last)
754 {
755 last = cur;
756 last_progress = now;
757 // The graph resumed: this outage is over, a future one gets a
758 // fresh set of recovery attempts.
759 recover_attempts.store(0, std::memory_order_relaxed);
760 continue;
761 }
762 const auto stalled_ms
763 = std::chrono::duration_cast<std::chrono::milliseconds>(
764 now - last_progress)
765 .count();
766 if (stalled_ms < stall_timeout_ms.load(std::memory_order_relaxed))
767 continue;
768
769 stalls_detected.fetch_add(1, std::memory_order_relaxed);
770 const auto attempt
771 = recover_attempts.fetch_add(1, std::memory_order_relaxed) + 1;
772 if (attempt > max_recover_attempts)
773 {
774 ossia::logger().error(
775 "PipeWire: still no process cycles after {} reconnections; "
776 "giving up. The daemon is likely not scheduling any audio "
777 "(no usable driver); restarting PipeWire may help",
778 max_recover_attempts);
779 return;
780 }
781
782 ossia::logger().warn(
783 "PipeWire: no process cycles for {} ms; re-exporting the node "
784 "(attempt {}/{}). This happens when the graph has no usable "
785 "driver or the node was left unscheduled",
786 stalled_ms, attempt, max_recover_attempts);
787
788 bool connect_failed = false;
789 loop->with_lock([&] {
790 if (!this->filter)
791 return;
792 if (int res = pw.filter_disconnect(this->filter); res < 0)
793 ossia::logger().warn(
794 "PipeWire: watchdog filter_disconnect failed: {}",
795 spa_strerror(res));
796 if (int res = pw.filter_connect(
797 this->filter, PW_FILTER_FLAG_RT_PROCESS, nullptr, 0);
798 res < 0)
799 {
800 // A failed connect leaves the filter in the CONNECTING state
801 // (only a successful proxy teardown resets it), so every later
802 // connect would return -EBUSY: retrying is pointless.
803 connect_failed = true;
804 ossia::logger().error(
805 "PipeWire: watchdog filter_connect failed: {}; automatic "
806 "recovery is not possible, restart the audio engine",
807 spa_strerror(res));
808 }
809 });
810 if (connect_failed)
811 return;
812 (void)loop->synchronize();
813
814 // Give the re-exported node a full timeout window to come up.
815 last = m_cycles.load(std::memory_order_relaxed);
816 last_progress = std::chrono::steady_clock::now();
817 }
818 }
819
820 std::atomic<std::uint64_t> m_cycles{};
821 std::thread m_watchdog;
822 std::atomic_bool m_watchdog_quit{};
823
824 ossia::pipewire::quantum_tracker m_quantum{};
825 ossia::pipewire::quantum_tracker m_rate{};
826
827 // Written by the process callback, read by the constructor to learn the
828 // rate the graph actually granted us.
829 std::atomic<std::uint32_t> m_observed_rate{};
830
831 // A graph flapping between quantums/rates every cycle would otherwise
832 // turn the transition logs back into per-cycle RT logging.
833 std::uint32_t m_quantum_logs{};
834 std::uint32_t m_rate_logs{};
835
836 static bool may_log(std::uint32_t& n) noexcept
837 {
838 if (n >= 16)
839 return false;
840 if (++n == 16)
841 ossia::logger().warn(
842 "PipeWire: the graph configuration keeps changing; further "
843 "changes will not be logged");
844 return true;
845 }
846
847 // Cycle-wide buffer starts (one entry per port; null when pipewire had
848 // no buffer for the port this cycle) and the per-chunk views handed to
849 // the tick. Sized in the constructor, touched only by the process
850 // callback afterwards.
851 ossia::pod_vector<float*> m_cycle_in, m_cycle_out;
852 ossia::pod_vector<float*> m_chunk_in, m_chunk_out;
853 ossia::pod_vector<pw_buffer*> m_buf_in, m_buf_out;
854
855 // Missing inputs read zeroes, missing outputs write into a discard
856 // buffer. Keep them distinct: one shared dummy would feed the previous
857 // chunk's discarded output back into the missing inputs.
858 ossia::pod_vector<float> m_silence;
859 ossia::pod_vector<float> m_scratch;
860};
861
862} // namespace ossia
863
864#endif
865#endif
Definition git_info.h:7
spdlog::logger & logger() noexcept
Where the errors will be logged. Default is stderr.
Definition context.cpp:120