1388 lines
43 KiB
Markdown
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,
|
|
®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<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,
|
|
®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<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
|
|
- [x] Initialize Qt6 project with CMake
|
|
- [x] Integrate libpipewire with `pw_thread_loop`
|
|
- [x] Implement node/port discovery via registry callbacks
|
|
- [x] Implement link creation/destruction
|
|
- [x] Create lock-free communication primitives (atomics, ring buffers)
|
|
- [x] **Acceptance Criteria:** CLI test app that lists nodes and creates a link programmatically
|
|
|
|
### Milestone 2: QtNodes Integration
|
|
**Estimated Time:** 2-3 weeks
|
|
- [x] Integrate QtNodes library (submodule or CMake package)
|
|
- [x] Create `AudioNodeDataModel` for PipeWire nodes
|
|
- [x] Map PipeWire ports to QtNodes handles
|
|
- [x] Implement connection validation
|
|
- [x] Create custom node widgets with embedded controls
|
|
- [x] **Acceptance Criteria:** Visual graph editor displays PipeWire nodes and allows dragging connections
|
|
|
|
### Milestone 3: Real-Time Meters & Performance
|
|
**Estimated Time:** 1-2 weeks
|
|
- [x] Implement `AudioLevelMeter` with optimized QGraphicsView
|
|
- [x] Create 30Hz update timer with manual viewport control
|
|
- [x] Integrate PipeWire audio callbacks for meter data
|
|
- [x] Implement lock-free meter data transfer (atomics)
|
|
- [x] 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
|
|
- [x] Implement virtual sink/source creation via PipeWire adapters
|
|
- [x] Create `PresetManager` with JSON serialization
|
|
- [x] Implement preset load/save functionality
|
|
- [x] 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
|
|
- [x] Design traditional mixer UI with faders
|
|
- [x] Implement volume slider with PipeWire parameter sync
|
|
- [x] Add mute buttons and solo functionality
|
|
- [x] 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)
|