Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions runtime/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion runtime/cmake/PublicProducts.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
14 changes: 14 additions & 0 deletions runtime/include/external_audio_macos.h
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion runtime/include/music_attenuation.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
87 changes: 87 additions & 0 deletions runtime/src/external_audio_macos.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#if defined(__APPLE__)

#include "external_audio_macos.h"

#include <CoreAudio/CoreAudio.h>
#include <unistd.h>
#include <vector>

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 <typename T>
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<AudioObjectID> 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
17 changes: 17 additions & 0 deletions runtime/src/music_attenuation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.Media.Control.h>
#elif defined(__APPLE__)
#include "external_audio_macos.h"
#elif defined(__linux__)
#include <dlfcn.h>

Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
13 changes: 12 additions & 1 deletion runtime/src/settings_overlay.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Expand Down
109 changes: 109 additions & 0 deletions runtime/tests/macos_external_audio_tests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#include "external_audio_macos.h"

#include <CoreAudio/CoreAudio.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <vector>

namespace {
struct Process {
pid_t pid;
UInt32 output;
bool disappeared = false;
};
std::vector<Process> 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<UInt32>(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<AudioObjectID*>(data)[i] = static_cast<AudioObjectID>(i + 100);
}
*size = static_cast<UInt32>(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";
}
Loading