diff --git a/CMakeLists.txt b/CMakeLists.txt index daca212..33b2e0d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,15 +5,15 @@ set(CMAKE_CXX_STANDARD 17) find_package(Qt6 REQUIRED COMPONENTS Widgets) -add_executable(soundboard main.cpp) +add_executable(soundboard + main.cpp + pipewire.cpp + pipewire.hpp + soundboard.cpp + soundboard.hpp +) target_precompile_headers(soundboard PRIVATE - - - - - - @@ -23,9 +23,12 @@ target_precompile_headers(soundboard PRIVATE + + + ) diff --git a/main.cpp b/main.cpp index 50a2173..c086a83 100644 --- a/main.cpp +++ b/main.cpp @@ -1,517 +1,45 @@ #include -#include -#include -#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; +#include "soundboard.hpp" + +int sig_pipe[2]; + +void signal_handler(int sig) { + char a = 1; + write(sig_pipe[1], &a, sizeof(a)); + std::cout << "[INFO] Signal received. Quitting application gracefully" << std::endl; } -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); + + pipe(sig_pipe); + auto notifier = std::make_unique(sig_pipe[0], QSocketNotifier::Read, &app); + QObject::connect(notifier.get(), &QSocketNotifier::activated, &app, [&](){ + char a; + read(sig_pipe[0], &a, sizeof(a)); + app.quit(); + }); + struct sigaction sa; + sa.sa_handler = signal_handler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESTART; + sigaction(SIGINT, &sa, nullptr); + sigaction(SIGTERM, &sa, nullptr); + Soundboard soundboard; soundboard.show(); - return app.exec(); + + int result = app.exec(); + + close(sig_pipe[0]); + close(sig_pipe[1]); + + return result; } diff --git a/pipewire.cpp b/pipewire.cpp new file mode 100644 index 0000000..271c73f --- /dev/null +++ b/pipewire.cpp @@ -0,0 +1,262 @@ +#include "pipewire.hpp" + +#include + +#include +#include +#include + +namespace Pipewire +{ + +bool link_ports(int out_port_id, int in_port_id) +{ + QProcess process; + process.start("pw-link", { QString::number(out_port_id), QString::number(in_port_id) }); + return process.waitForFinished() && process.exitCode() == 0; +} + +bool destroy_link(int link_id) +{ + QProcess process; + process.start("pw-cli", { "destroy", QString::number(link_id) }); + return process.waitForFinished() && process.exitCode() == 0; +} + +void set_volume(const QString& target_node, int volume_percent) +{ + volume_percent = std::max(0, std::min(100, volume_percent)); + + QProcess process; + process.startDetached("pactl", { + "set-sink-volume", target_node, + QString::number(volume_percent) + "%" + }); +} + +QString Node::get_display_name() const +{ + if (!application_name.isEmpty()) return application_name; + if (!node_name.isEmpty()) return node_name; + return "Unknown App"; +} + +Graph::Graph() +{ + refresh(); +} + +bool Graph::refresh() +{ + QProcess process; + process.start("pw-dump"); + if (!process.waitForFinished()) { + return false; + } + + QJsonParseError error; + QJsonDocument json = QJsonDocument::fromJson(process.readAllStandardOutput(), &error); + if (error.error != QJsonParseError::NoError) { + return false; + } + + m_nodes.clear(); + m_ports.clear(); + m_links.clear(); + + for (const QJsonValue& value : json.array()) { + QJsonObject obj = value.toObject(); + QString type = obj["type"].toString(); + int id = obj["id"].toInt(); + QJsonObject props = obj["info"].toObject()["props"].toObject(); + + if (type == "PipeWire:Interface:Node") { + m_nodes[id] = { + id, + props["node.name"].toString(), + props["application.name"].toString(), + props["media.class"].toString() + }; + } else if (type == "PipeWire:Interface:Port") { + m_ports[id] = { + id, + props["node.id"].toInt(), + props["port.direction"].toString(), + props["audio.channel"].toString() + }; + } else if (type == "PipeWire:Interface:Link") { + m_links[id] = { + id, + props["link.output.node"].toInt(), + props["link.input.node"].toInt() + }; + } + } + + return true; +} + +std::vector Graph::get_input_apps() const +{ + std::vector apps; + for (const auto& [id, node] : m_nodes) { + if (node.media_class == "Stream/Input/Audio") { + apps.push_back(node); + } + } + return apps; +} + +std::optional Graph::get_node_by_name(const QString& name) const +{ + for (const auto& [id, node] : m_nodes) { + if (node.node_name == name) return node; + } + return std::nullopt; +} + +std::vector Graph::get_ports_for_node(int node_id, const QString& direction) const +{ + std::vector ports; + for (const auto& [id, port] : m_ports) { + if (port.node_id == node_id && port.direction == direction) { + ports.push_back(port); + } + } + return ports; +} + +std::vector Graph::get_links_from_node(int output_node_id) const +{ + std::vector links; + for (const auto& [id, link] : m_links) { + if (link.output_node_id == output_node_id) { + links.push_back(link); + } + } + return links; +} + +std::vector Graph::get_all_sinks() const +{ + std::vector sinks; + for (const auto& [id, node] : m_nodes) { + if (node.media_class == "Audio/Sink") { + sinks.push_back(node); + } + } + return sinks; +} + +Player::~Player() +{ + stop_all(); +} + +void Player::play_file(const QString& file_path, const QString& target_node/* = "auto" */) +{ + auto process = std::make_unique(); + process->start("pw-play", { "--target", target_node, file_path }); + m_playing_processes.push_back(std::move(process)); +} + +void Player::stop_all() +{ + for (const auto& process : m_playing_processes) { + if (process->state() == QProcess::Running) { + process->terminate(); + process->waitForFinished(); + } + } + + m_playing_processes.clear(); +} + +std::optional Sink::create(const QString& name, Graph& graph) +{ + QProcess process; + process.start("pactl", { + "load-module", "module-null-sink", + "sink_name=" + name, + "sink_properties=device.description=" + name, + }); + + if (!process.waitForFinished()) { + return std::nullopt; + } + + QByteArray output = process.readAllStandardOutput().trimmed(); + bool ok = false; + int pulse_audio_id = output.toInt(&ok); + if (!ok) { + return std::nullopt; + } + + if (!graph.refresh()) { + destroy(pulse_audio_id); + return std::nullopt; + } + + auto node = graph.get_node_by_name(name); + if (!node) { + 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 = node->id; + + return sink; +} + +Sink::~Sink() +{ + destroy(m_pulse_audio_id); +} + +Sink::Sink(Sink&& other) : + m_name(std::move(other.m_name)), + m_pulse_audio_id(std::exchange(other.m_pulse_audio_id, -1)), + m_pipewire_id(std::exchange(other.m_pipewire_id, -1)) +{ +} + +Sink& Sink::operator=(Sink&& other) +{ + if (this == &other) { + return *this; + } + + destroy(m_pulse_audio_id); + + m_name = std::move(other.m_name); + m_pulse_audio_id = std::exchange(other.m_pulse_audio_id, -1); + m_pipewire_id = std::exchange(other.m_pipewire_id, -1); + + return *this; +} + +int Sink::get_id() const +{ + return m_pipewire_id; +} + +QString Sink::get_name() const +{ + return m_name; +} + +void Sink::destroy(int pulse_audio_id) +{ + if (pulse_audio_id == -1) { + return; + } + + QProcess process; + process.start("pactl", { "unload-module", QString::number(pulse_audio_id) }); + process.waitForFinished(); +} + +} // namespace Pipewire diff --git a/pipewire.hpp b/pipewire.hpp new file mode 100644 index 0000000..d95e4f6 --- /dev/null +++ b/pipewire.hpp @@ -0,0 +1,94 @@ +#include +#include +#include +#include + +#include +#include + +namespace Pipewire +{ + +bool link_ports(int out_port_id, int in_port_id); +bool destroy_link(int link_id); +void set_volume(const QString& target_node, int volume_percent); + +struct Port +{ + int id; + int node_id; + QString direction; // "in" or "out" + QString channel; +}; + +struct Link +{ + int id; + int output_node_id; + int input_node_id; +}; + +struct Node +{ + int id; + QString node_name; + QString application_name; + QString media_class; + + QString get_display_name() const; +}; + +class Graph +{ +public: + Graph(); + + bool refresh(); + std::vector get_input_apps() const; + std::optional get_node_by_name(const QString& name) const; + std::vector get_ports_for_node(int node_id, const QString& direction) const; + std::vector get_links_from_node(int output_node_id) const; + std::vector get_all_sinks() const; + +private: + std::unordered_map m_nodes; + std::unordered_map m_ports; + std::unordered_map m_links; +}; + +class Player +{ +public: + ~Player(); + + void play_file(const QString& file_path, const QString& target_node = "auto"); + void stop_all(); + +private: + std::vector> m_playing_processes; +}; + +class Sink +{ +public: + static std::optional create(const QString& name, Graph& graph); + ~Sink(); + + Sink(Sink&& other); + Sink& operator=(Sink&& other); + Sink(const Sink&) = delete; + Sink& operator=(const Sink&) = delete; + + int get_id() const; + QString get_name() const; + +private: + Sink() = default; + static void destroy(int pulse_audio_id); + + QString m_name; + int m_pulse_audio_id = -1; + int m_pipewire_id = -1; +}; + +} // namespace Pipewire diff --git a/soundboard.cpp b/soundboard.cpp new file mode 100644 index 0000000..aeab3ac --- /dev/null +++ b/soundboard.cpp @@ -0,0 +1,235 @@ +#include "soundboard.hpp" + +#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; +} + +Soundboard::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_directory); + + 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); + + // Setup Pipewire Components + m_graph.refresh(); + m_sink = Pipewire::Sink::create("soundboard", m_graph); + if (!m_sink) { + std::cerr << "[ERROR] Failed to initialize virtual sink for soundboard" << std::endl; + } + + refresh_apps(); +} + +Soundboard::~Soundboard() +{ + stop_sounds(); +} + +void Soundboard::on_app_selected(int index) +{ + if (index < 0 || index >= m_apps.size() || !m_sink) { + return; + } + + m_graph.refresh(); + + int target_app_id = m_apps[index].id; + int sink_id = m_sink->get_id(); + + // Destroy old output links from our sink + auto existing_links = m_graph.get_links_from_node(sink_id); + for (const auto& link : existing_links) { + Pipewire::destroy_link(link.id); + } + + auto sink_out_ports = m_graph.get_ports_for_node(sink_id, "out"); + auto target_in_ports = m_graph.get_ports_for_node(target_app_id, "in"); + + // Link sink to target app + // TODO(1): create a wrapper function to link all channels + for (const auto& out_port : sink_out_ports) { + for (const auto& in_port : target_in_ports) { + if (out_port.channel == in_port.channel && + !Pipewire::link_ports(out_port.id, in_port.id)) { + std::cerr << "[ERROR] Failed to link sink with target node" << std::endl; + break; + } + } + } + + // Link to monitor only if checked + if (m_monitor_toggle->isChecked()) { + auto sinks = m_graph.get_all_sinks(); + for (const auto& system_sink : sinks) { + // Skip our own virtual sink + if (system_sink.id == sink_id) { + continue; + } + + auto output_ids = m_graph.get_ports_for_node(system_sink.id, "in"); + // TODO(1): create a wrapper function to link all channels + for (const auto& out_port : sink_out_ports) { + for (const auto& in_port : output_ids) { + if (out_port.channel == in_port.channel) { + Pipewire::link_ports(out_port.id, in_port.id); + } + } + } + } + } + + std::cout << "[INFO] Updated routing" << std::endl; +} + +void Soundboard::refresh_apps() +{ + m_graph.refresh(); + m_apps = m_graph.get_input_apps(); + + m_combo_box->clear(); + for (const auto& app : m_apps) { + m_combo_box->addItem(app.get_display_name()); + } + + std::cout << "[INFO] Updated app list" << std::endl; +} + +void Soundboard::load_directory() +{ + m_audio_directory = QFileDialog::getExistingDirectory( + nullptr, + "Select audio directory", + QDir::homePath(), + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks + ); + + if (m_audio_directory.isEmpty()) { + return; + } + m_label->setText(m_audio_directory); + + m_sound_list->clear(); + + QDir dir(m_audio_directory); + 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); + } + + std::cout << "[INFO] Selected audio directory: " << m_audio_directory << std::endl; +} + +void Soundboard::play_sound() +{ + QListWidgetItem* item = m_sound_list->currentItem(); + if (item == nullptr || !m_sink) { + return; + } + + QString sound = item->text(); + QString full_path = QDir(m_audio_directory).filePath(sound); + + m_player.play_file(full_path, m_sink->get_name()); + std::cout << "[INFO] Playing: " << sound << std::endl; +} + +void Soundboard::stop_sounds() +{ + m_player.stop_all(); + std::cout << "[INFO] Stopped all playing sounds" << std::endl; +} + +void Soundboard::set_volume(int value) +{ + if (!m_sink) { + return; + } + + Pipewire::set_volume(m_sink->get_name(), value); + std::cout << "[INFO] Set volume to " << value << '%' << std::endl; +} + +void Soundboard::toggle_monitor(Qt::CheckState state) +{ + (void) state; + on_app_selected(m_combo_box->currentIndex()); +} diff --git a/soundboard.hpp b/soundboard.hpp new file mode 100644 index 0000000..9e68ce1 --- /dev/null +++ b/soundboard.hpp @@ -0,0 +1,46 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pipewire.hpp" + +class Soundboard : public QWidget +{ +public: + Soundboard(QWidget* parent = nullptr); + ~Soundboard(); + +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; + + Pipewire::Graph m_graph; + Pipewire::Player m_player; + std::optional m_sink; + + std::vector m_apps; + QString m_audio_directory; + + void on_app_selected(int index); + void refresh_apps(); + void load_directory(); + void play_sound(); + void stop_sounds(); + void set_volume(int value); + void toggle_monitor(Qt::CheckState state); +};