Skip to content

Unified executable with runtime audio backend selection#19

Draft
struktured wants to merge 12 commits into
masterfrom
feature/runtime-backend-selection
Draft

Unified executable with runtime audio backend selection#19
struktured wants to merge 12 commits into
masterfrom
feature/runtime-backend-selection

Conversation

@struktured

Copy link
Copy Markdown
Contributor

Follow-up to PR #14, addressing kblaschke's suggestion in #14 (comment) to give users runtime backend selection.

Summary

Adds a single projectM-qt executable that can use any of the three audio backends (PipeWire, PulseAudio, JACK) at runtime. Backends are selectable from Settings > Audio Backend and the choice is persisted via QSettings.

Architecture

  • QAudioBackend — abstract base (QObject) with start(), 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 through mainWindow->addPCM() (also fixes a pre-existing thread-safety issue where JACK called projectm_pcm_add_float() directly).
  • QAudioDeviceChooser — generic device chooser dialog that works with any backend. Disabled when supportsDeviceSwitching() returns false (JACK).
  • Backend caching — once a backend starts, it stays alive. Switching just deactivates the old one and activates the new. Required because PipeWire and PulseAudio can't be cleanly torn down and re-initialized within the same process.
  • Auto-detect priority — PipeWire > PulseAudio > JACK based on what was compiled in. Override via --backend pipewire|pulseaudio|jack CLI flag or QSettings.

Build modes

  • cmake -DENABLE_PIPEWIRE=ON — unified binary contains only PipeWire, no menu choice
  • cmake -DENABLE_PIPEWIRE=ON -DENABLE_PULSEAUDIO=ON -DENABLE_JACK=ON — all three available, menu lets user switch

The standalone projectM-pipewire / projectM-pulseaudio / projectM-jack targets still build for backward compatibility.

Notable fixes during development

  • PipeWire re-init crashpw_init/pw_deinit aren't designed for repeated calls; guard with a one-shot flag
  • JACK connection freezejack_client_open can block indefinitely if the server socket is unresponsive; wrap in a thread with 3-second timeout, use JackNoStartServer
  • Switch fallback — if the new backend's start() fails (no server, etc), keep the current backend running and revert the radio selection

Files changed

  • 16 new files (abstract backend, 3 adapters, generic chooser dialog, configutil, unified main, CMake)
  • 3 modified (CMakeLists, missing PA writeSettings, PipeWire init guard)

Test plan

  • Clean build with all three backends
  • PipeWire auto-detected on startup
  • Switch to PulseAudio via menu (works since PipeWire provides PA compat)
  • Switch to JACK via pw-jack projectM-qt --backend jack (happy path) — connects, audio flows
  • Switch to JACK with no server (sad path) — fails fast, reverts to previous backend
  • Tested locally end-to-end across all three backends with audio playing
  • Settings dialog persistence verified after backend switch

struktured and others added 8 commits April 18, 2026 11:46
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>
@struktured struktured requested a review from Copilot April 25, 2026 14:59
@struktured

Copy link
Copy Markdown
Contributor Author

This is very much a WIP still. It's pretty stable but I might rethink the interface.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 QAudioBackend abstraction 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.

Comment on lines +26 to +52

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();

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +205 to +220
// 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();
}

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/ui-pipewire/QPipeWireBackend.cpp Outdated
Comment on lines +63 to +65
// 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.

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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;

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +68
// Keep the thread alive — PulseAudio has similar re-init issues.
// The thread stays running but audio isn't consumed while another
// backend is active.

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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;

Copilot uses AI. Check for mistakes.
Comment on lines +123 to +142
// 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);
}

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +63
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}
)

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +139 to +151
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;
}

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/ui-jack/QJackBackend.cpp Outdated
Q_UNUSED(audioMutex);

if (m_client) {
return false; // already running

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
return false; // already running
return true; // already running

Copilot uses AI. Check for mistakes.
Comment thread src/ui-jack/QJackBackend.cpp Outdated
Comment on lines +62 to +75
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;

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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>
@struktured struktured force-pushed the feature/runtime-backend-selection branch from ba276d8 to 48873b2 Compare April 26, 2026 19:24
struktured and others added 3 commits April 26, 2026 16:53
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants