Refactor PW and SB logic
This commit is contained in:
@@ -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
|
||||
<iostream>
|
||||
<map>
|
||||
<memory>
|
||||
<optional>
|
||||
<utility>
|
||||
|
||||
<QApplication>
|
||||
<QCheckBox>
|
||||
<QComboBox>
|
||||
@@ -23,9 +23,12 @@ target_precompile_headers(soundboard PRIVATE
|
||||
<QJsonArray>
|
||||
<QJsonDocument>
|
||||
<QJsonObject>
|
||||
<QLabel>
|
||||
<QListWidget>
|
||||
<QProcess>
|
||||
<QPushButton>
|
||||
<QSlider>
|
||||
<QString>
|
||||
<QVBoxLayout>
|
||||
<QWidget>
|
||||
)
|
||||
|
||||
538
main.cpp
538
main.cpp
@@ -1,517 +1,45 @@
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QDirIterator>
|
||||
#include <QFileDialog>
|
||||
#include <QHBoxLayout>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QLabel>
|
||||
#include <QListWidget>
|
||||
#include <QProcess>
|
||||
#include <QPushButton>
|
||||
#include <QSlider>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
#include <QSocketNotifier>
|
||||
|
||||
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<QJsonArray> 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<QString, int> get_input_apps()
|
||||
{
|
||||
std::map<QString, int> 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<int> get_port_ids(int node_id, const std::string& direction /* "in" | "out" */)
|
||||
{
|
||||
std::vector<int> 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<QProcess> play_file(const std::string& file_path, const std::string& target)
|
||||
{
|
||||
auto process = std::make_unique<QProcess>();
|
||||
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<Sink> 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<QString, int> m_apps;
|
||||
std::string m_sounds_folder;
|
||||
std::vector<std::unique_ptr<QProcess>> 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<QSocketNotifier>(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;
|
||||
}
|
||||
|
||||
262
pipewire.cpp
Normal file
262
pipewire.cpp
Normal file
@@ -0,0 +1,262 @@
|
||||
#include "pipewire.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
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<Node> Graph::get_input_apps() const
|
||||
{
|
||||
std::vector<Node> apps;
|
||||
for (const auto& [id, node] : m_nodes) {
|
||||
if (node.media_class == "Stream/Input/Audio") {
|
||||
apps.push_back(node);
|
||||
}
|
||||
}
|
||||
return apps;
|
||||
}
|
||||
|
||||
std::optional<Node> 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<Port> Graph::get_ports_for_node(int node_id, const QString& direction) const
|
||||
{
|
||||
std::vector<Port> 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<Link> Graph::get_links_from_node(int output_node_id) const
|
||||
{
|
||||
std::vector<Link> links;
|
||||
for (const auto& [id, link] : m_links) {
|
||||
if (link.output_node_id == output_node_id) {
|
||||
links.push_back(link);
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
std::vector<Node> Graph::get_all_sinks() const
|
||||
{
|
||||
std::vector<Node> 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<QProcess>();
|
||||
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> 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
|
||||
94
pipewire.hpp
Normal file
94
pipewire.hpp
Normal file
@@ -0,0 +1,94 @@
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <QProcess>
|
||||
#include <QString>
|
||||
|
||||
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<Node> get_input_apps() const;
|
||||
std::optional<Node> get_node_by_name(const QString& name) const;
|
||||
std::vector<Port> get_ports_for_node(int node_id, const QString& direction) const;
|
||||
std::vector<Link> get_links_from_node(int output_node_id) const;
|
||||
std::vector<Node> get_all_sinks() const;
|
||||
|
||||
private:
|
||||
std::unordered_map<int, Node> m_nodes;
|
||||
std::unordered_map<int, Port> m_ports;
|
||||
std::unordered_map<int, Link> m_links;
|
||||
};
|
||||
|
||||
class Player
|
||||
{
|
||||
public:
|
||||
~Player();
|
||||
|
||||
void play_file(const QString& file_path, const QString& target_node = "auto");
|
||||
void stop_all();
|
||||
|
||||
private:
|
||||
std::vector<std::unique_ptr<QProcess>> m_playing_processes;
|
||||
};
|
||||
|
||||
class Sink
|
||||
{
|
||||
public:
|
||||
static std::optional<Sink> 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
|
||||
235
soundboard.cpp
Normal file
235
soundboard.cpp
Normal file
@@ -0,0 +1,235 @@
|
||||
#include "soundboard.hpp"
|
||||
|
||||
#include <iostream>
|
||||
#include <filesystem>
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QDirIterator>
|
||||
#include <QFileDialog>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QListWidget>
|
||||
#include <QPushButton>
|
||||
#include <QSlider>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
||||
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());
|
||||
}
|
||||
46
soundboard.hpp
Normal file
46
soundboard.hpp
Normal file
@@ -0,0 +1,46 @@
|
||||
#include <QApplication>
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QDirIterator>
|
||||
#include <QFileDialog>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QListWidget>
|
||||
#include <QPushButton>
|
||||
#include <QSlider>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
||||
#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<Pipewire::Sink> m_sink;
|
||||
|
||||
std::vector<Pipewire::Node> 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);
|
||||
};
|
||||
Reference in New Issue
Block a user