diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 3a29c3d..1e45029 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -6,6 +6,8 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 6.4 REQUIRED COMPONENTS Quick Widgets Bluetooth DBus) find_package(OpenSSL REQUIRED) +find_package(PkgConfig REQUIRED) +pkg_check_modules(PULSEAUDIO REQUIRED libpulse) qt_standard_project_setup(REQUIRES 6.4) @@ -14,6 +16,8 @@ qt_add_executable(librepods logger.h media/mediacontroller.cpp media/mediacontroller.h + media/pulseaudiocontroller.cpp + media/pulseaudiocontroller.h airpods_packets.h trayiconmanager.cpp trayiconmanager.h @@ -66,9 +70,11 @@ qt_add_resources(librepods "resources" ) target_link_libraries(librepods - PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus OpenSSL::SSL OpenSSL::Crypto + PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus OpenSSL::SSL OpenSSL::Crypto ${PULSEAUDIO_LIBRARIES} ) +target_include_directories(librepods PRIVATE ${PULSEAUDIO_INCLUDE_DIRS}) + include(GNUInstallDirs) install(TARGETS librepods BUNDLE DESTINATION . diff --git a/linux/main.cpp b/linux/main.cpp index 409929c..a9c3b88 100644 --- a/linux/main.cpp +++ b/linux/main.cpp @@ -967,7 +967,7 @@ int main(int argc, char *argv[]) { QApplication app(argc, argv); QSharedMemory sharedMemory; - sharedMemory.setKey("TcpServer-Key"); + sharedMemory.setKey("TcpServer-Key2"); // Check if app is already open if(sharedMemory.create(1) == false) diff --git a/linux/media/mediacontroller.cpp b/linux/media/mediacontroller.cpp index 03f23dd..9ba1c80 100644 --- a/linux/media/mediacontroller.cpp +++ b/linux/media/mediacontroller.cpp @@ -2,14 +2,21 @@ #include "logger.h" #include "eardetection.hpp" #include "playerstatuswatcher.h" +#include "pulseaudiocontroller.h" #include #include +#include #include #include #include MediaController::MediaController(QObject *parent) : QObject(parent) { + m_pulseAudio = new PulseAudioController(this); + if (!m_pulseAudio->initialize()) + { + LOG_ERROR("Failed to initialize PulseAudio controller"); + } } void MediaController::handleEarDetection(EarDetection *earDetection) @@ -87,12 +94,9 @@ void MediaController::followMediaChanges() { } bool MediaController::isActiveOutputDeviceAirPods() { - QProcess process; - process.start("pactl", QStringList() << "get-default-sink"); - process.waitForFinished(); - QString output = process.readAllStandardOutput().trimmed(); - LOG_DEBUG("Default sink: " << output); - return output.contains(connectedDeviceMacAddress); + QString defaultSink = m_pulseAudio->getDefaultSink(); + LOG_DEBUG("Default sink: " << defaultSink); + return defaultSink.contains(connectedDeviceMacAddress); } void MediaController::handleConversationalAwareness(const QByteArray &data) { @@ -102,32 +106,29 @@ void MediaController::handleConversationalAwareness(const QByteArray &data) { if (lowered) { if (initialVolume == -1 && isActiveOutputDeviceAirPods()) { - QProcess process; - process.start("pactl", QStringList() - << "get-sink-volume" << "@DEFAULT_SINK@"); - process.waitForFinished(); - QString output = process.readAllStandardOutput(); - QRegularExpression re("front-left: \\d+ /\\s*(\\d+)%"); - QRegularExpressionMatch match = re.match(output); - if (match.hasMatch()) { - LOG_DEBUG("Matched: " << match.captured(1)); - initialVolume = match.captured(1).toInt(); - } else { - LOG_ERROR("Failed to parse initial volume from output: " << output); + QString defaultSink = m_pulseAudio->getDefaultSink(); + initialVolume = m_pulseAudio->getSinkVolume(defaultSink); + if (initialVolume == -1) { + LOG_ERROR("Failed to get initial volume"); return; } + LOG_DEBUG("Initial volume: " << initialVolume << "%"); + } + QString defaultSink = m_pulseAudio->getDefaultSink(); + int targetVolume = initialVolume * 0.20; + if (m_pulseAudio->setSinkVolume(defaultSink, targetVolume)) { + LOG_INFO("Volume lowered to 0.20 of initial which is " << targetVolume << "%"); + } else { + LOG_ERROR("Failed to lower volume"); } - QProcess::execute( - "pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@" - << QString::number(initialVolume * 0.20) + "%"); - LOG_INFO("Volume lowered to 0.20 of initial which is " - << initialVolume * 0.20 << "%"); } else { if (initialVolume != -1 && isActiveOutputDeviceAirPods()) { - QProcess::execute("pactl", QStringList() - << "set-sink-volume" << "@DEFAULT_SINK@" - << QString::number(initialVolume) + "%"); - LOG_INFO("Volume restored to " << initialVolume << "%"); + QString defaultSink = m_pulseAudio->getDefaultSink(); + if (m_pulseAudio->setSinkVolume(defaultSink, initialVolume)) { + LOG_INFO("Volume restored to " << initialVolume << "%"); + } else { + LOG_ERROR("Failed to restore volume"); + } initialVolume = -1; } } @@ -138,26 +139,33 @@ bool MediaController::isA2dpProfileAvailable() { return false; } - QProcess process; - process.start("pactl", QStringList() << "list" << "cards"); - if (!process.waitForFinished(3000)) { - LOG_ERROR("pactl command timed out while checking A2DP availability"); - return false; + return m_pulseAudio->isProfileAvailable(m_deviceOutputName, "a2dp-sink-sbc_xq") || + m_pulseAudio->isProfileAvailable(m_deviceOutputName, "a2dp-sink-sbc") || + m_pulseAudio->isProfileAvailable(m_deviceOutputName, "a2dp-sink"); +} + +QString MediaController::getPreferredA2dpProfile() { + if (m_deviceOutputName.isEmpty()) { + return QString(); } - QString output = process.readAllStandardOutput(); - - // Check if the card section contains our device - int cardStart = output.indexOf(m_deviceOutputName); - if (cardStart == -1) { - return false; + if (!m_cachedA2dpProfile.isEmpty() && + m_pulseAudio->isProfileAvailable(m_deviceOutputName, m_cachedA2dpProfile)) { + return m_cachedA2dpProfile; } - // Look for a2dp-sink profile in the card's section - int nextCard = output.indexOf("Name: ", cardStart + m_deviceOutputName.length()); - QString cardSection = (nextCard == -1) ? output.mid(cardStart) : output.mid(cardStart, nextCard - cardStart); + QStringList profiles = {"a2dp-sink-sbc_xq", "a2dp-sink-sbc", "a2dp-sink"}; - return cardSection.contains("a2dp-sink"); + for (const QString &profile : profiles) { + if (m_pulseAudio->isProfileAvailable(m_deviceOutputName, profile)) { + LOG_INFO("Selected best available A2DP profile: " << profile); + m_cachedA2dpProfile = profile; + return profile; + } + } + + m_cachedA2dpProfile.clear(); + return QString(); } bool MediaController::restartWirePlumber() { @@ -165,11 +173,10 @@ bool MediaController::restartWirePlumber() { int result = QProcess::execute("systemctl", QStringList() << "--user" << "restart" << "wireplumber"); if (result == 0) { LOG_INFO("WirePlumber restarted successfully"); - // Wait a bit for WirePlumber to rediscover profiles - QProcess::execute("sleep", QStringList() << "2"); + QThread::sleep(2); return true; } else { - LOG_ERROR("Failed to restart WirePlumber"); + LOG_ERROR("Failed to restart WirePlumber. Do you use wireplumber?"); return false; } } @@ -180,11 +187,9 @@ void MediaController::activateA2dpProfile() { return; } - // Check if A2DP profile is available if (!isA2dpProfileAvailable()) { LOG_WARN("A2DP profile not available, attempting to restart WirePlumber"); if (restartWirePlumber()) { - // Update device output name after restart m_deviceOutputName = getAudioDeviceName(); if (!isA2dpProfileAvailable()) { LOG_ERROR("A2DP profile still not available after WirePlumber restart"); @@ -196,13 +201,15 @@ void MediaController::activateA2dpProfile() { } } - LOG_INFO("Activating A2DP profile for AirPods"); - int result = QProcess::execute( - "pactl", QStringList() - << "set-card-profile" - << m_deviceOutputName << "a2dp-sink"); - if (result != 0) { - LOG_ERROR("Failed to activate A2DP profile"); + QString preferredProfile = getPreferredA2dpProfile(); + if (preferredProfile.isEmpty()) { + LOG_ERROR("No suitable A2DP profile found"); + return; + } + + LOG_INFO("Activating A2DP profile for AirPods: " << preferredProfile); + if (!m_pulseAudio->setCardProfile(m_deviceOutputName, preferredProfile)) { + LOG_ERROR("Failed to activate A2DP profile: " << preferredProfile); } } @@ -213,11 +220,7 @@ void MediaController::removeAudioOutputDevice() { } LOG_INFO("Removing AirPods as audio output device"); - int result = QProcess::execute( - "pactl", QStringList() - << "set-card-profile" - << m_deviceOutputName << "off"); - if (result != 0) { + if (!m_pulseAudio->setCardProfile(m_deviceOutputName, "off")) { LOG_ERROR("Failed to remove AirPods as audio output device"); } } @@ -225,6 +228,7 @@ void MediaController::removeAudioOutputDevice() { void MediaController::setConnectedDeviceMacAddress(const QString &macAddress) { connectedDeviceMacAddress = macAddress; m_deviceOutputName = getAudioDeviceName(); + m_cachedA2dpProfile.clear(); LOG_INFO("Device output name set to: " << m_deviceOutputName); } @@ -345,40 +349,9 @@ QString MediaController::getAudioDeviceName() { if (connectedDeviceMacAddress.isEmpty()) { return QString(); } - // Set up QProcess to run pactl directly - QProcess process; - process.start("pactl", QStringList() << "list" << "cards" << "short"); - if (!process.waitForFinished(3000)) // Timeout after 3 seconds - { - LOG_ERROR("pactl command failed or timed out: " << process.errorString()); - return QString(); + QString cardName = m_pulseAudio->getCardNameForDevice(connectedDeviceMacAddress); + if (cardName.isEmpty()) { + LOG_ERROR("No matching Bluetooth card found for MAC address: " << connectedDeviceMacAddress); } - - // Check for execution errors - if (process.exitCode() != 0) - { - LOG_ERROR("pactl exited with error code: " << process.exitCode()); - return QString(); - } - - // Read and parse the command output - QString output = process.readAllStandardOutput(); - QStringList lines = output.split("\n", Qt::SkipEmptyParts); - - // Iterate through each line to find a matching Bluetooth sink - for (const QString &line : lines) - { - QStringList fields = line.split("\t", Qt::SkipEmptyParts); - if (fields.size() < 2) { continue; } - - QString sinkName = fields[1].trimmed(); - if (sinkName.startsWith("bluez") && sinkName.contains(connectedDeviceMacAddress)) - { - return sinkName; - } - } - - // No matching sink found - LOG_ERROR("No matching Bluetooth sink found for MAC address: " << connectedDeviceMacAddress); - return QString(); + return cardName; } \ No newline at end of file diff --git a/linux/media/mediacontroller.h b/linux/media/mediacontroller.h index 1bb0706..fdde25c 100644 --- a/linux/media/mediacontroller.h +++ b/linux/media/mediacontroller.h @@ -2,6 +2,7 @@ #define MEDIACONTROLLER_H #include +#include "pulseaudiocontroller.h" class QProcess; class EarDetection; @@ -38,6 +39,7 @@ public: void removeAudioOutputDevice(); void setConnectedDeviceMacAddress(const QString &macAddress); bool isA2dpProfileAvailable(); + QString getBestA2dpProfile(); bool restartWirePlumber(); void setEarDetectionBehavior(EarDetectionBehavior behavior); @@ -61,6 +63,8 @@ private: EarDetectionBehavior earDetectionBehavior = PauseWhenOneRemoved; QString m_deviceOutputName; PlayerStatusWatcher *playerStatusWatcher = nullptr; + PulseAudioController *m_pulseAudio = nullptr; + QString m_cachedA2dpProfile; }; #endif // MEDIACONTROLLER_H \ No newline at end of file diff --git a/linux/media/pulseaudiocontroller.cpp b/linux/media/pulseaudiocontroller.cpp new file mode 100644 index 0000000..dedaecb --- /dev/null +++ b/linux/media/pulseaudiocontroller.cpp @@ -0,0 +1,284 @@ +#include "pulseaudiocontroller.h" +#include "logger.h" +#include + +PulseAudioController::PulseAudioController(QObject *parent) + : QObject(parent), m_mainloop(nullptr), m_context(nullptr), m_initialized(false) +{ +} + +PulseAudioController::~PulseAudioController() +{ + if (m_context) + { + pa_context_disconnect(m_context); + pa_context_unref(m_context); + } + if (m_mainloop) + { + pa_threaded_mainloop_stop(m_mainloop); + pa_threaded_mainloop_free(m_mainloop); + } +} + +bool PulseAudioController::initialize() +{ + m_mainloop = pa_threaded_mainloop_new(); + if (!m_mainloop) + { + LOG_ERROR("Failed to create PulseAudio mainloop"); + return false; + } + + pa_mainloop_api *api = pa_threaded_mainloop_get_api(m_mainloop); + m_context = pa_context_new(api, "LibrePods"); + if (!m_context) + { + LOG_ERROR("Failed to create PulseAudio context"); + return false; + } + + pa_context_set_state_callback(m_context, contextStateCallback, this); + + if (pa_threaded_mainloop_start(m_mainloop) < 0) + { + LOG_ERROR("Failed to start PulseAudio mainloop"); + return false; + } + + pa_threaded_mainloop_lock(m_mainloop); + + if (pa_context_connect(m_context, nullptr, PA_CONTEXT_NOFLAGS, nullptr) < 0) + { + LOG_ERROR("Failed to connect to PulseAudio"); + pa_threaded_mainloop_unlock(m_mainloop); + return false; + } + + // Wait for context to be ready + while (pa_context_get_state(m_context) != PA_CONTEXT_READY) + { + if (!PA_CONTEXT_IS_GOOD(pa_context_get_state(m_context))) + { + LOG_ERROR("PulseAudio context failed"); + pa_threaded_mainloop_unlock(m_mainloop); + return false; + } + pa_threaded_mainloop_wait(m_mainloop); + } + + pa_threaded_mainloop_unlock(m_mainloop); + m_initialized = true; + LOG_INFO("PulseAudio controller initialized"); + return true; +} + +void PulseAudioController::contextStateCallback(pa_context *c, void *userdata) +{ + PulseAudioController *controller = static_cast(userdata); + pa_threaded_mainloop_signal(controller->m_mainloop, 0); +} + +QString PulseAudioController::getDefaultSink() +{ + if (!m_initialized) return QString(); + + struct CallbackData { + QString sinkName; + pa_threaded_mainloop *mainloop; + } data; + data.mainloop = m_mainloop; + + auto callback = [](pa_context *c, const pa_server_info *info, void *userdata) { + CallbackData *d = static_cast(userdata); + if (info && info->default_sink_name) + { + d->sinkName = QString::fromUtf8(info->default_sink_name); + } + pa_threaded_mainloop_signal(d->mainloop, 0); + }; + + pa_threaded_mainloop_lock(m_mainloop); + pa_operation *op = pa_context_get_server_info(m_context, callback, &data); + if (op) + { + waitForOperation(op); + pa_operation_unref(op); + } + pa_threaded_mainloop_unlock(m_mainloop); + + return data.sinkName; +} + +int PulseAudioController::getSinkVolume(const QString &sinkName) +{ + if (!m_initialized) return -1; + + struct CallbackData { + int volume; + QString targetSink; + pa_threaded_mainloop *mainloop; + } data; + data.volume = -1; + data.targetSink = sinkName; + data.mainloop = m_mainloop; + + auto callback = [](pa_context *c, const pa_sink_info *info, int eol, void *userdata) { + CallbackData *d = static_cast(userdata); + if (eol > 0) + { + pa_threaded_mainloop_signal(d->mainloop, 0); + return; + } + if (info && QString::fromUtf8(info->name) == d->targetSink) + { + d->volume = (pa_cvolume_avg(&info->volume) * 100) / PA_VOLUME_NORM; + pa_threaded_mainloop_signal(d->mainloop, 0); + } + }; + + pa_threaded_mainloop_lock(m_mainloop); + pa_operation *op = pa_context_get_sink_info_by_name(m_context, sinkName.toUtf8().constData(), callback, &data); + if (op) + { + waitForOperation(op); + pa_operation_unref(op); + } + pa_threaded_mainloop_unlock(m_mainloop); + + return data.volume; +} + +bool PulseAudioController::setSinkVolume(const QString &sinkName, int volumePercent) +{ + if (!m_initialized) return false; + + pa_cvolume volume; + pa_cvolume_set(&volume, 2, (volumePercent * PA_VOLUME_NORM) / 100); + + pa_threaded_mainloop_lock(m_mainloop); + pa_operation *op = pa_context_set_sink_volume_by_name(m_context, sinkName.toUtf8().constData(), &volume, nullptr, nullptr); + bool success = waitForOperation(op); + if (op) pa_operation_unref(op); + pa_threaded_mainloop_unlock(m_mainloop); + + return success; +} + +bool PulseAudioController::setCardProfile(const QString &cardName, const QString &profileName) +{ + if (!m_initialized) return false; + + pa_threaded_mainloop_lock(m_mainloop); + pa_operation *op = pa_context_set_card_profile_by_name(m_context, + cardName.toUtf8().constData(), + profileName.toUtf8().constData(), + nullptr, nullptr); + bool success = waitForOperation(op); + if (op) pa_operation_unref(op); + pa_threaded_mainloop_unlock(m_mainloop); + + return success; +} + +QString PulseAudioController::getCardNameForDevice(const QString &macAddress) +{ + if (!m_initialized) return QString(); + + struct CallbackData { + QString cardName; + QString targetMac; + pa_threaded_mainloop *mainloop; + } data; + data.targetMac = macAddress; + data.mainloop = m_mainloop; + + auto callback = [](pa_context *c, const pa_card_info *info, int eol, void *userdata) { + CallbackData *d = static_cast(userdata); + if (eol > 0) + { + pa_threaded_mainloop_signal(d->mainloop, 0); + return; + } + if (info) + { + QString name = QString::fromUtf8(info->name); + if (name.startsWith("bluez") && name.contains(d->targetMac)) + { + d->cardName = name; + pa_threaded_mainloop_signal(d->mainloop, 0); + } + } + }; + + pa_threaded_mainloop_lock(m_mainloop); + pa_operation *op = pa_context_get_card_info_list(m_context, callback, &data); + if (op) + { + waitForOperation(op); + pa_operation_unref(op); + } + pa_threaded_mainloop_unlock(m_mainloop); + + return data.cardName; +} + +bool PulseAudioController::isProfileAvailable(const QString &cardName, const QString &profileName) +{ + if (!m_initialized) return false; + + struct CallbackData { + bool available; + QString targetCard; + QString targetProfile; + pa_threaded_mainloop *mainloop; + } data; + data.available = false; + data.targetCard = cardName; + data.targetProfile = profileName; + data.mainloop = m_mainloop; + + auto callback = [](pa_context *c, const pa_card_info *info, int eol, void *userdata) { + CallbackData *d = static_cast(userdata); + if (eol > 0) + { + pa_threaded_mainloop_signal(d->mainloop, 0); + return; + } + if (info && QString::fromUtf8(info->name) == d->targetCard) + { + for (uint32_t i = 0; i < info->n_profiles; i++) + { + if (QString::fromUtf8(info->profiles[i].name) == d->targetProfile) + { + d->available = true; + break; + } + } + pa_threaded_mainloop_signal(d->mainloop, 0); + } + }; + + pa_threaded_mainloop_lock(m_mainloop); + pa_operation *op = pa_context_get_card_info_by_name(m_context, cardName.toUtf8().constData(), callback, &data); + if (op) + { + waitForOperation(op); + pa_operation_unref(op); + } + pa_threaded_mainloop_unlock(m_mainloop); + + return data.available; +} + +bool PulseAudioController::waitForOperation(pa_operation *op) +{ + if (!op) return false; + + while (pa_operation_get_state(op) == PA_OPERATION_RUNNING) + { + pa_threaded_mainloop_wait(m_mainloop); + } + + return pa_operation_get_state(op) == PA_OPERATION_DONE; +} diff --git a/linux/media/pulseaudiocontroller.h b/linux/media/pulseaudiocontroller.h new file mode 100644 index 0000000..9ee1b27 --- /dev/null +++ b/linux/media/pulseaudiocontroller.h @@ -0,0 +1,37 @@ +#ifndef PULSEAUDIOCONTROLLER_H +#define PULSEAUDIOCONTROLLER_H + +#include +#include +#include + +class PulseAudioController : public QObject +{ + Q_OBJECT + +public: + explicit PulseAudioController(QObject *parent = nullptr); + ~PulseAudioController(); + + bool initialize(); + QString getDefaultSink(); + int getSinkVolume(const QString &sinkName); + bool setSinkVolume(const QString &sinkName, int volumePercent); + bool setCardProfile(const QString &cardName, const QString &profileName); + QString getCardNameForDevice(const QString &macAddress); + bool isProfileAvailable(const QString &cardName, const QString &profileName); + +private: + pa_threaded_mainloop *m_mainloop; + pa_context *m_context; + bool m_initialized; + + static void contextStateCallback(pa_context *c, void *userdata); + static void sinkInfoCallback(pa_context *c, const pa_sink_info *info, int eol, void *userdata); + static void cardInfoCallback(pa_context *c, const pa_card_info *info, int eol, void *userdata); + static void serverInfoCallback(pa_context *c, const pa_server_info *info, void *userdata); + + bool waitForOperation(pa_operation *op); +}; + +#endif // PULSEAUDIOCONTROLLER_H