diff --git a/Quality.xcodeproj/project.pbxproj b/Quality.xcodeproj/project.pbxproj index ce2148a..3eec543 100644 --- a/Quality.xcodeproj/project.pbxproj +++ b/Quality.xcodeproj/project.pbxproj @@ -31,6 +31,11 @@ BF342D7D2E0937D20032F398 /* MediaRemoteAdapter in Embed Frameworks */ = {isa = PBXBuildFile; productRef = BF342D7B2E0935980032F398 /* MediaRemoteAdapter */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; BF342D802E0947890032F398 /* SampleRateLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF342D7F2E0947890032F398 /* SampleRateLabel.swift */; }; BF342D822E0948730032F398 /* MenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF342D812E0948730032F398 /* MenuView.swift */; }; + BF503DEE2F542CA600C361E6 /* LogReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF503DED2F542CA300C361E6 /* LogReader.swift */; }; + BF503DF22F5442D100C361E6 /* CMEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF503DF12F5442CF00C361E6 /* CMEntry.swift */; }; + BF503DF42F54646C00C361E6 /* InfoPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF503DF32F54644000C361E6 /* InfoPair.swift */; }; + BF503DF72F5464DB00C361E6 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = BF503DF62F5464DB00C361E6 /* OrderedCollections */; }; + BF503DF92F54680700C361E6 /* AudioFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF503DF82F54680400C361E6 /* AudioFormat.swift */; }; BF5A87BA2E01D746002033D6 /* MenuBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5A87B92E01D746002033D6 /* MenuBarController.swift */; }; BF7E0D09296336DA009FFEEC /* AudioStreamBasicDescription+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7E0D08296336DA009FFEEC /* AudioStreamBasicDescription+Equatable.swift */; }; /* End PBXBuildFile section */ @@ -72,6 +77,10 @@ BF0C90C92B20C163002F99C9 /* ScriptableApplicationCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptableApplicationCommand.swift; sourceTree = ""; }; BF342D7F2E0947890032F398 /* SampleRateLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleRateLabel.swift; sourceTree = ""; }; BF342D812E0948730032F398 /* MenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuView.swift; sourceTree = ""; }; + BF503DED2F542CA300C361E6 /* LogReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogReader.swift; sourceTree = ""; }; + BF503DF12F5442CF00C361E6 /* CMEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMEntry.swift; sourceTree = ""; }; + BF503DF32F54644000C361E6 /* InfoPair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoPair.swift; sourceTree = ""; }; + BF503DF82F54680400C361E6 /* AudioFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFormat.swift; sourceTree = ""; }; BF5A87B92E01D746002033D6 /* MenuBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarController.swift; sourceTree = ""; }; BF7E0D08296336DA009FFEEC /* AudioStreamBasicDescription+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AudioStreamBasicDescription+Equatable.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -83,6 +92,7 @@ files = ( 1221F3F9280F10A3003E8B77 /* SimplyCoreAudio in Frameworks */, 1272AAB1280DC71B00FD72BA /* Sweep in Frameworks */, + BF503DF72F5464DB00C361E6 /* OrderedCollections in Frameworks */, 1234F50A281E83D1007EC9F5 /* MediaRemote.framework in Frameworks */, BF342D7C2E0935980032F398 /* MediaRemoteAdapter in Frameworks */, 1234F508281E8372007EC9F5 /* PrivateMediaRemote in Frameworks */, @@ -120,6 +130,10 @@ 1272AA96280DBB4900FD72BA /* Quality */ = { isa = PBXGroup; children = ( + BF503DF82F54680400C361E6 /* AudioFormat.swift */, + BF503DF32F54644000C361E6 /* InfoPair.swift */, + BF503DF12F5442CF00C361E6 /* CMEntry.swift */, + BF503DED2F542CA300C361E6 /* LogReader.swift */, 1254A79D2814024300241107 /* Info.plist */, 12AFF5C02811AD40001CC6ED /* AppDelegate.swift */, 1272AA97280DBB4900FD72BA /* QualityApp.swift */, @@ -176,6 +190,7 @@ 1221F3F8280F10A3003E8B77 /* SimplyCoreAudio */, 1234F507281E8372007EC9F5 /* PrivateMediaRemote */, BF342D7B2E0935980032F398 /* MediaRemoteAdapter */, + BF503DF62F5464DB00C361E6 /* OrderedCollections */, ); productName = Quality; productReference = 1272AA94280DBB4900FD72BA /* LosslessSwitcher.app */; @@ -210,6 +225,7 @@ 1221F3F7280F10A3003E8B77 /* XCRemoteSwiftPackageReference "SimplyCoreAudio" */, 1234F504281E8372007EC9F5 /* XCRemoteSwiftPackageReference "MediaRemote" */, BF342D7A2E0935980032F398 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */, + BF503DF52F5464DB00C361E6 /* XCRemoteSwiftPackageReference "swift-collections" */, ); productRefGroup = 1272AA95280DBB4900FD72BA /* Products */; projectDirPath = ""; @@ -246,15 +262,19 @@ 1234F50E281E8F07007EC9F5 /* MediaTrack.swift in Sources */, 12F1AA572868639A006C1AD8 /* DeviceMenuItem.swift in Sources */, BF342D822E0948730032F398 /* MenuView.swift in Sources */, + BF503DF22F5442D100C361E6 /* CMEntry.swift in Sources */, 1221F3FB280F1EEF003E8B77 /* OutputDevices.swift in Sources */, 1272AAAE280DC68A00FD72BA /* CMPlayerStuff.swift in Sources */, 1272AAAC280DC5E900FD72BA /* Console.swift in Sources */, + BF503DEE2F542CA600C361E6 /* LogReader.swift in Sources */, + BF503DF92F54680700C361E6 /* AudioFormat.swift in Sources */, 1234F510281E9520007EC9F5 /* MediaRemoteController.swift in Sources */, 1272AA9A280DBB4900FD72BA /* ContentView.swift in Sources */, 1293436B28131591002E19A8 /* CurrentUser.swift in Sources */, 1272AA98280DBB4900FD72BA /* QualityApp.swift in Sources */, BF342D802E0947890032F398 /* SampleRateLabel.swift in Sources */, 127C972D281FCF000087313B /* AppVersion.swift in Sources */, + BF503DF42F54646C00C361E6 /* InfoPair.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -382,7 +402,7 @@ CODE_SIGN_ENTITLEMENTS = Quality/Quality.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 25; DEVELOPMENT_ASSET_PATHS = "\"Quality/Preview Content\""; DEVELOPMENT_TEAM = 3X69W4AQD6; ENABLE_HARDENED_RUNTIME = YES; @@ -398,7 +418,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 3.0; PRODUCT_BUNDLE_IDENTIFIER = "com.vincent-neo.LosslessSwitcher"; PRODUCT_NAME = LosslessSwitcher; SWIFT_EMIT_LOC_STRINGS = YES; @@ -418,7 +438,7 @@ CODE_SIGN_ENTITLEMENTS = Quality/Quality.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 25; DEVELOPMENT_ASSET_PATHS = "\"Quality/Preview Content\""; DEVELOPMENT_TEAM = 3X69W4AQD6; ENABLE_HARDENED_RUNTIME = YES; @@ -434,7 +454,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 3.0; PRODUCT_BUNDLE_IDENTIFIER = "com.vincent-neo.LosslessSwitcher"; PRODUCT_NAME = LosslessSwitcher; SWIFT_EMIT_LOC_STRINGS = YES; @@ -502,6 +522,14 @@ kind = branch; }; }; + BF503DF52F5464DB00C361E6 /* XCRemoteSwiftPackageReference "swift-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-collections.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.3.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -525,6 +553,11 @@ package = BF342D7A2E0935980032F398 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */; productName = MediaRemoteAdapter; }; + BF503DF62F5464DB00C361E6 /* OrderedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = BF503DF52F5464DB00C361E6 /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = OrderedCollections; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 1272AA8C280DBB4900FD72BA /* Project object */; diff --git a/Quality.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Quality.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d1fe94b..e29dd77 100644 --- a/Quality.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Quality.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "365d4d094399ef2f380af176e0b927a21023201306ca8a0aba3d681544ffb930", + "originHash" : "54228da2743eebbf59e879ecf51b132657e584b0c7d72d739c5a7c4768d70589", "pins" : [ { "identity" : "mediaremote", @@ -54,6 +54,15 @@ "revision" : "3e95ba32cd1b4c877f6163e8eea54afc4e63bf9f", "version" : "0.0.3" } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } } ], "version" : 3 diff --git a/Quality/AudioFormat.swift b/Quality/AudioFormat.swift new file mode 100644 index 0000000..e7b5c79 --- /dev/null +++ b/Quality/AudioFormat.swift @@ -0,0 +1,13 @@ +// +// AudioFormat.swift +// LosslessSwitcher +// +// Created by Vincent Neo on 1/3/26. +// + +import Foundation + +struct AudioFormat { + let sampleRate: Int + let bitDepth: Int? +} diff --git a/Quality/CMEntry.swift b/Quality/CMEntry.swift new file mode 100644 index 0000000..1a7ccd4 --- /dev/null +++ b/Quality/CMEntry.swift @@ -0,0 +1,15 @@ +// +// CMEntry.swift +// LosslessSwitcher +// +// Created by Vincent Neo on 1/3/26. +// + +import Foundation + +struct CMEntry { + let date: Date + let trackName: String? + let bitDepth: Int? + let sampleRate: Int +} diff --git a/Quality/CMPlayerStuff.swift b/Quality/CMPlayerStuff.swift index d40d5f5..98ea1f5 100644 --- a/Quality/CMPlayerStuff.swift +++ b/Quality/CMPlayerStuff.swift @@ -6,7 +6,7 @@ // import Foundation -import OSLog +//import OSLog import Sweep struct CMPlayerStats { diff --git a/Quality/Console.swift b/Quality/Console.swift index 62f55c5..5c7cdee 100644 --- a/Quality/Console.swift +++ b/Quality/Console.swift @@ -6,7 +6,7 @@ // // https://developer.apple.com/forums/thread/677068 -import OSLog +//import OSLog import Cocoa struct SimpleConsole { @@ -26,17 +26,18 @@ enum EntryType: String { class Console { 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: 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) - } - - return messages.reversed() +// var messages = [SimpleConsole]() +// let store = try OSLogStore.local() +// let duration = store.position(timeIntervalSinceEnd: -5.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) +// } +// +// return messages.reversed() + return [] } } diff --git a/Quality/ContentView.swift b/Quality/ContentView.swift index ea8618c..85dec97 100644 --- a/Quality/ContentView.swift +++ b/Quality/ContentView.swift @@ -6,7 +6,7 @@ // import SwiftUI -import OSLog +//import OSLog import SimplyCoreAudio struct ContentView: View { diff --git a/Quality/InfoPair.swift b/Quality/InfoPair.swift new file mode 100644 index 0000000..446a228 --- /dev/null +++ b/Quality/InfoPair.swift @@ -0,0 +1,27 @@ +// +// InfoPair.swift +// LosslessSwitcher +// +// Created by Vincent Neo on 1/3/26. +// + +import Foundation + +class InfoPair: CustomStringConvertible { + var track: DatedPair? + var format: DatedPair? + + init(track: DatedPair? = nil, format: DatedPair? = nil) { + self.track = track + self.format = format + } + + var description: String { + return "InfoPair(\(String(describing: track)), \(String(describing: format)))" + } +} + +struct DatedPair { + let date: Date + let object: T +} diff --git a/Quality/LogReader.swift b/Quality/LogReader.swift new file mode 100644 index 0000000..be8bf43 --- /dev/null +++ b/Quality/LogReader.swift @@ -0,0 +1,135 @@ +// +// LogReader.swift +// LosslessSwitcher +// +// Created by Vincent Neo on 1/3/26. +// + +import Foundation +import Combine +import Sweep + +class LogReader { + + let entryStream = PassthroughSubject() + + private var process: Process? + private let dateFormatter: DateFormatter + + init() { + let dateFormatter = DateFormatter() + dateFormatter.timeZone = .autoupdatingCurrent + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + self.dateFormatter = dateFormatter + } + + func spawnProcessIfNeeded() { + guard process == nil else { return } + self.spawnProcess() + } + + private func spawnProcess() { + let process = Process() + self.process = process + + process.executableURL = URL(filePath: "/usr/bin/log") + process.arguments = [ + "stream", + "--style", + "compact", + "--no-backtrace", + "--predicate", + "process = \"Music\" AND category=\"ampplay\"" + ] + + let pipe = Pipe() + process.standardOutput = pipe + + pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { + handle.readabilityHandler = nil + return + } + + guard let line = String(data: data, encoding: .utf8) else { return } + self?.processLine(line) + } + + do { + try process.run() + } + catch { + print("ProcessErr \(error)") + } + } + + private func processLine(_ line: String) { +// print("\n\nLINE: ", line) + guard let dateSubstring = line.firstSubstring(between: .start, and: " Df ") else { return } + guard let messageContentSubstring = line.firstSubstring(between: "[com.apple.Music:ampplay] play> cm>> " , and: .end) else { return } + let dateString = String(dateSubstring) + let message = String(messageContentSubstring) + let date = dateFormatter.date(from: dateString) + + let split = message.split(separator: ",") + var trackName: String? + var isLossless: Bool? + var bitDepth: Int? + var sampleRate: Int? + + for element in split { + + // in default circumstances + if trackName == nil, element.hasPrefix("mediaFormatinfo") { + guard let substring = element.firstSubstring(between: "\'", and: "\'") else { continue } + trackName = String(substring) + continue + } + + // notes: there is a field that may be "lossless", "high res lossless" and "stereo (lossy)" + + if isLossless == nil, element.hasPrefix(" sdFormatID") { + guard let substring = element.firstSubstring(between: "= ", and: .end) else { continue } + isLossless = substring == "alac" + continue + } + + if bitDepth == nil, element.hasPrefix(" sdBitDepth") { + guard let substring = element.firstSubstring(between: "= ", and: " bit") else { continue } + bitDepth = Int(substring) + } + + if sampleRate == nil, element.hasPrefix(" asbdSampleRate") { + guard let substring = element.firstSubstring(between: "= ", and: " kHz") else { continue } + let string = String(substring) + guard let double = Double(string) else { continue } + sampleRate = Int(double * 1000) + continue + } + + } + + // this requires an external profile to read this info + // might be helpful to prevent the early track sample rate switch issue. + // https://eclecticlight.co/2023/03/08/removing-privacy-censorship-from-the-log/ + if let tn = trackName, tn == "" { + trackName = nil + } + + guard let date, let isLossless, let sampleRate else { return } + + // discard if entry is known to be for lossy playback + // why?: while it could be nice to switch if you're playing a bunch of tracks where some are lossy, + // occassionally, there are lossless tracks where logs start off with these lossy information. + // to prevent over switching, i'm ignoring all lossy log entries. + guard isLossless else { return } + + let entry = CMEntry(date: date, trackName: trackName, bitDepth: bitDepth, sampleRate: sampleRate) +// print("\n\nXLINE", date, trackName, isLossless, bitDepth, sampleRate) +// print("\n\nLINE", date, split) + + entryStream.send(entry) + } +} diff --git a/Quality/MenuView.swift b/Quality/MenuView.swift index 0e0f252..093a690 100644 --- a/Quality/MenuView.swift +++ b/Quality/MenuView.swift @@ -7,11 +7,14 @@ import SwiftUI + struct MenuView: View { @EnvironmentObject private var outputDevices: OutputDevices @EnvironmentObject private var defaults: Defaults + @State var stream = LogReader() + var body: some View { VStack { ContentView() diff --git a/Quality/OutputDevices.swift b/Quality/OutputDevices.swift index 7e367ac..068fd20 100644 --- a/Quality/OutputDevices.swift +++ b/Quality/OutputDevices.swift @@ -10,6 +10,7 @@ import Foundation import SimplyCoreAudio import CoreAudioTypes import MediaRemoteAdapter +import OrderedCollections class OutputDevices: ObservableObject { @Published var selectedOutputDevice: AudioDevice? // auto if nil @@ -28,6 +29,17 @@ class OutputDevices: ObservableObject { private var timerCancellable: AnyCancellable? private var outputSelectionCancellable: AnyCancellable? + private let logReader = LogReader() + private var entryStreamReceiver: AnyCancellable? + private var lastTrackChangeTime: Date? + + private var collection: OrderedDictionary = [:] + private var currentTrackPair: DatedPair? + private var updateRequester = PassthroughSubject() + private var updateRequesterReceiver: AnyCancellable? + + private var pairHandlingQueue = DispatchQueue(label: "phq", qos: .userInteractive) + private var consoleQueue = DispatchQueue(label: "consoleQueue", qos: .userInteractive) private var processQueue = DispatchQueue(label: "processQueue", qos: .userInitiated) @@ -47,6 +59,62 @@ class OutputDevices: ObservableObject { self.defaultOutputDevice = self.coreAudio.defaultOutputDevice self.getDeviceSampleRate() + self.logReader.spawnProcessIfNeeded() + + // WIP: This code is dizzying to work with... + entryStreamReceiver = logReader.entryStream.receive(on: pairHandlingQueue).sink { [weak self] entry in + //print("ESR", self.lastTrackChangeTime, self.currentTrack, entry.date, entry.trackName, entry.sampleRate) + + let key = entry.trackName ?? UUID().uuidString + + if entry.trackName == nil, let lastKey = self?.collection.keys.last, let lastDate = self?.collection[lastKey]?.format?.date { + if abs(lastDate.timeIntervalSince1970 - entry.date.timeIntervalSince1970) < 1 { + return + } + } + + let format = DatedPair(date: entry.date, object: AudioFormat(sampleRate: entry.sampleRate, bitDepth: entry.bitDepth)) + if let pair = self?.collection[key] { + pair.format = format + } + else { + self?.collection[key] = InfoPair(format: format) + } + + self?.updateRequester.send() + } + + updateRequesterReceiver = updateRequester + .throttle(for: 0.2, scheduler: DispatchQueue.global(), latest: true) + .receive(on: pairHandlingQueue) + .sink { [weak self] in + guard let collection = self?.collection, let currentTrackPair = self?.currentTrackPair else { return } + print(collection) + var limit = 0 + for (_, value) in collection.reversed() { + guard limit < 5 else { return } + defer { + limit += 1 + } + + if let track = value.track?.object, let current = self?.currentTrack, track == current { + print("URR TRACK YES") + if let format = value.format?.object { + print("URR FORMAT YES") + self?.switchLatestSampleRate(format: format) + return + } + } + +// let trackValue = value.track +// let format = value.format +// if let title = currentTrackPair.object.title { +// +// return +// } + } + } + changesCancellable = NotificationCenter.default.publisher(for: .deviceListChanged).sink(receiveValue: { _ in self.outputDevices = self.coreAudio.allOutputDevices @@ -65,7 +133,6 @@ class OutputDevices: ObservableObject { enableBitDepthDetectionCancellable = Defaults.shared.$userPreferBitDepthDetection.sink(receiveValue: { newValue in self.enableBitDepthDetection = newValue }) - } @@ -74,6 +141,7 @@ class OutputDevices: ObservableObject { defaultChangesCancellable?.cancel() timerCancellable?.cancel() enableBitDepthDetectionCancellable?.cancel() + entryStreamReceiver?.cancel() //timer.upstream.connect().cancel() } @@ -91,9 +159,9 @@ class OutputDevices: ObservableObject { self.timerCancellable = nil } else { - self.processQueue.async { - self.switchLatestSampleRate() - } +// self.processQueue.async { +// self.switchLatestSampleRate() +// } } } } @@ -153,26 +221,25 @@ class OutputDevices: ObservableObject { return allStats } - func switchLatestSampleRate(recursion: Bool = false) { - let allStats = self.getAllStats() + func switchLatestSampleRate(format: AudioFormat) { 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 let supported = defaultDevice?.nominalSampleRates { + let sampleRate = Float64(format.sampleRate) + let bitDepth = Int32(format.bitDepth ?? 32) - if sampleRate == 48000 && !recursion { - processQueue.asyncAfter(deadline: .now() + 1) { - self.switchLatestSampleRate(recursion: true) - } - } +// if self.currentTrack == self.previousTrack, let prevSampleRate = currentSampleRate, prevSampleRate > sampleRate { +// print("same track, prev sample rate is higher") +// return +// } +// +// if sampleRate == 48000 && !recursion { +// processQueue.asyncAfter(deadline: .now() + 1) { +// self.switchLatestSampleRate(recursion: true) +// } +// } - let formats = self.getFormats(bestStat: first, device: defaultDevice!)! + let formats = self.getFormats(device: defaultDevice!)! // https://stackoverflow.com/a/65060134 var nearest = supported.min(by: { @@ -220,11 +287,11 @@ class OutputDevices: ObservableObject { // } // } } - else if !recursion { - processQueue.asyncAfter(deadline: .now() + 1) { - self.switchLatestSampleRate(recursion: true) - } - } +// else if !recursion { +// processQueue.asyncAfter(deadline: .now() + 1) { +// self.switchLatestSampleRate(recursion: true) +// } +// } else { // print("cache \(self.trackAndSample)") if self.currentTrack == self.previousTrack { @@ -242,7 +309,7 @@ class OutputDevices: ObservableObject { } - func getFormats(bestStat: CMPlayerStats, device: AudioDevice) -> [AudioStreamBasicDescription]? { + func getFormats(device: AudioDevice) -> [AudioStreamBasicDescription]? { // new sample rate + bit depth detection route let streams = device.streams(scope: .output) let availableFormats = streams?.first?.availablePhysicalFormats?.compactMap({$0.mFormat}) @@ -303,13 +370,37 @@ class OutputDevices: ObservableObject { } func trackDidChange(_ newTrack: TrackInfo) { + let mt = MediaTrack(trackInfo: newTrack) + + guard previousTrack != mt else { return } self.previousTrack = self.currentTrack - self.currentTrack = MediaTrack(trackInfo: newTrack) - if self.previousTrack != self.currentTrack { - self.renewTimer() - } - processQueue.async { [unowned self] in - self.switchLatestSampleRate() + self.currentTrack = mt + + pairHandlingQueue.async { [weak self] in + let now = Date.now + let pair = DatedPair(date: now, object: mt) + self?.currentTrackPair = pair + + let key = mt.title ?? UUID().uuidString + if let collectionPair = self?.collection[key] { + collectionPair.track = pair + } + else if let lastKey = self?.collection.keys.last, self?.collection[lastKey]?.track == nil, UUID(uuidString: lastKey) != nil { + self?.collection[lastKey]?.track = pair + } + else { + self?.collection[key] = .init(track: pair) + } + self?.updateRequester.send() } + + +// if self.previousTrack != self.currentTrack { +// self.renewTimer() +// } +// processQueue.async { [unowned self] in +// self.switchLatestSampleRate() +// } + lastTrackChangeTime = Date() } }