#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include std::ostream& operator<<(std::ostream& os, const QString& string) { os << string.toStdString(); return os; } class Pipewire { public: static std::optional get_graph() { QProcess process; process.start("pw-dump"); if (!process.waitForFinished()) { std::cout << "[ERROR] Could not retrieve PipeWire graph" << std::endl; return std::nullopt; } QByteArray output = process.readAllStandardOutput(); QJsonParseError parse_error; QJsonDocument json = QJsonDocument::fromJson(output, &parse_error); if (parse_error.error != QJsonParseError::NoError) { std::cout << "[ERROR] Invlid output from pw-dump" << std::endl; return std::nullopt; } return json.array(); } static std::map get_input_apps() { std::map apps; auto graph = Pipewire::get_graph(); if (!graph) { return apps; } for (const QJsonValue& value : *graph) { QJsonObject node = value.toObject(); if (node["type"] != "PipeWire:Interface:Node") { continue; } QJsonObject props = node["info"].toObject()["props"].toObject(); if (props["media.class"] != "Stream/Input/Audio") { continue; } QString name; if (props.contains("application.name")) { name = props["application.name"].toString(); } else if (props.contains("node.name")) { name = props["node.name"].toString(); } else { name = "Unknown App"; } int id = node["id"].toInt(); name = "[" + QString::number(id) + "] " + name; apps[name] = id; } return apps; } static std::vector get_port_ids(int node_id, const std::string& direction /* "in" | "out" */) { std::vector ids; auto graph = Pipewire::get_graph(); if (!graph) { return ids; } for (const QJsonValue& value : *graph) { QJsonObject node = value.toObject(); QJsonObject props = node["info"].toObject()["props"].toObject(); if (node["type"] != "PipeWire:Interface:Port" || props["node.id"].toInt() != node_id || props["port.direction"].toString() != direction) { continue; } ids.push_back(node["id"].toInt()); } return ids; } static bool link_ports(int id1, int id2) { QProcess process; process.start("pw-link", { QString::number(id1), QString::number(id2) }); return process.waitForFinished(); } static bool destroy_link(int id) { QProcess process; process.start("pw-cli", { "destroy", QString::number(id) }); return process.waitForFinished(); } static std::unique_ptr play_file(const std::string& file_path, const std::string& target) { auto process = std::make_unique(); process->start("pw-play", { "--target", QString::fromStdString(target), QString::fromStdString(file_path) }); return process; } static void set_volume(const std::string& target, int value) { QProcess process; process.startDetached("pactl", { "set-sink-volume", QString::fromStdString(target), QString::number(value) + "%" }); } }; class Sink { public: static std::optional create(const QString& name) { QProcess process; process.start("pactl", { "load-module", "module-null-sink", "sink_name=" + name, "sink_properties=device.description=" + name, }); std::cout << "[INFO] Asking Pipewire-Pulse to create node via pactl" << std::endl; if (!process.waitForFinished()) { std::cout << "[ERROR] Failed to create node " << name << std::endl; return std::nullopt; } QByteArray output = process.readAllStandardOutput().trimmed(); bool ok = false; int pulse_audio_id = output.toInt(&ok); if (!ok) { std::cout << "[ERROR] Invalid output from pactl" << std::endl; return std::nullopt; } std::cout << "[INFO] PulseAudio Module ID: " << pulse_audio_id << std::endl; auto graph = Pipewire::get_graph(); if (!graph) { destroy(pulse_audio_id); return std::nullopt; } int pipewire_id = -1; for (const QJsonValue& value : *graph) { QJsonObject node = value.toObject(); if (node["type"] != "PipeWire:Interface:Node") { continue; } QJsonObject props = node["info"].toObject()["props"].toObject(); if (props["node.name"] != name) { continue; } pipewire_id = node["id"].toInt(); } if (pipewire_id == -1) { std::cout << "[ERROR] Node created with pactl, but not found in PipeWire graph" << std::endl; destroy(pulse_audio_id); return std::nullopt; } Sink sink; sink.m_name = name; sink.m_pulse_audio_id = pulse_audio_id; sink.m_pipewire_id = pipewire_id; return std::move(sink); } Sink() {} int get_id() const { return m_pipewire_id; } std::string get_name() const { return m_name.toStdString(); } ~Sink() { destroy(m_pulse_audio_id); } Sink(Sink&& other) { m_name = other.m_name; m_pulse_audio_id = other.m_pulse_audio_id; m_pipewire_id = other.m_pipewire_id; other.m_name.clear(); other.m_pulse_audio_id = -1; other.m_pipewire_id = -1; } Sink& operator=(Sink&& other) { m_name = other.m_name; m_pulse_audio_id = other.m_pulse_audio_id; m_pipewire_id = other.m_pipewire_id; other.m_name.clear(); other.m_pulse_audio_id = -1; other.m_pipewire_id = -1; return *this; } private: QString m_name; int m_pulse_audio_id = -1; int m_pipewire_id = -1; static void destroy(int pulse_audio_id) { if (pulse_audio_id == -1) { return; } QProcess process; process.start("pactl", { "unload-module", QString::number(pulse_audio_id) }); if (!process.waitForFinished()) { return; } std::cout << "[INFO] Destroyed PulseAudio Module ID: " << pulse_audio_id << std::endl; } }; class Soundboard : public QWidget { public: Soundboard(QWidget* parent = nullptr) : QWidget(parent) { setWindowTitle("Soundboard"); resize(500, 700); QVBoxLayout* main_layout = new QVBoxLayout(this); // Top QHBoxLayout* top_layout = new QHBoxLayout(); m_combo_box = new QComboBox(); m_combo_box->setToolTip("Select the destination application for the audio stream"); top_layout->addWidget(m_combo_box, 1); connect(m_combo_box, &QComboBox::currentIndexChanged, this, &Soundboard::on_app_selected); m_refresh_button = new QPushButton(); m_refresh_button->setToolTip("Refresh the application list"); m_refresh_button->setIcon(style()->standardIcon(QStyle::SP_BrowserReload)); top_layout->addWidget(m_refresh_button); connect(m_refresh_button, &QPushButton::pressed, this, &Soundboard::refresh_apps); m_folder_button = new QPushButton(); m_folder_button->setToolTip("Select a folder containing audio files"); m_folder_button->setIcon(style()->standardIcon(QStyle::SP_DirIcon)); top_layout->addWidget(m_folder_button); connect(m_folder_button, &QPushButton::pressed, this, &Soundboard::load_folder); m_label = new QLabel("Select a folder"); top_layout->addWidget(m_label); main_layout->addLayout(top_layout); m_volume_slider = new QSlider(Qt::Horizontal); m_volume_slider->setToolTip("Adjust the output volume"); m_volume_slider->setRange(0, 80); m_volume_slider->setValue(80); m_volume_slider->setSingleStep(5); main_layout->addWidget(m_volume_slider); connect(m_volume_slider, &QSlider::valueChanged, this, &Soundboard::set_volume); // Middle m_sound_list = new QListWidget(); m_sound_list->setSelectionMode(QAbstractItemView::SingleSelection); main_layout->addWidget(m_sound_list); connect(m_sound_list, &QListWidget::itemSelectionChanged, this, &Soundboard::play_sound); // Bottom QHBoxLayout* bottom_layout = new QHBoxLayout(); m_stop_button = new QPushButton("Stop"); m_stop_button->setToolTip("Stop all audio streams"); bottom_layout->addWidget(m_stop_button); connect(m_stop_button, &QPushButton::pressed, this, &Soundboard::stop_sounds); m_monitor_toggle = new QCheckBox("Monitor"); m_monitor_toggle->setToolTip("Toggle audio monitoring on your system sink"); bottom_layout->addWidget(m_monitor_toggle); connect(m_monitor_toggle, &QCheckBox::checkStateChanged, this, &Soundboard::toggle_monitor); main_layout->addLayout(bottom_layout); auto sink = Sink::create("soundboard"); if (!sink) { return; } m_sink = std::move(*sink); refresh_apps(); } ~Soundboard() { stop_sounds(); } private: QComboBox* m_combo_box = nullptr; QPushButton* m_refresh_button = nullptr; QPushButton* m_folder_button = nullptr; QLabel* m_label = nullptr; QSlider* m_volume_slider = nullptr; QListWidget* m_sound_list = nullptr; QPushButton* m_stop_button = nullptr; QCheckBox* m_monitor_toggle = nullptr; Sink m_sink; std::map m_apps; std::string m_sounds_folder; std::vector> m_playing_processes; void on_app_selected(int index) { if (index < 0) { return; } auto graph = Pipewire::get_graph(); if (!graph) { return; } QString selection = m_combo_box->currentText(); int target_app_id = m_apps[selection]; int sink_id = m_sink.get_id(); // Destroy old output links for (const QJsonValue& value : *graph) { QJsonObject node = value.toObject(); QJsonObject props = node["info"].toObject()["props"].toObject(); if (props["link.output.node"] == sink_id) { Pipewire::destroy_link(node["id"].toInt()); } } auto sink_out_ids = Pipewire::get_port_ids(sink_id, "out"); auto target_in_ids = Pipewire::get_port_ids(target_app_id, "in"); // Link to target app bool ok = true; for (size_t i = 0; i < sink_out_ids.size(); ++i) { if (!Pipewire::link_ports(sink_out_ids[i], target_in_ids[i])) { std::cout << "[ERROR] Failed to link sink with target node" << std::endl; ok = false; break; } } if (ok) { std::cout << "[INFO] Linked Sink with: " << selection << std::endl; } // Link to monitor only if checked if (!m_monitor_toggle->isChecked()) { return; } for (const QJsonValue& value : *graph) { QJsonObject node = value.toObject(); if (node["type"] != "PipeWire:Interface:Node") { continue; } QJsonObject props = value.toObject()["info"].toObject()["props"].toObject(); if (props["media.class"] != "Audio/Sink" || node["id"] == m_sink.get_id()) { continue; } auto output_ids = Pipewire::get_port_ids(node["id"].toInt(), "in"); for (const int& out_id : sink_out_ids) { for (const int& in_id : output_ids) { Pipewire::link_ports(out_id, in_id); } } } } void refresh_apps() { m_apps = Pipewire::get_input_apps(); m_combo_box->clear(); for (const auto& [app, id] : m_apps) { m_combo_box->addItem(app); } std::cout << "[INFO] App list refreshed" << std::endl;; } void load_folder() { m_sounds_folder = QFileDialog::getExistingDirectory( nullptr, "Select Directory", QDir::homePath(), QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks ).toStdString(); if (m_sounds_folder.empty()) { return; } m_sound_list->clear(); QDir dir(QString::fromStdString(m_sounds_folder)); dir.setSorting(QDir::SortFlag::Name); dir.setNameFilters({ "*.mp3", "*.wav" }); dir.setFilter(QDir::Files | QDir::NoDotAndDotDot); QDirIterator it(dir); while (it.hasNext()) { QString full_path = it.next(); QString basename = QString::fromStdString( std::filesystem::path(full_path.toStdString()).filename().string()); m_sound_list->addItem(basename); } m_label->setText(QString::fromStdString(m_sounds_folder)); } void play_sound() { QListWidgetItem* item = m_sound_list->currentItem(); if (item == nullptr) { return; } std::string sound = item->text().toStdString(); std::string full_path = m_sounds_folder + sound; auto process = Pipewire::play_file(full_path, m_sink.get_name()); m_playing_processes.push_back(std::move(process)); std::cout << "[INFO] Playing: " << sound << std::endl; } void stop_sounds() { for (const auto& process : m_playing_processes) { if (process->state() == QProcess::Running) { process->terminate(); process->waitForFinished(); } } m_playing_processes.clear(); } void set_volume(int value) { Pipewire::set_volume(m_sink.get_name(), value); std::cout << "[INFO] Set volume to " << value << '%' << std::endl; } void toggle_monitor(Qt::CheckState state) { (void) state; on_app_selected(m_combo_box->currentIndex()); } }; int main(int argc, char *argv[]) { QApplication app(argc, argv); Soundboard soundboard; soundboard.show(); return app.exec(); }