Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
qpipewire_audiodevicemonitor.cpp
Go to the documentation of this file.
1// Copyright (C) 2025 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
3
5
9
10#include <QtCore/qcoreapplication.h>
11#include <QtCore/qdebug.h>
12#include <QtCore/qloggingcategory.h>
13#include <QtCore/private/qcoreapplication_p.h>
14#include <QtCore/private/qthread_p.h>
15#include <QtCore/private/qflatmap_p.h>
16
17#include <QtMultimedia/private/qmultimedia_ranges_p.h>
18
19#include <mutex>
20#include <q20vector.h>
21
23
24namespace QtPipeWire {
25
26using namespace QtMultimediaPrivate;
27
28Q_STATIC_LOGGING_CATEGORY(lcPipewireDeviceMonitor, "qt.multimedia.pipewire.devicemonitor")
29
31 : m_observedSerial(objectSerial)
32{
33}
34
36{
37 return m_observedSerial;
38}
39
41{
42 if (!QThread::isMainThread())
43 // ensure that device monitor runs on application thread
44 moveToThread(qApp->thread());
45
46 constexpr auto compressionTime = std::chrono::milliseconds(50);
47
48 m_compressionTimer.setTimerType(Qt::TimerType::CoarseTimer);
49 m_compressionTimer.setInterval(compressionTime);
50 m_compressionTimer.setSingleShot(true);
51
52 m_compressionTimer.callOnTimeout(this, [this] {
53 audioDevicesChanged();
54 });
55
56 m_compressionTimerThread.setObjectName("PWDevMon");
57 m_compressionTimerThread.setServiceLevel(QThread::QualityOfService::Eco);
58 m_compressionTimerThread.setStackSize(1024 * 1024); // 1MB should be enough
59 m_compressionTimerThread.start();
60 m_compressionTimerThread.setPriority(QThread::Priority::LowPriority);
61 m_compressionTimer.moveToThread(&m_compressionTimerThread);
62
63 qAddPostRoutine([] {
65 QMetaObject::invokeMethod(&monitor.m_compressionTimer, [&] {
66 monitor.m_compressionTimer.stop();
67 monitor.m_compressionTimer.moveToThread(qApp->thread());
68 }, Qt::BlockingQueuedConnection);
69 });
70}
71
73{
74 if (m_compressionTimer.thread() != thread()) {
75 QMetaObject::invokeMethod(&m_compressionTimer, [this] {
76 m_compressionTimer.stop();
77 m_compressionTimer.moveToThread(thread());
78 }, Qt::BlockingQueuedConnection);
79 }
80
81 m_compressionTimerThread.quit();
82 m_compressionTimerThread.wait();
83}
84
85void QAudioDeviceMonitor::objectAdded(ObjectId id, uint32_t /*permissions*/,
86 PipewireRegistryType objectType, uint32_t /*version*/,
87 const spa_dict &propDict)
88{
90
91 Q_ASSERT(objectType == PipewireRegistryType::Device
92 || objectType == PipewireRegistryType::Node);
93
94 PwPropertyDict props = toPropertyDict(propDict);
95 std::optional<std::string_view> mediaClass = getMediaClass(props);
96 if (!mediaClass)
97 return;
98
99 std::optional<ObjectSerial> serial = getObjectSerial(props);
100 Q_ASSERT(serial);
101 {
102 QWriteLocker lock{ &m_objectDictMutex };
103 m_objectSerialDict.emplace(id, *serial);
104 m_serialObjectDict.emplace(*serial, id);
105 }
106
107 switch (objectType) {
109 if (mediaClass != "Audio/Device")
110 return;
111
112 // we can store devices immediately
113 qCDebug(lcPipewireDeviceMonitor)
114 << "added device" << *serial << getDeviceDescription(props).value_or("");
115
116 QWriteLocker lock{ &m_mutex };
117 m_devices.emplace(*serial, DeviceRecord{ *serial, std::move(props) });
118
119 return;
120 }
122 // for nodes we need to enumerate the formats
123
124 auto addPendingNode = [&](std::list<PendingNodeRecord> &pendingRecords) {
125 std::optional<std::string_view> nodeName = getNodeName(props);
126 if (!nodeName) {
127 qCWarning(lcPipewireDeviceMonitor) << "node without name (ignoring):" << props;
128 return;
129 }
130
131 if (nodeName == "auto_null") {
132 // pipewire will create a dummy output in case theres' no physical output. We want
133 // to filter that out
134 qCWarning(lcPipewireDeviceMonitor) << "Ignoring dummy output:" << props;
135 return;
136 }
137
138 // Note: virtual devices have neither deviceId, nor deviceSerial. Physical devices have both
139 std::optional<ObjectId> deviceId = getDeviceId(props);
140 std::optional<ObjectSerial> deviceSerial =
141 deviceId ? findObjectSerial(*deviceId) : std::nullopt;
142
143 if (deviceId && !deviceSerial) {
144 qCInfo(lcPipewireDeviceMonitor) << "Cannot add node: device removed";
145 return;
146 }
147
148 std::lock_guard guard{ m_pendingRecordsMutex };
149
150 qCDebug(lcPipewireDeviceMonitor) << "added node for device" << serial << deviceSerial;
151
152 // enumerating the audio format is asynchronous: we enumerate the formats asynchronously
153 // and wait for the result before updating the device list
154 pendingRecords.emplace_back(id, *serial, deviceSerial, std::move(props));
155 pendingRecords.back().formatFuture.then(
156 &m_compressionTimer,
157 [this, weakResults = std::weak_ptr{ pendingRecords.back().formatResults }](
158 std::vector<SpaObjectAudioFormat> formats) {
159 // we do not handle the formats immediately, but coalesce multiple format updates, reduces the number
160 // of changes to the device list
161 if (auto ptr = weakResults.lock()) {
162 *ptr = std::move(formats);
163 startCompressionTimer();
164 }
165 });
166 };
167
168 if (mediaClass == "Audio/Source" || mediaClass == "Audio/Source/Virtual") {
169 addPendingNode(m_pendingRecords.m_sources);
170 return;
171 }
172 if (mediaClass == "Audio/Sink" || mediaClass == "Audio/Sink/Virtual") {
173 addPendingNode(m_pendingRecords.m_sinks);
174 return;
175 }
176
177 break;
178 }
179 default:
180 return;
181 }
182}
183
185{
187
188 std::optional<ObjectSerial> serial = findObjectSerial(id);
189
190 if (!serial)
191 return; // we didn't track the object.
192
193 qCDebug(lcPipewireDeviceMonitor) << "removing object" << *serial;
194
195 std::vector<SharedObjectRemoveObserver> removalObserversForObject;
196 {
197 QWriteLocker lock{ &m_objectDictMutex };
198
199 for (const auto &observer : m_objectRemoveObserver) {
200 if (observer->serial() == serial)
201 removalObserversForObject.push_back(observer);
202 }
203 q20::erase_if(m_objectRemoveObserver, [&](const SharedObjectRemoveObserver &element) {
204 return element->serial() == serial;
205 });
206
207 m_objectSerialDict.erase(id);
208 m_serialObjectDict.erase(*serial);
209 }
210
211 for (const SharedObjectRemoveObserver &element : removalObserversForObject)
212 emit element->objectRemoved();
213
214 {
215 std::lock_guard guard{ m_pendingRecordsMutex };
216
217 m_pendingRecords.removeRecordsForObject(*serial);
218 m_pendingRecords.m_removals.push_back(*serial);
219 }
220
221 startCompressionTimer();
222}
223
231
239
240void QAudioDeviceMonitor::audioDevicesChanged(bool verifyThreading)
241{
242 // Note: we don't want to assert here if we're called from the QtPipeWire::QAudioDevices()
243 // constructor, as that might run on a worker thread (which pushed the instance to the app
244 // thread)
245 if (verifyThreading)
246 Q_ASSERT(this->thread()->isCurrentThread());
247
248 PendingRecords pendingRecords = [&] {
249 std::lock_guard guard{ m_pendingRecordsMutex };
250 PendingRecords resolvedRecords;
251
252 std::swap(m_pendingRecords.m_removals, resolvedRecords.m_removals);
253 std::swap(m_pendingRecords.m_defaultSource, resolvedRecords.m_defaultSource);
254 std::swap(m_pendingRecords.m_defaultSink, resolvedRecords.m_defaultSink);
255
256 // we may still have unresolved records, which wait on their format, but we only want to
257 // handle the fully resolved elements
258 auto takeFullyResolvedRecords = [](std::list<PendingNodeRecord> &toResolve,
259 std::list<PendingNodeRecord> &resolved) {
260 auto it = toResolve.begin();
261 while (it != toResolve.end()) {
262 // we do not only need the future to be resolved, but the continuation needs to
263 // have run (just checking for the future being ready is not sufficient)
264 const bool isFullyResolved = it->formatResults->has_value();
265 if (isFullyResolved) {
266 auto next = std::next(it);
267 resolved.splice(resolved.end(), toResolve, it);
268 it = next;
269 } else {
270 it++;
271 }
272 }
273 };
274 takeFullyResolvedRecords(m_pendingRecords.m_sources, resolvedRecords.m_sources);
275 takeFullyResolvedRecords(m_pendingRecords.m_sinks, resolvedRecords.m_sinks);
276
277 return resolvedRecords;
278 }();
279
280 auto getNodeName =
281 [](std::variant<QByteArray, NoDefaultDeviceType> arg) -> std::optional<QByteArray> {
282 if (std::holds_alternative<NoDefaultDeviceType>(arg))
283 return std::nullopt;
284
285 return std::get<QByteArray>(arg);
286 };
287
288 bool defaultSourceChanged = pendingRecords.m_defaultSource.has_value();
289 if (defaultSourceChanged)
290 m_defaultSourceName = getNodeName(*pendingRecords.m_defaultSource);
291
292 bool defaultSinkChanged = pendingRecords.m_defaultSink.has_value();
293 if (defaultSinkChanged)
294 m_defaultSinkName = getNodeName(*pendingRecords.m_defaultSink);
295
296 if (!pendingRecords.m_sources.empty() || !pendingRecords.m_removals.empty()
297 || defaultSourceChanged)
298 updateSources(std::move(pendingRecords.m_sources), pendingRecords.m_removals);
299
300 if (!pendingRecords.m_sinks.empty() || !pendingRecords.m_removals.empty() || defaultSinkChanged)
301 updateSinks(std::move(pendingRecords.m_sinks), pendingRecords.m_removals);
302}
303
304void QAudioDeviceMonitor::PendingRecords::removeRecordsForObject(ObjectSerial id)
305{
306 for (std::list<PendingNodeRecord> *recordList : { &m_sources, &m_sinks }) {
307 recordList->remove_if([&](const PendingNodeRecord &record) {
308 return record.serial == id || record.deviceSerial == id;
309 });
310 }
311}
312
313template <QAudioDeviceMonitor::Direction Mode>
314std::optional<ObjectSerial>
315QAudioDeviceMonitor::findNodeSerialForNodeName(std::string_view nodeName) const
316{
317 // find node by name
318 QReadLocker guard(&m_mutex);
319
320 QSpan records = Mode == Direction::sink ? QSpan{ m_sinks } : QSpan{ m_sources };
321 auto it = std::find_if(records.begin(), records.end(), [&](const NodeRecord &sink) {
322 return getNodeName(sink.properties) == nodeName;
323 });
324
325 if (it == records.end())
326 return std::nullopt;
327 return it->serial;
328}
329
330std::optional<ObjectSerial> QAudioDeviceMonitor::findSinkNodeSerial(std::string_view nodeName) const
331{
332 return findNodeSerialForNodeName<Direction::sink>(nodeName);
333}
334
335std::optional<ObjectSerial>
336QAudioDeviceMonitor::findSourceNodeSerial(std::string_view nodeName) const
337{
338 return findNodeSerialForNodeName<Direction::source>(nodeName);
339}
340
341template <QAudioDeviceMonitor::Direction Mode>
342void QAudioDeviceMonitor::updateSourcesOrSinks(std::list<PendingNodeRecord> addedNodes,
343 QSpan<const ObjectSerial> removedObjects)
344{
345 QWriteLocker guard(&m_mutex);
346
347 std::vector<NodeRecord> &sinksOrSources = Mode == Direction::sink ? m_sinks : m_sources;
348
349 if (!removedObjects.empty()) {
350 for (ObjectSerial id : removedObjects) {
351 q20::erase_if(sinksOrSources, [&](const auto &record) {
352 return record.serial == id || record.deviceSerial == id;
353 });
354 }
355 }
356
357 for (PendingNodeRecord &record : addedNodes) {
358 Q_ASSERT(record.formatResults && record.formatResults->has_value());
359 std::vector<SpaObjectAudioFormat> &results = **record.formatResults;
360
361 q20::erase_if(results, [](SpaObjectAudioFormat const &arg) {
362 const bool isIEC61937EncapsulatedDevice = std::visit([](const auto &format) {
363 if constexpr (std::is_same_v<std::decay_t<decltype(format)>,
364 spa_audio_iec958_codec>) {
365 // we only support PCM devices
366 return format != SPA_AUDIO_IEC958_CODEC_PCM;
367 } else
368 return false;
369 }, arg.sampleTypes);
370 return isIEC61937EncapsulatedDevice;
371 });
372
373 // sort to list non-iec958 devices first
374 std::sort(results.begin(), results.end(),
375 [](SpaObjectAudioFormat const &lhs, SpaObjectAudioFormat const &rhs) {
376 auto lhs_has_iec958 = std::holds_alternative<spa_audio_iec958_codec>(lhs.sampleTypes);
377 auto rhs_has_iec958 = std::holds_alternative<spa_audio_iec958_codec>(rhs.sampleTypes);
378 return lhs_has_iec958 < rhs_has_iec958;
379 });
380
381 if (results.size() > 1) {
382 qCDebug(lcPipewireDeviceMonitor)
383 << "Multiple formats supported by node, prefer non-iec958: format"
384 << record.serial;
385 }
386
387 if (!results.empty()) {
388 sinksOrSources.push_back(NodeRecord{
389 record.serial,
390 record.deviceSerial,
391 std::move(record.properties),
392 std::move(results.front()),
393 });
394 } else {
395 qCDebug(lcPipewireDeviceMonitor)
396 << "Could not resolve audio format for" << record.serial;
397 }
398 }
399
400 QList<QAudioDevice> oldDeviceList =
401 Mode == Direction::sink ? m_sinkDeviceList : m_sourceDeviceList;
402
403 const std::optional<QByteArray> &defaultSinkOrSourceNodeNameBA =
404 Mode == Direction::sink ? m_defaultSinkName : m_defaultSourceName;
405
406 // revert once QTBUG-134902 is fixed
407 const auto defaultSinkOrSourceNodeName = [&]() -> std::optional<std::string_view> {
408 if (defaultSinkOrSourceNodeNameBA)
409 return std::string_view{
410 defaultSinkOrSourceNodeNameBA->data(),
411 std::size_t(defaultSinkOrSourceNodeNameBA->size()),
412 };
413 return std::nullopt;
414 }();
415
416 QList<QAudioDevice> newDeviceList;
417
418 // we brute-force re-create the device list ... not smart and it can certainly be improved
419 for (NodeRecord &sinkOrSource : sinksOrSources) {
420 std::optional<std::string_view> nodeName = getNodeName(sinkOrSource.properties);
421 bool isDefault = (defaultSinkOrSourceNodeName == nodeName);
422
423 auto devicePrivate = std::make_unique<QPipewireAudioDevicePrivate>(
424 sinkOrSource.properties, sinkOrSource.format, QAudioDevice::Mode::Output,
425 isDefault);
426
427 QAudioDevice device = QAudioDevicePrivate::createQAudioDevice(std::move(devicePrivate));
428
429 newDeviceList.push_back(device);
430
431 qCDebug(lcPipewireDeviceMonitor) << "adding device" << nodeName;
432 }
433
434 // sort by description
435 std::sort(newDeviceList.begin(), newDeviceList.end(),
436 [](const QAudioDevice &lhs, const QAudioDevice &rhs) {
437 return lhs.description() < rhs.description();
438 });
439
440 guard.unlock();
441
442 bool deviceListsEqual = ranges::equal(oldDeviceList, newDeviceList,
443 [](const QAudioDevice &lhs, const QAudioDevice &rhs) {
444 return (lhs.id() == rhs.id()) && (lhs.isDefault() == rhs.isDefault());
445 });
446
447 if (!deviceListsEqual) {
448 qCDebug(lcPipewireDeviceMonitor) << "updated device list";
449
450 if constexpr (Mode == Direction::sink) {
451 m_sinkDeviceList = newDeviceList;
452 emit audioSinksChanged(m_sinkDeviceList);
453 } else {
454 m_sourceDeviceList = newDeviceList;
455 emit audioSourcesChanged(m_sourceDeviceList);
456 }
457 }
458}
459
460void QAudioDeviceMonitor::updateSinks(std::list<PendingNodeRecord> addedNodes,
461 QSpan<const ObjectSerial> removedObjects)
462{
463 updateSourcesOrSinks<Direction::sink>(std::move(addedNodes), removedObjects);
464}
465
466void QAudioDeviceMonitor::updateSources(std::list<PendingNodeRecord> addedNodes,
467 QSpan<const ObjectSerial> removedObjects)
468{
469 updateSourcesOrSinks<Direction::source>(std::move(addedNodes), removedObjects);
470}
471
472std::optional<ObjectSerial> QAudioDeviceMonitor::findDeviceSerial(std::string_view deviceName) const
473{
474 QReadLocker guard(&m_mutex);
475 auto it = std::find_if(m_devices.begin(), m_devices.end(), [&](auto const &entry) {
476 return getDeviceName(entry.second.properties) == deviceName;
477 });
478 if (it == m_devices.end())
479 return std::nullopt;
480 return it->first;
481}
482
484{
485 QReadLocker lock{ &m_objectDictMutex };
486
487 auto it = m_serialObjectDict.find(serial);
488 if (it != m_serialObjectDict.end())
489 return it->second;
490 return std::nullopt;
491}
492
494{
495 QReadLocker lock{ &m_objectDictMutex };
496
497 auto it = m_objectSerialDict.find(id);
498 if (it != m_objectSerialDict.end())
499 return it->second;
500 return std::nullopt;
501}
502
504{
505 QWriteLocker lock{ &m_objectDictMutex };
506
507 if (m_serialObjectDict.find(observer->serial()) == m_serialObjectDict.end())
508 return false; // don't register observer if the object has already been removed
509
510 m_objectRemoveObserver.push_back(std::move(observer));
511 return true;
512}
513
515{
516 QWriteLocker lock{ &m_objectDictMutex };
517
518 q20::erase(m_objectRemoveObserver, observer);
519}
520
522{
523 // force initial device enumeration
525
526 // sync with format futures
527 for (;;) {
529
530 std::lock_guard pendingRecordLock{
531 m_pendingRecordsMutex,
532 };
533
534 for (ObjectSerial removed : m_pendingRecords.m_removals)
535 m_pendingRecords.removeRecordsForObject(removed);
536
537 auto allFormatsResolved = [](const std::list<PendingNodeRecord> &list) {
538 return std::all_of(list.begin(), list.end(), [](const PendingNodeRecord &record) {
539 return record.formatFuture.isFinished();
540 });
541 };
542
543 if (allFormatsResolved(m_pendingRecords.m_sources)
544 && allFormatsResolved(m_pendingRecords.m_sinks))
545 break;
546 }
547
548 // HACK: getDeviceLists is synchronous, so we need to wait for all the pending format have
549 // been populated force the continuations posted on &m_compressionTimer to be executed (compare
550 // QAudioDeviceMonitor::objectAdded)
551 // LATER: can we asynchronously resolve the format, similar to std::future<AudioDeviceFormat>?
552 QMetaObject::invokeMethod(&m_compressionTimer, [&] {
553 QCoreApplication::sendPostedEvents(&m_compressionTimer, QEvent::MetaCall);
554 audioDevicesChanged(verifyThreading);
555 m_compressionTimer.stop();
556 }, Qt::BlockingQueuedConnection);
557
558 QReadLocker lock{ &m_mutex };
559 return {
560 .sources = m_sourceDeviceList,
561 .sinks = m_sinkDeviceList,
562 };
563}
564
565void QAudioDeviceMonitor::startCompressionTimer()
566{
567 QMetaObject::invokeMethod(&m_compressionTimer, [this] {
568 if (m_compressionTimer.isActive())
569 return;
570 m_compressionTimer.start();
571 });
572}
573
574QAudioDeviceMonitor::PendingNodeRecord::PendingNodeRecord(ObjectId object, ObjectSerial serial,
575 std::optional<ObjectSerial> deviceSerial,
576 PwPropertyDict properties):
577 serial{
578 serial,
579 },
580 deviceSerial{
581 deviceSerial,
582 },
583 properties{
584 std::move(properties),
585 },
586 formatResults{
587 std::make_shared<std::optional<std::vector<SpaObjectAudioFormat>>>()
588 }
589{
591
592 auto promise = std::make_shared<QPromise<std::vector<SpaObjectAudioFormat>>>();
593 formatFuture = promise->future();
594
595 auto shared_results = std::make_shared<std::vector<SpaObjectAudioFormat>>();
596
597 auto onParam = [shared_results](int /*seq*/, uint32_t /*id*/, uint32_t /*index*/,
598 uint32_t /*next*/, const struct spa_pod *param) mutable {
599 std::optional<SpaObjectAudioFormat> format = SpaObjectAudioFormat::parse(param);
600 if (format)
601 shared_results->emplace_back(*format);
602 };
603
604 QAudioContextManager::withEventLoopLock([&] {
606 PwNodeHandle nodeProxy = context->bindNode(object);
607
608 enumFormatListener = std::make_unique<NodeEventListener>(std::move(nodeProxy),
609 NodeEventListener::NodeHandler{
610 {},
611 std::move(onParam),
612 });
613
614 enumFormatListener->enumParams(SPA_PARAM_EnumFormat);
615
616 // we potentially receive multiple calls to pw_core_events->param if devices support
617 // multiple formats. e.g. hdmi devices potentially report "raw" pcm and iec958. so we sync
618 // with the pipewire server, to act as barrier.
619 enumFormatDoneListener = std::make_unique<CoreEventDoneListener>();
620 enumFormatDoneListener->asyncWait(context->coreConnection().get(),
621 [promise, shared_results] {
622 promise->start();
623 promise->emplaceResult(std::move(*shared_results));
624 promise->finish();
625 });
626 });
627}
628
629} // namespace QtPipeWire
630
631QT_END_NAMESPACE
632
633#include "moc_qpipewire_audiodevicemonitor_p.cpp"
std::optional< ObjectSerial > findObjectSerial(ObjectId) const
void unregisterObserver(const SharedObjectRemoveObserver &)
std::optional< ObjectSerial > findSourceNodeSerial(std::string_view nodeName) const
DeviceLists getDeviceLists(bool verifyThreading=true)
std::optional< ObjectSerial > findSinkNodeSerial(std::string_view nodeName) const
void objectAdded(ObjectId, uint32_t permissions, PipewireRegistryType, uint32_t version, const spa_dict &props)
std::optional< ObjectId > findObjectId(ObjectSerial) const
bool registerObserver(SharedObjectRemoveObserver)
Combined button and popup list for selecting options.
Q_STATIC_LOGGING_CATEGORY(lcPipewireAudioSink, "qt.multimedia.pipewire.audiosink")
StrongIdType< uint32_t, ObjectIdTag > ObjectId
StrongIdType< uint64_t, ObjectSerialTag > ObjectSerial
std::shared_ptr< ObjectRemoveObserver > SharedObjectRemoveObserver