From debc7f1853a2efff56db57594bd71d44fe9f62b4 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 27 Jan 2026 21:49:50 -0700 Subject: [PATCH] Mixer --- PROJECT_PLAN.md | 8 +- src/gui/GraphEditorWidget.cpp | 214 ++++++++++++++++++++++++++++++++- src/gui/GraphEditorWidget.h | 20 +++ src/gui/PipeWireGraphModel.cpp | 39 ++++++ src/gui/PipeWireGraphModel.h | 2 + 5 files changed, 276 insertions(+), 7 deletions(-) diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 81325c4..e97b9ff 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -1229,10 +1229,10 @@ private: ### 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 +- [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 diff --git a/src/gui/GraphEditorWidget.cpp b/src/gui/GraphEditorWidget.cpp index 52e3e37..ab53b1e 100644 --- a/src/gui/GraphEditorWidget.cpp +++ b/src/gui/GraphEditorWidget.cpp @@ -11,6 +11,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -69,8 +72,16 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi m_splitter->setOrientation(Qt::Horizontal); m_splitter->addWidget(m_view); - auto *meterPanel = new QWidget(m_splitter); - meterPanel->setStyleSheet("background-color: #1f2126; border-left: 1px solid #2b2f38;"); + m_sidebarTabs = new QTabWidget(m_splitter); + m_sidebarTabs->setStyleSheet( + "QTabWidget::pane { border: none; background: #1f2126; border-left: 1px solid #2b2f38; }" + "QTabBar::tab { background: #1f2126; color: #8c94a5; padding: 8px 12px; border: none; font-weight: 700; font-size: 11px; }" + "QTabBar::tab:selected { color: #dbe2ee; border-bottom: 2px solid #5fcf8d; }" + "QTabBar::tab:hover { color: #c7cfdd; }" + ); + + auto *meterPanel = new QWidget(); + meterPanel->setStyleSheet("background-color: #1f2126;"); meterPanel->setMinimumWidth(260); meterPanel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); @@ -106,7 +117,31 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi meterLayout->addStretch(); - m_splitter->addWidget(meterPanel); + m_sidebarTabs->addTab(meterPanel, "METERS"); + + m_mixerTab = new QWidget(); + m_mixerTab->setStyleSheet("background-color: #1f2126;"); + auto *mixerTabLayout = new QVBoxLayout(m_mixerTab); + mixerTabLayout->setContentsMargins(0, 0, 0, 0); + + auto *mixerScroll = new QScrollArea(m_mixerTab); + mixerScroll->setFrameShape(QFrame::NoFrame); + mixerScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + mixerScroll->setWidgetResizable(true); + + m_mixerList = new QWidget(mixerScroll); + m_mixerListLayout = new QHBoxLayout(m_mixerList); + m_mixerListLayout->setContentsMargins(20, 20, 20, 20); + m_mixerListLayout->setSpacing(12); + m_mixerListLayout->setAlignment(Qt::AlignLeft); + m_mixerList->setLayout(m_mixerListLayout); + + mixerScroll->setWidget(m_mixerList); + mixerTabLayout->addWidget(mixerScroll); + + m_sidebarTabs->addTab(m_mixerTab, "MIXER"); + + m_splitter->addWidget(m_sidebarTabs); m_splitter->setStretchFactor(0, 1); m_splitter->setStretchFactor(1, 0); @@ -288,6 +323,7 @@ void GraphEditorWidget::syncGraph() if (isAudioEndpoint(node)) { m_model->addPipeWireNode(node); refreshNodeMeter(node.id, node); + refreshMixerStrip(node.id, node); } } const QVector links = m_controller->links(); @@ -318,6 +354,7 @@ void GraphEditorWidget::onNodeAdded(const Potato::NodeInfo &node) if (isAudioEndpoint(node)) { m_model->addPipeWireNode(node); refreshNodeMeter(node.id, node); + refreshMixerStrip(node.id, node); } } @@ -333,6 +370,8 @@ void GraphEditorWidget::onNodeChanged(const Potato::NodeInfo &node) refreshNodeMeter(node.id, node); updateNodeMeterState(node.id, node); + refreshMixerStrip(node.id, node); + updateMixerState(node.id, node); } void GraphEditorWidget::onNodeRemoved(uint32_t nodeId) @@ -351,6 +390,8 @@ void GraphEditorWidget::onNodeRemoved(uint32_t nodeId) m_nodeMeters.remove(nodeId); row->deleteLater(); } + + removeMixerStrip(nodeId); } void GraphEditorWidget::onLinkAdded(const Potato::LinkInfo &link) @@ -514,6 +555,12 @@ void GraphEditorWidget::updateMeter() it.value()->setLevel(nodePeak); } + for (auto it = m_mixerMeters.begin(); it != m_mixerMeters.end(); ++it) { + const uint32_t nodeId = static_cast(it.key()); + const float nodePeak = m_controller->nodeMeterPeak(nodeId); + it.value()->setLevel(nodePeak); + } + if (m_meterProfileReady) { const qint64 duration = m_meterProfileTimer.nsecsElapsed() - startNanos; m_meterProfileNanos += duration; @@ -723,3 +770,164 @@ bool GraphEditorWidget::eventFilter(QObject *object, QEvent *event) return QWidget::eventFilter(object, event); } + +void GraphEditorWidget::refreshMixerStrip(uint32_t nodeId, const Potato::NodeInfo &node) +{ + if (m_mixerStrips.contains(nodeId)) { + return; + } + + auto *strip = new QWidget(m_mixerList); + strip->setFixedWidth(90); + auto *layout = new QVBoxLayout(strip); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(8); + + auto *meter = new AudioLevelMeter(strip); + meter->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + + auto *fader = new QSlider(Qt::Vertical, strip); + fader->setRange(0, 100); + fader->setValue(100); + fader->setToolTip("Volume"); + fader->setStyleSheet( + "QSlider::groove:vertical { width: 4px; background: #2b2f38; border-radius: 2px; }" + "QSlider::sub-page:vertical { background: #2b2f38; border-radius: 2px; }" + "QSlider::add-page:vertical { background: #5fcf8d; border-radius: 2px; }" + "QSlider::handle:vertical { height: 10px; margin: 0 -6px; background: #dbe2ee; border-radius: 5px; }" + ); + + auto *muteBtn = new QToolButton(strip); + muteBtn->setText("M"); + muteBtn->setCheckable(true); + muteBtn->setFixedSize(24, 24); + muteBtn->setToolTip("Mute"); + muteBtn->setStyleSheet( + "QToolButton { background: #2b2f38; color: #8c94a5; border: none; border-radius: 4px; font-weight: 700; }" + "QToolButton:checked { background: #c94f4f; color: #ffffff; }" + "QToolButton:hover { background: #3c4350; }" + ); + + auto *soloBtn = new QToolButton(strip); + soloBtn->setText("S"); + soloBtn->setCheckable(true); + soloBtn->setFixedSize(24, 24); + soloBtn->setToolTip("Solo"); + soloBtn->setStyleSheet( + "QToolButton { background: #2b2f38; color: #8c94a5; border: none; border-radius: 4px; font-weight: 700; }" + "QToolButton:checked { background: #e0b045; color: #ffffff; }" + "QToolButton:hover { background: #3c4350; }" + ); + + auto *label = new QLabel(node.description.isEmpty() ? node.name : node.description, strip); + label->setStyleSheet("color: #8c94a5; font-size: 10px; font-weight: 600;"); + label->setAlignment(Qt::AlignCenter); + label->setWordWrap(true); + label->setMinimumHeight(48); + + layout->addWidget(meter, 1); + layout->addWidget(fader, 0, Qt::AlignHCenter); + + auto *btnLayout = new QHBoxLayout(); + btnLayout->setContentsMargins(0, 0, 0, 0); + btnLayout->setSpacing(4); + btnLayout->addWidget(muteBtn); + btnLayout->addWidget(soloBtn); + layout->addLayout(btnLayout); + + layout->addWidget(label); + + m_mixerListLayout->addWidget(strip); + + m_mixerStrips.insert(nodeId, strip); + m_mixerMeters.insert(nodeId, meter); + m_mixerFaders.insert(nodeId, fader); + m_mixerMutes.insert(nodeId, muteBtn); + m_mixerSolos.insert(nodeId, soloBtn); + m_mixerUserMute.insert(nodeId, false); + + connect(fader, &QSlider::valueChanged, [this, nodeId](int value) { + if (!m_mixerMutes.contains(nodeId)) { + return; + } + const float volume = static_cast(value) / 100.0f; + const bool userMute = m_mixerUserMute.value(nodeId, false); + const bool soloActive = !m_mixerSoloNodes.isEmpty(); + const bool effectiveMute = userMute || (soloActive && !m_mixerSoloNodes.contains(nodeId)); + m_controller->setNodeVolume(nodeId, volume, effectiveMute); + if (m_model) { + m_model->setNodeVolumeState(nodeId, NodeVolumeState{volume, userMute}); + } + }); + + connect(muteBtn, &QToolButton::toggled, [this, nodeId](bool checked) { + m_mixerUserMute[nodeId] = checked; + applySoloState(); + }); + + connect(soloBtn, &QToolButton::toggled, [this, nodeId](bool checked) { + if (checked) { + m_mixerSoloNodes.insert(nodeId); + } else { + m_mixerSoloNodes.remove(nodeId); + } + applySoloState(); + }); + + updateMixerState(nodeId, node); +} + +void GraphEditorWidget::updateMixerState(uint32_t nodeId, const Potato::NodeInfo &node) +{ + if (!m_mixerFaders.contains(nodeId) || !m_mixerMutes.contains(nodeId)) { + return; + } + if (node.stableId.isEmpty() || !m_model) { + return; + } + + NodeVolumeState state; + if (!m_model->nodeVolumeState(nodeId, state)) { + return; + } + + auto *fader = m_mixerFaders.value(nodeId); + auto *muteBtn = m_mixerMutes.value(nodeId); + fader->blockSignals(true); + muteBtn->blockSignals(true); + fader->setValue(static_cast(state.volume * 100.0f)); + muteBtn->setChecked(state.mute); + fader->blockSignals(false); + muteBtn->blockSignals(false); + m_mixerUserMute[nodeId] = state.mute; + applySoloState(); +} + +void GraphEditorWidget::removeMixerStrip(uint32_t nodeId) +{ + if (m_mixerStrips.contains(nodeId)) { + auto *strip = m_mixerStrips.take(nodeId); + m_mixerMeters.remove(nodeId); + m_mixerFaders.remove(nodeId); + m_mixerMutes.remove(nodeId); + m_mixerSolos.remove(nodeId); + m_mixerUserMute.remove(nodeId); + m_mixerSoloNodes.remove(nodeId); + strip->deleteLater(); + } +} + +void GraphEditorWidget::applySoloState() +{ + for (auto it = m_mixerFaders.begin(); it != m_mixerFaders.end(); ++it) { + const uint32_t nodeId = static_cast(it.key()); + const float volume = static_cast(it.value()->value()) / 100.0f; + const bool userMute = m_mixerUserMute.value(nodeId, false); + const bool soloActive = !m_mixerSoloNodes.isEmpty(); + const bool effectiveMute = userMute || (soloActive && !m_mixerSoloNodes.contains(nodeId)); + m_controller->setNodeVolume(nodeId, volume, effectiveMute); + if (m_model) { + m_model->setNodeVolumeState(nodeId, NodeVolumeState{volume, userMute}); + } + } +} diff --git a/src/gui/GraphEditorWidget.h b/src/gui/GraphEditorWidget.h index fe2f8c7..69c9060 100644 --- a/src/gui/GraphEditorWidget.h +++ b/src/gui/GraphEditorWidget.h @@ -17,7 +17,11 @@ class QLabel; class QTimer; class QScrollArea; class QVBoxLayout; +class QHBoxLayout; +class QSlider; +class QToolButton; class QSplitter; +class QTabWidget; class PresetManager; class GraphEditorWidget : public QWidget @@ -44,6 +48,10 @@ private: void updateLayoutState(); void updateNodeMeterLabel(QLabel *label); void updateNodeMeterState(uint32_t nodeId, const Potato::NodeInfo &node); + void refreshMixerStrip(uint32_t nodeId, const Potato::NodeInfo &node); + void updateMixerState(uint32_t nodeId, const Potato::NodeInfo &node); + void removeMixerStrip(uint32_t nodeId); + void applySoloState(); void handleLinkRemoved(uint32_t linkId); bool isMeterNode(uint32_t nodeId) const; int activeLinkCount(uint32_t nodeId) const; @@ -57,6 +65,18 @@ private: QtNodes::GraphicsView *m_view = nullptr; QSplitter *m_splitter = nullptr; + QTabWidget *m_sidebarTabs = nullptr; + QWidget *m_mixerTab = nullptr; + QWidget *m_mixerList = nullptr; + QHBoxLayout *m_mixerListLayout = nullptr; + QMap m_mixerMeters; + QMap m_mixerStrips; + QMap m_mixerFaders; + QMap m_mixerMutes; + QMap m_mixerSolos; + QMap m_mixerUserMute; + QSet m_mixerSoloNodes; + QSet m_ignoreCreate; QSet m_ignoreDelete; QSet m_ignoreLinkRemoved; diff --git a/src/gui/PipeWireGraphModel.cpp b/src/gui/PipeWireGraphModel.cpp index 17c5d80..8d120ab 100644 --- a/src/gui/PipeWireGraphModel.cpp +++ b/src/gui/PipeWireGraphModel.cpp @@ -830,6 +830,45 @@ void PipeWireGraphModel::applyVolumeStates(const QHash } } +void PipeWireGraphModel::setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state) +{ + m_nodeVolumeState.insert(nodeId, state); + + auto nodeIt = m_pwToNode.find(nodeId); + if (nodeIt == m_pwToNode.end()) { + return; + } + auto widgetIt = m_nodeWidgets.find(nodeIt->second); + if (widgetIt == m_nodeWidgets.end()) { + return; + } + + QWidget *widget = widgetIt->second; + if (!widget) { + return; + } + + if (auto *slider = widget->findChild()) { + slider->blockSignals(true); + slider->setValue(static_cast(state.volume * 100.0f)); + slider->blockSignals(false); + } + if (auto *button = widget->findChild()) { + button->blockSignals(true); + button->setChecked(state.mute); + button->blockSignals(false); + } +} + +bool PipeWireGraphModel::nodeVolumeState(uint32_t nodeId, NodeVolumeState &state) const +{ + if (!m_nodeVolumeState.contains(nodeId)) { + return false; + } + state = m_nodeVolumeState.value(nodeId); + return true; +} + void PipeWireGraphModel::saveLayout() const { const QString path = layoutFilePath(); diff --git a/src/gui/PipeWireGraphModel.h b/src/gui/PipeWireGraphModel.h index 695e3ad..775f83f 100644 --- a/src/gui/PipeWireGraphModel.h +++ b/src/gui/PipeWireGraphModel.h @@ -81,6 +81,8 @@ public: void applyLayoutJson(const QJsonObject &root); QHash volumeStates() const; void applyVolumeStates(const QHash &states); + void setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state); + bool nodeVolumeState(uint32_t nodeId, NodeVolumeState &state) const; private: QWidget *nodeWidget(QtNodes::NodeId nodeId) const;