potato/PROJECT_PLAN.md
2026-01-27 14:29:20 -07:00

1388 lines
43 KiB
Markdown

# 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,
&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:
```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<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)**
```cpp
// 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)**
```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<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
```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<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
```cpp
// 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
```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<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
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<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
```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)