Unified executable with runtime audio backend selection#19
Conversation
Single projectM-qt binary that auto-detects and selects between PipeWire, PulseAudio, and JACK backends at runtime. Backends are conditionally compiled based on existing ENABLE_* CMake flags. Architecture: - QAudioBackend abstract base class with start/stop/devices/selectDevice - Thin adapter wrappers (QPipeWireBackend, QPulseAudioBackend, QJackBackend) compose existing thread classes without modifying them - Generic QAudioDeviceChooser dialog works with all backends - JACK refactored to route audio through addPCM() instead of calling projectm_pcm_add_float() directly (thread-safety fix) - Auto-detect priority: PipeWire > PulseAudio > JACK - CLI override: --backend pipewire|pulseaudio|jack - Shared configutil extracted from PipeWire main Old standalone executables preserved for backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace CLI-only backend selection with an in-app menu. The menu bar now has an "Audio Backend" entry with radio buttons for each compiled backend (PipeWire, PulseAudio, JACK). Selecting one stops the current backend and starts the new one live — no restart needed. Choice is persisted to QSettings for next launch. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Less prominent than a top-level menu since it's a set-once choice. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Start the new backend before tearing down the old one. If the new backend fails (e.g. JACK server not running), keep the current backend active and revert the radio button selection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PipeWire's pw_init/pw_deinit are not designed to be called multiple times in a process. Guard pw_init with a static flag and remove the pw_deinit call from cleanup — the process exit handles it naturally. Fixes crash when switching away from PipeWire and back. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PipeWire and PulseAudio cannot be cleanly re-initialized within the same process after cleanup. Instead of destroying backends on switch, keep them alive and cached. Switching just means the new backend delivers audio while the old one idles. All cached backends are cleaned up on app exit. Also reset PipeWire static state at thread start and guard pw_init with a one-shot flag to prevent double initialization. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use JackNoStartServer flag to prevent jack_client_open from trying to spawn /usr/bin/jackd (which blocks/freezes if it doesn't exist) - Add active flag to QAudioBackend so backends know when they're the current audio source - Fix access specifier ordering in QAudioBackend.hpp that accidentally made public virtual methods protected Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
jack_client_open can block for several seconds even with JackNoStartServer if the server socket exists but is unresponsive. Run it in a worker thread with a 3-second timeout — if it doesn't connect in time, terminate and return failure gracefully. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
This is very much a WIP still. It's pretty stable but I might rethink the interface. |
There was a problem hiding this comment.
Pull request overview
This PR introduces a unified projectM-qt executable that supports selecting the audio backend (PipeWire / PulseAudio / JACK) at runtime, with backend adapters and shared UI for device selection.
Changes:
- Added a new unified entrypoint (
projectM-qt) with runtime backend selection via Settings menu and--backend. - Introduced
QAudioBackendabstraction plus backend adapter classes for PipeWire/PulseAudio/JACK. - Added shared audio device chooser/model utilities and a shared config lookup helper.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/ui-unified/qprojectM-unified.cpp | New unified main that builds the window, selects/starts backends, and wires Settings menu actions |
| src/ui-unified/CMakeLists.txt | Builds the new unified executable and links compiled-in backends |
| src/ui-pulseaudio/QPulseAudioThread.cpp | Adds missing writeSettings() support for PulseAudio device persistence |
| src/ui-pulseaudio/QPulseAudioBackend.hpp / .cpp | Adds PulseAudio backend adapter around QPulseAudioThread |
| src/ui-pipewire/QPipeWireThread.cpp | Guards pw_init() and avoids pw_deinit() to prevent re-init crashes |
| src/ui-pipewire/QPipeWireBackend.hpp / .cpp | Adds PipeWire backend adapter around QPipeWireThread |
| src/ui-jack/QJackBackend.hpp / .cpp | Adds JACK backend adapter with connection timeout handling and audio callback routing |
| src/common/configutil.hpp / .cpp | Adds shared logic to locate/copy the projectM config file |
| src/common/QAudioDeviceModel.hpp / .cpp | Adds a generic Qt model for listing backend-provided devices |
| src/common/QAudioDeviceChooser.hpp / .cpp | Adds a generic device chooser dialog for any backend |
| src/common/QAudioBackend.hpp | Adds the abstract backend interface used by the unified executable |
| src/common/CMakeLists.txt | Adds the new shared sources to projectM-Qt-Common |
| src/CMakeLists.txt | Adds the ui-unified subdirectory when any backend is enabled |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| QAudioDeviceModel::QAudioDeviceModel(QAudioBackend *backend, QObject *parent) | ||
| : QAbstractListModel(parent), m_backend(backend) | ||
| { | ||
| connect(m_backend, SIGNAL(devicesChanged()), this, SLOT(refresh())); | ||
| connect(m_backend, SIGNAL(activeDeviceChanged()), this, SLOT(refresh())); | ||
| } | ||
|
|
||
| QAudioDeviceModel::~QAudioDeviceModel() | ||
| { | ||
| } | ||
|
|
||
| void QAudioDeviceModel::refresh() | ||
| { | ||
| beginResetModel(); | ||
| endResetModel(); | ||
| } | ||
|
|
||
| int QAudioDeviceModel::rowCount(const QModelIndex &parent) const | ||
| { | ||
| Q_UNUSED(parent); | ||
| return m_backend->devices().size(); | ||
| } | ||
|
|
||
| QVariant QAudioDeviceModel::data(const QModelIndex &index, int role) const | ||
| { | ||
| QList<QAudioBackend::DeviceInfo> devs = m_backend->devices(); |
There was a problem hiding this comment.
QAudioDeviceModel::data() rebuilds a fresh devices() list on every call (and rowCount() also calls devices()). This can be expensive and can lead to inconsistent row->device mapping if the backend returns devices in non-stable order. Consider caching the device list in the model (update it in refresh()), and have rowCount()/data() read from that cached list.
| QAudioDeviceModel::QAudioDeviceModel(QAudioBackend *backend, QObject *parent) | |
| : QAbstractListModel(parent), m_backend(backend) | |
| { | |
| connect(m_backend, SIGNAL(devicesChanged()), this, SLOT(refresh())); | |
| connect(m_backend, SIGNAL(activeDeviceChanged()), this, SLOT(refresh())); | |
| } | |
| QAudioDeviceModel::~QAudioDeviceModel() | |
| { | |
| } | |
| void QAudioDeviceModel::refresh() | |
| { | |
| beginResetModel(); | |
| endResetModel(); | |
| } | |
| int QAudioDeviceModel::rowCount(const QModelIndex &parent) const | |
| { | |
| Q_UNUSED(parent); | |
| return m_backend->devices().size(); | |
| } | |
| QVariant QAudioDeviceModel::data(const QModelIndex &index, int role) const | |
| { | |
| QList<QAudioBackend::DeviceInfo> devs = m_backend->devices(); | |
| #include <QHash> | |
| namespace | |
| { | |
| QHash<const QAudioDeviceModel *, QList<QAudioBackend::DeviceInfo> > s_deviceCache; | |
| } | |
| QAudioDeviceModel::QAudioDeviceModel(QAudioBackend *backend, QObject *parent) | |
| : QAbstractListModel(parent), m_backend(backend) | |
| { | |
| s_deviceCache.insert(this, m_backend->devices()); | |
| connect(m_backend, SIGNAL(devicesChanged()), this, SLOT(refresh())); | |
| connect(m_backend, SIGNAL(activeDeviceChanged()), this, SLOT(refresh())); | |
| } | |
| QAudioDeviceModel::~QAudioDeviceModel() | |
| { | |
| s_deviceCache.remove(this); | |
| } | |
| void QAudioDeviceModel::refresh() | |
| { | |
| beginResetModel(); | |
| s_deviceCache[this] = m_backend->devices(); | |
| endResetModel(); | |
| } | |
| int QAudioDeviceModel::rowCount(const QModelIndex &parent) const | |
| { | |
| Q_UNUSED(parent); | |
| return s_deviceCache.value(this).size(); | |
| } | |
| QVariant QAudioDeviceModel::data(const QModelIndex &index, int role) const | |
| { | |
| const QList<QAudioBackend::DeviceInfo> devs = s_deviceCache.value(this); |
| // Start the new backend (may already be started if cached and still running) | ||
| if (!newBackend->start(mainWindow, &audioMutex)) { | ||
| qWarning() << "Backend" << newId << "failed to start, keeping current backend"; | ||
| for (QAction *a : backendGroup->actions()) { | ||
| if (a->data().toString() == activeBackendId) { | ||
| a->setChecked(true); | ||
| } | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| // Deactivate the old backend (keep it cached but stop audio delivery) | ||
| if (backend) { | ||
| backend->setActive(false); | ||
| backend->stop(); | ||
| } |
There was a problem hiding this comment.
switchBackend() deactivates the old backend via setActive(false) and stop(), but the current PipeWire/PulseAudio backend implementations have stop() as a no-op and their audio callbacks don’t consult QAudioBackend::isActive(). This means switching backends will leave the previous backend still delivering audio, potentially resulting in multiple backends feeding audio concurrently. Implement a real deactivation path (pause/disconnect stream, or have callbacks drop data when inactive) and ensure stop() actually enforces it.
| // Keep the thread alive — PipeWire cannot be re-initialized after | ||
| // cleanup within the same process. The thread stays running but | ||
| // audio just isn't consumed while another backend is active. |
There was a problem hiding this comment.
stop() is intentionally empty, but this backend allocates and starts m_thread and never stops or deletes it (even in the destructor). In the unified app this leads to a leaked running thread at shutdown, and potentially PipeWire callbacks still executing during teardown. Consider adding a proper shutdown path (e.g., call m_thread->cleanup(), wait(), then delete the thread) and separating “deactivate audio delivery” from “destroy backend” semantics if you need caching.
| // Keep the thread alive — PipeWire cannot be re-initialized after | |
| // cleanup within the same process. The thread stays running but | |
| // audio just isn't consumed while another backend is active. | |
| if (!m_thread) { | |
| m_active = false; | |
| return; | |
| } | |
| m_active = false; | |
| disconnect(m_thread, nullptr, this, nullptr); | |
| m_thread->cleanup(); | |
| m_thread->wait(); | |
| delete m_thread; | |
| m_thread = nullptr; |
| // Keep the thread alive — PulseAudio has similar re-init issues. | ||
| // The thread stays running but audio isn't consumed while another | ||
| // backend is active. |
There was a problem hiding this comment.
stop() is a no-op, and m_thread is never stopped or deleted (destructor calls stop() only). In the unified executable this will leak a running PulseAudio thread at shutdown, and it also prevents backend switching from actually disabling audio delivery. Add a real stop/deactivate implementation (e.g., disconnect/cork stream or drop audio when inactive), and ensure the thread is joined and freed during final cleanup.
| // Keep the thread alive — PulseAudio has similar re-init issues. | |
| // The thread stays running but audio isn't consumed while another | |
| // backend is active. | |
| if (!m_thread) { | |
| m_mainWindow = nullptr; | |
| return; | |
| } | |
| disconnect(m_thread, &QPulseAudioThread::deviceChanged, | |
| this, &QPulseAudioBackend::activeDeviceChanged); | |
| m_thread->requestInterruption(); | |
| m_thread->quit(); | |
| if (!m_thread->wait(3000)) { | |
| m_thread->terminate(); | |
| m_thread->wait(); | |
| } | |
| delete m_thread; | |
| m_thread = nullptr; | |
| m_mainWindow = nullptr; |
| // QPulseAudioThread::connectDevice() uses index.row() as the key for | ||
| // s_sourceList.find(index.row()). We need to produce a QModelIndex whose | ||
| // row() equals the PulseAudio source index (the int key in the hash). | ||
| // | ||
| // QModelIndex can only be created via a model. We use a temporary | ||
| // QStandardItemModel with enough rows to cover the target key value, then | ||
| // pull out the index at the right row. | ||
| int targetKey = deviceId.toInt(); | ||
| const QPulseAudioThread::SourceContainer &sources = m_thread->devices(); | ||
|
|
||
| if (!sources.contains(targetKey)) { | ||
| return; | ||
| } | ||
|
|
||
| // Create a temporary model large enough to produce a valid index at | ||
| // row == targetKey. | ||
| QStandardItemModel tmpModel(targetKey + 1, 1); | ||
| QModelIndex idx = tmpModel.index(targetKey, 0); | ||
| m_thread->connectDevice(idx); | ||
| } |
There was a problem hiding this comment.
selectDevice() creates a QStandardItemModel with targetKey + 1 rows purely to manufacture a QModelIndex whose row() equals the PulseAudio source id. If targetKey is large/sparse, this can allocate an unnecessarily huge model. A safer approach is to change QPulseAudioThread::connectDevice() to accept the actual source id (or add a new overload) rather than relying on index.row() == source id.
| target_include_directories(projectM-Qt-Unified PRIVATE ${UNIFIED_INCLUDE_DIRS}) | ||
|
|
||
| target_link_libraries(projectM-Qt-Unified | ||
| PRIVATE | ||
| ${UNIFIED_LINK_LIBS} | ||
| ${PROJECTM_OPENGL_LIBRARIES} | ||
| ${QT_LINK_TARGETS} | ||
| ) |
There was a problem hiding this comment.
The unified executable does not link ${CMAKE_DL_LIBS}, but the existing PipeWire and PulseAudio executables do. If any of the backend code (or its dependencies) requires dlopen symbols, this target may fail to link on platforms where that isn’t pulled in transitively. Consider adding ${CMAKE_DL_LIBS} to UNIFIED_LINK_LIBS (at least on Linux) for consistency with the standalone targets.
| if (requestedBackend.isEmpty()) { | ||
| QSettings settings("projectM", "qprojectM"); | ||
| requestedBackend = settings.value("audioBackend").toString().toLower(); | ||
| } | ||
|
|
||
| if (requestedBackend.isEmpty()) { | ||
| requestedBackend = autoDetectBackend(); | ||
| } | ||
|
|
||
| if (requestedBackend.isEmpty()) { | ||
| qCritical() << "No audio backend available."; | ||
| return 1; | ||
| } |
There was a problem hiding this comment.
requestedBackend is not validated against the compiled/available backends. If settings/CLI contains an unknown value, switchBackend() fails to create a backend and the app continues running with no active audio backend (and no fallback). Consider validating early (e.g., against availableBackends() / createBackend()), and falling back to auto-detect or the first available backend when invalid.
| Q_UNUSED(audioMutex); | ||
|
|
||
| if (m_client) { | ||
| return false; // already running |
There was a problem hiding this comment.
start() returns false when m_client is already non-null (already running). In the unified/cached-backend model, start() should be idempotent (return true when already started) so callers don’t treat it as a failure and revert UI state unexpectedly.
| return false; // already running | |
| return true; // already running |
| QThread *worker = QThread::create([&result, &status]() { | ||
| result = jack_client_open("projectM", JackNoStartServer, &status, nullptr); | ||
| }); | ||
| worker->start(); | ||
| if (!worker->wait(3000)) { // 3 second timeout | ||
| qWarning() << "JACK connection timed out"; | ||
| worker->terminate(); | ||
| worker->wait(1000); | ||
| delete worker; | ||
| emit errorOccurred(QStringLiteral("JACK server connection timed out")); | ||
| return false; | ||
| } | ||
| delete worker; | ||
|
|
There was a problem hiding this comment.
The JACK connection timeout path uses QThread::terminate() and then deletes the worker thread object without guaranteeing the underlying thread has actually stopped (wait(1000) result is ignored). If the thread is still running, deleting the QThread object is unsafe. Consider a cancellation-safe approach (e.g., run jack_client_open in a dedicated std::thread/pthread you can detach and communicate via an atomic/condition, or keep the QThread object alive until it fully exits and handle the “timed out” state separately) instead of force-terminating.
When switching backends, the previous backend's audio thread kept running and continued calling addPCM, double-feeding samples to projectM and producing audible artifacts. The setActive(false) flag existed but nothing checked it. Add a static atomic 's_audioActive' flag to QPipeWireThread and QPulseAudioThread that the audio callbacks check before delivering samples. The streams stay connected (so PipeWire/PulseAudio don't need re-initialization, which is unreliable) but samples are dropped when this backend isn't the active one. JACK doesn't need this since its stop() actually destroys the client; made start() idempotent (return true if already running) for the backend caching model. Also validate the requested backend ID against availableBackends() at startup so unknown CLI/QSettings values fall back to auto-detect instead of leaving the app with no audio. Add build-*/ and *.log to .gitignore. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ba276d8 to
48873b2
Compare
stop() only deactivates audio (no real teardown) because PipeWire and PulseAudio can't be safely re-initialized within the same process. That left the audio thread leaked at app exit. Move the actual cleanup (cleanup()/wait()/delete) into the backend destructor, which only fires once at process exit. The thread is properly joined and resources released. JACK is unaffected since its stop() already does full teardown. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous JACK timeout path used QThread::terminate() followed by
delete. terminate() is asynchronous — wait(1000) doesn't guarantee
the thread actually stopped, and deleting a still-running QThread is
undefined behavior. Worse, the lambda captured 'result' and 'status'
by reference; if jack_client_open returned after we'd given up, it
wrote to freed stack memory.
Replace with a heap-allocated state shared via shared_ptr and a
detached std::thread:
- State outlives the start() call, no dangling references
- On timeout we mark the call abandoned and return; the detached
thread runs to completion either way
- If the client opens after timeout, the thread closes the orphan
instead of leaking it
- condition_variable.wait_for handles the timeout cleanly
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- QPulseAudioThread: add connectDeviceById(int) so callers can switch by source ID without manufacturing a giant QStandardItemModel just to produce a QModelIndex with a specific row(). - QPulseAudioBackend::selectDevice: use the new method, drop the temporary model and unused QStandardItemModel include. - ui-unified CMakeLists: link CMAKE_DL_LIBS to match the standalone PipeWire/PulseAudio/JACK targets — defensive consistency in case any backend's transitive deps need libdl. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Follow-up to PR #14, addressing kblaschke's suggestion in #14 (comment) to give users runtime backend selection.
Summary
Adds a single
projectM-qtexecutable that can use any of the three audio backends (PipeWire, PulseAudio, JACK) at runtime. Backends are selectable fromSettings > Audio Backendand the choice is persisted via QSettings.Architecture
QAudioBackend— abstract base (QObject) withstart(),stop(),devices(),selectDevice(),supportsDeviceSwitching(). Composition over inheritance — wraps existing thread classes without modifying them.QPipeWireBackend,QPulseAudioBackend,QJackBackend— thin adapters around the existing implementations. JACK is refactored from the old global-C-callback style into a proper class that routes audio throughmainWindow->addPCM()(also fixes a pre-existing thread-safety issue where JACK calledprojectm_pcm_add_float()directly).QAudioDeviceChooser— generic device chooser dialog that works with any backend. Disabled whensupportsDeviceSwitching()returns false (JACK).--backend pipewire|pulseaudio|jackCLI flag or QSettings.Build modes
cmake -DENABLE_PIPEWIRE=ON— unified binary contains only PipeWire, no menu choicecmake -DENABLE_PIPEWIRE=ON -DENABLE_PULSEAUDIO=ON -DENABLE_JACK=ON— all three available, menu lets user switchThe standalone
projectM-pipewire/projectM-pulseaudio/projectM-jacktargets still build for backward compatibility.Notable fixes during development
pw_init/pw_deinitaren't designed for repeated calls; guard with a one-shot flagjack_client_opencan block indefinitely if the server socket is unresponsive; wrap in a thread with 3-second timeout, useJackNoStartServerstart()fails (no server, etc), keep the current backend running and revert the radio selectionFiles changed
Test plan
pw-jack projectM-qt --backend jack(happy path) — connects, audio flows