From 2659307a8ce31ad700dad5422597431e141da725 Mon Sep 17 00:00:00 2001 From: Loacky Date: Fri, 26 Jun 2026 15:52:23 +0200 Subject: [PATCH 1/4] feat(auto-pause): add option to duck music player volume instead of pausing (#541) Add support for auto-ducking the selected music player app's volume to 30% when other applications play audio, rather than pausing it. Closes #541 --- BGMApp/BGMApp/BGMAutoPauseMenuItem.m | 6 +- BGMApp/BGMApp/BGMAutoPauseMusic.mm | 241 +++++++++++++++--- BGMApp/BGMApp/BGMUserDefaults.h | 1 + BGMApp/BGMApp/BGMUserDefaults.m | 14 +- .../Preferences/BGMAutoPauseMusicPrefs.h | 3 +- .../Preferences/BGMAutoPauseMusicPrefs.mm | 23 +- .../BGMApp/Preferences/BGMPreferencesMenu.mm | 3 +- .../UnitTests/BGMMusicPlayersUnitTests.mm | 8 + 8 files changed, 259 insertions(+), 40 deletions(-) diff --git a/BGMApp/BGMApp/BGMAutoPauseMenuItem.m b/BGMApp/BGMApp/BGMAutoPauseMenuItem.m index 472557be..17c52ff9 100644 --- a/BGMApp/BGMApp/BGMAutoPauseMenuItem.m +++ b/BGMApp/BGMApp/BGMAutoPauseMenuItem.m @@ -30,7 +30,8 @@ #pragma clang assume_nonnull begin -static NSString* const kMenuItemTitleFormat = @"Auto-pause %@"; +static NSString* const kMenuItemTitleFormatPause = @"Auto-pause %@"; +static NSString* const kMenuItemTitleFormatDuck = @"Auto-duck %@"; static NSString* const kMenuItemDisabledToolTipFormat = @"%@ doesn't appear to be running."; // Wait time to disable/enable the auto-pause menu item, in seconds. @@ -123,7 +124,8 @@ - (void) updateMenuItemTitle { - (void) updateMenuItemTitleWithHighlight:(BOOL)highlight { // Set the title of the Auto-pause Music menu item, including the name of the selected music player. NSString* musicPlayerName = musicPlayers.selectedMusicPlayer.name; - menuItem.title = [NSString stringWithFormat:kMenuItemTitleFormat, musicPlayerName]; + NSString* titleFormat = userDefaults.autoDuckMusic ? kMenuItemTitleFormatDuck : kMenuItemTitleFormatPause; + menuItem.title = [NSString stringWithFormat:titleFormat, musicPlayerName]; // Make the Auto-pause Music menu item appear disabled if the application is not running. // diff --git a/BGMApp/BGMApp/BGMAutoPauseMusic.mm b/BGMApp/BGMApp/BGMAutoPauseMusic.mm index 5df3e9fe..5a9d53ca 100644 --- a/BGMApp/BGMApp/BGMAutoPauseMusic.mm +++ b/BGMApp/BGMApp/BGMAutoPauseMusic.mm @@ -24,8 +24,11 @@ #import "BGMAutoPauseMusic.h" // Local Includes -#include "BGM_Types.h" +#import "BGM_Types.h" #import "BGMMusicPlayer.h" +#import "CACFArray.h" +#import "CACFDictionary.h" +#import "CACFString.h" // STL Includes #import // std::max, std::min @@ -54,6 +57,12 @@ @implementation BGMAutoPauseMusic { // True if BGMApp has paused musicPlayer and hasn't unpaused it yet. (Will be out of sync with the music player app if the // user has unpaused it themselves.) BOOL wePaused; + // True if BGMApp has ducked the musicPlayer and hasn't unducked it yet. + BOOL weDucked; + // The original volume before ducking. + int originalVolume; + // The ducked volume we set. + int duckedVolume; // The times, in absolute time, that the BGMDevice last changed its audible state to silent... UInt64 wentSilent; // ...and to audible. @@ -68,6 +77,9 @@ - (id) initWithAudioDevices:(BGMAudioDeviceManager*)inAudioDevices musicPlayers: enabled = NO; wePaused = NO; + weDucked = NO; + originalVolume = kAppRelativeVolumeMaxRawValue; + duckedVolume = kAppRelativeVolumeMaxRawValue; dispatch_queue_attr_t attr; @@ -84,6 +96,15 @@ - (id) initWithAudioDevices:(BGMAudioDeviceManager*)inAudioDevices musicPlayers: listenerQueue = dispatch_queue_create("com.bearisdriving.BGM.AutoPauseMusic.Listener", attr); pauseUnpauseMusicQueue = dispatch_queue_create("com.bearisdriving.BGM.AutoPauseMusic.PauseUnpauseMusic", attr); + [userDefaults addObserver:self + forKeyPath:@"autoDuckMusic" + options:NSKeyValueObservingOptionNew + context:nil]; + [musicPlayers addObserver:self + forKeyPath:@"selectedMusicPlayer" + options:NSKeyValueObservingOptionNew + context:nil]; + [self initListenerBlock]; } @@ -92,6 +113,14 @@ - (id) initWithAudioDevices:(BGMAudioDeviceManager*)inAudioDevices musicPlayers: - (void) dealloc { [self disable]; + try { + [userDefaults removeObserver:self forKeyPath:@"autoDuckMusic" context:nil]; + } catch (const std::exception& e) { + } + try { + [musicPlayers removeObserver:self forKeyPath:@"selectedMusicPlayer" context:nil]; + } catch (const std::exception& e) { + } } - (void) initListenerBlock { @@ -120,8 +149,9 @@ - (void) initListenerBlock { } else if (audibleState == kBGMDeviceIsSilentExceptMusic) { // If we pause the music player and then the user unpauses it before the other audio stops, we need to set // wePaused to false at some point before the other audio starts again so we know we should pause - DebugMsg("BGMAutoPauseMusic: Device is silent except music, resetting wePaused flag"); + DebugMsg("BGMAutoPauseMusic: Device is silent except music, resetting wePaused/weDucked flags"); wePaused = NO; + weDucked = NO; } // TODO: Add a fourth audible state, something like "AudibleAndMusicPlaying", and check it here to // handle the user unpausing and then repausing music while also playing other audio? @@ -141,35 +171,43 @@ - (void) queuePauseBlock { UInt64 pauseDelayMS = userDefaults.pauseDelayMS; - // If pause delay is 0, pause immediately (no delay) + // If pause delay is 0, pause/duck immediately (no delay) if (pauseDelayMS == 0) { - DebugMsg("BGMAutoPauseMusic::queuePauseBlock: Pause delay is 0, pausing immediately"); + DebugMsg("BGMAutoPauseMusic::queuePauseBlock: Pause/duck delay is 0, pausing/ducking immediately"); - // Pause immediately if device is audible and we haven't already paused - if (!wePaused && ([self deviceAudibleState] == kBGMDeviceIsAudible)) { - wePaused = ([musicPlayers.selectedMusicPlayer pause] || wePaused); + // Pause/duck immediately if device is audible and we haven't already paused/ducked + if (!wePaused && !weDucked && ([self deviceAudibleState] == kBGMDeviceIsAudible)) { + if (userDefaults.autoDuckMusic) { + [self duckMusicPlayer]; + } else { + wePaused = ([musicPlayers.selectedMusicPlayer pause] || wePaused); + } } return; } UInt64 pauseDelayNSec = pauseDelayMS * NSEC_PER_MSEC; - DebugMsg("BGMAutoPauseMusic::queuePauseBlock: Dispatching pause block at %llu", now); + DebugMsg("BGMAutoPauseMusic::queuePauseBlock: Dispatching pause/duck block at %llu", now); dispatch_after(dispatch_time(DISPATCH_TIME_NOW, pauseDelayNSec), pauseUnpauseMusicQueue, ^{ BOOL stillAudible = ([self deviceAudibleState] == kBGMDeviceIsAudible); - DebugMsg("BGMAutoPauseMusic::queuePauseBlock: Running pause block dispatched at %llu.%s wentAudible=%llu", + DebugMsg("BGMAutoPauseMusic::queuePauseBlock: Running pause/duck block dispatched at %llu.%s wentAudible=%llu", startedPauseDelay, - stillAudible ? "" : " Not pausing because the device isn't audible.", + stillAudible ? "" : " Not pausing/ducking because the device isn't audible.", wentAudible); - // Pause if this is the most recent pause block and the device is still audible, which means the audible - // state hasn't changed since this block was queued. Also set wePaused to true if the player wasn't - // already paused. - if (!wePaused && (startedPauseDelay == wentAudible) && stillAudible) { - wePaused = ([musicPlayers.selectedMusicPlayer pause] || wePaused); + // Pause/duck if this is the most recent pause block and the device is still audible, which means the audible + // state hasn't changed since this block was queued. Also set wePaused/weDucked to true if the player wasn't + // already paused/ducked. + if (!wePaused && !weDucked && (startedPauseDelay == wentAudible) && stillAudible) { + if (userDefaults.autoDuckMusic) { + [self duckMusicPlayer]; + } else { + wePaused = ([musicPlayers.selectedMusicPlayer pause] || wePaused); + } } }); } @@ -182,29 +220,30 @@ - (void) queueUnpauseBlock { // Get user-configurable max delay UInt64 maxUnpauseDelayMS = userDefaults.maxUnpauseDelayMS; - // If max unpause delay is 0, unpause immediately (no delay) + // If max unpause delay is 0, unpause/unduck immediately (no delay) if (maxUnpauseDelayMS == 0) { - DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Max unpause delay is 0, unpausing immediately"); + DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Max unpause/unduck delay is 0, unpausing/unducking immediately"); - // Unpause immediately if we were the one who paused and device is still silent BGMDeviceAudibleState currentState = [self deviceAudibleState]; - DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Immediate unpause - wePaused=%s, currentState=%s", + DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Immediate unpause/unduck - wePaused=%s, weDucked=%s, currentState=%s", wePaused ? "YES" : "NO", + weDucked ? "YES" : "NO", currentState == kBGMDeviceIsSilent ? "Silent" : (currentState == kBGMDeviceIsAudible ? "Audible" : "SilentExceptMusic")); - if (wePaused && (currentState == kBGMDeviceIsSilent)) { - wePaused = NO; - [musicPlayers.selectedMusicPlayer unpause]; + if (currentState == kBGMDeviceIsSilent) { + if (wePaused) { + wePaused = NO; + [musicPlayers.selectedMusicPlayer unpause]; + } else if (weDucked) { + [self unduckMusicPlayer]; + } } return; } - // Unpause sooner if we've only been paused for a short time. This is so a notification sound causing an auto-pause is + // Unpause sooner if we've only been paused/ducked for a short time. This is so a notification sound causing an auto-pause/duck is // less of an interruption. - // - // TODO: Fading in and out would make short pauses a lot less jarring because, if they were short enough, we wouldn't - // actually pause the music player. So you'd hear a dip in the music's volume rather than a gap. UInt64 unpauseDelayNsec = static_cast(static_cast(wentSilent - wentAudible) * kUnpauseDelayWeightingFactor); @@ -220,7 +259,7 @@ - (void) queueUnpauseBlock { unpauseDelayNsec = std::min(maxUnpauseDelayNSec, unpauseDelayNsec); unpauseDelayNsec = std::max(minUnpauseDelayNSec, unpauseDelayNsec); - DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Dispatched unpause block at %llu. unpauseDelayNsec=%llu", + DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Dispatched unpause/unduck block at %llu. unpauseDelayNsec=%llu", now, unpauseDelayNsec); @@ -231,20 +270,23 @@ - (void) queueUnpauseBlock { BOOL stillSilent = (currentState == kBGMDeviceIsSilent); BOOL isLatestUnpause = (startedUnpauseDelay == wentSilent); - DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Running unpause block dispatched at %llu. wePaused=%s, isLatest=%s, currentState=%s, wentSilent=%llu", + DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Running unpause/unduck block dispatched at %llu. wePaused=%s, weDucked=%s, isLatest=%s, currentState=%s, wentSilent=%llu", startedUnpauseDelay, wePaused ? "YES" : "NO", + weDucked ? "YES" : "NO", isLatestUnpause ? "YES" : "NO", currentState == kBGMDeviceIsSilent ? "Silent" : (currentState == kBGMDeviceIsAudible ? "Audible" : "SilentExceptMusic"), wentSilent); - // Unpause if we were the one who paused. Also check that this is the most recent unpause block and the - // device is still silent, which means the audible state hasn't changed since this block was queued. - if (wePaused && isLatestUnpause && stillSilent) { - DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Unpausing music player"); - wePaused = NO; - [musicPlayers.selectedMusicPlayer unpause]; + if (isLatestUnpause && stillSilent) { + if (wePaused) { + DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Unpausing music player"); + wePaused = NO; + [musicPlayers.selectedMusicPlayer unpause]; + } else if (weDucked) { + [self unduckMusicPlayer]; + } } }); } @@ -263,5 +305,136 @@ - (void) disable { } } +- (void) observeValueForKeyPath:(NSString* __nullable)keyPath + ofObject:(id __nullable)object + change:(NSDictionary* __nullable)change + context:(void* __nullable)context +{ + #pragma unused (object, change, context) + if ([keyPath isEqualToString:@"autoDuckMusic"]) { + if (wePaused || weDucked) { + dispatch_async(pauseUnpauseMusicQueue, ^{ + BGMDeviceAudibleState state = [self deviceAudibleState]; + if (state == kBGMDeviceIsAudible) { + if (userDefaults.autoDuckMusic && wePaused) { + // Transition from paused to ducked: + // 1. Unpause + [musicPlayers.selectedMusicPlayer unpause]; + wePaused = NO; + // 2. Duck + [self duckMusicPlayer]; + } else if (!userDefaults.autoDuckMusic && weDucked) { + // Transition from ducked to paused: + // 1. Unduck + [self unduckMusicPlayer]; + // 2. Pause + wePaused = ([musicPlayers.selectedMusicPlayer pause] || wePaused); + } + } + }); + } + } else if ([keyPath isEqualToString:@"selectedMusicPlayer"]) { + wePaused = NO; + weDucked = NO; + } +} + +- (void) duckMusicPlayer { + id player = musicPlayers.selectedMusicPlayer; + if (!player.isRunning || !player.isPlaying) { + return; + } + + originalVolume = [self getMusicPlayerVolume]; + + static float const kDuckingFactor = 0.3f; + duckedVolume = (int)(static_cast(originalVolume) * kDuckingFactor); + if (duckedVolume >= originalVolume && originalVolume > 0) { + duckedVolume = originalVolume - 1; + } + + DebugMsg("BGMAutoPauseMusic::duckMusicPlayer: originalVolume=%d, duckedVolume=%d", originalVolume, duckedVolume); + + [self setMusicPlayerVolume:duckedVolume]; + weDucked = YES; +} + +- (void) unduckMusicPlayer { + if (!weDucked) { + return; + } + + int currentVolume = [self getMusicPlayerVolume]; + DebugMsg("BGMAutoPauseMusic::unduckMusicPlayer: currentVolume=%d, expectedDuckedVolume=%d, originalVolume=%d", + currentVolume, duckedVolume, originalVolume); + + if (currentVolume == duckedVolume) { + DebugMsg("BGMAutoPauseMusic::unduckMusicPlayer: Restoring volume to originalVolume=%d", originalVolume); + [self setMusicPlayerVolume:originalVolume]; + } else { + DebugMsg("BGMAutoPauseMusic::unduckMusicPlayer: Volume was changed manually while ducked. Keeping currentVolume=%d", currentVolume); + } + + weDucked = NO; +} + +- (int) getMusicPlayerVolume { + id player = musicPlayers.selectedMusicPlayer; + NSString* playerBundleID = player.bundleID; + pid_t playerPid = player.pid ? [player.pid intValue] : -1; + + if (playerPid == -1 && playerBundleID != nil) { + NSArray* apps = [NSRunningApplication runningApplicationsWithBundleIdentifier:playerBundleID]; + if (apps.count > 0) { + playerPid = apps.firstObject.processIdentifier; + } + } + + try { + CACFArray volumes([audioDevices bgmDevice].GetAppVolumes(), false); + for (UInt32 i = 0; i < volumes.GetNumberItems(); i++) { + CACFDictionary appVolume(false); + volumes.GetCACFDictionary(i, appVolume); + + CACFString bundleID; + bundleID.DontAllowRelease(); + appVolume.GetCACFString(CFSTR(kBGMAppVolumesKey_BundleID), bundleID); + + pid_t pid; + appVolume.GetSInt32(CFSTR(kBGMAppVolumesKey_ProcessID), pid); + + if ((playerPid != -1 && playerPid == pid) || + (playerBundleID != nil && [playerBundleID isEqualToString:(__bridge NSString*)bundleID.GetCFString()])) { + int volume = -1; + appVolume.GetSInt32(CFSTR(kBGMAppVolumesKey_RelativeVolume), volume); + return volume; + } + } + } catch (const std::exception& e) { + NSLog(@"BGMAutoPauseMusic::getMusicPlayerVolume error: %s", e.what()); + } + + return kAppRelativeVolumeMaxRawValue; +} + +- (void) setMusicPlayerVolume:(int)volume { + id player = musicPlayers.selectedMusicPlayer; + NSString* playerBundleID = player.bundleID; + pid_t playerPid = player.pid ? [player.pid intValue] : -1; + + if (playerPid == -1 && playerBundleID != nil) { + NSArray* apps = [NSRunningApplication runningApplicationsWithBundleIdentifier:playerBundleID]; + if (apps.count > 0) { + playerPid = apps.firstObject.processIdentifier; + } + } + + try { + [audioDevices bgmDevice].SetAppVolume(volume, playerPid, (__bridge CFStringRef)playerBundleID); + } catch (const std::exception& e) { + NSLog(@"BGMAutoPauseMusic::setMusicPlayerVolume error: %s", e.what()); + } +} + @end diff --git a/BGMApp/BGMApp/BGMUserDefaults.h b/BGMApp/BGMApp/BGMUserDefaults.h index 252dbb7c..678afe23 100644 --- a/BGMApp/BGMApp/BGMUserDefaults.h +++ b/BGMApp/BGMApp/BGMUserDefaults.h @@ -44,6 +44,7 @@ @property NSString* __nullable selectedMusicPlayerID; @property BOOL autoPauseMusicEnabled; +@property BOOL autoDuckMusic; // The UIDs of the output devices most recently selected by the user. The most-recently selected // device is at index 0. See BGMPreferredOutputDevices. diff --git a/BGMApp/BGMApp/BGMUserDefaults.m b/BGMApp/BGMApp/BGMUserDefaults.m index f934ba9b..aac88a77 100644 --- a/BGMApp/BGMApp/BGMUserDefaults.m +++ b/BGMApp/BGMApp/BGMUserDefaults.m @@ -31,6 +31,7 @@ // Keys static NSString* const kDefaultKeyAutoPauseMusicEnabled = @"AutoPauseMusicEnabled"; +static NSString* const kDefaultKeyAutoDuckMusic = @"AutoDuckMusic"; static NSString* const kDefaultKeySelectedMusicPlayerID = @"SelectedMusicPlayerID"; static NSString* const kDefaultKeyPreferredDeviceUIDs = @"PreferredDeviceUIDs"; static NSString* const kDefaultKeyStatusBarIcon = @"StatusBarIcon"; @@ -62,7 +63,8 @@ - (instancetype) initWithDefaults:(NSUserDefaults* __nullable)inDefaults { NSDictionary* defaultsDict = @{ kDefaultKeyAutoPauseMusicEnabled: @YES, kDefaultKeyPauseDelayMS: @1500, - kDefaultKeyMaxUnpauseDelayMS: @3500 + kDefaultKeyMaxUnpauseDelayMS: @3500, + kDefaultKeyAutoDuckMusic: @NO }; if (defaults) { @@ -95,6 +97,16 @@ - (void) setAutoPauseMusicEnabled:(BOOL)autoPauseMusicEnabled { [self setBool:kDefaultKeyAutoPauseMusicEnabled to:autoPauseMusicEnabled]; } +#pragma mark Auto-duck + +- (BOOL) autoDuckMusic { + return [self getBool:kDefaultKeyAutoDuckMusic]; +} + +- (void) setAutoDuckMusic:(BOOL)autoDuckMusic { + [self setBool:kDefaultKeyAutoDuckMusic to:autoDuckMusic]; +} + #pragma mark Auto-pause Delays - (NSUInteger) pauseDelayMS { diff --git a/BGMApp/BGMApp/Preferences/BGMAutoPauseMusicPrefs.h b/BGMApp/BGMApp/Preferences/BGMAutoPauseMusicPrefs.h index 259e92e3..1cf43c65 100644 --- a/BGMApp/BGMApp/Preferences/BGMAutoPauseMusicPrefs.h +++ b/BGMApp/BGMApp/Preferences/BGMAutoPauseMusicPrefs.h @@ -34,7 +34,8 @@ - (id) initWithPreferencesMenu:(NSMenu*)inPrefsMenu audioDevices:(BGMAudioDeviceManager*)inAudioDevices - musicPlayers:(BGMMusicPlayers*)inMusicPlayers; + musicPlayers:(BGMMusicPlayers*)inMusicPlayers + userDefaults:(BGMUserDefaults*)inUserDefaults; @end diff --git a/BGMApp/BGMApp/Preferences/BGMAutoPauseMusicPrefs.mm b/BGMApp/BGMApp/Preferences/BGMAutoPauseMusicPrefs.mm index 9d586e3f..57de5a11 100644 --- a/BGMApp/BGMApp/Preferences/BGMAutoPauseMusicPrefs.mm +++ b/BGMApp/BGMApp/Preferences/BGMAutoPauseMusicPrefs.mm @@ -26,6 +26,7 @@ // Local Includes #import "BGM_Types.h" #import "BGMMusicPlayer.h" +#import "BGMUserDefaults.h" #pragma clang assume_nonnull begin @@ -36,17 +37,20 @@ @implementation BGMAutoPauseMusicPrefs { BGMAudioDeviceManager* audioDevices; BGMMusicPlayers* musicPlayers; + BGMUserDefaults* userDefaults; NSMenu* prefsMenu; NSArray* musicPlayerMenuItems; } - (id) initWithPreferencesMenu:(NSMenu*)inPrefsMenu audioDevices:(BGMAudioDeviceManager*)inAudioDevices - musicPlayers:(BGMMusicPlayers*)inMusicPlayers { + musicPlayers:(BGMMusicPlayers*)inMusicPlayers + userDefaults:(BGMUserDefaults*)inUserDefaults { if ((self = [super init])) { prefsMenu = inPrefsMenu; audioDevices = inAudioDevices; musicPlayers = inMusicPlayers; + userDefaults = inUserDefaults; musicPlayerMenuItems = @[]; @@ -96,6 +100,17 @@ - (void) initPreferencesMenuSection { menuItem.target = self; menuItem.indentationLevel = 1; } + + // Add "Duck instead of pause" menu item below the music players + NSInteger duckMusicItemIndex = musicPlayerItemsIndex + musicPlayers.musicPlayers.count; + NSMenuItem* duckMusicMenuItem = [prefsMenu insertItemWithTitle:@"Duck instead of pause" + action:@selector(handleDuckMusicChange:) + keyEquivalent:@"" + atIndex:duckMusicItemIndex]; + duckMusicMenuItem.representedObject = nil; + duckMusicMenuItem.target = self; + duckMusicMenuItem.indentationLevel = 1; + duckMusicMenuItem.state = userDefaults.autoDuckMusic ? NSOnState : NSOffState; } - (void) handleMusicPlayerChange:(NSMenuItem*)sender { @@ -112,6 +127,12 @@ - (void) handleMusicPlayerChange:(NSMenuItem*)sender { } } +- (void) handleDuckMusicChange:(NSMenuItem*)sender { + BOOL nextState = (sender.state == NSOffState); + sender.state = nextState ? NSOnState : NSOffState; + userDefaults.autoDuckMusic = nextState; +} + @end #pragma clang assume_nonnull end diff --git a/BGMApp/BGMApp/Preferences/BGMPreferencesMenu.mm b/BGMApp/BGMApp/Preferences/BGMPreferencesMenu.mm index 6a919431..b223083b 100644 --- a/BGMApp/BGMApp/Preferences/BGMPreferencesMenu.mm +++ b/BGMApp/BGMApp/Preferences/BGMPreferencesMenu.mm @@ -73,7 +73,8 @@ - (id) initWithBGMMenu:(NSMenu*)inBGMMenu autoPauseMusicPrefs = [[BGMAutoPauseMusicPrefs alloc] initWithPreferencesMenu:prefsMenu audioDevices:inAudioDevices - musicPlayers:inMusicPlayers]; + musicPlayers:inMusicPlayers + userDefaults:inUserDefaults]; aboutPanel = [[BGMAboutPanel alloc] initWithPanel:inAboutPanel licenseView:inAboutPanelLicenseView]; diff --git a/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm b/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm index 8700bcb3..a2557422 100644 --- a/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm +++ b/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm @@ -100,6 +100,14 @@ - (void) setAutoPauseMusicEnabled:(BOOL)autoPauseMusicEnabled { #pragma unused (autoPauseMusicEnabled) } +- (BOOL) autoDuckMusic { + return NO; +} + +- (void) setAutoDuckMusic:(BOOL)autoDuckMusic { + #pragma unused (autoDuckMusic) +} + @end // ------------------------------------------------------------------------------------------------- From 252ed9345bb87861dcf5b2c322263448e79e3f82 Mon Sep 17 00:00:00 2001 From: Loacky Date: Fri, 26 Jun 2026 16:05:18 +0200 Subject: [PATCH 2/4] feat(auto-pause): fix auto-ducking restore and add ducking volume slider (#541) - Fix issue where volume is not restored after ducking by correctly checking for kBGMDeviceIsSilentExceptMusic state in the listener block and unpause queue. - Add a custom NSSlider to BGMPreferencesMenu to allow users to adjust the ducking volume percentage dynamically (defaulting to 30%). - Ensure the slider is enabled/disabled reactively depending on the "Duck instead of pause" state. - Update tests and user defaults database structure. Closes #541 --- BGMApp/BGMApp/BGMAutoPauseMusic.mm | 59 +++++++++---- BGMApp/BGMApp/BGMUserDefaults.h | 1 + BGMApp/BGMApp/BGMUserDefaults.m | 14 ++- .../BGMApp/Preferences/BGMPreferencesMenu.mm | 88 ++++++++++++++++++- .../UnitTests/BGMMusicPlayersUnitTests.mm | 8 ++ 5 files changed, 152 insertions(+), 18 deletions(-) diff --git a/BGMApp/BGMApp/BGMAutoPauseMusic.mm b/BGMApp/BGMApp/BGMAutoPauseMusic.mm index 5a9d53ca..0bdef1eb 100644 --- a/BGMApp/BGMApp/BGMAutoPauseMusic.mm +++ b/BGMApp/BGMApp/BGMAutoPauseMusic.mm @@ -32,6 +32,8 @@ // STL Includes #import // std::max, std::min +#include + // System Includes #include @@ -100,6 +102,10 @@ - (id) initWithAudioDevices:(BGMAudioDeviceManager*)inAudioDevices musicPlayers: forKeyPath:@"autoDuckMusic" options:NSKeyValueObservingOptionNew context:nil]; + [userDefaults addObserver:self + forKeyPath:@"autoDuckPercent" + options:NSKeyValueObservingOptionNew + context:nil]; [musicPlayers addObserver:self forKeyPath:@"selectedMusicPlayer" options:NSKeyValueObservingOptionNew @@ -117,6 +123,10 @@ - (void) dealloc { [userDefaults removeObserver:self forKeyPath:@"autoDuckMusic" context:nil]; } catch (const std::exception& e) { } + try { + [userDefaults removeObserver:self forKeyPath:@"autoDuckPercent" context:nil]; + } catch (const std::exception& e) { + } try { [musicPlayers removeObserver:self forKeyPath:@"selectedMusicPlayer" context:nil]; } catch (const std::exception& e) { @@ -132,7 +142,10 @@ - (void) initListenerBlock { // so we have to check them all for (int i = 0; i < inNumberAddresses; i++) { if (inAddresses[i].mSelector == kAudioDeviceCustomPropertyDeviceAudibleState) { - BGMDeviceAudibleState audibleState = [weakSelf deviceAudibleState]; + BGMAutoPauseMusic* strongSelf = weakSelf; + if (!strongSelf) return; + + BGMDeviceAudibleState audibleState = [strongSelf deviceAudibleState]; #if DEBUG const char audibleStateStr[5] = CA4CCToCString(audibleState); @@ -143,18 +156,19 @@ - (void) initListenerBlock { // TODO: We shouldn't assume this block will only get called when BGMDevice's audible state changes. (Even if // the Core Audio docs did specify that, there's no reason not to be fault tolerant.) if (audibleState == kBGMDeviceIsAudible) { - [weakSelf queuePauseBlock]; + [strongSelf queuePauseBlock]; } else if (audibleState == kBGMDeviceIsSilent) { - [weakSelf queueUnpauseBlock]; + [strongSelf queueUnpauseBlock]; } else if (audibleState == kBGMDeviceIsSilentExceptMusic) { - // If we pause the music player and then the user unpauses it before the other audio stops, we need to set - // wePaused to false at some point before the other audio starts again so we know we should pause - DebugMsg("BGMAutoPauseMusic: Device is silent except music, resetting wePaused/weDucked flags"); - wePaused = NO; - weDucked = NO; + if (strongSelf->weDucked) { + [strongSelf queueUnpauseBlock]; + } else { + // If we pause the music player and then the user unpauses it before the other audio stops, we need to set + // wePaused to false at some point before the other audio starts again so we know we should pause + DebugMsg("BGMAutoPauseMusic: Device is silent except music, resetting wePaused flag"); + strongSelf->wePaused = NO; + } } - // TODO: Add a fourth audible state, something like "AudibleAndMusicPlaying", and check it here to - // handle the user unpausing and then repausing music while also playing other audio? } } }; @@ -231,7 +245,8 @@ - (void) queueUnpauseBlock { currentState == kBGMDeviceIsSilent ? "Silent" : (currentState == kBGMDeviceIsAudible ? "Audible" : "SilentExceptMusic")); - if (currentState == kBGMDeviceIsSilent) { + BOOL silentEnough = (currentState == kBGMDeviceIsSilent) || (weDucked && (currentState == kBGMDeviceIsSilentExceptMusic)); + if (silentEnough) { if (wePaused) { wePaused = NO; [musicPlayers.selectedMusicPlayer unpause]; @@ -267,7 +282,7 @@ - (void) queueUnpauseBlock { pauseUnpauseMusicQueue, ^{ BGMDeviceAudibleState currentState = [self deviceAudibleState]; - BOOL stillSilent = (currentState == kBGMDeviceIsSilent); + BOOL silentEnough = (currentState == kBGMDeviceIsSilent) || (weDucked && (currentState == kBGMDeviceIsSilentExceptMusic)); BOOL isLatestUnpause = (startedUnpauseDelay == wentSilent); DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Running unpause/unduck block dispatched at %llu. wePaused=%s, weDucked=%s, isLatest=%s, currentState=%s, wentSilent=%llu", @@ -279,7 +294,7 @@ - (void) queueUnpauseBlock { (currentState == kBGMDeviceIsAudible ? "Audible" : "SilentExceptMusic"), wentSilent); - if (isLatestUnpause && stillSilent) { + if (isLatestUnpause && silentEnough) { if (wePaused) { DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Unpausing music player"); wePaused = NO; @@ -333,6 +348,18 @@ - (void) observeValueForKeyPath:(NSString* __nullable)keyPath } }); } + } else if ([keyPath isEqualToString:@"autoDuckPercent"]) { + if (weDucked) { + dispatch_async(pauseUnpauseMusicQueue, ^{ + // Recalculate ducked volume and update the player volume + float duckingFactor = (float)userDefaults.autoDuckPercent / 100.0f; + duckedVolume = (int)(static_cast(originalVolume) * duckingFactor); + if (duckedVolume >= originalVolume && originalVolume > 0) { + duckedVolume = originalVolume - 1; + } + [self setMusicPlayerVolume:duckedVolume]; + }); + } } else if ([keyPath isEqualToString:@"selectedMusicPlayer"]) { wePaused = NO; weDucked = NO; @@ -347,8 +374,8 @@ - (void) duckMusicPlayer { originalVolume = [self getMusicPlayerVolume]; - static float const kDuckingFactor = 0.3f; - duckedVolume = (int)(static_cast(originalVolume) * kDuckingFactor); + float duckingFactor = (float)userDefaults.autoDuckPercent / 100.0f; + duckedVolume = (int)(static_cast(originalVolume) * duckingFactor); if (duckedVolume >= originalVolume && originalVolume > 0) { duckedVolume = originalVolume - 1; } @@ -368,7 +395,7 @@ - (void) unduckMusicPlayer { DebugMsg("BGMAutoPauseMusic::unduckMusicPlayer: currentVolume=%d, expectedDuckedVolume=%d, originalVolume=%d", currentVolume, duckedVolume, originalVolume); - if (currentVolume == duckedVolume) { + if (std::abs(currentVolume - duckedVolume) <= 1) { DebugMsg("BGMAutoPauseMusic::unduckMusicPlayer: Restoring volume to originalVolume=%d", originalVolume); [self setMusicPlayerVolume:originalVolume]; } else { diff --git a/BGMApp/BGMApp/BGMUserDefaults.h b/BGMApp/BGMApp/BGMUserDefaults.h index 678afe23..e705ffc2 100644 --- a/BGMApp/BGMApp/BGMUserDefaults.h +++ b/BGMApp/BGMApp/BGMUserDefaults.h @@ -45,6 +45,7 @@ @property BOOL autoPauseMusicEnabled; @property BOOL autoDuckMusic; +@property NSUInteger autoDuckPercent; // The UIDs of the output devices most recently selected by the user. The most-recently selected // device is at index 0. See BGMPreferredOutputDevices. diff --git a/BGMApp/BGMApp/BGMUserDefaults.m b/BGMApp/BGMApp/BGMUserDefaults.m index aac88a77..cdf15551 100644 --- a/BGMApp/BGMApp/BGMUserDefaults.m +++ b/BGMApp/BGMApp/BGMUserDefaults.m @@ -32,6 +32,7 @@ // Keys static NSString* const kDefaultKeyAutoPauseMusicEnabled = @"AutoPauseMusicEnabled"; static NSString* const kDefaultKeyAutoDuckMusic = @"AutoDuckMusic"; +static NSString* const kDefaultKeyAutoDuckPercent = @"AutoDuckPercent"; static NSString* const kDefaultKeySelectedMusicPlayerID = @"SelectedMusicPlayerID"; static NSString* const kDefaultKeyPreferredDeviceUIDs = @"PreferredDeviceUIDs"; static NSString* const kDefaultKeyStatusBarIcon = @"StatusBarIcon"; @@ -64,7 +65,8 @@ - (instancetype) initWithDefaults:(NSUserDefaults* __nullable)inDefaults { kDefaultKeyAutoPauseMusicEnabled: @YES, kDefaultKeyPauseDelayMS: @1500, kDefaultKeyMaxUnpauseDelayMS: @3500, - kDefaultKeyAutoDuckMusic: @NO + kDefaultKeyAutoDuckMusic: @NO, + kDefaultKeyAutoDuckPercent: @30 }; if (defaults) { @@ -107,6 +109,16 @@ - (void) setAutoDuckMusic:(BOOL)autoDuckMusic { [self setBool:kDefaultKeyAutoDuckMusic to:autoDuckMusic]; } +- (NSUInteger) autoDuckPercent { + NSInteger percent = [self getInt:kDefaultKeyAutoDuckPercent or:30]; + return (NSUInteger)MAX(0, MIN(100, percent)); +} + +- (void) setAutoDuckPercent:(NSUInteger)autoDuckPercent { + NSUInteger clampedPercent = MAX(0, MIN(100, autoDuckPercent)); + [self setInt:kDefaultKeyAutoDuckPercent to:(NSInteger)clampedPercent]; +} + #pragma mark Auto-pause Delays - (NSUInteger) pauseDelayMS { diff --git a/BGMApp/BGMApp/Preferences/BGMPreferencesMenu.mm b/BGMApp/BGMApp/Preferences/BGMPreferencesMenu.mm index b223083b..91da24c2 100644 --- a/BGMApp/BGMApp/Preferences/BGMPreferencesMenu.mm +++ b/BGMApp/BGMApp/Preferences/BGMPreferencesMenu.mm @@ -59,6 +59,11 @@ @implementation BGMPreferencesMenu { NSTextField* pauseDelayLabel; NSTextField* maxUnpauseDelayLabel; BGMUserDefaults* userDefaults; + + // Ducking preferences + NSMenuItem* duckVolumeMenuItem; + NSSlider* duckVolumeSlider; + NSTextField* duckVolumeLabel; } - (id) initWithBGMMenu:(NSMenu*)inBGMMenu @@ -101,6 +106,11 @@ - (id) initWithBGMMenu:(NSMenu*)inBGMMenu // Set up delay preferences userDefaults = inUserDefaults; [self setupDelayPreferences:prefsMenu]; + + [userDefaults addObserver:self + forKeyPath:@"autoDuckMusic" + options:NSKeyValueObservingOptionNew + context:nil]; } return self; @@ -195,8 +205,36 @@ - (void) setupDelayPreferences:(NSMenu*)prefsMenu { maxUnpauseDelayMenuItem.view = maxUnpauseDelayView; [prefsMenu addItem:maxUnpauseDelayMenuItem]; + + // Create ducking volume menu item with slider + duckVolumeMenuItem = [[NSMenuItem alloc] initWithTitle:@"" action:nil keyEquivalent:@""]; + NSView* duckVolumeView = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 280, 25)]; + + // Ducking volume label + NSTextField* duckVolumeTitleLabel = [[NSTextField alloc] initWithFrame:NSMakeRect(10, 5, 110, 15)]; + duckVolumeTitleLabel.stringValue = @"Ducking Volume:"; + duckVolumeTitleLabel.editable = NO; + duckVolumeTitleLabel.bordered = NO; + duckVolumeTitleLabel.backgroundColor = [NSColor clearColor]; + duckVolumeTitleLabel.font = [NSFont menuFontOfSize:13]; + [duckVolumeView addSubview:duckVolumeTitleLabel]; + + // Ducking volume slider + duckVolumeSlider = [[NSSlider alloc] initWithFrame:NSMakeRect(115, 5, 100, 15)]; + [duckVolumeView addSubview:duckVolumeSlider]; + + // Ducking volume value label + duckVolumeLabel = [[NSTextField alloc] initWithFrame:NSMakeRect(220, 5, 55, 15)]; + duckVolumeLabel.editable = NO; + duckVolumeLabel.bordered = NO; + duckVolumeLabel.backgroundColor = [NSColor clearColor]; + duckVolumeLabel.font = [NSFont menuFontOfSize:11]; + [duckVolumeView addSubview:duckVolumeLabel]; - // Initialize the delay sliders with current values and targets + duckVolumeMenuItem.view = duckVolumeView; + [prefsMenu addItem:duckVolumeMenuItem]; + + // Initialize the sliders with current values and targets [self initDelaySliders]; } @@ -216,10 +254,19 @@ - (void) initDelaySliders { maxUnpauseDelaySlider.integerValue = [self msToSliderValue:userDefaults.maxUnpauseDelayMS]; maxUnpauseDelaySlider.target = self; maxUnpauseDelaySlider.action = @selector(maxUnpauseDelaySliderChanged:); + + // Configure ducking volume slider (0% to 100%) + duckVolumeSlider.minValue = 0; + duckVolumeSlider.maxValue = 100; + duckVolumeSlider.integerValue = userDefaults.autoDuckPercent; + duckVolumeSlider.target = self; + duckVolumeSlider.action = @selector(duckVolumeSliderChanged:); // Update labels with current values [self updatePauseDelayLabel]; [self updateMaxUnpauseDelayLabel]; + [self updateDuckVolumeLabel]; + [self updateSliderStates]; } - (void) pauseDelaySliderChanged:(NSSlider*)sender { @@ -285,6 +332,45 @@ - (NSInteger) msToSliderValue:(NSUInteger)ms { return (NSInteger)MIN(MAX(sliderValue, 0), 100); } +- (void) dealloc { + try { + [userDefaults removeObserver:self forKeyPath:@"autoDuckMusic" context:nil]; + } catch (const std::exception& e) { + } +} + +- (void) observeValueForKeyPath:(NSString* __nullable)keyPath + ofObject:(id __nullable)object + change:(NSDictionary* __nullable)change + context:(void* __nullable)context +{ + #pragma unused (object, change, context) + if ([keyPath isEqualToString:@"autoDuckMusic"]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self updateSliderStates]; + }); + } +} + +- (void) duckVolumeSliderChanged:(NSSlider*)sender { + userDefaults.autoDuckPercent = (NSUInteger)sender.integerValue; + [self updateDuckVolumeLabel]; +} + +- (void) updateDuckVolumeLabel { + duckVolumeLabel.stringValue = [NSString stringWithFormat:@"%lu%%", (unsigned long)userDefaults.autoDuckPercent]; +} + +- (void) updateSliderStates { + BOOL duckEnabled = userDefaults.autoDuckMusic; + duckVolumeSlider.enabled = duckEnabled; + if (duckEnabled) { + duckVolumeLabel.textColor = [NSColor controlTextColor]; + } else { + duckVolumeLabel.textColor = [NSColor disabledControlTextColor]; + } +} + @end NS_ASSUME_NONNULL_END diff --git a/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm b/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm index a2557422..d7ba76e3 100644 --- a/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm +++ b/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm @@ -108,6 +108,14 @@ - (void) setAutoDuckMusic:(BOOL)autoDuckMusic { #pragma unused (autoDuckMusic) } +- (NSUInteger) autoDuckPercent { + return 30; +} + +- (void) setAutoDuckPercent:(NSUInteger)autoDuckPercent { + #pragma unused (autoDuckPercent) +} + @end // ------------------------------------------------------------------------------------------------- From 11ea603cd6160893bf52a0af6625c1c3803a94e6 Mon Sep 17 00:00:00 2001 From: Loacky Date: Fri, 26 Jun 2026 16:13:52 +0200 Subject: [PATCH 3/4] fix(auto-pause): correct fallback default volume to 50 instead of 100 (#541) Setting the fallback volume to 50 (the neutral midpoint) solves two issues: 1. Prevents ducking from failing (being a no-op) when set to exactly 50%. 2. Prevents the restored volume from jumping to 100% (boosted) upon unducking. --- BGMApp/BGMApp/BGMAutoPauseMusic.mm | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/BGMApp/BGMApp/BGMAutoPauseMusic.mm b/BGMApp/BGMApp/BGMAutoPauseMusic.mm index 0bdef1eb..dbb94f0b 100644 --- a/BGMApp/BGMApp/BGMAutoPauseMusic.mm +++ b/BGMApp/BGMApp/BGMAutoPauseMusic.mm @@ -80,8 +80,8 @@ - (id) initWithAudioDevices:(BGMAudioDeviceManager*)inAudioDevices musicPlayers: enabled = NO; wePaused = NO; weDucked = NO; - originalVolume = kAppRelativeVolumeMaxRawValue; - duckedVolume = kAppRelativeVolumeMaxRawValue; + originalVolume = 50; + duckedVolume = 50; dispatch_queue_attr_t attr; @@ -441,7 +441,7 @@ - (int) getMusicPlayerVolume { NSLog(@"BGMAutoPauseMusic::getMusicPlayerVolume error: %s", e.what()); } - return kAppRelativeVolumeMaxRawValue; + return 50; } - (void) setMusicPlayerVolume:(int)volume { From 50798f001d63d503b9f4b30cb487e7de3ae1bbf8 Mon Sep 17 00:00:00 2001 From: Loacky Date: Wed, 8 Jul 2026 11:49:18 +0200 Subject: [PATCH 4/4] feat(review): Implements @kyleneideck review comments --- BGMApp/BGMApp/BGMAutoPauseMusic.mm | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/BGMApp/BGMApp/BGMAutoPauseMusic.mm b/BGMApp/BGMApp/BGMAutoPauseMusic.mm index dbb94f0b..88245e80 100644 --- a/BGMApp/BGMApp/BGMAutoPauseMusic.mm +++ b/BGMApp/BGMApp/BGMAutoPauseMusic.mm @@ -26,6 +26,7 @@ // Local Includes #import "BGM_Types.h" #import "BGMMusicPlayer.h" +#import "BGM_Utils.h" #import "CACFArray.h" #import "CACFDictionary.h" #import "CACFString.h" @@ -257,8 +258,7 @@ - (void) queueUnpauseBlock { return; } - // Unpause sooner if we've only been paused/ducked for a short time. This is so a notification sound causing an auto-pause/duck is - // less of an interruption. + // Unpause sooner if we've only been paused/ducked for a short time. This is so a notification sound causing an auto-pause/duck is less of an interruption. See issue #311 for the longer fade-out/fade-in transition idea. UInt64 unpauseDelayNsec = static_cast(static_cast(wentSilent - wentAudible) * kUnpauseDelayWeightingFactor); @@ -294,6 +294,7 @@ - (void) queueUnpauseBlock { (currentState == kBGMDeviceIsAudible ? "Audible" : "SilentExceptMusic"), wentSilent); + // Unpause or unduck if we were the one who paused/ducked. Also check that the device is still silent (or silent-except-music if we ducked), which means the audible state hasn't changed since this block was queued. if (isLatestUnpause && silentEnough) { if (wePaused) { DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Unpausing music player"); @@ -409,6 +410,7 @@ - (int) getMusicPlayerVolume { id player = musicPlayers.selectedMusicPlayer; NSString* playerBundleID = player.bundleID; pid_t playerPid = player.pid ? [player.pid intValue] : -1; + __block int volume = 50; if (playerPid == -1 && playerBundleID != nil) { NSArray* apps = [NSRunningApplication runningApplicationsWithBundleIdentifier:playerBundleID]; @@ -417,7 +419,7 @@ - (int) getMusicPlayerVolume { } } - try { + BGMLogAndSwallowExceptions("BGMAutoPauseMusic::getMusicPlayerVolume", ([&] { CACFArray volumes([audioDevices bgmDevice].GetAppVolumes(), false); for (UInt32 i = 0; i < volumes.GetNumberItems(); i++) { CACFDictionary appVolume(false); @@ -432,16 +434,13 @@ - (int) getMusicPlayerVolume { if ((playerPid != -1 && playerPid == pid) || (playerBundleID != nil && [playerBundleID isEqualToString:(__bridge NSString*)bundleID.GetCFString()])) { - int volume = -1; appVolume.GetSInt32(CFSTR(kBGMAppVolumesKey_RelativeVolume), volume); - return volume; + break; } } - } catch (const std::exception& e) { - NSLog(@"BGMAutoPauseMusic::getMusicPlayerVolume error: %s", e.what()); - } + })); - return 50; + return volume; } - (void) setMusicPlayerVolume:(int)volume { @@ -456,11 +455,9 @@ - (void) setMusicPlayerVolume:(int)volume { } } - try { + BGMLogAndSwallowExceptions("BGMAutoPauseMusic::setMusicPlayerVolume", ([&] { [audioDevices bgmDevice].SetAppVolume(volume, playerPid, (__bridge CFStringRef)playerBundleID); - } catch (const std::exception& e) { - NSLog(@"BGMAutoPauseMusic::setMusicPlayerVolume error: %s", e.what()); - } + })); } @end