From bd4db56cc01d20810ec30043e1a8692246d5fe75 Mon Sep 17 00:00:00 2001 From: Cristian Gora Date: Mon, 6 Jul 2026 19:04:29 +0200 Subject: [PATCH] Initial commit --- .gitignore | 1 + CMakeLists.txt | 33 +++ compile_flags.txt | 9 + main.cpp | 517 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 560 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 compile_flags.txt create mode 100644 main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..daca212 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,33 @@ +cmake_minimum_required(VERSION 3.16) +project(Soundboard) + +set(CMAKE_CXX_STANDARD 17) + +find_package(Qt6 REQUIRED COMPONENTS Widgets) + +add_executable(soundboard main.cpp) + +target_precompile_headers(soundboard PRIVATE + + + + + + + + + + + + + + + + + + + + +) + +target_link_libraries(soundboard PRIVATE Qt6::Widgets) diff --git a/compile_flags.txt b/compile_flags.txt new file mode 100644 index 0000000..3ead11a --- /dev/null +++ b/compile_flags.txt @@ -0,0 +1,9 @@ +-std=c++17 +-I/usr/include/qt6/QtWidgets +-I/usr/include/qt6 +-DQT_WIDGETS_LIB +-I/usr/include/qt6/QtGui +-DQT_GUI_LIB +-I/usr/include/qt6/QtCore +-DQT_CORE_LIB +-I/usr/lib/qt6/mkspecs/linux-g++ diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..50a2173 --- /dev/null +++ b/main.cpp @@ -0,0 +1,517 @@ +#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(); +}