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