From a1094ab7ea62f985f748aff07c4bcc527d6720b6 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 27 Jan 2026 14:29:20 -0700 Subject: [PATCH] Project plan --- PROJECT_PLAN.md | 1388 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1388 insertions(+) create mode 100644 PROJECT_PLAN.md diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md new file mode 100644 index 0000000..f719b11 --- /dev/null +++ b/PROJECT_PLAN.md @@ -0,0 +1,1388 @@ +# 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 +- [ ] Initialize Qt6 project with CMake +- [ ] Integrate libpipewire with `pw_thread_loop` +- [ ] Implement node/port discovery via registry callbacks +- [ ] Implement link creation/destruction +- [ ] Create lock-free communication primitives (atomics, ring buffers) +- [ ] **Acceptance Criteria:** CLI test app that lists nodes and creates a link programmatically + +### Milestone 2: QtNodes Integration ✅ +**Estimated Time:** 2-3 weeks +- [ ] Integrate QtNodes library (submodule or CMake package) +- [ ] Create `AudioNodeDataModel` for PipeWire nodes +- [ ] Map PipeWire ports to QtNodes handles +- [ ] Implement connection validation +- [ ] Create custom node widgets with embedded controls +- [ ] **Acceptance Criteria:** Visual graph editor displays PipeWire nodes and allows dragging connections + +### Milestone 3: Real-Time Meters & Performance ✅ +**Estimated Time:** 1-2 weeks +- [ ] Implement `AudioLevelMeter` with optimized QGraphicsView +- [ ] Create 30Hz update timer with manual viewport control +- [ ] Integrate PipeWire audio callbacks for meter data +- [ ] 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)