diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f92066c --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Created by https://www.toptal.com/developers/gitignore/api/macos,xcode +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,xcode + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Xcode ### +## User settings +xcuserdata/ + +## Xcode 8 and earlier +*.xcscmblueprint +*.xccheckout + +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno +**/xcshareddata/WorkspaceSettings.xcsettings + +# End of https://www.toptal.com/developers/gitignore/api/macos,xcode diff --git a/Quality.xcodeproj/project.pbxproj b/Quality.xcodeproj/project.pbxproj index 456f166..cdb18ad 100644 --- a/Quality.xcodeproj/project.pbxproj +++ b/Quality.xcodeproj/project.pbxproj @@ -9,6 +9,10 @@ /* Begin PBXBuildFile section */ 1221F3F9280F10A3003E8B77 /* SimplyCoreAudio in Frameworks */ = {isa = PBXBuildFile; productRef = 1221F3F8280F10A3003E8B77 /* SimplyCoreAudio */; }; 1221F3FB280F1EEF003E8B77 /* OutputDevices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1221F3FA280F1EEF003E8B77 /* OutputDevices.swift */; }; + 1234F508281E8372007EC9F5 /* PrivateMediaRemote in Frameworks */ = {isa = PBXBuildFile; productRef = 1234F507281E8372007EC9F5 /* PrivateMediaRemote */; }; + 1234F50A281E83D1007EC9F5 /* MediaRemote.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1234F509281E83D1007EC9F5 /* MediaRemote.framework */; }; + 1234F50E281E8F07007EC9F5 /* MediaTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234F50D281E8F07007EC9F5 /* MediaTrack.swift */; }; + 1234F510281E9520007EC9F5 /* MediaRemoteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234F50F281E9520007EC9F5 /* MediaRemoteController.swift */; }; 1254A79C2813FB9400241107 /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1254A79B2813FB9400241107 /* Defaults.swift */; }; 1272AA98280DBB4900FD72BA /* QualityApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1272AA97280DBB4900FD72BA /* QualityApp.swift */; }; 1272AA9A280DBB4900FD72BA /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1272AA99280DBB4900FD72BA /* ContentView.swift */; }; @@ -17,12 +21,20 @@ 1272AAAC280DC5E900FD72BA /* Console.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1272AAAB280DC5E900FD72BA /* Console.swift */; }; 1272AAAE280DC68A00FD72BA /* CMPlayerStuff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1272AAAD280DC68A00FD72BA /* CMPlayerStuff.swift */; }; 1272AAB1280DC71B00FD72BA /* Sweep in Frameworks */ = {isa = PBXBuildFile; productRef = 1272AAB0280DC71B00FD72BA /* Sweep */; }; + 127C972D281FCF000087313B /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 127C972C281FCF000087313B /* AppVersion.swift */; }; 1293436B28131591002E19A8 /* CurrentUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1293436A28131591002E19A8 /* CurrentUser.swift */; }; 12AFF5C12811AD40001CC6ED /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12AFF5C02811AD40001CC6ED /* AppDelegate.swift */; }; + 12F1AA572868639A006C1AD8 /* DeviceMenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F1AA562868639A006C1AD8 /* DeviceMenuItem.swift */; }; + BF0C90C82B20BFD6002F99C9 /* LosslessSwitcher.sdef in Resources */ = {isa = PBXBuildFile; fileRef = BF0C90C72B20BFD6002F99C9 /* LosslessSwitcher.sdef */; }; + BF0C90CA2B20C163002F99C9 /* ScriptableApplicationCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C90C92B20C163002F99C9 /* ScriptableApplicationCommand.swift */; }; + BF7E0D09296336DA009FFEEC /* AudioStreamBasicDescription+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7E0D08296336DA009FFEEC /* AudioStreamBasicDescription+Equatable.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 1221F3FA280F1EEF003E8B77 /* OutputDevices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputDevices.swift; sourceTree = ""; }; + 1234F509281E83D1007EC9F5 /* MediaRemote.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemote.framework; path = ../../../../../System/Library/PrivateFrameworks/MediaRemote.framework; sourceTree = ""; }; + 1234F50D281E8F07007EC9F5 /* MediaTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTrack.swift; sourceTree = ""; }; + 1234F50F281E9520007EC9F5 /* MediaRemoteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaRemoteController.swift; sourceTree = ""; }; 1254A79B2813FB9400241107 /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = ""; }; 1254A79D2814024300241107 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 1272AA94280DBB4900FD72BA /* LosslessSwitcher.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LosslessSwitcher.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -33,8 +45,13 @@ 1272AAA0280DBB4B00FD72BA /* Quality.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Quality.entitlements; sourceTree = ""; }; 1272AAAB280DC5E900FD72BA /* Console.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Console.swift; sourceTree = ""; }; 1272AAAD280DC68A00FD72BA /* CMPlayerStuff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMPlayerStuff.swift; sourceTree = ""; }; + 127C972C281FCF000087313B /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; 1293436A28131591002E19A8 /* CurrentUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentUser.swift; sourceTree = ""; }; 12AFF5C02811AD40001CC6ED /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 12F1AA562868639A006C1AD8 /* DeviceMenuItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceMenuItem.swift; sourceTree = ""; }; + BF0C90C72B20BFD6002F99C9 /* LosslessSwitcher.sdef */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = LosslessSwitcher.sdef; sourceTree = ""; }; + BF0C90C92B20C163002F99C9 /* ScriptableApplicationCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptableApplicationCommand.swift; sourceTree = ""; }; + BF7E0D08296336DA009FFEEC /* AudioStreamBasicDescription+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AudioStreamBasicDescription+Equatable.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -44,17 +61,28 @@ files = ( 1221F3F9280F10A3003E8B77 /* SimplyCoreAudio in Frameworks */, 1272AAB1280DC71B00FD72BA /* Sweep in Frameworks */, + 1234F50A281E83D1007EC9F5 /* MediaRemote.framework in Frameworks */, + 1234F508281E8372007EC9F5 /* PrivateMediaRemote in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1234F4FD281E80A2007EC9F5 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1234F509281E83D1007EC9F5 /* MediaRemote.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 1272AA8B280DBB4900FD72BA = { isa = PBXGroup; children = ( 1272AA96280DBB4900FD72BA /* Quality */, 1272AA95280DBB4900FD72BA /* Products */, + 1234F4FD281E80A2007EC9F5 /* Frameworks */, ); sourceTree = ""; }; @@ -81,6 +109,13 @@ 1221F3FA280F1EEF003E8B77 /* OutputDevices.swift */, 1293436A28131591002E19A8 /* CurrentUser.swift */, 1254A79B2813FB9400241107 /* Defaults.swift */, + 1234F50D281E8F07007EC9F5 /* MediaTrack.swift */, + 1234F50F281E9520007EC9F5 /* MediaRemoteController.swift */, + 127C972C281FCF000087313B /* AppVersion.swift */, + 12F1AA562868639A006C1AD8 /* DeviceMenuItem.swift */, + BF7E0D08296336DA009FFEEC /* AudioStreamBasicDescription+Equatable.swift */, + BF0C90C92B20C163002F99C9 /* ScriptableApplicationCommand.swift */, + BF0C90C72B20BFD6002F99C9 /* LosslessSwitcher.sdef */, ); path = Quality; sourceTree = ""; @@ -112,6 +147,7 @@ packageProductDependencies = ( 1272AAB0280DC71B00FD72BA /* Sweep */, 1221F3F8280F10A3003E8B77 /* SimplyCoreAudio */, + 1234F507281E8372007EC9F5 /* PrivateMediaRemote */, ); productName = Quality; productReference = 1272AA94280DBB4900FD72BA /* LosslessSwitcher.app */; @@ -144,6 +180,7 @@ packageReferences = ( 1272AAAF280DC71B00FD72BA /* XCRemoteSwiftPackageReference "Sweep" */, 1221F3F7280F10A3003E8B77 /* XCRemoteSwiftPackageReference "SimplyCoreAudio" */, + 1234F504281E8372007EC9F5 /* XCRemoteSwiftPackageReference "MediaRemote" */, ); productRefGroup = 1272AA95280DBB4900FD72BA /* Products */; projectDirPath = ""; @@ -160,6 +197,7 @@ buildActionMask = 2147483647; files = ( 1272AA9F280DBB4B00FD72BA /* Preview Assets.xcassets in Resources */, + BF0C90C82B20BFD6002F99C9 /* LosslessSwitcher.sdef in Resources */, 1272AA9C280DBB4B00FD72BA /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -172,13 +210,19 @@ buildActionMask = 2147483647; files = ( 12AFF5C12811AD40001CC6ED /* AppDelegate.swift in Sources */, + BF0C90CA2B20C163002F99C9 /* ScriptableApplicationCommand.swift in Sources */, 1254A79C2813FB9400241107 /* Defaults.swift in Sources */, + BF7E0D09296336DA009FFEEC /* AudioStreamBasicDescription+Equatable.swift in Sources */, + 1234F50E281E8F07007EC9F5 /* MediaTrack.swift in Sources */, + 12F1AA572868639A006C1AD8 /* DeviceMenuItem.swift in Sources */, 1221F3FB280F1EEF003E8B77 /* OutputDevices.swift in Sources */, 1272AAAE280DC68A00FD72BA /* CMPlayerStuff.swift in Sources */, 1272AAAC280DC5E900FD72BA /* Console.swift in Sources */, + 1234F510281E9520007EC9F5 /* MediaRemoteController.swift in Sources */, 1272AA9A280DBB4900FD72BA /* ContentView.swift in Sources */, 1293436B28131591002E19A8 /* CurrentUser.swift in Sources */, 1272AA98280DBB4900FD72BA /* QualityApp.swift in Sources */, + 127C972D281FCF000087313B /* AppVersion.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -306,24 +350,29 @@ CODE_SIGN_ENTITLEMENTS = Quality/Quality.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_ASSET_PATHS = "\"Quality/Preview Content\""; DEVELOPMENT_TEAM = 3X69W4AQD6; - ENABLE_HARDENED_RUNTIME = NO; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Quality/Info.plist; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "This permission is required for local file sample rate detection."; INFOPLIST_KEY_NSHumanReadableCopyright = "2022 Vincent Neo"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 11.4; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = "com.vincent-neo.LosslessSwitcher"; PRODUCT_NAME = LosslessSwitcher; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; + SYSTEM_FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SYSTEM_LIBRARY_DIR)/PrivateFrameworks", + ); }; name = Debug; }; @@ -335,24 +384,29 @@ CODE_SIGN_ENTITLEMENTS = Quality/Quality.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_ASSET_PATHS = "\"Quality/Preview Content\""; DEVELOPMENT_TEAM = 3X69W4AQD6; - ENABLE_HARDENED_RUNTIME = NO; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Quality/Info.plist; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "This permission is required for local file sample rate detection."; INFOPLIST_KEY_NSHumanReadableCopyright = "2022 Vincent Neo"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 11.4; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = "com.vincent-neo.LosslessSwitcher"; PRODUCT_NAME = LosslessSwitcher; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; + SYSTEM_FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SYSTEM_LIBRARY_DIR)/PrivateFrameworks", + ); }; name = Release; }; @@ -388,6 +442,14 @@ kind = branch; }; }; + 1234F504281E8372007EC9F5 /* XCRemoteSwiftPackageReference "MediaRemote" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/PrivateFrameworks/MediaRemote"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.1.0; + }; + }; 1272AAAF280DC71B00FD72BA /* XCRemoteSwiftPackageReference "Sweep" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/JohnSundell/Sweep.git"; @@ -404,6 +466,11 @@ package = 1221F3F7280F10A3003E8B77 /* XCRemoteSwiftPackageReference "SimplyCoreAudio" */; productName = SimplyCoreAudio; }; + 1234F507281E8372007EC9F5 /* PrivateMediaRemote */ = { + isa = XCSwiftPackageProductDependency; + package = 1234F504281E8372007EC9F5 /* XCRemoteSwiftPackageReference "MediaRemote" */; + productName = PrivateMediaRemote; + }; 1272AAB0280DC71B00FD72BA /* Sweep */ = { isa = XCSwiftPackageProductDependency; package = 1272AAAF280DC71B00FD72BA /* XCRemoteSwiftPackageReference "Sweep" */; diff --git a/Quality.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Quality.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..27fe146 --- /dev/null +++ b/Quality.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,50 @@ +{ + "pins" : [ + { + "identity" : "mediaremote", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PrivateFrameworks/MediaRemote", + "state" : { + "revision" : "ee5ae0cc17c0f759049d50e6ea95e1cf718dfda5", + "version" : "0.1.0" + } + }, + { + "identity" : "protocolbuffer", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PrivateFrameworks/ProtocolBuffer", + "state" : { + "revision" : "20b692d09393389120bea65098ddf66129352bb7", + "version" : "0.1.0" + } + }, + { + "identity" : "simplycoreaudio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rnine/SimplyCoreAudio.git", + "state" : { + "branch" : "develop", + "revision" : "343d463cffef1f30458d02ce2dc441138e9e0134" + } + }, + { + "identity" : "sweep", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JohnSundell/Sweep.git", + "state" : { + "revision" : "801c2878e4c6c5baf32fe132e1f3f3af6f9fd1b0", + "version" : "0.4.0" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "3e95ba32cd1b4c877f6163e8eea54afc4e63bf9f", + "version" : "0.0.3" + } + } + ], + "version" : 2 +} diff --git a/Quality.xcodeproj/xcshareddata/xcschemes/LosslessSwitcher.xcscheme b/Quality.xcodeproj/xcshareddata/xcschemes/LosslessSwitcher.xcscheme new file mode 100644 index 0000000..cee0e06 --- /dev/null +++ b/Quality.xcodeproj/xcshareddata/xcschemes/LosslessSwitcher.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Quality.xcodeproj/xcuserdata/vincentneo.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Quality.xcodeproj/xcuserdata/vincentneo.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..1eac07c --- /dev/null +++ b/Quality.xcodeproj/xcuserdata/vincentneo.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/Quality/AppDelegate.swift b/Quality/AppDelegate.swift index cd69f5d..7abf513 100644 --- a/Quality/AppDelegate.swift +++ b/Quality/AppDelegate.swift @@ -6,17 +6,25 @@ // import Cocoa +import Combine import SwiftUI +import SimplyCoreAudio +import PrivateMediaRemote class AppDelegate: NSObject, NSApplicationDelegate { // https://stackoverflow.com/a/66160164 static private(set) var instance: AppDelegate! = nil - private var outputDevices: OutputDevices! + var outputDevices: OutputDevices! private let defaults = Defaults.shared + private var mrController: MediaRemoteController! + private var devicesMenu: NSMenu! var statusItem: NSStatusItem? + var cancellable: AnyCancellable? + var currentScriptSelectionMenuItem: NSMenuItem? + private var _statusItemTitle = "Loading..." var statusItemTitle: String { get { @@ -51,10 +59,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { AppDelegate.instance = self outputDevices = OutputDevices() + mrController = MediaRemoteController(outputDevices: outputDevices) checkPermissions() let menu = NSMenu() + + menu.delegate = self let sampleRateView = ContentView().environmentObject(outputDevices) let view = NSHostingView(rootView: sampleRateView) @@ -68,6 +79,38 @@ class AppDelegate: NSObject, NSApplicationDelegate { let showSampleRateItem = NSMenuItem(title: defaults.statusBarItemTitle, action: #selector(toggleSampleRate(item:)), keyEquivalent: "") menu.addItem(showSampleRateItem) + let enableBitDepthItem = NSMenuItem(title: "Bit Depth Switching", action: #selector(toggleBitDepthDetection(item:)), keyEquivalent: "") + menu.addItem(enableBitDepthItem) + enableBitDepthItem.state = defaults.userPreferBitDepthDetection ? .on : .off + + let selectedDeviceItem = NSMenuItem(title: "Selected Device", action: nil, keyEquivalent: "") + self.devicesMenu = NSMenu() + selectedDeviceItem.submenu = self.devicesMenu + menu.addItem(selectedDeviceItem) + self.handleDevicesMenu() + + menu.addItem(NSMenuItem.separator()) + + let aboutItem = NSMenuItem(title: "About", action: nil, keyEquivalent: "") + let versionItem = NSMenuItem(title: "Version - \(currentVersion)", action: nil, keyEquivalent: "") + let buildItem = NSMenuItem(title: "Build - \(currentBuild)", action: nil, keyEquivalent: "") + + aboutItem.submenu = NSMenu() + aboutItem.submenu?.addItem(versionItem) + aboutItem.submenu?.addItem(buildItem) + menu.addItem(aboutItem) + + let scriptMenu = NSMenuItem(title: "Scripting", action: nil, keyEquivalent: "") + let selectScript = NSMenuItem(title: "Select Script...", action: #selector(selectScript(_:)), keyEquivalent: "") + let resetScript = NSMenuItem(title: "Clear selection", action: #selector(resetScript(_:)), keyEquivalent: "") + let currentScriptSelectionMenuItem = NSMenuItem(title: "No selection", action: nil, keyEquivalent: "") + self.currentScriptSelectionMenuItem = currentScriptSelectionMenuItem + scriptMenu.submenu = NSMenu() + scriptMenu.submenu?.addItem(selectScript) + scriptMenu.submenu?.addItem(resetScript) + scriptMenu.submenu?.addItem(currentScriptSelectionMenuItem) + menu.addItem(scriptMenu) + let quitItem = NSMenuItem(title: "Quit", action: #selector(NSApp.terminate(_:)), keyEquivalent: "") menu.addItem(quitItem) @@ -75,8 +118,54 @@ class AppDelegate: NSObject, NSApplicationDelegate { self.statusItem?.menu = menu self.statusItem?.button?.title = "Loading..." self.statusItemDisplay() + + cancellable = NotificationCenter.default.publisher(for: .deviceListChanged).sink(receiveValue: { _ in + self.handleDevicesMenu() + }) + } + func handleDevicesMenu() { + self.devicesMenu.removeAllItems() + let autoItem = DeviceMenuItem(title: "Default Device", action: #selector(deviceSelection(_:)), keyEquivalent: "", device: nil) + self.devicesMenu.addItem(autoItem) + autoItem.tag = -1 + let selectedUID = Defaults.shared.selectedDeviceUID + if selectedUID == nil || (selectedUID != nil && !self.doesDeviceUID(selectedUID, existsIn: outputDevices.outputDevices)) { + autoItem.state = .on + } + outputDevices.selectedOutputDevice = nil + + var idx = 0 + for device in outputDevices.outputDevices { + + let uid = device.uid + let name = device.name + let item = DeviceMenuItem(title: name, action: #selector(deviceSelection(_:)), keyEquivalent: "", device: device) + item.tag = idx + if let uid, uid == Defaults.shared.selectedDeviceUID { + item.state = .on + outputDevices.selectedOutputDevice = device + } + else { + item.state = .off + } + idx += 1 + self.devicesMenu.addItem(item) + } + } + + private func doesDeviceUID(_ uid: String?, existsIn outputDevices: [AudioDevice]) -> Bool { + return !outputDevices.filter({$0.uid == uid}).isEmpty + } + + @objc func deviceSelection(_ sender: DeviceMenuItem) { + self.devicesMenu.items.forEach({$0.state = .off}) + sender.state = .on + outputDevices.selectedOutputDevice = sender.device + Defaults.shared.selectedDeviceUID = sender.device?.uid + } + func statusItemDisplay() { if defaults.userPreferIconStatusBarItem { self.statusItem?.button?.image = NSImage(systemSymbolName: "music.note", accessibilityDescription: "") @@ -94,4 +183,33 @@ class AppDelegate: NSObject, NSApplicationDelegate { item.title = defaults.statusBarItemTitle } + @objc func toggleBitDepthDetection(item: NSMenuItem) { + Task { + await defaults.setPreferBitDepthDetection(newValue: !defaults.userPreferBitDepthDetection) + item.state = defaults.userPreferBitDepthDetection ? .on : .off + } + } + + @objc func selectScript(_ item: NSMenuItem) { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.message = "Select a script that should be invoked when sample rate changes." + + panel.begin { response in + Defaults.shared.shellScriptPath = panel.url?.path + } + } + + @objc func resetScript(_ item: NSMenuItem) { + Defaults.shared.shellScriptPath = nil + } + +} + +extension AppDelegate: NSMenuDelegate { + func menuWillOpen(_ menu: NSMenu) { + currentScriptSelectionMenuItem?.title = Defaults.shared.shellScriptPath ?? "No selection" + } } diff --git a/Quality/AppVersion.swift b/Quality/AppVersion.swift new file mode 100644 index 0000000..67b66b7 --- /dev/null +++ b/Quality/AppVersion.swift @@ -0,0 +1,11 @@ +// +// AppVersion.swift +// LosslessSwitcher +// +// Created by Vincent Neo on 2/5/22. +// + +import Foundation + +let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as! String +let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String diff --git a/Quality/AudioStreamBasicDescription+Equatable.swift b/Quality/AudioStreamBasicDescription+Equatable.swift new file mode 100644 index 0000000..0bfc44e --- /dev/null +++ b/Quality/AudioStreamBasicDescription+Equatable.swift @@ -0,0 +1,14 @@ +// +// AudioStreamBasicDescription+Equatable.swift +// LosslessSwitcher +// +// Created by Vincent Neo on 2/1/23. +// + +import CoreAudioTypes + +extension AudioStreamBasicDescription: Equatable { + public static func == (lhs: AudioStreamBasicDescription, rhs: AudioStreamBasicDescription) -> Bool { + return lhs.mSampleRate == rhs.mSampleRate && lhs.mBitsPerChannel == rhs.mBitsPerChannel + } +} diff --git a/Quality/CMPlayerStuff.swift b/Quality/CMPlayerStuff.swift index cbfd935..c34a829 100644 --- a/Quality/CMPlayerStuff.swift +++ b/Quality/CMPlayerStuff.swift @@ -13,6 +13,7 @@ struct CMPlayerStats { let sampleRate: Double // Hz let bitDepth: Int let date: Date + let priority: Int } class CMPlayerParser { @@ -25,6 +26,11 @@ class CMPlayerParser { var stats = [CMPlayerStats]() for entry in entries { + // ignore useless log messages for faster switching + if !entry.message.contains("audioCapabilities:") { + continue + } + let date = entry.date let rawMessage = entry.message @@ -48,7 +54,7 @@ class CMPlayerParser { if let sr = sampleRate, let bd = bitDepth { - let stat = CMPlayerStats(sampleRate: sr * 1000, bitDepth: bd, date: date) + let stat = CMPlayerStats(sampleRate: sr * 1000, bitDepth: bd, date: date, priority: 1) stats.append(stat) sampleRate = nil bitDepth = nil @@ -61,4 +67,86 @@ class CMPlayerParser { } return stats } + + static func parseCoreAudioConsoleLogs(_ entries: [SimpleConsole]) -> [CMPlayerStats] { + let kTimeDifferenceAcceptance = 5.0 // seconds + var lastDate: Date? + var sampleRate: Double? + var bitDepth: Int? + + var stats = [CMPlayerStats]() + + for entry in entries { + let date = entry.date + let rawMessage = entry.message + + if let lastDate = lastDate, date.timeIntervalSince(lastDate) > kTimeDifferenceAcceptance { + sampleRate = nil + bitDepth = nil + } + + if rawMessage.contains("ACAppleLosslessDecoder") && rawMessage.contains("Input format: ") { + if let subSampleRate = rawMessage.firstSubstring(between: "ch, ", and: " Hz") { + let strSampleRate = String(subSampleRate) + sampleRate = Double(strSampleRate) + } + + if let subBitDepth = rawMessage.firstSubstring(between: "from ", and: "-bit source") { + let strBitDepth = String(subBitDepth) + bitDepth = Int(strBitDepth) + } + } + + if let sr = sampleRate, + let bd = bitDepth { + let stat = CMPlayerStats(sampleRate: sr, bitDepth: bd, date: date, priority: 5) + stats.append(stat) + sampleRate = nil + bitDepth = nil + print("detected stat \(stat)") + break + } + + lastDate = date + + } + return stats + } + + static func parseCoreMediaConsoleLogs(_ entries: [SimpleConsole]) -> [CMPlayerStats] { + let kTimeDifferenceAcceptance = 5.0 // seconds + var lastDate: Date? + var sampleRate: Double? + let bitDepth = 24 // Core Media don't provide bit depth, but I am keeping this for now, since it seems to be the first to deliver accurate bitrate data, fairly consistently. + + var stats = [CMPlayerStats]() + + for entry in entries { + let date = entry.date + let rawMessage = entry.message + + if let lastDate = lastDate, date.timeIntervalSince(lastDate) > kTimeDifferenceAcceptance { + sampleRate = nil + } + + if rawMessage.contains("Creating AudioQueue") { + if let subSampleRate = rawMessage.firstSubstring(between: "sampleRate:", and: .end) { + let strSampleRate = String(subSampleRate) + sampleRate = Double(strSampleRate) + } + } + + if let sr = sampleRate { + let stat = CMPlayerStats(sampleRate: sr, bitDepth: bitDepth, date: date, priority: 2) + stats.append(stat) + sampleRate = nil + print("detected stat \(stat)") + break + } + + lastDate = date + + } + return stats + } } diff --git a/Quality/Console.swift b/Quality/Console.swift index 6bd0c7a..f87bdd0 100644 --- a/Quality/Console.swift +++ b/Quality/Console.swift @@ -14,15 +14,26 @@ struct SimpleConsole { let message: String } +enum EntryType: String { + case music = "com.apple.Music" + case coreAudio = "com.apple.coreaudio" + case coreMedia = "com.apple.coremedia" + + var predicate: NSPredicate { + NSPredicate(format: "(subsystem = %@) AND (process = %@)", argumentArray: [rawValue, "Music"]) + } +} + class Console { - static func getRecentEntries() throws -> [SimpleConsole] { + static func getRecentEntries(type: EntryType) throws -> [SimpleConsole] { var messages = [SimpleConsole]() let store = try OSLogStore.local() - let duration = store.position(timeIntervalSinceEnd: -5.0) - let entries = try store.getEntries(with: [], at: duration, matching: NSPredicate(format: "subsystem = %@", "com.apple.Music")) + let duration = store.position(timeIntervalSinceEnd: -3.0) + let entries = try store.getEntries(with: [], at: duration, matching: type.predicate) // for some reason AnySequence to Array turns it into a empty array? for entry in entries { let consoleMessage = SimpleConsole(date: entry.date, message: entry.composedMessage) + //print((date: entry.date, message: entry.composedMessage)) messages.append(consoleMessage) } diff --git a/Quality/ContentView.swift b/Quality/ContentView.swift index 1357e17..cc047b5 100644 --- a/Quality/ContentView.swift +++ b/Quality/ContentView.swift @@ -19,10 +19,11 @@ struct ContentView: View { Text(formattedSampleRate) .font(.system(size: 23, weight: .semibold, design: .default)) } - if let device = outputDevices.defaultOutputDevice { + if let device = outputDevices.selectedOutputDevice ?? outputDevices.defaultOutputDevice { Text(device.name) - .font(.system(size: 16, weight: .regular, design: .default)) + .font(.system(size: 14.5, weight: .regular, design: .default)) .foregroundColor(.secondary) + .multilineTextAlignment(.center) } } } diff --git a/Quality/Defaults.swift b/Quality/Defaults.swift index d7a8817..a31ad76 100644 --- a/Quality/Defaults.swift +++ b/Quality/Defaults.swift @@ -7,12 +7,20 @@ import Foundation -class Defaults { +class Defaults: ObservableObject { static let shared = Defaults() private let kUserPreferIconStatusBarItem = "com.vincent-neo.LosslessSwitcher-Key-UserPreferIconStatusBarItem" + private let kSelectedDeviceUID = "com.vincent-neo.LosslessSwitcher-Key-SelectedDeviceUID" + private let kUserPreferBitDepthDetection = "com.vincent-neo.LosslessSwitcher-Key-BitDepthDetection" + private let kShellScriptPath = "KeyShellScriptPath" private init() { - UserDefaults.standard.register(defaults: [kUserPreferIconStatusBarItem : true]) + UserDefaults.standard.register(defaults: [ + kUserPreferIconStatusBarItem : true, + kUserPreferBitDepthDetection : false + ]) + + self.userPreferBitDepthDetection = UserDefaults.standard.bool(forKey: kUserPreferBitDepthDetection) } var userPreferIconStatusBarItem: Bool { @@ -24,6 +32,32 @@ class Defaults { } } + var selectedDeviceUID: String? { + get { + return UserDefaults.standard.string(forKey: kSelectedDeviceUID) + } + set { + UserDefaults.standard.set(newValue, forKey: kSelectedDeviceUID) + } + } + + var shellScriptPath: String? { + get { + return UserDefaults.standard.string(forKey: kShellScriptPath) + } + set { + UserDefaults.standard.setValue(newValue, forKey: kShellScriptPath) + } + } + + @Published var userPreferBitDepthDetection: Bool + + + @MainActor func setPreferBitDepthDetection(newValue: Bool) { + UserDefaults.standard.set(newValue, forKey: kUserPreferBitDepthDetection) + self.userPreferBitDepthDetection = newValue + } + var statusBarItemTitle: String { let title = self.userPreferIconStatusBarItem ? "Show Sample Rate" : "Show Icon" return title diff --git a/Quality/DeviceMenuItem.swift b/Quality/DeviceMenuItem.swift new file mode 100644 index 0000000..5c5ef3f --- /dev/null +++ b/Quality/DeviceMenuItem.swift @@ -0,0 +1,22 @@ +// +// DeviceMenuItem.swift +// LosslessSwitcher +// +// Created by Vincent Neo on 26/6/22. +// + +import Cocoa +import SimplyCoreAudio + +class DeviceMenuItem: NSMenuItem { + var device: AudioDevice? + + init(title string: String, action selector: Selector?, keyEquivalent charCode: String, device: AudioDevice? = nil) { + self.device = device + super.init(title: string, action: selector, keyEquivalent: charCode) + } + + required init(coder: NSCoder) { + super.init(coder: coder) + } +} diff --git a/Quality/Info.plist b/Quality/Info.plist index 6f7ae58..12822ad 100644 --- a/Quality/Info.plist +++ b/Quality/Info.plist @@ -4,5 +4,11 @@ LSUIElement + NSAppleEventsUsageDescription + This permission is required for local file sample rate detection. + NSAppleScriptEnabled + + OSAScriptingDefinition + LosslessSwitcher.sdef diff --git a/Quality/LosslessSwitcher.sdef b/Quality/LosslessSwitcher.sdef new file mode 100644 index 0000000..766ce16 --- /dev/null +++ b/Quality/LosslessSwitcher.sdef @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Quality/MediaRemoteController.swift b/Quality/MediaRemoteController.swift new file mode 100644 index 0000000..2137fd9 --- /dev/null +++ b/Quality/MediaRemoteController.swift @@ -0,0 +1,64 @@ +// +// MediaRemoteController.swift +// LosslessSwitcher +// +// Created by Vincent Neo on 1/5/22. +// + +import Cocoa +import Combine +import PrivateMediaRemote + +fileprivate let kMusicAppBundle = "com.apple.Music" + +class MediaRemoteController { + + private var infoChangedCancellable: AnyCancellable? + private var queueChangedCancellable: AnyCancellable? + + //private var previousTrack: MediaTrack? + + init(outputDevices: OutputDevices) { + infoChangedCancellable = NotificationCenter.default.publisher(for: NSNotification.Name.mrMediaRemoteNowPlayingInfoDidChange) + .throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true) + .sink(receiveValue: { notification in + //print(notification) + print("Info Changed Notification Received") + MRMediaRemoteGetNowPlayingInfo(.main) { info in + if let info = info as? [String : Any] { + let currentTrack = MediaTrack(mediaRemote: info) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + print("Current Track \(outputDevices.currentTrack?.title ?? "nil"), previous: \(outputDevices.previousTrack?.title ?? "nil"), isSame: \(outputDevices.previousTrack == outputDevices.currentTrack)") + outputDevices.previousTrack = outputDevices.currentTrack + outputDevices.currentTrack = currentTrack + if outputDevices.previousTrack != outputDevices.currentTrack { + outputDevices.renewTimer() + } + outputDevices.switchLatestSampleRate() + } +// if currentTrack != self.previousTrack { +// self.send(command: MRMediaRemoteCommandPause, ifBundleMatches: kMusicAppBundle) { +// DispatchQueue.main.asyncAfter(deadline: .now() + 2) { +// outputDevices.switchLatestSampleRate() +// //self.send(command: MRMediaRemoteCommandPlay, ifBundleMatches: kMusicAppBundle) {} +// } +// //} +// } + //self.previousTrack = currentTrack + } + } + }) + + MRMediaRemoteRegisterForNowPlayingNotifications(.main) + } + + func send(command: MRMediaRemoteCommand, ifBundleMatches bundleId: String, completion: @escaping () -> ()) { + MRMediaRemoteGetNowPlayingClient(.main) { client in + guard let client = client else { return } + if client.bundleIdentifier == bundleId { + MRMediaRemoteSendCommand(command, nil) + } + completion() + } + } +} diff --git a/Quality/MediaTrack.swift b/Quality/MediaTrack.swift new file mode 100644 index 0000000..1878aec --- /dev/null +++ b/Quality/MediaTrack.swift @@ -0,0 +1,29 @@ +// +// MediaTrack.swift +// LosslessSwitcher +// +// Created by Vincent Neo on 1/5/22. +// + +import Foundation +import PrivateMediaRemote + +struct MediaTrack: Equatable, Hashable { + + let isMusicApp: Bool + let id: String? + + let title: String? + let album: String? + let artist: String? + let trackNumber: String? + + init(mediaRemote info: [String : Any]) { + self.id = info[kMRMediaRemoteNowPlayingInfoUniqueIdentifier] as? String + self.isMusicApp = info[kMRMediaRemoteNowPlayingInfoIsMusicApp] as? Bool ?? false + self.title = info[kMRMediaRemoteNowPlayingInfoTitle] as? String + self.album = info[kMRMediaRemoteNowPlayingInfoAlbum] as? String + self.artist = info[kMRMediaRemoteNowPlayingInfoArtist] as? String + self.trackNumber = info[kMRMediaRemoteNowPlayingInfoTrackNumber] as? String + } +} diff --git a/Quality/OutputDevices.swift b/Quality/OutputDevices.swift index 3c8bea8..78357ff 100644 --- a/Quality/OutputDevices.swift +++ b/Quality/OutputDevices.swift @@ -8,21 +8,34 @@ import Combine import Foundation import SimplyCoreAudio +import CoreAudioTypes class OutputDevices: ObservableObject { + @Published var selectedOutputDevice: AudioDevice? // auto if nil @Published var defaultOutputDevice: AudioDevice? @Published var outputDevices = [AudioDevice]() @Published var currentSampleRate: Float64? + private var enableBitDepthDetection = Defaults.shared.userPreferBitDepthDetection + private var enableBitDepthDetectionCancellable: AnyCancellable? + private let coreAudio = SimplyCoreAudio() private var changesCancellable: AnyCancellable? private var defaultChangesCancellable: AnyCancellable? - - private let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect() private var timerCancellable: AnyCancellable? + private var outputSelectionCancellable: AnyCancellable? + private var consoleQueue = DispatchQueue(label: "consoleQueue", qos: .userInteractive) + private var previousSampleRate: Float64? + var trackAndSample = [MediaTrack : Float64]() + var previousTrack: MediaTrack? + var currentTrack: MediaTrack? + + var timerActive = false + var timerCalls = 0 + init() { self.outputDevices = self.coreAudio.allOutputDevices self.defaultOutputDevice = self.coreAudio.defaultOutputDevice @@ -39,53 +52,199 @@ class OutputDevices: ObservableObject { self.getDeviceSampleRate() }) - timerCancellable = timer.sink(receiveValue: { _ in - self.consoleQueue.async { - self.switchLatestSampleRate() - } + outputSelectionCancellable = selectedOutputDevice.publisher.sink(receiveValue: { _ in + self.getDeviceSampleRate() + }) + + enableBitDepthDetectionCancellable = Defaults.shared.$userPreferBitDepthDetection.sink(receiveValue: { newValue in + self.enableBitDepthDetection = newValue }) + + } deinit { changesCancellable?.cancel() defaultChangesCancellable?.cancel() timerCancellable?.cancel() - timer.upstream.connect().cancel() + enableBitDepthDetectionCancellable?.cancel() + //timer.upstream.connect().cancel() + } + + func renewTimer() { + if timerCancellable != nil { return } + timerCancellable = Timer + .publish(every: 2, on: .main, in: .default) + .autoconnect() + .sink { _ in + if self.timerCalls == 5 { + self.timerCalls = 0 + self.timerCancellable?.cancel() + self.timerCancellable = nil + } + else { + self.timerCalls += 1 + self.consoleQueue.async { + self.switchLatestSampleRate() + } + } + } } func getDeviceSampleRate() { - let defaultDevice = self.defaultOutputDevice + let defaultDevice = self.selectedOutputDevice ?? self.defaultOutputDevice guard let sampleRate = defaultDevice?.nominalSampleRate else { return } self.updateSampleRate(sampleRate) } - func switchLatestSampleRate() { + func getSampleRateFromAppleScript() -> Double? { + let scriptContents = "tell application \"Music\" to get sample rate of current track" + var error: NSDictionary? + + if let script = NSAppleScript(source: scriptContents) { + let output = script.executeAndReturnError(&error).stringValue + + if let error = error { + print("[APPLESCRIPT] - \(error)") + } + guard let output = output else { return nil } + + if output == "missing value" { + return nil + } + else { + return Double(output) + } + } + + return nil + } + + func getAllStats() -> [CMPlayerStats] { + var allStats = [CMPlayerStats]() + do { - let musicLog = try Console.getRecentEntries() - let cmStats = CMPlayerParser.parseMusicConsoleLogs(musicLog) + let musicLogs = try Console.getRecentEntries(type: .music) + let coreAudioLogs = try Console.getRecentEntries(type: .coreAudio) + let coreMediaLogs = try Console.getRecentEntries(type: .coreMedia) - let defaultDevice = self.defaultOutputDevice - if let first = cmStats.first, let supported = defaultDevice?.nominalSampleRates { - let sampleRate = Float64(first.sampleRate) - // https://stackoverflow.com/a/65060134 - let nearest = supported.enumerated().min(by: { - abs($0.element - sampleRate) < abs($1.element - sampleRate) - }) - if let nearest = nearest { - let nearestSampleRate = nearest.element - if nearestSampleRate != defaultDevice?.nominalSampleRate { - defaultDevice?.setNominalSampleRate(nearestSampleRate) - self.updateSampleRate(nearestSampleRate) - } - } + allStats.append(contentsOf: CMPlayerParser.parseMusicConsoleLogs(musicLogs)) + if enableBitDepthDetection { + allStats.append(contentsOf: CMPlayerParser.parseCoreAudioConsoleLogs(coreAudioLogs)) } + else { + allStats.append(contentsOf: CMPlayerParser.parseCoreMediaConsoleLogs(coreMediaLogs)) + } + + allStats.sort(by: {$0.priority > $1.priority}) + print("[getAllStats] \(allStats)") } catch { - print(error) + print("[getAllStats, error] \(error)") + } + + return allStats + } + + func switchLatestSampleRate(recursion: Bool = false) { + let allStats = self.getAllStats() + let defaultDevice = self.selectedOutputDevice ?? self.defaultOutputDevice + + if let first = allStats.first, let supported = defaultDevice?.nominalSampleRates { + let sampleRate = Float64(first.sampleRate) + let bitDepth = Int32(first.bitDepth) + + if self.currentTrack == self.previousTrack, let prevSampleRate = currentSampleRate, prevSampleRate > sampleRate { + print("same track, prev sample rate is higher") + return + } + + if sampleRate == 48000 { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.switchLatestSampleRate(recursion: true) + } + } + + let formats = self.getFormats(bestStat: first, device: defaultDevice!)! + + // https://stackoverflow.com/a/65060134 + let nearest = supported.min(by: { + abs($0 - sampleRate) < abs($1 - sampleRate) + }) + + let nearestBitDepth = formats.min(by: { + abs(Int32($0.mBitsPerChannel) - bitDepth) < abs(Int32($1.mBitsPerChannel) - bitDepth) + }) + + let nearestFormat = formats.filter({ + $0.mSampleRate == nearest && $0.mBitsPerChannel == nearestBitDepth?.mBitsPerChannel + }) + + print("NEAREST FORMAT \(nearestFormat)") + + if let suitableFormat = nearestFormat.first { + if enableBitDepthDetection { + self.setFormats(device: defaultDevice, format: suitableFormat) + } + else if suitableFormat.mSampleRate != previousSampleRate { // bit depth disabled + defaultDevice?.setNominalSampleRate(suitableFormat.mSampleRate) + } + self.updateSampleRate(suitableFormat.mSampleRate) + if let currentTrack = currentTrack { + self.trackAndSample[currentTrack] = suitableFormat.mSampleRate + } + } + +// if let nearest = nearest { +// let nearestSampleRate = nearest.element +// if nearestSampleRate != previousSampleRate { +// defaultDevice?.setNominalSampleRate(nearestSampleRate) +// self.updateSampleRate(nearestSampleRate) +// if let currentTrack = currentTrack { +// self.trackAndSample[currentTrack] = nearestSampleRate +// } +// } +// } + } + else if !recursion { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.switchLatestSampleRate(recursion: true) + } + } + else { +// print("cache \(self.trackAndSample)") + if self.currentTrack == self.previousTrack { + print("same track, ignore cache") + return + } +// if let currentTrack = currentTrack, let cachedSampleRate = trackAndSample[currentTrack] { +// print("using cached data") +// if cachedSampleRate != previousSampleRate { +// defaultDevice?.setNominalSampleRate(cachedSampleRate) +// self.updateSampleRate(cachedSampleRate) +// } +// } + } + + } + + func getFormats(bestStat: CMPlayerStats, device: AudioDevice) -> [AudioStreamBasicDescription]? { + // new sample rate + bit depth detection route + let streams = device.streams(scope: .output) + let availableFormats = streams?.first?.availablePhysicalFormats?.compactMap({$0.mFormat}) + return availableFormats + } + + func setFormats(device: AudioDevice?, format: AudioStreamBasicDescription?) { + guard let device, let format else { return } + let streams = device.streams(scope: .output) + if streams?.first?.physicalFormat != format { + streams?.first?.physicalFormat = format } } func updateSampleRate(_ sampleRate: Float64) { + self.previousSampleRate = sampleRate DispatchQueue.main.async { let readableSampleRate = sampleRate / 1000 self.currentSampleRate = readableSampleRate @@ -93,5 +252,24 @@ class OutputDevices: ObservableObject { let delegate = AppDelegate.instance delegate?.statusItemTitle = String(format: "%.1f kHz", readableSampleRate) } + self.runUserScript(sampleRate) + } + + func runUserScript(_ sampleRate: Float64) { + guard let scriptPath = Defaults.shared.shellScriptPath else { return } + let argumentSampleRate = String(Int(sampleRate)) + Task.detached { + let scriptURL = URL(fileURLWithPath: scriptPath) + do { + let task = try NSUserUnixTask(url: scriptURL) + let arguments = [ + argumentSampleRate + ] + try await task.execute(withArguments: arguments) + } + catch { + print("TASK ERR \(error)") + } + } } } diff --git a/Quality/Quality.entitlements b/Quality/Quality.entitlements index 0c67376..49ad0bb 100644 --- a/Quality/Quality.entitlements +++ b/Quality/Quality.entitlements @@ -1,5 +1,8 @@ - + + com.apple.security.automation.apple-events + + diff --git a/Quality/ScriptableApplicationCommand.swift b/Quality/ScriptableApplicationCommand.swift new file mode 100644 index 0000000..a0d8d00 --- /dev/null +++ b/Quality/ScriptableApplicationCommand.swift @@ -0,0 +1,22 @@ +// +// ScriptableApplicationCommand.swift +// LosslessSwitcher +// +// Created by Vincent Neo on 6/12/23. +// + +import Cocoa + +class ScriptableApplicationCommand: NSScriptCommand { + + override func performDefaultImplementation() -> Any? { + guard let delegate = AppDelegate.instance else { + return -1000 + } + let od = delegate.outputDevices + guard let sampleRate = od?.currentSampleRate else { + return -1 + } + return Int(sampleRate * 1000) + } +}