Mixer
This commit is contained in:
parent
f57d39af48
commit
debc7f1853
5 changed files with 276 additions and 7 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
#include <QLabel>
|
||||
#include <QHBoxLayout>
|
||||
#include <QSplitter>
|
||||
#include <QTabWidget>
|
||||
#include <QSlider>
|
||||
#include <QToolButton>
|
||||
#include <QTimer>
|
||||
#include <QEvent>
|
||||
#include <QVBoxLayout>
|
||||
|
|
@ -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<Potato::LinkInfo> 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<uint32_t>(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<float>(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<int>(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<uint32_t>(it.key());
|
||||
const float volume = static_cast<float>(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});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<uint32_t, AudioLevelMeter*> m_mixerMeters;
|
||||
QMap<uint32_t, QWidget*> m_mixerStrips;
|
||||
QMap<uint32_t, QSlider*> m_mixerFaders;
|
||||
QMap<uint32_t, QToolButton*> m_mixerMutes;
|
||||
QMap<uint32_t, QToolButton*> m_mixerSolos;
|
||||
QMap<uint32_t, bool> m_mixerUserMute;
|
||||
QSet<uint32_t> m_mixerSoloNodes;
|
||||
|
||||
QSet<QString> m_ignoreCreate;
|
||||
QSet<QString> m_ignoreDelete;
|
||||
QSet<uint32_t> m_ignoreLinkRemoved;
|
||||
|
|
|
|||
|
|
@ -830,6 +830,45 @@ void PipeWireGraphModel::applyVolumeStates(const QHash<QString, NodeVolumeState>
|
|||
}
|
||||
}
|
||||
|
||||
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<QSlider*>()) {
|
||||
slider->blockSignals(true);
|
||||
slider->setValue(static_cast<int>(state.volume * 100.0f));
|
||||
slider->blockSignals(false);
|
||||
}
|
||||
if (auto *button = widget->findChild<QToolButton*>()) {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ public:
|
|||
void applyLayoutJson(const QJsonObject &root);
|
||||
QHash<QString, NodeVolumeState> volumeStates() const;
|
||||
void applyVolumeStates(const QHash<QString, NodeVolumeState> &states);
|
||||
void setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state);
|
||||
bool nodeVolumeState(uint32_t nodeId, NodeVolumeState &state) const;
|
||||
|
||||
private:
|
||||
QWidget *nodeWidget(QtNodes::NodeId nodeId) const;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue