Initial commit

This commit is contained in:
2026-07-06 19:04:29 +02:00
commit bd4db56cc0
4 changed files with 560 additions and 0 deletions

517
main.cpp Normal file
View File

@@ -0,0 +1,517 @@
#include <iostream>
#include <map>
#include <memory>
#include <optional>
#include <utility>
#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>
std::ostream& operator<<(std::ostream& os, const QString& string) {
os << string.toStdString();
return os;
}
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);
Soundboard soundboard;
soundboard.show();
return app.exec();
}