# Technical Specification: PipeWire Audio Router (PAR) - Qt/C++ Desktop Application ## 1. Overview A high-performance, PipeWire-native audio routing application built with Qt6 and C++. It provides a real-time visual node-based interface using QtNodes for managing virtual cables, hardware I/O, and DSP processing. The system is a monolithic desktop application with direct libpipewire integration, eliminating the need for a separate daemon and web frontend. ## 2. Architecture ### **Monolithic Qt/C++ Application:** * **PipeWire Integration Layer (C++):** * Interfaces directly with `libpipewire` using `pw_thread_loop` for non-blocking operation * Maintains the "desired state" vs. "actual state" of the audio graph * Uses lock-free ring buffers (`spa_ringbuffer`) for real-time safe communication * Event-driven architecture using PipeWire callbacks integrated with Qt event loop * **UI Layer (Qt6 Widgets):** * **Node Editor (QtNodes):** Visual representation of PipeWire nodes using the QtNodes library * **Mixer View:** Traditional fader-based UI for quick volume adjustments * **Real-time Meters:** 30Hz audio level visualization using optimized QGraphicsView * **State Sync:** Direct signal/slot connections for reactive UI updates * **Threading Model:** * **PipeWire Thread:** High-priority real-time thread managed by `pw_thread_loop` * **Qt GUI Thread:** Standard priority main thread for UI rendering * **Lock-free Communication:** Atomic variables and ring buffers for cross-thread data ### **Key Technology Choices:** | Component | Technology | Rationale | |-----------|-----------|-----------| | UI Framework | Qt6 Widgets | Mature, cross-platform, excellent performance | | Node Editor | QtNodes (paceholder/nodeeditor) | Production-ready, 3600+ stars, dataflow support | | Audio Backend | libpipewire 1.0+ | Native Linux audio routing | | Threading | pw_thread_loop + QThread | Separates real-time audio from GUI | | IPC | Lock-free ring buffers | Real-time safe, zero-copy where possible | | Build System | CMake + Qt6 | Industry standard | | Language | C++17 | Modern C++ with Qt integration | ## 3. Data Model & Graph Schema The application treats the PipeWire graph as a directed graph where nodes are audio processors and edges are links. ### Node Types * **Hardware:** Physical inputs (mics) and outputs (speakers) * **Virtual:** Software-defined sinks/sources (e.g., "Game Audio", "Browser") * **App:** Dynamic nodes representing running applications * **Bus:** Mix buses for routing multiple sources to destinations ### Preset Schema (JSON) ```json { "version": "1.0", "virtual_devices": [ { "name": "Virtual_Sink_1", "channels": 2, "description": "Desktop Audio", "stable_id": "v-sink-alpha" } ], "routing": [ { "source": "node_id:port", "target": "node_id:port", "volume": 1.0, "muted": false } ], "persistent_volumes": { "app_name": 0.8 }, "ui_layout": { "nodes": [ { "id": "node_1", "x": 100, "y": 200 } ], "zoom": 1.0, "view_center": {"x": 0, "y": 0} } } ``` ## 4. PipeWire Integration Details ### Initialization Sequence **Critical Order (from real-world implementations):** ```cpp // 1. Initialize PipeWire library pw_init(nullptr, nullptr); // 2. Create threaded loop (separates PipeWire events from Qt event loop) m_threadLoop = pw_thread_loop_new("PAR-Audio", nullptr); // 3. Lock before setup operations pw_thread_loop_lock(m_threadLoop); // 4. Create context m_context = pw_context_new( pw_thread_loop_get_loop(m_threadLoop), nullptr, 0); // 5. Connect to PipeWire daemon m_core = pw_context_connect(m_context, nullptr, 0); // 6. Setup registry for node discovery m_registry = pw_core_get_registry(m_core, PW_VERSION_REGISTRY, 0); pw_registry_add_listener(m_registry, &m_registryListener, ®istryEvents, this); // 7. Create streams as needed m_stream = pw_stream_new_simple( pw_thread_loop_get_loop(m_threadLoop), "PAR-Stream", properties, &streamEvents, this); // 8. Unlock pw_thread_loop_unlock(m_threadLoop); // 9. Start the thread pw_thread_loop_start(m_threadLoop); ``` ### Module Choices and Virtual Device Creation Virtual devices are instantiated using PipeWire's adapter module: ```cpp // Virtual Sink Creation struct spa_dict_item items[] = { { PW_KEY_FACTORY_NAME, "support.null-audio-sink" }, { PW_KEY_NODE_NAME, "PAR_Virtual_Sink" }, { PW_KEY_NODE_DESCRIPTION, "Desktop Audio" }, { PW_KEY_MEDIA_CLASS, "Audio/Sink" }, { PW_KEY_AUDIO_CHANNELS, "2" }, { PW_KEY_AUDIO_RATE, "48000" }, { "object.linger", "true" }, // Persist even when no clients { "application.name", "Potato-Manager" } // Tag for tracking }; struct spa_dict dict = SPA_DICT_INIT(items, SPA_N_ELEMENTS(items)); pw_core_create_object(m_core, "adapter", PW_TYPE_INTERFACE_Node, PW_VERSION_NODE, &dict, 0); ``` ### Node and Port Mapping to QtNodes **Mapping Rules:** * PipeWire `node_id` → QtNodes `Node` with stable identifier from metadata * PipeWire ports with `direction: out` → QtNodes source handles (right side) * PipeWire ports with `direction: in` → QtNodes target handles (left side) * Port compatibility validation before link creation **QtNodes Data Model:** ```cpp class AudioNodeDataModel : public QtNodes::NodeDataModel { public: QString caption() const override { return m_nodeName; } QString name() const override { return m_nodeId; } unsigned int nPorts(QtNodes::PortType portType) const override { return portType == QtNodes::PortType::In ? m_inputPorts.size() : m_outputPorts.size(); } QtNodes::NodeDataType dataType(QtNodes::PortType portType, QtNodes::PortIndex portIndex) const override { return {"audio", "Audio Stream"}; } private: QString m_nodeId; // PipeWire node stable ID QString m_nodeName; // Human-readable name uint32_t m_pwNodeId; // PipeWire integer ID QVector m_inputPorts; QVector m_outputPorts; std::atomic m_peakLevel{0.0f}; // Real-time meter data }; ``` ### Real-Time Communication Patterns **Pattern 1: Audio Meters (PipeWire → Qt)** ```cpp // PipeWire callback (real-time thread) - NO LOCKS static void onProcess(void *userdata) { auto *self = static_cast(userdata); pw_buffer *buf = pw_stream_dequeue_buffer(self->m_stream); if (!buf) return; float *samples = static_cast(buf->buffer->datas[0].data); uint32_t n_samples = buf->buffer->datas[0].chunk->size / sizeof(float); // Calculate peak (real-time safe) float peak = 0.0f; for (uint32_t i = 0; i < n_samples; ++i) { peak = std::max(peak, std::abs(samples[i])); } // Atomic write (lock-free) self->m_peakLevel.store(peak, std::memory_order_relaxed); pw_stream_queue_buffer(self->m_stream, buf); } // Qt timer (GUI thread) - 30Hz refresh void MeterWidget::timerEvent(QTimerEvent*) { // Atomic read (lock-free) float peak = m_controller->getPeakLevel(); // atomic load // Update visual m_meterItem->setLevel(peak); viewport()->update(); } ``` **Pattern 2: Volume Control (Qt → PipeWire)** ```cpp // Qt GUI thread void VolumeSlider::onValueChanged(int value) { float volume = value / 100.0f; // Queue command to PipeWire thread QMetaObject::invokeMethod(m_controller, "setNodeVolume", Qt::QueuedConnection, Q_ARG(uint32_t, m_nodeId), Q_ARG(float, volume)); } // PipeWire controller (runs in Qt thread context, not real-time) void PipeWireController::setNodeVolume(uint32_t nodeId, float volume) { pw_thread_loop_lock(m_threadLoop); // Build volume parameter uint8_t buffer[1024]; spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer)); spa_pod_frame f; spa_pod_builder_push_object(&b, &f, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props); spa_pod_builder_add(&b, SPA_PROP_volume, SPA_POD_Float(volume), 0); const spa_pod *param = spa_pod_builder_pop(&b, &f); // Apply to node pw_node_set_param(nodeId, SPA_PARAM_Props, 0, param); pw_thread_loop_unlock(m_threadLoop); } ``` ### Thread Safety Rules | Operation | Thread | Locking Strategy | Notes | |-----------|--------|------------------|-------| | Audio processing callbacks | PipeWire real-time | NO LOCKS | Use atomics only | | Node discovery/registry | PipeWire thread | `pw_thread_loop_lock` | Required for PipeWire API calls | | UI updates (meters, faders) | Qt GUI thread | None (single-threaded) | Qt handles thread affinity | | Cross-thread signals | Any → Any | Qt::QueuedConnection | Qt's event queue handles safety | | Ring buffer access | Both | Lock-free `spa_ringbuffer` | Producer/consumer pattern | ## 5. QtNodes UI Implementation ### Node Types and Visuals **Node Categories:** ```cpp enum class NodeCategory { Source, // Microphones, line inputs Sink, // Speakers, line outputs Virtual, // Virtual sinks/sources Application, // Running apps (Spotify, Firefox) Bus // Mix buses }; ``` **Visual Style (using QtNodes styling):** ```json { "FlowViewStyle": { "BackgroundColor": [18, 18, 24], "FineGridColor": [35, 35, 40], "CoarseGridColor": [50, 50, 60] }, "NodeStyle": { "NormalBoundaryColor": [80, 80, 100], "SelectedBoundaryColor": [139, 233, 253], "GradientColor0": [45, 45, 60], "GradientColor1": [30, 30, 40], "ShadowColor": [0, 0, 0, 200], "FontColor": [220, 220, 220], "FontColorFaded": [140, 140, 140], "ConnectionPointColor": [169, 169, 169], "FilledConnectionPointColor": [139, 233, 253] }, "ConnectionStyle": { "ConstructionColor": [169, 169, 169], "NormalColor": [100, 149, 237], "SelectedColor": [139, 233, 253], "SelectedHaloColor": [139, 233, 253, 100], "HoveredColor": [147, 112, 219] } } ``` ### Custom Node Widgets Embed volume controls and meters directly in nodes: ```cpp class AudioNodeWidget : public QWidget { Q_OBJECT public: explicit AudioNodeWidget(QWidget *parent = nullptr) { auto *layout = new QVBoxLayout(this); // Node name/type m_nameLabel = new QLabel(this); m_nameLabel->setStyleSheet("font-weight: bold; color: #E0E0E0;"); layout->addWidget(m_nameLabel); // Volume slider m_volumeSlider = new QSlider(Qt::Horizontal, this); m_volumeSlider->setRange(0, 150); // 0-150% volume m_volumeSlider->setValue(100); layout->addWidget(m_volumeSlider); // Mute button m_muteButton = new QPushButton("M", this); m_muteButton->setCheckable(true); m_muteButton->setMaximumWidth(30); layout->addWidget(m_muteButton); // Level meter m_levelMeter = new AudioLevelMeter(this); m_levelMeter->setFixedHeight(80); layout->addWidget(m_levelMeter); // Connections connect(m_volumeSlider, &QSlider::valueChanged, this, &AudioNodeWidget::volumeChanged); connect(m_muteButton, &QPushButton::toggled, this, &AudioNodeWidget::muteToggled); } void setLevel(float level) { m_levelMeter->setLevel(level); } signals: void volumeChanged(int value); void muteToggled(bool muted); private: QLabel *m_nameLabel; QSlider *m_volumeSlider; QPushButton *m_muteButton; AudioLevelMeter *m_levelMeter; }; ``` ### Real-Time Meter Implementation Optimized for 30Hz updates using manual viewport control: ```cpp class AudioLevelMeter : public QGraphicsView { Q_OBJECT public: AudioLevelMeter(QWidget *parent = nullptr) : QGraphicsView(parent) { // Performance optimizations setViewportUpdateMode(QGraphicsView::NoViewportUpdate); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setFrameStyle(QFrame::NoFrame); setCacheMode(QGraphicsView::CacheNone); setOptimizationFlag(QGraphicsView::DontSavePainterState, true); // Scene setup m_scene = new QGraphicsScene(this); setScene(m_scene); // Meter bar (no caching for dynamic items) m_meterBar = new MeterBarItem(); m_meterBar->setCacheMode(QGraphicsItem::NoCache); m_scene->addItem(m_meterBar); // 30Hz update timer m_updateTimer = startTimer(33); // ~30Hz } void setLevel(float level) { m_currentLevel = level; } protected: void timerEvent(QTimerEvent *event) override { if (event->timerId() == m_updateTimer) { // Skip if level unchanged (0.5dB threshold) if (std::abs(m_currentLevel - m_lastLevel) < 0.01f) { return; } m_meterBar->setLevel(m_currentLevel); m_lastLevel = m_currentLevel; viewport()->update(); // Manual update } } private: class MeterBarItem : public QGraphicsItem { public: void setLevel(float level) { prepareGeometryChange(); // CRITICAL: notify scene m_level = std::clamp(level, 0.0f, 1.0f); } QRectF boundingRect() const override { return QRectF(0, 0, 40, 100); } void paint(QPainter *painter, const QStyleOptionGraphicsItem*, QWidget*) override { // Color gradient based on level QColor color; if (m_level > 0.9f) { color = QColor(255, 50, 50); // Red (clipping) } else if (m_level > 0.7f) { color = QColor(255, 200, 50); // Yellow (hot) } else { color = QColor(50, 255, 100); // Green (normal) } // Draw filled rectangle from bottom float height = 100.0f * m_level; painter->fillRect(QRectF(0, 100 - height, 40, height), color); // Draw peak line painter->setPen(QPen(Qt::white, 2)); painter->drawLine(0, 100 - height, 40, 100 - height); } private: float m_level = 0.0f; }; QGraphicsScene *m_scene; MeterBarItem *m_meterBar; int m_updateTimer; float m_currentLevel = 0.0f; float m_lastLevel = 0.0f; }; ``` ### Connection Validation Implement type checking before allowing connections: ```cpp class AudioConnectionPolicy { public: static bool canConnect(const QtNodes::NodeDataType &source, const QtNodes::NodeDataType &target) { // Audio can only connect to audio if (source.id != "audio" || target.id != "audio") { return false; } // Additional validation: sample rate, channel count matching // (stored in NodeDataType.name as JSON) return true; } static bool validateBeforeCreate(uint32_t sourceNodeId, uint32_t sourcePort, uint32_t targetNodeId, uint32_t targetPort) { // Query PipeWire for port directions PortInfo sourceInfo = queryPortInfo(sourceNodeId, sourcePort); PortInfo targetInfo = queryPortInfo(targetNodeId, targetPort); // Must be output → input if (sourceInfo.direction != PW_DIRECTION_OUTPUT || targetInfo.direction != PW_DIRECTION_INPUT) { return false; } // Check format compatibility return formatsCompatible(sourceInfo.format, targetInfo.format); } }; ``` ### Keyboard Shortcuts ```cpp void GraphEditorWidget::setupShortcuts() { // Delete selected nodes/connections auto *deleteAction = new QAction(this); deleteAction->setShortcuts({QKeySequence::Delete, Qt::Key_Backspace}); connect(deleteAction, &QAction::triggered, this, &GraphEditorWidget::deleteSelected); addAction(deleteAction); // Duplicate selected auto *duplicateAction = new QAction(this); duplicateAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_D)); connect(duplicateAction, &QAction::triggered, this, &GraphEditorWidget::duplicateSelected); addAction(duplicateAction); // Undo/Redo auto *undoAction = new QAction(this); undoAction->setShortcut(QKeySequence::Undo); connect(undoAction, &QAction::triggered, m_undoStack, &QUndoStack::undo); addAction(undoAction); auto *redoAction = new QAction(this); redoAction->setShortcut(QKeySequence::Redo); connect(redoAction, &QAction::triggered, m_undoStack, &QUndoStack::redo); addAction(redoAction); // Auto-arrange layout auto *arrangeAction = new QAction(this); arrangeAction->setShortcut(Qt::Key_L); connect(arrangeAction, &QAction::triggered, this, &GraphEditorWidget::autoArrange); addAction(arrangeAction); // Pan mode (hold Space) auto *panAction = new QAction(this); panAction->setShortcut(Qt::Key_Space); connect(panAction, &QAction::triggered, this, &GraphEditorWidget::togglePanMode); addAction(panAction); } ``` ### Context Menus ```cpp void GraphEditorWidget::showContextMenu(const QPoint &pos) { QMenu menu(this); auto *selectedNodes = scene()->selectedNodes(); if (selectedNodes.isEmpty()) { // Canvas context menu QMenu *addMenu = menu.addMenu("Add Node"); addMenu->addAction("Hardware Input", this, &GraphEditorWidget::addHardwareInput); addMenu->addAction("Hardware Output", this, &GraphEditorWidget::addHardwareOutput); addMenu->addAction("Virtual Sink", this, &GraphEditorWidget::addVirtualSink); addMenu->addAction("Virtual Source", this, &GraphEditorWidget::addVirtualSource); addMenu->addAction("Mix Bus", this, &GraphEditorWidget::addMixBus); menu.addSeparator(); menu.addAction("Auto-Arrange (L)", this, &GraphEditorWidget::autoArrange); if (!m_clipboard.isEmpty()) { menu.addAction("Paste", this, &GraphEditorWidget::paste); } } else { // Node context menu menu.addAction("Copy", this, &GraphEditorWidget::copy); menu.addAction("Duplicate (Ctrl+D)", this, &GraphEditorWidget::duplicateSelected); menu.addSeparator(); menu.addAction("Bypass", this, &GraphEditorWidget::bypassSelected); menu.addAction("Reset Parameters", this, &GraphEditorWidget::resetParameters); menu.addSeparator(); menu.addAction("Delete", this, &GraphEditorWidget::deleteSelected); } menu.exec(mapToGlobal(pos)); } ``` ## 6. UI State and Undo/Redo Model ### Undo/Redo Implementation Use Qt's QUndoStack for graph operations: ```cpp class CreateLinkCommand : public QUndoCommand { public: CreateLinkCommand(PipeWireController *controller, uint32_t sourceNode, uint32_t sourcePort, uint32_t targetNode, uint32_t targetPort) : m_controller(controller) , m_sourceNode(sourceNode), m_sourcePort(sourcePort) , m_targetNode(targetNode), m_targetPort(targetPort) { setText("Create Link"); } void undo() override { if (m_linkId != 0) { m_controller->destroyLink(m_linkId); } } void redo() override { m_linkId = m_controller->createLink( m_sourceNode, m_sourcePort, m_targetNode, m_targetPort); } private: PipeWireController *m_controller; uint32_t m_sourceNode, m_sourcePort; uint32_t m_targetNode, m_targetPort; uint32_t m_linkId = 0; }; class SetVolumeCommand : public QUndoCommand { public: SetVolumeCommand(PipeWireController *controller, uint32_t nodeId, float oldVolume, float newVolume) : m_controller(controller) , m_nodeId(nodeId) , m_oldVolume(oldVolume) , m_newVolume(newVolume) { setText("Set Volume"); } void undo() override { m_controller->setNodeVolume(m_nodeId, m_oldVolume); } void redo() override { m_controller->setNodeVolume(m_nodeId, m_newVolume); } // Merge consecutive volume changes int id() const override { return 1; } bool mergeWith(const QUndoCommand *other) override { const auto *cmd = static_cast(other); if (cmd->m_nodeId != m_nodeId) { return false; } m_newVolume = cmd->m_newVolume; return true; } private: PipeWireController *m_controller; uint32_t m_nodeId; float m_oldVolume, m_newVolume; }; ``` ### State Persistence ```cpp class PresetManager { public: struct Preset { QString id; QString name; QDateTime updatedAt; QJsonObject graph; // PipeWire graph state QJsonObject uiLayout; // QtNodes layout }; bool savePreset(const QString &id, const QString &name) { Preset preset; preset.id = id; preset.name = name; preset.updatedAt = QDateTime::currentDateTime(); // Serialize graph state preset.graph = m_controller->serializeGraph(); // Serialize UI layout preset.uiLayout = m_graphEditor->serializeLayout(); // Write to file QString path = QString("%1/presets/%2.json") .arg(QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)) .arg(id); QFile file(path); if (!file.open(QIODevice::WriteOnly)) { return false; } QJsonObject root; root["id"] = preset.id; root["name"] = preset.name; root["updated_at"] = preset.updatedAt.toString(Qt::ISODate); root["graph"] = preset.graph; root["ui_layout"] = preset.uiLayout; file.write(QJsonDocument(root).toJson()); return true; } bool loadPreset(const QString &id) { QString path = QString("%1/presets/%2.json") .arg(QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)) .arg(id); QFile file(path); if (!file.open(QIODevice::ReadOnly)) { return false; } QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); QJsonObject root = doc.object(); // Apply graph state to PipeWire m_controller->deserializeGraph(root["graph"].toObject()); // Restore UI layout m_graphEditor->deserializeLayout(root["ui_layout"].toObject()); return true; } }; ``` ## 7. Error Handling and Edge Cases ### Device Unplug/Replug ```cpp // Registry callback for node removal static void onNodeRemoved(void *userdata, uint32_t id) { auto *self = static_cast(userdata); // Mark node as offline in UI (cross-thread signal) QMetaObject::invokeMethod(self, "nodeOffline", Qt::QueuedConnection, Q_ARG(uint32_t, id)); } void GraphEditorWidget::onNodeOffline(uint32_t nodeId) { auto *node = findNodeById(nodeId); if (node) { // Gray out the node node->setEnabled(false); node->setToolTip("Device unavailable - waiting for reconnection"); // Keep connections but mark as inactive for (auto *connection : node->connections()) { connection->setEnabled(false); connection->setStyle(ConnectionStyle::Offline); } } } // Registry callback for node added (replug) static void onNodeAdded(void *userdata, uint32_t id, const struct pw_node_info *info) { auto *self = static_cast(userdata); // Try to match by stable_id QString stableId = extractStableId(info); QMetaObject::invokeMethod(self, "nodeOnline", Qt::QueuedConnection, Q_ARG(uint32_t, id), Q_ARG(QString, stableId)); } void GraphEditorWidget::onNodeOnline(uint32_t nodeId, const QString &stableId) { // Find offline node with matching stable_id auto *offlineNode = findOfflineNodeByStableId(stableId); if (offlineNode) { // Re-enable node offlineNode->setEnabled(true); offlineNode->setToolTip(""); // Reconnect links for (auto *connection : offlineNode->connections()) { connection->setEnabled(true); connection->setStyle(ConnectionStyle::Normal); // Actually recreate PipeWire link m_controller->createLink(connection->sourceNodeId(), connection->sourcePortId(), connection->targetNodeId(), connection->targetPortId()); } } } ``` ### PipeWire Service Restart ```cpp // Core state change callback static void onCoreError(void *userdata, uint32_t id, int seq, int res, const char *message) { auto *self = static_cast(userdata); if (res == -EPIPE) { // Broken pipe = PipeWire died QMetaObject::invokeMethod(self, "pipewireDisconnected", Qt::QueuedConnection); } } void PipeWireController::pipewireDisconnected() { // Show banner in UI emit connectionLost(); // Start reconnection timer m_reconnectTimer->start(2000); // Try every 2 seconds } void PipeWireController::attemptReconnect() { // Try to reconnect pw_thread_loop_lock(m_threadLoop); m_core = pw_context_connect(m_context, nullptr, 0); if (!m_core) { pw_thread_loop_unlock(m_threadLoop); return; // Try again next timer tick } // Success - re-enumerate graph m_registry = pw_core_get_registry(m_core, PW_VERSION_REGISTRY, 0); pw_registry_add_listener(m_registry, &m_registryListener, ®istryEvents, this); pw_thread_loop_unlock(m_threadLoop); m_reconnectTimer->stop(); emit connectionRestored(); // Re-apply active preset if (!m_activePresetId.isEmpty()) { loadPreset(m_activePresetId); } } ``` ### Error Banners and Toasts ```cpp class StatusBar : public QWidget { Q_OBJECT public: enum class MessageType { Info, Warning, Error, Success }; void showBanner(const QString &message, MessageType type) { m_bannerLabel->setText(message); m_bannerWidget->setProperty("messageType", static_cast(type)); m_bannerWidget->style()->unpolish(m_bannerWidget); m_bannerWidget->style()->polish(m_bannerWidget); m_bannerWidget->show(); // Auto-hide after 5 seconds for non-error messages if (type != MessageType::Error) { QTimer::singleShot(5000, m_bannerWidget, &QWidget::hide); } } void showToast(const QString &message, MessageType type, int durationMs = 3000) { auto *toast = new ToastWidget(message, type, this); toast->move(width() - toast->width() - 20, height() - toast->height() - 20); toast->show(); QTimer::singleShot(durationMs, toast, &QWidget::deleteLater); } private: QWidget *m_bannerWidget; QLabel *m_bannerLabel; }; // Usage void MainWindow::onPipeWireError(const QString &error) { m_statusBar->showBanner( QString("PipeWire error: %1").arg(error), StatusBar::MessageType::Error); } void MainWindow::onLinkCreated() { m_statusBar->showToast( "Link created", StatusBar::MessageType::Success, 2000); } void MainWindow::onPipeWireRestarting() { m_statusBar->showBanner( "PipeWire is restarting. Controls are temporarily disabled.", StatusBar::MessageType::Warning); } ``` ## 8. Build System and Dependencies ### CMakeLists.txt ```cmake cmake_minimum_required(VERSION 3.16) project(potato-audio-router VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) # Qt6 Components find_package(Qt6 6.2 REQUIRED COMPONENTS Core Widgets Gui ) # PipeWire find_package(PkgConfig REQUIRED) pkg_check_modules(PIPEWIRE REQUIRED libpipewire-0.3>=1.0.0) pkg_check_modules(SPA REQUIRED libspa-0.2>=0.2) # QtNodes (as submodule or system package) add_subdirectory(external/nodeeditor) # Source files set(SOURCES src/main.cpp src/mainwindow.cpp src/pipewire/pipewirecontroller.cpp src/pipewire/noderegistry.cpp src/graph/grapheditorwidget.cpp src/graph/audionodedatamodel.cpp src/graph/audionodewidget.cpp src/graph/connectionvalidator.cpp src/meters/audiolevelme ter.cpp src/mixer/mixerview.cpp src/presets/presetmanager.cpp src/ui/statusbar.cpp src/ui/toastwidget.cpp src/undo/graphcommands.cpp ) set(HEADERS src/mainwindow.h src/pipewire/pipewirecontroller.h src/pipewire/noderegistry.h src/graph/grapheditorwidget.h src/graph/audionodedatamodel.h src/graph/audionodewidget.h src/graph/connectionvalidator.h src/meters/audiolevelme ter.h src/mixer/mixerview.h src/presets/presetmanager.h src/ui/statusbar.h src/ui/toastwidget.h src/undo/graphcommands.h ) # Resources set(RESOURCES resources/resources.qrc ) # Executable add_executable(${PROJECT_NAME} ${SOURCES} ${HEADERS} ${RESOURCES} ) target_link_libraries(${PROJECT_NAME} PRIVATE Qt6::Core Qt6::Widgets Qt6::Gui ${PIPEWIRE_LIBRARIES} ${SPA_LIBRARIES} QtNodes::QtNodes ) target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src ${PIPEWIRE_INCLUDE_DIRS} ${SPA_INCLUDE_DIRS} ) target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic -Werror=return-type ) # Install install(TARGETS ${PROJECT_NAME} RUNTIME DESTINATION bin ) install(FILES resources/potato-audio-router.desktop DESTINATION share/applications ) install(FILES resources/icons/potato-audio-router.svg DESTINATION share/icons/hicolor/scalable/apps ) ``` ### Dependencies **Runtime:** - Qt6 >= 6.2 (Core, Widgets, Gui) - libpipewire >= 1.0.0 - libspa >= 0.2 **Build:** - CMake >= 3.16 - C++17 compiler (GCC 9+, Clang 10+) - pkg-config - QtNodes (included as submodule) **Optional:** - Qt6::Svg (for SVG icons) - Qt6::Concurrent (for background preset loading) ## 9. Config and Storage Layout ### XDG Base Directory Paths ```cpp class Paths { public: static QString configDir() { return QString("%1/potato") .arg(QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)); // Typically: ~/.config/potato/ } static QString stateDir() { return QString("%1/potato") .arg(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)); // Typically: ~/.local/share/potato/ } static QString cacheDir() { return QString("%1/potato") .arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)); // Typically: ~/.cache/potato/ } }; ``` ### File Layout ``` ~/.config/potato/ ├── config.json # Application settings └── presets/ # User presets ├── default.json ├── streaming.json └── gaming.json ~/.local/share/potato/ ├── active.json # Last applied preset └── window-state.dat # Qt window geometry/state ~/.cache/potato/ ├── node-positions.json # Last graph layout └── recent-connections.json # Connection history ``` ### Config Schema ```json { "version": "1.0", "pipewire": { "auto_connect": true, "reconnect_interval_ms": 2000, "default_sample_rate": 48000, "default_buffer_size": 512 }, "ui": { "theme": "dark", "meter_update_hz": 30, "show_grid": true, "grid_size": 20, "auto_arrange_on_load": false, "enable_animations": true }, "presets": { "default_preset": "default", "auto_load_last": true }, "shortcuts": { "delete": "Delete", "duplicate": "Ctrl+D", "undo": "Ctrl+Z", "redo": "Ctrl+Shift+Z", "auto_arrange": "L", "toggle_mixer": "M" } } ``` ## 10. Logging and Debugging ### Logging Infrastructure ```cpp class Logger { public: enum class Level { Debug, Info, Warning, Error }; static Logger& instance() { static Logger logger; return logger; } void log(Level level, const QString &component, const QString &message) { QJsonObject entry; entry["timestamp"] = QDateTime::currentDateTime().toString(Qt::ISODate); entry["level"] = levelToString(level); entry["component"] = component; entry["message"] = message; entry["thread"] = QString::number(reinterpret_cast(QThread::currentThreadId())); // Write to stdout (journald pickup) QTextStream(stdout) << QJsonDocument(entry).toJson(QJsonDocument::Compact) << "\n"; // Also log to Qt message handler switch (level) { case Level::Debug: qDebug().noquote() << message; break; case Level::Info: qInfo().noquote() << message; break; case Level::Warning: qWarning().noquote() << message; break; case Level::Error: qCritical().noquote() << message; break; } } private: static QString levelToString(Level level) { switch (level) { case Level::Debug: return "DEBUG"; case Level::Info: return "INFO"; case Level::Warning: return "WARN"; case Level::Error: return "ERROR"; } return "UNKNOWN"; } }; // Convenience macros #define LOG_DEBUG(component, msg) Logger::instance().log(Logger::Level::Debug, component, msg) #define LOG_INFO(component, msg) Logger::instance().log(Logger::Level::Info, component, msg) #define LOG_WARN(component, msg) Logger::instance().log(Logger::Level::Warning, component, msg) #define LOG_ERROR(component, msg) Logger::instance().log(Logger::Level::Error, component, msg) // Usage LOG_INFO("PipeWire", QString("Link created: %1 -> %2").arg(sourceId).arg(targetId)); LOG_ERROR("PipeWire", QString("Failed to create node: %1").arg(error)); ``` ### Debug Window ```cpp class DebugWindow : public QWidget { Q_OBJECT public: DebugWindow(PipeWireController *controller, QWidget *parent = nullptr) { auto *layout = new QVBoxLayout(this); // Live PipeWire graph dump m_graphDump = new QTextEdit(this); m_graphDump->setReadOnly(true); m_graphDump->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); layout->addWidget(m_graphDump); // Refresh button auto *refreshButton = new QPushButton("Refresh Graph", this); connect(refreshButton, &QPushButton::clicked, this, [this, controller]() { m_graphDump->setPlainText(controller->dumpGraph()); }); layout->addWidget(refreshButton); // Threading info auto *threadInfo = new QLabel(this); threadInfo->setText(QString( "GUI Thread: %1\n" "PipeWire Thread: %2") .arg(QThread::currentThreadId()) .arg(controller->pipewireThreadId())); layout->addWidget(threadInfo); setWindowTitle("Potato Debug"); resize(800, 600); } private: QTextEdit *m_graphDump; }; ``` ## 11. Implementation Milestone Plan ### Milestone 1: Core PipeWire Integration **Estimated Time:** 2-3 weeks - [x] Initialize Qt6 project with CMake - [x] Integrate libpipewire with `pw_thread_loop` - [x] Implement node/port discovery via registry callbacks - [x] Implement link creation/destruction - [x] Create lock-free communication primitives (atomics, ring buffers) - [x] **Acceptance Criteria:** CLI test app that lists nodes and creates a link programmatically ### Milestone 2: QtNodes Integration **Estimated Time:** 2-3 weeks - [x] Integrate QtNodes library (submodule or CMake package) - [x] Create `AudioNodeDataModel` for PipeWire nodes - [x] Map PipeWire ports to QtNodes handles - [x] Implement connection validation - [x] Create custom node widgets with embedded controls - [x] **Acceptance Criteria:** Visual graph editor displays PipeWire nodes and allows dragging connections ### Milestone 3: Real-Time Meters & Performance **Estimated Time:** 1-2 weeks - [x] Implement `AudioLevelMeter` with optimized QGraphicsView - [x] Create 30Hz update timer with manual viewport control - [x] Integrate PipeWire audio callbacks for meter data - [x] Implement lock-free meter data transfer (atomics) - [ ] Profile and optimize rendering performance - [ ] **Acceptance Criteria:** Smooth 30Hz meters with no GUI lag, validated with profiler ### Milestone 4: Virtual Devices & State Management **Estimated Time:** 2 weeks - [ ] Implement virtual sink/source creation via PipeWire adapters - [ ] Create `PresetManager` with JSON serialization - [ ] Implement preset load/save functionality - [ ] Store UI layout alongside audio graph state - [ ] Implement auto-reconnect for device hotplug - [ ] **Acceptance Criteria:** Create virtual device, save preset, restore on restart ### Milestone 5: Mixer View & Volume Control **Estimated Time:** 1-2 weeks - [ ] Design traditional mixer UI with faders - [ ] Implement volume slider with PipeWire parameter sync - [ ] Add mute buttons and solo functionality - [ ] Create stereo/multi-channel level meters - [ ] Implement undo/redo for volume changes - [ ] **Acceptance Criteria:** Mixer panel controls node volumes, changes persist in presets ### Milestone 6: Undo/Redo & Polish **Estimated Time:** 1-2 weeks - [ ] Integrate QUndoStack for all graph operations - [ ] Implement command classes for link, volume, node operations - [ ] Add keyboard shortcuts (Delete, Ctrl+D, Ctrl+Z, etc.) - [ ] Implement context menus for nodes/canvas - [ ] Add copy/paste/duplicate functionality - [ ] **Acceptance Criteria:** Full undo/redo history, keyboard shortcuts work ### Milestone 7: Error Handling & Edge Cases **Estimated Time:** 1-2 weeks - [ ] Implement device unplug/replug detection - [ ] Handle PipeWire service restart with auto-reconnect - [ ] Create error banner and toast notification system - [ ] Implement offline node visualization (gray out) - [ ] Add error logging with structured JSON output - [ ] **Acceptance Criteria:** App survives device unplug and PipeWire restart without crashing ### Milestone 8: Final Polish & Release **Estimated Time:** 1-2 weeks - [ ] Create application icon and desktop file - [ ] Implement dark/light theme support - [ ] Add about dialog with version info - [ ] Write user documentation - [ ] Create installation packages (AppImage, Flatpak) - [ ] Performance profiling and optimization pass - [ ] **Acceptance Criteria:** Polished, installable application ready for alpha testing **Total Estimated Time:** 11-17 weeks (3-4 months) ## 12. Performance Targets ### Latency Goals * **GUI Responsiveness:** < 16ms per frame (60 FPS) * **Meter Updates:** 30Hz (33ms intervals) with < 1ms jitter * **Node Creation:** < 100ms from PipeWire event to UI update * **Link Creation:** < 50ms from user click to audio routing * **Preset Load:** < 500ms for graphs with < 50 nodes ### Memory Constraints * **Base Memory:** < 50MB resident * **Per Node:** < 10KB overhead * **Ring Buffers:** 64KB per audio stream * **Undo Stack:** < 5MB (50 actions limit) ### Scalability Targets * **Max Nodes:** 100+ nodes without performance degradation * **Max Connections:** 200+ simultaneous links * **Max Channels:** 32-channel busses supported * **Meter Channels:** Up to 50 channels at 30Hz ## 13. Testing Strategy ### Unit Tests ```cpp class PipeWireControllerTest : public QObject { Q_OBJECT private slots: void testNodeDiscovery() { // Test registry callbacks } void testLinkCreation() { // Test valid/invalid link scenarios } void testThreadSafety() { // Test cross-thread communication } }; ``` ### Integration Tests - PipeWire mock server for CI/CD - Automated UI tests with Qt Test framework - Performance benchmarks for meter rendering ### Manual Test Checklist - [ ] Device hotplug (USB audio interface) - [ ] PipeWire service restart - [ ] High node count (50+ nodes) - [ ] Rapid link creation/deletion - [ ] Preset load under various graph states - [ ] Undo/redo edge cases - [ ] Multi-monitor setup ## 14. Risks & Mitigations | Risk | Impact | Mitigation | |------|--------|------------| | PipeWire API instability | High | Pin to PipeWire 1.0+ LTS, dynamic loading for forward compatibility | | QtNodes performance with large graphs | Medium | Profile early, implement viewport culling, consider alternative if needed | | Real-time audio glitches | High | Strict lock-free design, profiling, buffer tuning | | Cross-distro compatibility | Medium | AppImage/Flatpak packaging, runtime PipeWire version checks | | Threading bugs | High | Extensive testing, ThreadSanitizer in CI, clear threading rules | ## 15. Future Enhancements ### Phase 2 (Post-MVP) - [ ] Global hotkeys (XDG desktop portal for Wayland) - [ ] Plugin system for DSP effects (LADSPA/LV2) - [ ] Waveform visualization for recorded streams - [ ] JACK application compatibility mode - [ ] Multi-language support (i18n) ### Phase 3 (Advanced) - [ ] Remote control via REST API (optional HTTP server) - [ ] Automation/scripting with Lua/Python bindings - [ ] Session management (save/restore entire desktop audio state) - [ ] A/B comparison mode for mixing - [ ] Spectrum analyzer integration ## Appendix A: Key Reference Implementations ### Production Qt + PipeWire Applications 1. **Qt Multimedia (Official):** Qt 6.10+ native PipeWire backend 2. **Mumble:** VoIP with Qt GUI and PipeWire audio 3. **OBS Studio:** Streaming software with PipeWire plugin 4. **Waybar:** Status bar with PipeWire backend ### QtNodes Production Users 1. **CANdevStudio:** Automotive CAN bus simulation 2. **Cascade:** GPU-accelerated image editor 3. **Groot:** BehaviorTree editor for robotics 4. **Chigraph:** Visual programming language ### Real-Time Audio References 1. **Mixxx:** DJ software with 30Hz meters 2. **LMMS:** Digital audio workstation 3. **Ardour:** Professional DAW with low-latency design ## Appendix B: Technology Decision Matrix | Aspect | Chosen | Alternatives Considered | Reason for Choice | |--------|--------|------------------------|-------------------| | Language | C++17 | Rust, Go | Qt ecosystem, PipeWire C API, performance | | UI Framework | Qt6 Widgets | GTK4, QML | Mature, cross-platform, good performance | | Node Editor | QtNodes | QuickQanava, Custom | Production-ready, active development, best docs | | Build System | CMake | Meson, QMake | Qt6 standard, QtNodes compatible | | Threading | pw_thread_loop + QThread | Custom threads | PipeWire recommended, Qt integration | | Serialization | QJsonDocument | Protobuf, YAML | Native Qt support, human-readable | | Undo/Redo | QUndoStack | Custom stack | Built-in Qt framework, merge support | --- **Document Version:** 2.0 **Last Updated:** 2026-01-27 **Target Platform:** Linux (PipeWire 1.0+) **Minimum Qt Version:** 6.2 **License:** TBD (GPL-3.0 or MIT suggested)