potato/PROJECT_PLAN.md
2026-01-27 18:57:54 -07:00

43 KiB

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)

{
  "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):

// 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, 
                        &registryEvents, 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:

// 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:

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<PortInfo> m_inputPorts;
    QVector<PortInfo> m_outputPorts;
    std::atomic<float> m_peakLevel{0.0f};  // Real-time meter data
};

Real-Time Communication Patterns

Pattern 1: Audio Meters (PipeWire → Qt)

// PipeWire callback (real-time thread) - NO LOCKS
static void onProcess(void *userdata) {
    auto *self = static_cast<PipeWireController*>(userdata);
    
    pw_buffer *buf = pw_stream_dequeue_buffer(self->m_stream);
    if (!buf) return;
    
    float *samples = static_cast<float*>(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)

// 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:

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):

{
  "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:

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:

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:

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

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

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:

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<const SetVolumeCommand*>(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

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

// Registry callback for node removal
static void onNodeRemoved(void *userdata, uint32_t id) {
    auto *self = static_cast<PipeWireController*>(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<PipeWireController*>(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

// Core state change callback
static void onCoreError(void *userdata, uint32_t id, int seq, int res, const char *message) {
    auto *self = static_cast<PipeWireController*>(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, 
                            &registryEvents, 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

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<int>(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_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

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

{
  "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

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<quintptr>(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

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

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)