diff --git a/README.md b/README.md index d616cb4be..e549ab15e 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,10 @@ Play at several times the console's resolution. **Music ducking.** Start playing something else, Spotify, a YouTube video, and the game automatically mutes its own music until the other audio stops. Optional, if you'd -rather it didn't. All audio that shows in your display media controls on your windows pc fall under this. +rather it didn't. Windows uses system media controls and Linux uses MPRIS players. +On macOS 14.2 or later, this detects other apps with active audio output and excludes +the game's own audio. Apps that keep an output stream running silently can keep +game music muted even when nothing is audible. **An in-game settings bar.** Press **F10** while the game window has focus: diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 0bee6f1ad..4155f0531 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -342,6 +342,13 @@ if(MKW_PLATFORM_MACOS) target_include_directories(mkw_macos_guest_flat_memory_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") target_compile_features(mkw_macos_guest_flat_memory_tests PRIVATE cxx_std_17) add_test(NAME mkw_macos_guest_flat_memory_tests COMMAND mkw_macos_guest_flat_memory_tests) + + add_executable(mkw_macos_external_audio_tests + "${CMAKE_CURRENT_LIST_DIR}/tests/macos_external_audio_tests.cpp" + "${CMAKE_CURRENT_LIST_DIR}/src/external_audio_macos.cpp") + target_include_directories(mkw_macos_external_audio_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") + target_compile_features(mkw_macos_external_audio_tests PRIVATE cxx_std_17) + add_test(NAME mkw_macos_external_audio_tests COMMAND mkw_macos_external_audio_tests) endif() # The translator emits the complete, content-addressed source graph. Consuming diff --git a/runtime/cmake/PublicProducts.cmake b/runtime/cmake/PublicProducts.cmake index ad2a535b6..5a147a87a 100644 --- a/runtime/cmake/PublicProducts.cmake +++ b/runtime/cmake/PublicProducts.cmake @@ -28,6 +28,7 @@ list(REMOVE_DUPLICATES SOURCES) if(MKW_PLATFORM_MACOS) find_library(MKW_IOKIT_FRAMEWORK IOKit REQUIRED) find_library(MKW_COREFOUNDATION_FRAMEWORK CoreFoundation REQUIRED) + find_library(MKW_COREAUDIO_FRAMEWORK CoreAudio REQUIRED) endif() function(mkw_apply_common_compile_options target) @@ -84,6 +85,8 @@ target_link_libraries(mkw_runtime_common PRIVATE target_link_libraries(mkw_runtime_common PRIVATE mkw_platform mkw::pugixml mkw::toml11 mkw::cryptopp) if(MKW_PLATFORM_WINDOWS) target_link_libraries(mkw_runtime_common PRIVATE shell32 windowsapp) +elseif(MKW_PLATFORM_MACOS) + target_link_libraries(mkw_runtime_common PRIVATE "${MKW_COREAUDIO_FRAMEWORK}") elseif(MKW_PLATFORM_LINUX) # ${CMAKE_DL_LIBS} for music_attenuation.cpp's dlopen of libdbus-1 (MPRIS # media monitoring). Empty string on glibc >= 2.34 where dl* is in libc. @@ -205,7 +208,8 @@ function(mkw_configure_product target) aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx) if(MKW_PLATFORM_MACOS) target_link_libraries(${target} PRIVATE - "${MKW_IOKIT_FRAMEWORK}" "${MKW_COREFOUNDATION_FRAMEWORK}") + "${MKW_IOKIT_FRAMEWORK}" "${MKW_COREFOUNDATION_FRAMEWORK}" + "${MKW_COREAUDIO_FRAMEWORK}") endif() if(EXISTS "${MKW_AURORA_DIR}/cmake/AuroraCopyRuntimeDLLs.cmake") include("${MKW_AURORA_DIR}/cmake/AuroraCopyRuntimeDLLs.cmake") diff --git a/runtime/include/external_audio_macos.h b/runtime/include/external_audio_macos.h new file mode 100644 index 000000000..de8612c4a --- /dev/null +++ b/runtime/include/external_audio_macos.h @@ -0,0 +1,14 @@ +#pragma once + +namespace MusicAttenuation { + +struct MacOSAudioStatus { + bool available = false; + bool playing = false; +}; + +// Queries output activity without capturing audio. Requires Core Audio process +// objects (macOS 14.2+); older systems return an unavailable sample. +MacOSAudioStatus QueryMacOSExternalAudio() noexcept; + +} // namespace MusicAttenuation diff --git a/runtime/include/music_attenuation.h b/runtime/include/music_attenuation.h index f4a8b9ab4..e2f11dbe6 100644 --- a/runtime/include/music_attenuation.h +++ b/runtime/include/music_attenuation.h @@ -5,7 +5,8 @@ namespace MusicAttenuation { // Enables the optional external-media integration (Windows media sessions, -// or MPRIS over D-Bus on Linux). The monitor is started lazily the first time +// Core Audio output activity on macOS, or MPRIS over D-Bus on Linux). +// The monitor is started lazily the first time // this is enabled. void SetEnabled(bool enabled) noexcept; void SetMusicVolume(float volume) noexcept; diff --git a/runtime/src/external_audio_macos.cpp b/runtime/src/external_audio_macos.cpp new file mode 100644 index 000000000..0b38a924f --- /dev/null +++ b/runtime/src/external_audio_macos.cpp @@ -0,0 +1,87 @@ +#if defined(__APPLE__) + +#include "external_audio_macos.h" + +#include +#include +#include + +namespace MusicAttenuation { +namespace { + +#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 140200 +constexpr auto kProcessObjectList = kAudioHardwarePropertyProcessObjectList; +constexpr auto kProcessPid = kAudioProcessPropertyPID; +constexpr auto kProcessRunningOutput = kAudioProcessPropertyIsRunningOutput; +#else +// Public Core Audio selector ABI values, for SDKs predating process objects. +// The runtime HasProperty check still determines whether the OS supports them. +constexpr AudioObjectPropertySelector kProcessObjectList = 'prs#'; +constexpr AudioObjectPropertySelector kProcessPid = 'ppid'; +constexpr AudioObjectPropertySelector kProcessRunningOutput = 'piro'; +#endif + +template +bool ReadAudioProcessProperty(AudioObjectID object, AudioObjectPropertySelector selector, + T& value) noexcept { + const AudioObjectPropertyAddress address{ + selector, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain}; + UInt32 size = sizeof(value); + return AudioObjectGetPropertyData(object, &address, 0, nullptr, &size, &value) == noErr && + size == sizeof(value); +} + +} // namespace + +MacOSAudioStatus QueryMacOSExternalAudio() noexcept { + const AudioObjectPropertyAddress address{ + kProcessObjectList, + kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain}; + // Probe the property instead of raising the game's minimum macOS version. + if (!AudioObjectHasProperty(kAudioObjectSystemObject, &address)) { + return {}; + } + + // Processes may start between the size and data queries. Retry a changed + // list a bounded number of times; subsequent monitor polls also retry. + for (int attempt = 0; attempt < 3; ++attempt) { + UInt32 size = 0; + if (AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &address, + 0, nullptr, &size) != noErr || + size % sizeof(AudioObjectID) != 0) { + return {}; + } + if (size == 0) { + return {true, false}; + } + std::vector processes(size / sizeof(AudioObjectID)); + const auto result = AudioObjectGetPropertyData(kAudioObjectSystemObject, &address, + 0, nullptr, &size, processes.data()); + if (result == kAudioHardwareBadPropertySizeError) { + continue; + } + if (result != noErr || size % sizeof(AudioObjectID) != 0 || + size / sizeof(AudioObjectID) > processes.size()) { + return {}; + } + const pid_t ownPid = getpid(); + for (size_t index = 0; index < size / sizeof(AudioObjectID); ++index) { + pid_t pid = 0; + UInt32 runningOutput = 0; + // An exiting process may disappear mid-query. Never count an + // unknown PID or our own SDL output as external playback. + if (ReadAudioProcessProperty(processes[index], kProcessPid, pid) && + pid > 0 && pid != ownPid && + ReadAudioProcessProperty(processes[index], kProcessRunningOutput, + runningOutput) && runningOutput != 0) { + return {true, true}; + } + } + return {true, false}; + } + return {}; +} + +} // namespace MusicAttenuation + +#endif diff --git a/runtime/src/music_attenuation.cpp b/runtime/src/music_attenuation.cpp index 3ce2b82c9..1f179f0d0 100644 --- a/runtime/src/music_attenuation.cpp +++ b/runtime/src/music_attenuation.cpp @@ -16,6 +16,8 @@ #include #include #include +#elif defined(__APPLE__) +#include "external_audio_macos.h" #elif defined(__linux__) #include @@ -448,6 +450,19 @@ void MonitorLinuxMprisSessions() noexcept { } #endif +#if defined(__APPLE__) +void MonitorMacOSAudio() noexcept { + using namespace std::chrono_literals; + for (;;) { + const auto status = QueryMacOSExternalAudio(); + g_externalMediaPlaying.store(status.playing, std::memory_order_release); + g_mediaControlAvailable.store(status.available, std::memory_order_release); + g_mediaControlInitializationComplete.store(true, std::memory_order_release); + std::this_thread::sleep_for(250ms); + } +} +#endif + void StartMonitor() noexcept { #if defined(_WIN32) // The process owns this monitor for its remaining lifetime. Keeping it @@ -456,6 +471,8 @@ void StartMonitor() noexcept { #elif defined(__linux__) // Detached so there is no shutdown ordering to manage against static audio state. std::thread(MonitorLinuxMprisSessions).detach(); +#elif defined(__APPLE__) + std::thread(MonitorMacOSAudio).detach(); #else g_mediaControlAvailable.store(false, std::memory_order_release); g_mediaControlInitializationComplete.store(true, std::memory_order_release); diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 8856ba5b0..5ec005313 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -734,13 +734,24 @@ void DrawAudioSettings() { MusicAttenuation::SetEnabled(g_attenuateMusicWhenMediaPlays); RuntimeConfigFile::SetAttenuateMusicWhenMediaPlays(g_attenuateMusicWhenMediaPlays); } +#if defined(__APPLE__) + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "Detects other apps with active audio output on macOS 14.2 or later. " + "Apps that keep an output stream running silently may keep game music muted."); + } +#endif if (g_attenuateMusicWhenMediaPlays) { if (MusicAttenuation::IsExternalMediaPlaying()) { ImGui::TextDisabled("External media is playing; game music is muted."); } else if (!MusicAttenuation::IsMediaControlInitializationComplete()) { - ImGui::TextDisabled("Waiting for media controls..."); + ImGui::TextDisabled("Checking external audio..."); } else if (!MusicAttenuation::IsMediaControlAvailable()) { +#if defined(__APPLE__) + ImGui::TextDisabled("External audio detection unavailable (requires macOS 14.2 or later)."); +#else ImGui::TextDisabled("Media controls are unavailable."); +#endif } else { ImGui::TextDisabled("No external media is currently playing."); } diff --git a/runtime/tests/macos_external_audio_tests.cpp b/runtime/tests/macos_external_audio_tests.cpp new file mode 100644 index 000000000..2672affd4 --- /dev/null +++ b/runtime/tests/macos_external_audio_tests.cpp @@ -0,0 +1,109 @@ +#include "external_audio_macos.h" + +#include +#include +#include +#include +#include +#include + +namespace { +struct Process { + pid_t pid; + UInt32 output; + bool disappeared = false; +}; +std::vector processes; +bool supported = true; +bool queryFailed = false; +int sizeRaces = 0; + +void Require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +void Expect(bool available, bool playing, const char* message) { + const auto status = MusicAttenuation::QueryMacOSExternalAudio(); + Require(status.available == available && status.playing == playing, message); +} +} // namespace + +// Fake only the Core Audio boundary so the production enumeration and process +// filtering run unchanged, without depending on other apps on the test machine. +// Use the public selector ABI values so these mocks also build with older SDKs. +extern "C" Boolean AudioObjectHasProperty(AudioObjectID object, + const AudioObjectPropertyAddress* address) { + Require(object == kAudioObjectSystemObject && + address->mSelector == 'prs#', + "Must query the system process list"); + return supported; +} + +extern "C" OSStatus AudioObjectGetPropertyDataSize( + AudioObjectID, const AudioObjectPropertyAddress*, UInt32, const void*, UInt32* size) { + if (queryFailed) return kAudioHardwareUnspecifiedError; + *size = static_cast(processes.size() * sizeof(AudioObjectID)); + return noErr; +} + +extern "C" OSStatus AudioObjectGetPropertyData( + AudioObjectID object, const AudioObjectPropertyAddress* address, UInt32, + const void*, UInt32* size, void* data) { + Require(address->mScope == kAudioObjectPropertyScopeGlobal, "Must use global scope"); + if (object == kAudioObjectSystemObject) { + if (sizeRaces > 0) { + --sizeRaces; + return kAudioHardwareBadPropertySizeError; + } + for (size_t i = 0; i < processes.size(); ++i) { + static_cast(data)[i] = static_cast(i + 100); + } + *size = static_cast(processes.size() * sizeof(AudioObjectID)); + return noErr; + } + const auto& process = processes.at(object - 100); + if (process.disappeared) return kAudioHardwareBadObjectError; + if (address->mSelector == 'ppid') { + std::memcpy(data, &process.pid, sizeof(process.pid)); + *size = sizeof(process.pid); + } else { + Require(address->mSelector == 'piro', + "Input-only activity must not count as playback"); + Require(process.pid != getpid(), "Must exclude the game's own output"); + std::memcpy(data, &process.output, sizeof(process.output)); + *size = sizeof(process.output); + } + return noErr; +} + +int main() { + supported = false; + Expect(false, false, "Older macOS must report unavailable"); + supported = true; + Expect(true, false, "An empty process list is available but inactive"); + processes = {{getpid(), 1}}; + Expect(true, false, "Game audio alone must not trigger muting"); + processes.push_back({getpid() + 1, 0}); + Expect(true, false, "An idle or input-only app must not trigger muting"); + processes.back().output = 1; + Expect(true, true, "External playback must trigger muting"); + processes.back().output = 0; + Expect(true, false, "Stopping playback must restore music"); + processes.back().output = 1; + processes.back().disappeared = true; + Expect(true, false, "Exiting apps must not leave music muted"); + processes.push_back({getpid() + 2, 1}); + Expect(true, true, "A disappearing app must not hide another active player"); + sizeRaces = 1; + Expect(true, true, "A growing process list must be retried"); + sizeRaces = 3; + Expect(false, false, "List retries must be bounded"); + Expect(true, true, "Later polls must recover from list races"); + queryFailed = true; + Expect(false, false, "Query failure must clear playing state"); + queryFailed = false; + Expect(true, true, "Monitoring must recover from query failure"); + processes = {{0, 1}}; + Expect(true, false, "Unknown PIDs must not count as external playback"); + std::cout << "macOS external audio tests passed\n"; +}