diff --git a/Examples/MusaveraLab/APPLE_SAMPLE_LICENSE.txt b/Examples/MusaveraLab/APPLE_SAMPLE_LICENSE.txt new file mode 100644 index 0000000..1ecede7 --- /dev/null +++ b/Examples/MusaveraLab/APPLE_SAMPLE_LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2026 Apple Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj b/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1d5ce90 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj @@ -0,0 +1,449 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 24785B8BAB77C0EBC9A28DA1 /* MusaveraLabModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E038E4851D68CBE647F8DF98 /* MusaveraLabModel.swift */; }; + 2AD8EE2019F19C3DC7536C8E /* MusicBrowserSidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5DD336EA101AD39E6141936 /* MusicBrowserSidebar.swift */; }; + 2BA84BC4C0402FADE8E84A6F /* MusaveraLabApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0BE121349F66D59ED4BD2A /* MusaveraLabApp.swift */; }; + 4E9EB78FBA07F3AED856D197 /* AnalysisDashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72FE5CB39CDA7B494D1DE3A4 /* AnalysisDashboard.swift */; }; + 50E590E35281E47FBB31B57A /* MusicSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2965F30D5C9A008DC2AFD30 /* MusicSelectionSheet.swift */; }; + A81A422116DAC9B8A6125471 /* PreviewPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B3D4276646810C2DA45A3E8 /* PreviewPlayer.swift */; }; + A91B6A70C878816A79A7C3AF /* AnalysisJSONDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7DEA2C229BDA2A4B85CAAA /* AnalysisJSONDocument.swift */; }; + B0F5DFB080DD500DF9A8B5FF /* AnalysisComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28134147038B1E3872648893 /* AnalysisComponents.swift */; }; + D55BBD16F76645103279A0DC /* TimelineComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92C546425026A586D4AE6F60 /* TimelineComponents.swift */; }; + E3553AC3D8690FBDCEC66F53 /* MusaveraKit in Frameworks */ = {isa = PBXBuildFile; productRef = 79D134AB9DE2AC7ECDA156A9 /* MusaveraKit */; }; + E9A96DD35B7FDEF43189AA2A /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F91F1BF6C6D59CC92807B9 /* Theme.swift */; }; + EC67369138AEA5397F184A95 /* AudioFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A018FE4324BF125ABD274B2D /* AudioFileStore.swift */; }; + F0D97C334B4F79CA294898F9 /* TransportBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6ECFFBEFCF66FA2E2FAB48D /* TransportBar.swift */; }; + F17FAD96862797EAC998B10D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D7010759E3CE72A46D76413 /* ContentView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 16850E396168613A82B4E05D /* MusaveraLab.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MusaveraLab.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 17F91F1BF6C6D59CC92807B9 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + 28134147038B1E3872648893 /* AnalysisComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalysisComponents.swift; sourceTree = ""; }; + 351EE86AE67DD541E20901CC /* MusaveraLab.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MusaveraLab.entitlements; sourceTree = ""; }; + 4B3D4276646810C2DA45A3E8 /* PreviewPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewPlayer.swift; sourceTree = ""; }; + 5D7010759E3CE72A46D76413 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 6D0BE121349F66D59ED4BD2A /* MusaveraLabApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusaveraLabApp.swift; sourceTree = ""; }; + 72FE5CB39CDA7B494D1DE3A4 /* AnalysisDashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalysisDashboard.swift; sourceTree = ""; }; + 92C546425026A586D4AE6F60 /* TimelineComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineComponents.swift; sourceTree = ""; }; + A018FE4324BF125ABD274B2D /* AudioFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileStore.swift; sourceTree = ""; }; + B6ECFFBEFCF66FA2E2FAB48D /* TransportBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportBar.swift; sourceTree = ""; }; + C5DD336EA101AD39E6141936 /* MusicBrowserSidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicBrowserSidebar.swift; sourceTree = ""; }; + DBF41C8160D78F589E34CEE8 /* MusaveraKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MusaveraKit; path = ../..; sourceTree = SOURCE_ROOT; }; + DE7DEA2C229BDA2A4B85CAAA /* AnalysisJSONDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalysisJSONDocument.swift; sourceTree = ""; }; + E038E4851D68CBE647F8DF98 /* MusaveraLabModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusaveraLabModel.swift; sourceTree = ""; }; + E2965F30D5C9A008DC2AFD30 /* MusicSelectionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicSelectionSheet.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 977874206CB93E56BD73BA4D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E3553AC3D8690FBDCEC66F53 /* MusaveraKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 01615905EABEE2D5C93439D3 /* Visualizations */ = { + isa = PBXGroup; + children = ( + 92C546425026A586D4AE6F60 /* TimelineComponents.swift */, + ); + path = Visualizations; + sourceTree = ""; + }; + 29800BCA3A37D96D264A5266 /* Views */ = { + isa = PBXGroup; + children = ( + 28134147038B1E3872648893 /* AnalysisComponents.swift */, + 72FE5CB39CDA7B494D1DE3A4 /* AnalysisDashboard.swift */, + 5D7010759E3CE72A46D76413 /* ContentView.swift */, + C5DD336EA101AD39E6141936 /* MusicBrowserSidebar.swift */, + E2965F30D5C9A008DC2AFD30 /* MusicSelectionSheet.swift */, + B6ECFFBEFCF66FA2E2FAB48D /* TransportBar.swift */, + ); + path = Views; + sourceTree = ""; + }; + 5C6CC3829C1CFAA276F3EC9C = { + isa = PBXGroup; + children = ( + CD691D72EA74C87BF3D616F5 /* MusaveraLab */, + 6F326FABD5331BB995B41986 /* Packages */, + 6143F35E9F4688A52B756DB4 /* Products */, + ); + sourceTree = ""; + }; + 5D907943F997C17C6829F95A /* Services */ = { + isa = PBXGroup; + children = ( + A018FE4324BF125ABD274B2D /* AudioFileStore.swift */, + 4B3D4276646810C2DA45A3E8 /* PreviewPlayer.swift */, + ); + path = Services; + sourceTree = ""; + }; + 6143F35E9F4688A52B756DB4 /* Products */ = { + isa = PBXGroup; + children = ( + 16850E396168613A82B4E05D /* MusaveraLab.app */, + ); + name = Products; + sourceTree = ""; + }; + 6F326FABD5331BB995B41986 /* Packages */ = { + isa = PBXGroup; + children = ( + DBF41C8160D78F589E34CEE8 /* MusaveraKit */, + ); + name = Packages; + sourceTree = ""; + }; + 9F3BF4EA07744FF279BBA3D0 /* App */ = { + isa = PBXGroup; + children = ( + 6D0BE121349F66D59ED4BD2A /* MusaveraLabApp.swift */, + E038E4851D68CBE647F8DF98 /* MusaveraLabModel.swift */, + ); + path = App; + sourceTree = ""; + }; + CD691D72EA74C87BF3D616F5 /* MusaveraLab */ = { + isa = PBXGroup; + children = ( + 351EE86AE67DD541E20901CC /* MusaveraLab.entitlements */, + 17F91F1BF6C6D59CC92807B9 /* Theme.swift */, + 9F3BF4EA07744FF279BBA3D0 /* App */, + F7F79764B4E0D5C905588239 /* Documents */, + 5D907943F997C17C6829F95A /* Services */, + 29800BCA3A37D96D264A5266 /* Views */, + 01615905EABEE2D5C93439D3 /* Visualizations */, + ); + path = MusaveraLab; + sourceTree = ""; + }; + F7F79764B4E0D5C905588239 /* Documents */ = { + isa = PBXGroup; + children = ( + DE7DEA2C229BDA2A4B85CAAA /* AnalysisJSONDocument.swift */, + ); + path = Documents; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + EB2D92BACB058B1FBE4D3EF2 /* MusaveraLab */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4A95BEC55B4F4C2577B13035 /* Build configuration list for PBXNativeTarget "MusaveraLab" */; + buildPhases = ( + CE074CFB82092D3783CAFD50 /* Sources */, + 977874206CB93E56BD73BA4D /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MusaveraLab; + packageProductDependencies = ( + 79D134AB9DE2AC7ECDA156A9 /* MusaveraKit */, + ); + productName = MusaveraLab; + productReference = 16850E396168613A82B4E05D /* MusaveraLab.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 616A856774CD404816E91CA0 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + EB2D92BACB058B1FBE4D3EF2 = { + DevelopmentTeam = YQZQG7N4WG; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = A8185B12452914BDC34967F7 /* Build configuration list for PBXProject "MusaveraLab" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 5C6CC3829C1CFAA276F3EC9C; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + F7DC3FBA02C54B0465DB6775 /* XCLocalSwiftPackageReference "../.." */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 6143F35E9F4688A52B756DB4 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + EB2D92BACB058B1FBE4D3EF2 /* MusaveraLab */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + CE074CFB82092D3783CAFD50 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B0F5DFB080DD500DF9A8B5FF /* AnalysisComponents.swift in Sources */, + 4E9EB78FBA07F3AED856D197 /* AnalysisDashboard.swift in Sources */, + A91B6A70C878816A79A7C3AF /* AnalysisJSONDocument.swift in Sources */, + EC67369138AEA5397F184A95 /* AudioFileStore.swift in Sources */, + F17FAD96862797EAC998B10D /* ContentView.swift in Sources */, + 2BA84BC4C0402FADE8E84A6F /* MusaveraLabApp.swift in Sources */, + 24785B8BAB77C0EBC9A28DA1 /* MusaveraLabModel.swift in Sources */, + 2AD8EE2019F19C3DC7536C8E /* MusicBrowserSidebar.swift in Sources */, + 50E590E35281E47FBB31B57A /* MusicSelectionSheet.swift in Sources */, + A81A422116DAC9B8A6125471 /* PreviewPlayer.swift in Sources */, + E9A96DD35B7FDEF43189AA2A /* Theme.swift in Sources */, + D55BBD16F76645103279A0DC /* TimelineComponents.swift in Sources */, + F0D97C334B4F79CA294898F9 /* TransportBar.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 7BCC3FF39830C9D6883CD43A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = YQZQG7N4WG; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 27.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + BA6F66BD215783BC54571F70 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = MusaveraLab/MusaveraLab.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Musavera Lab"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; + INFOPLIST_KEY_NSAppleMusicUsageDescription = "Musavera Lab uses Apple Music to search the catalog and play songs you choose."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rudrankriyam.musaveralab; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + }; + name = Debug; + }; + DC3D19AEF2B3314DB1BCCF7D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = MusaveraLab/MusaveraLab.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Musavera Lab"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; + INFOPLIST_KEY_NSAppleMusicUsageDescription = "Musavera Lab uses Apple Music to search the catalog and play songs you choose."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rudrankriyam.musaveralab; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + }; + name = Release; + }; + FDAF4B23ABE8E5C8E47724E1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = YQZQG7N4WG; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 27.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 4A95BEC55B4F4C2577B13035 /* Build configuration list for PBXNativeTarget "MusaveraLab" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BA6F66BD215783BC54571F70 /* Debug */, + DC3D19AEF2B3314DB1BCCF7D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + A8185B12452914BDC34967F7 /* Build configuration list for PBXProject "MusaveraLab" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7BCC3FF39830C9D6883CD43A /* Debug */, + FDAF4B23ABE8E5C8E47724E1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + F7DC3FBA02C54B0465DB6775 /* XCLocalSwiftPackageReference "../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 79D134AB9DE2AC7ECDA156A9 /* MusaveraKit */ = { + isa = XCSwiftPackageProductDependency; + productName = MusaveraKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 616A856774CD404816E91CA0 /* Project object */; +} diff --git a/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/MusaveraLab/MusaveraLab.xcodeproj/xcshareddata/xcschemes/MusaveraLab.xcscheme b/Examples/MusaveraLab/MusaveraLab.xcodeproj/xcshareddata/xcschemes/MusaveraLab.xcscheme new file mode 100644 index 0000000..e19be89 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab.xcodeproj/xcshareddata/xcschemes/MusaveraLab.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabApp.swift b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabApp.swift new file mode 100644 index 0000000..c5e06a5 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabApp.swift @@ -0,0 +1,16 @@ +import SwiftUI + +@main +struct MusaveraLabApp: App { + @State private var model = MusaveraLabModel() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(model) + .environment(model.previewPlayer) + .frame(minWidth: 1_080, minHeight: 720) + } + .defaultSize(width: 1_320, height: 860) + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift new file mode 100644 index 0000000..6c35ded --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift @@ -0,0 +1,327 @@ +@preconcurrency import AVFoundation +import Foundation +@preconcurrency import MusicKit +import MusaveraKit +import Observation + +@Observable +@MainActor +final class MusaveraLabModel { + struct AudioSource { + let title: String + let subtitle: String + let artworkURL: URL? + let localURL: URL + let isAppleMusicPreview: Bool + + var exportFilename: String { + let filename = "\(title) - \(subtitle) Analysis" + let disallowedCharacters = CharacterSet(charactersIn: "/:") + return filename + .components(separatedBy: disallowedCharacters) + .joined(separator: "-") + } + } + + enum WorkState { + case idle + case authorizing + case searching + case downloading + case importing + case analyzing + + var title: String { + switch self { + case .idle: + "" + case .authorizing: + "Connecting to Apple Music" + case .searching: + "Searching the Apple Music catalog" + case .downloading: + "Downloading the 30-second preview" + case .importing: + "Preparing the audio file" + case .analyzing: + "Listening for key, rhythm, structure, and texture" + } + } + } + + var authorizationStatus = MusicAuthorization.currentStatus + var selectedSong: Song? + var source: AudioSource? + var analysis: MusaveraAnalysis? + var workState: WorkState = .idle + var errorMessage: String? + private(set) var fullSongPlaybackStatus = ApplicationMusicPlayer.shared.state.playbackStatus + private(set) var isStartingFullSong = false + + let previewPlayer = PreviewPlayer() + + @ObservationIgnored private let fileStore = AudioFileStore() + @ObservationIgnored private let fullSongPlayer = ApplicationMusicPlayer.shared + @ObservationIgnored private var fullSongStateTask: Task? + @ObservationIgnored private var pendingFullSongPlayID: UUID? + + init() { + observeFullSongState() + } + + isolated deinit { + fullSongStateTask?.cancel() + } + + var isBusy: Bool { + workState != .idle + } + + var isAuthorized: Bool { + authorizationStatus == .authorized + } + + var isFullSongPlaybackActive: Bool { + if isStartingFullSong { + true + } else { + switch fullSongPlaybackStatus { + case .playing, .seekingForward, .seekingBackward: + true + default: + false + } + } + } + + func prepare() { + authorizationStatus = MusicAuthorization.currentStatus + } + + func requestAuthorization() async { + guard workState == .idle else { return } + + errorMessage = nil + workState = .authorizing + authorizationStatus = await MusicAuthorization.request() + workState = .idle + + guard authorizationStatus == .authorized else { + errorMessage = "Apple Music access is required for catalog search. Local audio analysis still works without it." + return + } + } + + func analyze(song: Song) async { + guard workState == .idle else { return } + + errorMessage = nil + selectedSong = song + analysis = nil + source = nil + previewPlayer.stop() + cancelFullSongPlayback() + + do { + guard let previewURL = song.previewAssets?.compactMap(\.url).first else { + throw LabError.previewUnavailable + } + + workState = .downloading + let localURL = try await fileStore.download( + previewURL, + identifier: song.id.rawValue + ) + + try await analyzeAudio( + at: localURL, + title: song.title, + subtitle: song.artistName, + artworkURL: song.artwork?.url(width: 640, height: 640), + isAppleMusicPreview: true + ) + } catch { + fail(with: error) + } + } + + func analyzeLocalFile(at sourceURL: URL) async { + guard workState == .idle else { return } + + errorMessage = nil + selectedSong = nil + analysis = nil + source = nil + previewPlayer.stop() + cancelFullSongPlayback() + workState = .importing + + do { + let localURL = try await fileStore.importFile(sourceURL) + try await analyzeAudio( + at: localURL, + title: displayTitle(for: sourceURL), + subtitle: "Local audio file", + artworkURL: nil, + isAppleMusicPreview: false + ) + } catch { + fail(with: error) + } + } + + func togglePreviewPlayback() { + if previewPlayer.isPlaying { + previewPlayer.pause() + } else { + cancelFullSongPlayback() + previewPlayer.play() + } + } + + func toggleFullSongPlayback() async { + guard let selectedSong else { return } + + if isFullSongPlaybackActive { + cancelFullSongPlayback() + return + } + + errorMessage = nil + previewPlayer.pause() + + let requestID = UUID() + pendingFullSongPlayID = requestID + isStartingFullSong = true + defer { + if pendingFullSongPlayID == requestID { + pendingFullSongPlayID = nil + isStartingFullSong = false + } + } + + do { + fullSongPlayer.queue = ApplicationMusicPlayer.Queue(for: [selectedSong]) + try await fullSongPlayer.play() + guard pendingFullSongPlayID == requestID else { + fullSongPlayer.pause() + return + } + } catch { + guard pendingFullSongPlayID == requestID else { return } + errorMessage = "Full-song playback could not start: \(error.localizedDescription)" + } + } + + func reset() { + previewPlayer.unload() + cancelFullSongPlayback() + selectedSong = nil + source = nil + analysis = nil + workState = .idle + errorMessage = nil + } + + private func analyzeAudio( + at url: URL, + title: String, + subtitle: String, + artworkURL: URL?, + isAppleMusicPreview: Bool + ) async throws { + let asset = AVURLAsset( + url: url, + options: [AVURLAssetPreferPreciseDurationAndTimingKey: true] + ) + + let isProtected = try await asset.load(.hasProtectedContent) + guard !isProtected else { + throw LabError.protectedContent + } + + source = AudioSource( + title: title, + subtitle: subtitle, + artworkURL: artworkURL, + localURL: url, + isAppleMusicPreview: isAppleMusicPreview + ) + workState = .analyzing + + async let playerLoad: Void = previewPlayer.load(asset) + let result: MusaveraAnalysis + do { + result = try await Musavera.analyze(asset: asset) + } catch { + await playerLoad + throw error + } + await playerLoad + + analysis = result + workState = .idle + } + + private func displayTitle(for url: URL) -> String { + let stem = url.deletingPathExtension().lastPathComponent + let words = stem + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: "_", with: " ") + .split(whereSeparator: \.isWhitespace) + + guard !words.isEmpty else { return "Local Audio" } + return words.map { $0.capitalized }.joined(separator: " ") + } + + private func fail(with error: Error) { + previewPlayer.unload() + cancelFullSongPlayback() + selectedSong = nil + source = nil + analysis = nil + workState = .idle + errorMessage = error.localizedDescription + } + + private func cancelFullSongPlayback() { + pendingFullSongPlayID = nil + isStartingFullSong = false + fullSongPlayer.pause() + } + + private func observeFullSongState() { + let player = fullSongPlayer + fullSongStateTask = Task { @MainActor [weak self] in + let statuses = Observations { + player.state.playbackStatus + } + + for await status in statuses { + guard let self else { return } + self.fullSongPlaybackStatus = status + + switch status { + case .playing, .seekingForward, .seekingBackward: + self.previewPlayer.pause() + default: + break + } + } + } + } +} + +private enum LabError: LocalizedError { + case previewUnavailable + case protectedContent + + var errorDescription: String? { + switch self { + case .previewUnavailable: + "Apple Music does not provide an analyzable preview for this song. Try another result." + case .protectedContent: + "This audio is DRM-protected and cannot be decoded by MusicUnderstanding." + } + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Documents/AnalysisJSONDocument.swift b/Examples/MusaveraLab/MusaveraLab/Documents/AnalysisJSONDocument.swift new file mode 100644 index 0000000..bb5e26a --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Documents/AnalysisJSONDocument.swift @@ -0,0 +1,32 @@ +import Foundation +import MusaveraKit +import SwiftUI +import UniformTypeIdentifiers + +struct AnalysisJSONDocument: FileDocument { + static let readableContentTypes: [UTType] = [.json] + + private let analysis: MusaveraAnalysis + + init(analysis: MusaveraAnalysis) { + self.analysis = analysis + } + + init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents else { + throw CocoaError(.fileReadCorruptFile) + } + analysis = try JSONDecoder().decode(MusaveraAnalysis.self, from: data) + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + let encoder = JSONEncoder() + encoder.outputFormatting = [ + .prettyPrinted, + .sortedKeys, + .withoutEscapingSlashes + ] + let data = try encoder.encode(analysis) + return FileWrapper(regularFileWithContents: data) + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/MusaveraLab.entitlements b/Examples/MusaveraLab/MusaveraLab/MusaveraLab.entitlements new file mode 100644 index 0000000..a046386 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/MusaveraLab.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + + diff --git a/Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift b/Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift new file mode 100644 index 0000000..4a646c1 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift @@ -0,0 +1,88 @@ +import Foundation + +actor AudioFileStore { + enum StoreError: LocalizedError { + case invalidResponse + + var errorDescription: String? { + switch self { + case .invalidResponse: + "The Apple Music preview server returned an invalid response." + } + } + } + + private let fileManager = FileManager.default + private let directory: URL + + init() { + directory = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + )[0] + .appending(path: "MusaveraLab", directoryHint: .isDirectory) + } + + func download(_ remoteURL: URL, identifier: String) async throws -> URL { + let (temporaryURL, response) = try await URLSession.shared.download(from: remoteURL) + + guard let httpResponse = response as? HTTPURLResponse, + 200..<300 ~= httpResponse.statusCode else { + throw StoreError.invalidResponse + } + + let destination = try destinationURL( + identifier: identifier, + sourceURL: remoteURL, + defaultExtension: "m4a" + ) + try replaceItem(at: destination, with: temporaryURL, copy: false) + return destination + } + + func importFile(_ sourceURL: URL) throws -> URL { + let destination = try destinationURL( + identifier: UUID().uuidString, + sourceURL: sourceURL, + defaultExtension: "audio" + ) + try replaceItem(at: destination, with: sourceURL, copy: true) + return destination + } + + private func destinationURL( + identifier: String, + sourceURL: URL, + defaultExtension: String + ) throws -> URL { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + + let safeIdentifier = String( + identifier.map { character in + character.isLetter || character.isNumber ? character : "-" + } + ) + let pathExtension = sourceURL.pathExtension.isEmpty + ? defaultExtension + : sourceURL.pathExtension + + return directory + .appending(path: String(safeIdentifier.prefix(80))) + .appendingPathExtension(pathExtension) + } + + private func replaceItem(at destination: URL, with source: URL, copy: Bool) throws { + if fileManager.fileExists(atPath: destination.path()) { + try fileManager.removeItem(at: destination) + } + + if copy { + try fileManager.copyItem(at: source, to: destination) + } else { + try fileManager.moveItem(at: source, to: destination) + } + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift b/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift new file mode 100644 index 0000000..5b6668c --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift @@ -0,0 +1,130 @@ +@preconcurrency import AVFoundation +import CoreMedia +import Foundation +import Observation + +@Observable +@MainActor +final class PreviewPlayer { + private static let defaultSampleRate: CMTimeScale = 44_100 + + let player = AVPlayer() + + private(set) var isPlaying = false + private(set) var currentTime: Double = 0 + private(set) var duration: Double = 0 + private(set) var sampleRate = defaultSampleRate + + var volume: Float = 1 { + didSet { + player.volume = volume + } + } + + @ObservationIgnored private var endOfPlaybackTask: Task? + @ObservationIgnored private var timeObserver: Any? + + init() { + installTimeObserver() + } + + isolated deinit { + endOfPlaybackTask?.cancel() + if let timeObserver { + player.removeTimeObserver(timeObserver) + } + } + + func load(_ asset: AVURLAsset) async { + unload() + + let item = AVPlayerItem(asset: asset) + player.replaceCurrentItem(with: item) + observeEndOfPlayback(for: item) + + async let durationLoad = asset.load(.duration) + async let tracksLoad = asset.loadTracks(withMediaType: .audio) + + if let assetDuration = try? await durationLoad, assetDuration.isNumeric { + duration = assetDuration.seconds + } + + if let audioTrack = try? await tracksLoad.first, + let formatDescription = try? await audioTrack.load(.formatDescriptions).first, + let streamDescription = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription) { + sampleRate = CMTimeScale(streamDescription.pointee.mSampleRate) + } + } + + func unload() { + stop() + endOfPlaybackTask?.cancel() + endOfPlaybackTask = nil + player.replaceCurrentItem(with: nil) + duration = 0 + sampleRate = Self.defaultSampleRate + } + + func play() { + player.play() + isPlaying = true + } + + func pause() { + player.pause() + isPlaying = false + } + + func stop() { + pause() + player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero) + currentTime = 0 + } + + func seek(to seconds: Double) { + let clampedSeconds = min(max(seconds, 0), duration) + let time = CMTime(seconds: clampedSeconds, preferredTimescale: sampleRate) + player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) + currentTime = clampedSeconds + } + + var formattedCurrentTime: String { + format(currentTime) + } + + var formattedDuration: String { + format(duration) + } + + private func observeEndOfPlayback(for item: AVPlayerItem) { + endOfPlaybackTask?.cancel() + endOfPlaybackTask = Task { @MainActor [weak self] in + for await _ in NotificationCenter.default.notifications( + named: AVPlayerItem.didPlayToEndTimeNotification, + object: item + ) { + guard let self else { continue } + self.stop() + } + } + } + + private func installTimeObserver() { + let interval = CMTime(seconds: 0.05, preferredTimescale: sampleRate) + timeObserver = player.addPeriodicTimeObserver( + forInterval: interval, + queue: .main + ) { [weak self] time in + MainActor.assumeIsolated { + guard let self, time.seconds.isFinite else { return } + self.currentTime = time.seconds + self.isPlaying = self.player.rate != 0 + } + } + } + + private func format(_ seconds: Double) -> String { + guard seconds.isFinite, seconds >= 0 else { return "00:00" } + return String(format: "%02d:%02d", Int(seconds) / 60, Int(seconds) % 60) + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Theme.swift b/Examples/MusaveraLab/MusaveraLab/Theme.swift new file mode 100644 index 0000000..d99ba53 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Theme.swift @@ -0,0 +1,14 @@ +import AppKit +import SwiftUI + +enum LabTheme { + static let background = Color(nsColor: .windowBackgroundColor) + static let card = Color(nsColor: .controlBackgroundColor) + static let cardBorder = Color(nsColor: .separatorColor).opacity(0.45) + static let playhead = Color.primary.opacity(0.62) + static let structure = Color.blue + static let segment = Color.teal + static let phrase = Color.mint + static let pace = Color.orange + static let loudness = Color.indigo +} diff --git a/Examples/MusaveraLab/MusaveraLab/Views/AnalysisComponents.swift b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisComponents.swift new file mode 100644 index 0000000..208f323 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisComponents.swift @@ -0,0 +1,118 @@ +import CoreMedia +import MusicUnderstanding +import MusaveraKit +import SwiftUI + +struct ArtworkView: View { + let url: URL? + let size: CGFloat + + var body: some View { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + default: + ZStack { + LabTheme.card + + Image(systemName: "music.note") + .font(.system(size: size * 0.34, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + } + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: size * 0.18, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: size * 0.18, style: .continuous) + .stroke(LabTheme.cardBorder) + } + } +} + +struct AnalysisTile: View { + let title: String + let systemImage: String + @ViewBuilder let content: Content + + var body: some View { + GroupBox { + content + .frame(maxWidth: .infinity) + .padding(.top, 6) + } label: { + Label(title, systemImage: systemImage) + .font(.headline) + } + .accessibilityElement(children: .contain) + } +} + +struct KeySummaryView: View { + let key: KeyResult? + + var body: some View { + VStack(spacing: 2) { + Text(key?.primarySignature?.tonic.musaveraDescription ?? "--") + .font(.system(size: 54, weight: .bold, design: .rounded)) + + Text(key?.primarySignature?.mode.rawValue.capitalized ?? "No key detected") + .font(.title3.weight(.medium)) + .foregroundStyle(.secondary) + } + .frame(height: 92) + .accessibilityElement(children: .ignore) + .accessibilityLabel(key?.primarySignature?.musaveraDescription ?? "No key detected") + } +} + +struct RhythmSummaryView: View { + @Environment(PreviewPlayer.self) private var player + + let rhythm: RhythmResult? + + var body: some View { + HStack(spacing: 24) { + VStack(alignment: .leading, spacing: 0) { + Text(rhythm?.beatsPerMinute.map { Int($0).formatted() } ?? "--") + .font(.system(size: 52, weight: .bold, design: .rounded)) + + Text("BPM") + .font(.headline) + .foregroundStyle(.secondary) + } + + Spacer() + + HStack(spacing: 7) { + ForEach(0..<4, id: \.self) { beat in + Capsule() + .fill( + Color.accentColor.opacity( + isActive(beat: beat) ? 0.9 : beat == 0 ? 0.34 : 0.14 + ) + ) + .frame(width: 25, height: 8) + } + } + } + .frame(height: 92) + .accessibilityElement(children: .ignore) + .accessibilityLabel( + rhythm?.beatsPerMinute.map { "\(Int($0)) beats per minute" } + ?? "No tempo detected" + ) + } + + private func isActive(beat: Int) -> Bool { + guard player.isPlaying, let beats = rhythm?.beats, !beats.isEmpty else { + return false + } + + let completedBeatCount = beats.prefix { $0.seconds <= player.currentTime }.count + return completedBeatCount > 0 && (completedBeatCount - 1) % 4 == beat + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift new file mode 100644 index 0000000..a0fb900 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift @@ -0,0 +1,177 @@ +import MusaveraKit +import SwiftUI +import UniformTypeIdentifiers + +struct AnalysisDashboard: View { + @Environment(MusaveraLabModel.self) private var model + @State private var isExporting = false + + let source: MusaveraLabModel.AudioSource + let analysis: MusaveraAnalysis + let onChooseMusic: () -> Void + + private let summaryColumns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + + private let activityColumns = [ + GridItem(.adaptive(minimum: 220, maximum: 360), spacing: 12) + ] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + SourceHeader( + source: source, + onExport: exportAnalysis, + onChooseMusic: onChooseMusic + ) + + Text("Analysis") + .font(.title2.bold()) + .padding(.top, 4) + + LazyVGrid(columns: summaryColumns, spacing: 12) { + AnalysisTile(title: "Key", systemImage: "music.quarternote.3") { + KeySummaryView(key: analysis.key) + } + + AnalysisTile(title: "Rhythm", systemImage: "metronome") { + RhythmSummaryView(rhythm: analysis.rhythm) + } + } + + if let structure = analysis.structure { + AnalysisTile(title: "Structure", systemImage: "square.3.layers.3d") { + StructureTimeline(result: structure) + .frame(height: 88) + .playheadOverlay() + } + } + + if let pace = analysis.pace { + AnalysisTile(title: "Pace", systemImage: "speedometer") { + PaceChart(result: pace) + .frame(height: 150) + .playheadOverlay() + } + } + + if let instruments = analysis.instrumentActivity { + AnalysisTile(title: "Instrument Ranges", systemImage: "pianokeys") { + InstrumentRangesChart(result: instruments) + .frame(height: 190) + .playheadOverlay() + } + + LazyVGrid(columns: activityColumns, spacing: 12) { + ForEach(InstrumentActivityResult.Instrument.labOrder, id: \.rawValue) { instrument in + AnalysisTile( + title: "\(instrument.rawValue.capitalized) Activity", + systemImage: instrument.systemImage + ) { + InstrumentActivityChart( + values: instruments.activity(for: instrument), + color: instrument.labColor + ) + .frame(height: 110) + .playheadOverlay() + } + } + } + } + + if let loudness = analysis.loudness { + AnalysisTile(title: "Loudness", systemImage: "waveform") { + LoudnessAnalysisView(result: loudness) + } + } + } + .frame(maxWidth: 1_400, alignment: .leading) + .padding(28) + } + .fileExporter( + isPresented: $isExporting, + document: AnalysisJSONDocument(analysis: analysis), + contentType: .json, + defaultFilename: source.exportFilename + ) { result in + switch result { + case .success: + model.errorMessage = nil + case .failure(let error): + model.errorMessage = "The analysis could not be exported: \(error.localizedDescription)" + } + } + } + + private func exportAnalysis() { + model.errorMessage = nil + isExporting = true + } +} + +private struct SourceHeader: View { + @Environment(MusaveraLabModel.self) private var model + + let source: MusaveraLabModel.AudioSource + let onExport: () -> Void + let onChooseMusic: () -> Void + + var body: some View { + HStack(spacing: source.artworkURL == nil ? 20 : 24) { + ArtworkView( + url: source.artworkURL, + size: source.artworkURL == nil ? 116 : 152 + ) + .shadow(color: .black.opacity(0.16), radius: 12, y: 6) + + VStack(alignment: .leading, spacing: 7) { + Text(source.title) + .font(.largeTitle.bold()) + .lineLimit(2) + + Text(source.subtitle) + .font(.title3) + .foregroundStyle(.secondary) + + Label( + source.isAppleMusicPreview ? "30-second Apple Music preview" : "Local audio", + systemImage: source.isAppleMusicPreview ? "clock" : "internaldrive" + ) + .font(.callout) + .foregroundStyle(.secondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 10) { + if model.selectedSong != nil { + Button { + Task { + await model.toggleFullSongPlayback() + } + } label: { + Label( + model.isFullSongPlaybackActive ? "Pause Full Song" : "Play Full Song", + systemImage: model.isFullSongPlaybackActive ? "pause.fill" : "music.note" + ) + } + .buttonStyle(.borderedProminent) + } + + Button("Export JSON", systemImage: "square.and.arrow.up", action: onExport) + .buttonStyle(.bordered) + + Button { + onChooseMusic() + } label: { + Label("Find Music", systemImage: "magnifyingglass") + } + .buttonStyle(.bordered) + } + } + .padding(.vertical, source.artworkURL == nil ? 2 : 8) + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift b/Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift new file mode 100644 index 0000000..96e7d50 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift @@ -0,0 +1,260 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct ContentView: View { + @Environment(MusaveraLabModel.self) private var model + @State private var isImportingAudio = false + @State private var isChoosingMusic = false + + var body: some View { + NavigationSplitView { + MusicBrowserSidebar( + onChooseMusic: { + isChoosingMusic = true + }, + onImportAudio: { + isImportingAudio = true + } + ) + .navigationSplitViewColumnWidth(min: 240, ideal: 270, max: 340) + } detail: { + DetailContentView( + onChooseMusic: { + isChoosingMusic = true + }, + onImportAudio: { + isImportingAudio = true + } + ) + } + .navigationSplitViewStyle(.balanced) + .sheet(isPresented: $isChoosingMusic) { + MusicSelectionSheet() + } + .fileImporter( + isPresented: $isImportingAudio, + allowedContentTypes: [.audio] + ) { result in + importAudio(from: result) + } + .alert( + "Musavera Lab", + isPresented: Binding( + get: { model.errorMessage != nil }, + set: { isPresented in + if !isPresented { + model.errorMessage = nil + } + } + ) + ) { + Button("OK") { + model.errorMessage = nil + } + } message: { + Text(model.errorMessage ?? "") + } + .task { + model.prepare() + } + } + + private func importAudio(from result: Result) { + guard case .success(let url) = result else { return } + + let isAccessing = url.startAccessingSecurityScopedResource() + Task { + defer { + if isAccessing { + url.stopAccessingSecurityScopedResource() + } + } + await model.analyzeLocalFile(at: url) + } + } +} + +private struct DetailContentView: View { + @Environment(MusaveraLabModel.self) private var model + + let onChooseMusic: () -> Void + let onImportAudio: () -> Void + + var body: some View { + ZStack { + LabTheme.background + .ignoresSafeArea() + + if let source = model.source, let analysis = model.analysis { + AnalysisDashboard( + source: source, + analysis: analysis, + onChooseMusic: onChooseMusic + ) + .transition(.opacity.combined(with: .scale(scale: 0.98))) + } else if model.isBusy { + WorkingView(state: model.workState) + } else { + WelcomeDetailView( + onChooseMusic: onChooseMusic, + onImportAudio: onImportAudio + ) + } + } + .animation(.smooth(duration: 0.35), value: model.analysis != nil) + .safeAreaInset(edge: .bottom, spacing: 0) { + if model.analysis != nil { + TransportBar() + } + } + } +} + +private struct WelcomeDetailView: View { + let onChooseMusic: () -> Void + let onImportAudio: () -> Void + + private let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 30) { + VStack(alignment: .leading, spacing: 8) { + Text("Listen Deeper") + .font(.largeTitle.bold()) + + Text("Turn an Apple Music preview or local audio file into a synchronized map of the song.") + .font(.title3) + .foregroundStyle(.secondary) + .frame(maxWidth: 700, alignment: .leading) + } + + GroupBox { + HStack(spacing: 22) { + Image(systemName: "waveform.path.ecg.rectangle") + .font(.system(size: 48, weight: .regular)) + .foregroundStyle(.secondary) + .frame(width: 86, height: 86) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 18)) + + VStack(alignment: .leading, spacing: 6) { + Text("Choose your source") + .font(.title2.bold()) + + Text("Search Apple Music from the sidebar, or open an audio file already on this Mac. Musavera Lab downloads only the catalog preview for local analysis.") + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Button("Choose Music", systemImage: "music.note.list") { + onChooseMusic() + } + .buttonStyle(.borderedProminent) + + Button("Open Audio File", systemImage: "folder") { + onImportAudio() + } + } + .padding(.top, 6) + } + + Spacer() + } + .padding(8) + } + + Text("What Musavera understands") + .font(.title2.bold()) + + LazyVGrid(columns: columns, spacing: 12) { + CapabilityCard( + title: "Key", + detail: "Tonic and mode over time", + systemImage: "music.quarternote.3" + ) + CapabilityCard( + title: "Rhythm", + detail: "Tempo, beats, and bars", + systemImage: "metronome" + ) + CapabilityCard( + title: "Structure", + detail: "Sections, segments, and phrases", + systemImage: "square.3.layers.3d" + ) + CapabilityCard( + title: "Pace", + detail: "Perceived musical motion", + systemImage: "speedometer" + ) + CapabilityCard( + title: "Instruments", + detail: "Vocal, drum, bass, and other activity", + systemImage: "pianokeys" + ) + CapabilityCard( + title: "Loudness", + detail: "Peak, integrated, and momentary levels", + systemImage: "waveform" + ) + } + } + .frame(maxWidth: 1_000, alignment: .leading) + .padding(32) + } + } +} + +private struct CapabilityCard: View { + let title: String + let detail: String + let systemImage: String + + var body: some View { + GroupBox { + HStack(alignment: .top, spacing: 12) { + Image(systemName: systemImage) + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.headline) + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: 58, alignment: .topLeading) + } + } +} + +private struct WorkingView: View { + let state: MusaveraLabModel.WorkState + + var body: some View { + VStack(spacing: 18) { + ProgressView() + .controlSize(.large) + + Text(state.title) + .font(.title2.bold()) + + if state == .analyzing { + Text("The first analysis may take a moment. MusicUnderstanding is decoding the preview locally.") + .foregroundStyle(.secondary) + } + } + .multilineTextAlignment(.center) + .padding(40) + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Views/MusicBrowserSidebar.swift b/Examples/MusaveraLab/MusaveraLab/Views/MusicBrowserSidebar.swift new file mode 100644 index 0000000..8eed107 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/MusicBrowserSidebar.swift @@ -0,0 +1,61 @@ +import MusicKit +import SwiftUI + +struct MusicBrowserSidebar: View { + @Environment(MusaveraLabModel.self) private var model + let onChooseMusic: () -> Void + let onImportAudio: () -> Void + + var body: some View { + List { + Section("Apple Music") { + if model.isAuthorized { + Label("Connected", systemImage: "checkmark.circle.fill") + .foregroundStyle(.secondary) + } else { + AuthorizationRow() + } + + Button(action: onChooseMusic) { + Label("Choose Music", systemImage: "music.note.list") + } + .disabled(model.isBusy) + } + + Section("On This Mac") { + Button(action: onImportAudio) { + Label("Open Audio File", systemImage: "folder") + } + .buttonStyle(.plain) + .disabled(model.isBusy) + } + } + .listStyle(.sidebar) + .navigationTitle("Musavera Lab") + } +} + +private struct AuthorizationRow: View { + @Environment(MusaveraLabModel.self) private var model + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Label("Connect to search", systemImage: "music.note") + .font(.headline) + + Text("Connect to search the catalog and play full songs. Preview analysis stays on this Mac.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Button("Connect Apple Music") { + Task { + await model.requestAuthorization() + } + } + .buttonStyle(.borderedProminent) + .disabled(model.isBusy) + } + .padding(.vertical, 4) + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift b/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift new file mode 100644 index 0000000..2dc8853 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift @@ -0,0 +1,180 @@ +import MusicKit +import SwiftUI + +struct MusicSelectionSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(MusaveraLabModel.self) private var model + + @State private var query = "" + @State private var songs: [Song] = [] + @State private var isSearching = false + @State private var searchMessage: String? + @State private var activeSearchID: UUID? + + var body: some View { + NavigationStack { + Group { + if !model.isAuthorized { + ContentUnavailableView { + Label("Connect Apple Music", systemImage: "music.note") + } description: { + Text("Catalog search requires Apple Music access.") + } actions: { + Button("Connect") { + Task { + await model.requestAuthorization() + } + } + .buttonStyle(.borderedProminent) + } + } else if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + ContentUnavailableView( + "Search Apple Music", + systemImage: "music.note.list", + description: Text("Find a song to analyze its 30-second catalog preview.") + ) + } else if isSearching && songs.isEmpty { + ProgressView("Searching Apple Music") + .controlSize(.large) + } else if songs.isEmpty { + ContentUnavailableView( + "No Results", + systemImage: "magnifyingglass", + description: Text(searchMessage ?? "Try another song or artist.") + ) + } else { + List(songs) { song in + MusicSelectionRow(song: song) { + select(song) + } + .disabled(model.isBusy) + } + .listStyle(.inset) + } + } + .navigationTitle("Choose Music") + .searchable( + text: $query, + placement: .toolbar, + prompt: "Song or artist" + ) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + .frame(minWidth: 680, minHeight: 540) + .task(id: SearchContext(query: query, isAuthorized: model.isAuthorized)) { + await search() + } + } + + private func select(_ song: Song) { + guard !model.isBusy else { return } + + dismiss() + Task { + await model.analyze(song: song) + } + } + + private func search() async { + let searchID = UUID() + activeSearchID = searchID + defer { + if activeSearchID == searchID { + isSearching = false + } + } + + let term = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard model.isAuthorized, !term.isEmpty else { + songs = [] + searchMessage = nil + return + } + + isSearching = true + songs = [] + searchMessage = nil + + do { + try await Task.sleep(for: .milliseconds(350)) + + var request = MusicCatalogSearchRequest(term: term, types: [Song.self]) + request.limit = 25 + let response = try await request.response() + try Task.checkCancellation() + + guard activeSearchID == searchID else { return } + songs = Array(response.songs) + searchMessage = songs.isEmpty ? "Nothing matched “\(term)”." : nil + } catch is CancellationError { + return + } catch { + guard activeSearchID == searchID else { return } + songs = [] + searchMessage = error.localizedDescription + } + } +} + +private struct SearchContext: Hashable { + let query: String + let isAuthorized: Bool +} + +private struct MusicSelectionRow: View { + let song: Song + let action: () -> Void + + private var hasPreview: Bool { + song.previewAssets?.contains(where: { $0.url != nil }) == true + } + + var body: some View { + Button(action: action) { + HStack(spacing: 14) { + ArtworkView( + url: song.artwork?.url(width: 144, height: 144), + size: 58 + ) + + VStack(alignment: .leading, spacing: 3) { + Text(song.title) + .font(.headline) + .lineLimit(1) + + Text(song.artistName) + .foregroundStyle(.secondary) + .lineLimit(1) + + Label( + hasPreview ? "Preview available" : "Preview unavailable", + systemImage: hasPreview ? "waveform" : "exclamationmark.circle" + ) + .font(.caption) + .foregroundStyle(.tertiary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!hasPreview) + .padding(.vertical, 3) + .accessibilityLabel( + hasPreview + ? "Analyze \(song.title) by \(song.artistName)" + : "\(song.title) by \(song.artistName), preview unavailable" + ) + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift b/Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift new file mode 100644 index 0000000..29f5c49 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct TransportBar: View { + @Environment(MusaveraLabModel.self) private var model + @Environment(PreviewPlayer.self) private var player + + var body: some View { + @Bindable var bindablePlayer = player + + HStack(spacing: 16) { + Button { + model.togglePreviewPlayback() + } label: { + Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") + .font(.body.weight(.semibold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.circle) + .controlSize(.large) + .keyboardShortcut(" ", modifiers: []) + .accessibilityLabel(player.isPlaying ? "Pause preview" : "Play preview") + + Text(player.formattedCurrentTime) + .font(.callout.monospacedDigit()) + .frame(width: 44, alignment: .trailing) + + Slider( + value: Binding( + get: { player.currentTime }, + set: { player.seek(to: $0) } + ), + in: 0...max(player.duration, 0.01) + ) + + Text(player.formattedDuration) + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 44, alignment: .leading) + + Image(systemName: "speaker.fill") + .foregroundStyle(.secondary) + + Slider(value: $bindablePlayer.volume, in: 0...1) + .frame(width: 110) + .accessibilityLabel("Preview volume") + } + .frame(maxWidth: 760) + .padding(.horizontal, 18) + .padding(.vertical, 12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) + .overlay { + RoundedRectangle(cornerRadius: 16) + .stroke(LabTheme.cardBorder) + } + .shadow(color: .black.opacity(0.12), radius: 16, y: 8) + .padding(.horizontal, 24) + .padding(.bottom, 14) + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Visualizations/TimelineComponents.swift b/Examples/MusaveraLab/MusaveraLab/Visualizations/TimelineComponents.swift new file mode 100644 index 0000000..55cca2b --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Visualizations/TimelineComponents.swift @@ -0,0 +1,264 @@ +import Charts +import CoreMedia +import MusicUnderstanding +import SwiftUI + +private struct PlayheadOverlay: View { + @Environment(PreviewPlayer.self) private var player + @State private var width: CGFloat = 0 + + var body: some View { + ZStack(alignment: .leading) { + TimelineView(.animation(paused: !player.isPlaying)) { _ in + let progress = player.duration > 0 + ? min(max(player.currentTime / player.duration, 0), 1) + : 0 + + Rectangle() + .fill(LabTheme.playhead) + .frame(width: 2) + .offset(x: width * progress) + } + + Color.clear + .contentShape(Rectangle()) + .onTapGesture { location in + guard width > 0 else { return } + player.seek(to: (location.x / width) * player.duration) + } + } + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.width + } action: { newWidth in + width = newWidth + } + } +} + +extension View { + func playheadOverlay() -> some View { + overlay { + PlayheadOverlay() + } + } +} + +struct StructureTimeline: View { + let result: StructureResult + + var body: some View { + VStack(spacing: 8) { + StructureBar(ranges: result.sections, color: LabTheme.structure) + .frame(height: 22) + + StructureBar(ranges: result.segments, color: LabTheme.segment) + .frame(height: 16) + + StructureBar(ranges: result.phrases, color: LabTheme.phrase) + .frame(height: 11) + } + .accessibilityLabel( + "\(result.sections.count) sections, \(result.segments.count) segments, and \(result.phrases.count) phrases" + ) + } +} + +private struct StructureBar: View { + @Environment(PreviewPlayer.self) private var player + @State private var size: CGSize = .zero + + let ranges: [CMTimeRange] + let color: Color + + var body: some View { + Color.clear + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { newSize in + size = newSize + } + .overlay(alignment: .leading) { + if player.duration > 0 { + ForEach(Array(ranges.enumerated()), id: \.offset) { _, range in + let start = max(range.start.seconds, 0) + let end = min(CMTimeRangeGetEnd(range).seconds, player.duration) + let width = max(((end - start) / player.duration) * size.width - 2, 1) + let offset = (start / player.duration) * size.width + let isActive = player.currentTime >= start && player.currentTime < end + + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(color.opacity(isActive && player.isPlaying ? 0.9 : 0.4)) + .frame(width: width, height: size.height) + .offset(x: offset) + } + } + } + } +} + +struct PaceChart: View { + @Environment(PreviewPlayer.self) private var player + + let result: PaceResult + + var body: some View { + let maximum = max(result.ranges.map(\.value).max() ?? 1, 1) + + Chart(Array(result.ranges.enumerated()), id: \.offset) { _, rangedValue in + BarMark( + xStart: .value("Start", rangedValue.range.start.seconds), + xEnd: .value("End", CMTimeRangeGetEnd(rangedValue.range).seconds), + y: .value("Pace", rangedValue.value) + ) + .foregroundStyle(LabTheme.pace.gradient) + .cornerRadius(5) + } + .chartXScale(domain: 0...max(player.duration, 1)) + .chartYScale(domain: 0...maximum) + .chartXAxis(.hidden) + .chartYAxis { + AxisMarks(position: .leading) + } + } +} + +struct InstrumentRangesChart: View { + @Environment(PreviewPlayer.self) private var player + + let result: InstrumentActivityResult + + var body: some View { + Chart { + ForEach(InstrumentActivityResult.Instrument.labOrder, id: \.rawValue) { instrument in + ForEach(result.ranges[instrument] ?? [], id: \.self) { range in + BarMark( + xStart: .value("Start", range.start.seconds), + xEnd: .value("End", CMTimeRangeGetEnd(range).seconds), + y: .value("Instrument", instrument.rawValue.capitalized) + ) + .foregroundStyle(instrument.labColor.gradient) + .cornerRadius(5) + } + } + } + .chartXScale(domain: 0...max(player.duration, 1)) + .chartXAxis(.hidden) + } +} + +struct InstrumentActivityChart: View { + let values: [MusicUnderstandingSession.TimedValue] + let color: Color + + var body: some View { + TimeSeriesChart( + values: values, + color: color, + yDomain: 0...1 + ) + } +} + +struct LoudnessAnalysisView: View { + let result: LoudnessResult + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 28) { + LoudnessMetric( + label: "Peak", + value: String(format: "%.1f dB", result.peak.value) + ) + + LoudnessMetric( + label: "Integrated", + value: String(format: "%.1f LUFS", result.integrated.value) + ) + + Spacer() + } + + TimeSeriesChart( + values: result.momentary, + color: LabTheme.loudness, + yDomain: -60...0 + ) + .frame(height: 180) + .playheadOverlay() + } + } +} + +private struct LoudnessMetric: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + + Text(value) + .font(.title3.bold().monospacedDigit()) + } + .accessibilityElement(children: .combine) + } +} + +private struct TimeSeriesChart: View { + @Environment(PreviewPlayer.self) private var player + + let values: [MusicUnderstandingSession.TimedValue] + let color: Color + let yDomain: ClosedRange + + var body: some View { + Chart(Array(values.enumerated()), id: \.offset) { _, point in + LineMark( + x: .value("Time", point.time.seconds), + y: .value("Value", point.value) + ) + .foregroundStyle(color) + .interpolationMethod(.linear) + } + .chartXScale(domain: 0...max(player.duration, 1)) + .chartYScale(domain: yDomain) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + } +} + +extension InstrumentActivityResult.Instrument { + static let labOrder: [Self] = [.vocal, .drum, .bass, .other] + + var labColor: Color { + switch self { + case .vocal: + .pink + case .drum: + .orange + case .bass: + .blue + case .other: + .purple + default: + .purple + } + } + + var systemImage: String { + switch self { + case .vocal: + "mic.fill" + case .drum: + "circle.grid.cross.fill" + case .bass: + "guitars.fill" + case .other: + "waveform" + default: + "music.note" + } + } +} diff --git a/Examples/MusaveraLab/README.md b/Examples/MusaveraLab/README.md new file mode 100644 index 0000000..24bd854 --- /dev/null +++ b/Examples/MusaveraLab/README.md @@ -0,0 +1,96 @@ +# Musavera Lab + +Musavera Lab composes two Apple frameworks without coupling their wrapper libraries: + +1. `MusicKit` searches Apple Music and exposes a song's downloadable preview URL. +2. The app caches that DRM-free preview locally. +3. `MusaveraKit` analyzes the local asset with `MusicUnderstanding`. +4. `AVPlayer`, SwiftUI, and Swift Charts keep playback and analysis visuals in sync. +5. `ApplicationMusicPlayer` remains available as a separate full-song playback path. + +MusadoraKit is intentionally not a dependency. MusadoraKit and MusaveraKit remain focused siblings, while this app demonstrates how a product can compose their underlying Apple frameworks. + +## Signal Chain Checklist + +### Foundation + +- [x] Keep the example inside the MusaveraKit repository. +- [x] Target macOS 27 so the app can run locally against MusicUnderstanding. +- [x] Reference the local MusaveraKit package product. +- [x] Use a dedicated bundle identifier: `com.rudrankriyam.musaveralab`. +- [x] Enable MusicKit for the App ID in Certificates, Identifiers & Profiles. + +### Apple Music + +- [x] Request MusicKit authorization with a clear usage description. +- [x] Search catalog songs with `MusicCatalogSearchRequest`. +- [x] Show artwork, title, artist, and preview availability. +- [x] Read `Song.previewAssets` directly from first-party MusicKit. +- [x] Offer full-song playback through `ApplicationMusicPlayer`. +- [x] Verify catalog search with a signed build and a real Apple Music account. +- [ ] Verify graceful behavior for a song with no preview. + +### Audio Pipeline + +- [x] Download the 30-second preview with `URLSession`. +- [x] Cache previews outside the source tree. +- [x] Copy security-scoped local files into the same cache. +- [x] Reject protected assets before analysis. +- [x] Build `AVURLAsset` with precise timing enabled. +- [x] Load the same asset into preview playback and MusaveraKit analysis. +- [ ] Exercise a preview with redirects and a non-`m4a` extension. + +### Understanding + +- [x] Analyze key, rhythm, structure, pace, instrument activity, and loudness. +- [x] Use MusaveraKit's key and instrument convenience helpers. +- [x] Keep analysis work off the UI while exposing a simple app state machine. +- [ ] Add cancellation when a user chooses a different track mid-analysis. +- [x] Export the complete native MusicUnderstanding result as formatted JSON. + +### Visuals + +- [x] Show a synchronized playhead over time-based results. +- [x] Render structure sections, segments, and phrases. +- [x] Render pace, instrument ranges, activity curves, and loudness. +- [x] Make the timeline seekable. +- [x] Support local audio as a generic fallback. +- [x] Adapt activity charts from one to four columns as the window grows. +- [ ] Add a compact mode for smaller windows. +- [ ] Add reduced-motion tuning and VoiceOver summaries for every chart. + +### Ship It + +- [x] Build the app with Xcode 27. +- [x] Run the signed macOS app. +- [x] Search Apple Music and analyze a real preview end to end. +- [x] Visually inspect the idle, loading, and results states. +- [ ] Keep the package's existing tests green. + +## Generate the Project + +The checked-in Xcode project is generated from `project.yml`: + +```bash +cd Examples/MusaveraLab +xcodegen generate +``` + +Then open `MusaveraLab.xcodeproj` with Xcode 27. + +MusicKit is enabled as an App Service for the bundle identifier. It does not +add a MusicKit key to the app's code-signing entitlements. + +## Verified Track + +The signed app was tested with Bruno Mars' "Grenade" from the Apple Music +catalog. Its artwork and 30-second preview loaded successfully, playback stayed +synchronized with the timelines, and MusicUnderstanding reported F major at +110 BPM for that preview excerpt. The result can be exported through the native +macOS save panel as formatted JSON. + +## Apple Sample Attribution + +The synchronized playback and visualization approach is adapted from Apple's +WWDC26 Music Understanding Lab sample. Apple's license is included in +`APPLE_SAMPLE_LICENSE.txt`. diff --git a/Examples/MusaveraLab/project.yml b/Examples/MusaveraLab/project.yml new file mode 100644 index 0000000..4ea9b25 --- /dev/null +++ b/Examples/MusaveraLab/project.yml @@ -0,0 +1,57 @@ +name: MusaveraLab + +options: + minimumXcodeGenVersion: 2.44.1 + deploymentTarget: + macOS: "27.0" + +packages: + MusaveraKit: + path: ../.. + +settings: + base: + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER: YES + DEVELOPMENT_TEAM: YQZQG7N4WG + ENABLE_USER_SCRIPT_SANDBOXING: YES + SWIFT_APPROACHABLE_CONCURRENCY: YES + SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY: YES + SWIFT_VERSION: 6.0 + +targets: + MusaveraLab: + type: application + platform: macOS + sources: + - path: MusaveraLab + dependencies: + - package: MusaveraKit + product: MusaveraKit + settings: + base: + CODE_SIGN_ENTITLEMENTS: MusaveraLab/MusaveraLab.entitlements + CODE_SIGN_STYLE: Automatic + CURRENT_PROJECT_VERSION: 1 + ENABLE_APP_SANDBOX: YES + ENABLE_HARDENED_RUNTIME: YES + ENABLE_OUTGOING_NETWORK_CONNECTIONS: YES + ENABLE_PREVIEWS: YES + ENABLE_USER_SELECTED_FILES: readonly + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: Musavera Lab + INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.music + INFOPLIST_KEY_NSAppleMusicUsageDescription: Musavera Lab uses Apple Music to search the catalog and play songs you choose. + MARKETING_VERSION: 1.0 + PRODUCT_BUNDLE_IDENTIFIER: com.rudrankriyam.musaveralab + PRODUCT_NAME: "$(TARGET_NAME)" + +schemes: + MusaveraLab: + build: + targets: + MusaveraLab: all + run: + config: Debug + archive: + config: Release diff --git a/README.md b/README.md index 98584c5..d4ebceb 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,18 @@ MusaveraKit analyzes audio assets for musical characteristics. The packages are independent so apps can adopt either one, while remaining complementary for music discovery, playback, and visualization experiences. +## Musavera Lab + +[`Examples/MusaveraLab`](Examples/MusaveraLab) is a signed macOS 27 sample app +that composes first-party MusicKit with MusaveraKit. It searches Apple Music, +shows catalog artwork, downloads a song's 30-second preview for local analysis, +offers separate full-song playback, and renders synchronized key, rhythm, +structure, pace, instrument, and loudness views. The complete native +MusicUnderstanding result can also be exported as formatted JSON. + +The activity charts adapt from one to four columns, so a large window can show +all four instrument activity timelines side by side. + ## Development Select Xcode 27 if it is not already active: diff --git a/Sources/MusaveraKit/Models/MusaveraAnalysis.swift b/Sources/MusaveraKit/Models/MusaveraAnalysis.swift index 9f1073a..645f5d5 100644 --- a/Sources/MusaveraKit/Models/MusaveraAnalysis.swift +++ b/Sources/MusaveraKit/Models/MusaveraAnalysis.swift @@ -3,7 +3,10 @@ import Foundation import MusicUnderstanding /// A small, app-friendly wrapper around `MusicUnderstandingSession.SessionResult`. -public struct MusaveraAnalysis: Sendable { +/// +/// Encoding a `MusaveraAnalysis` writes the underlying session result directly, +/// preserving MusicUnderstanding's native JSON representation. +public struct MusaveraAnalysis: Codable, Sendable { /// The original aggregate returned by Music Understanding. public let result: MusicUnderstandingSession.SessionResult @@ -12,6 +15,14 @@ public struct MusaveraAnalysis: Sendable { self.result = result } + public init(from decoder: any Decoder) throws { + result = try MusicUnderstandingSession.SessionResult(from: decoder) + } + + public func encode(to encoder: any Encoder) throws { + try result.encode(to: encoder) + } + /// Activity ranges and values for detected instrument categories. public var instrumentActivity: InstrumentActivityResult? { result.instrumentActivity diff --git a/Tests/MusaveraKitTests/MusaveraAnalysisCodableTests.swift b/Tests/MusaveraKitTests/MusaveraAnalysisCodableTests.swift new file mode 100644 index 0000000..e9d15df --- /dev/null +++ b/Tests/MusaveraKitTests/MusaveraAnalysisCodableTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing +@testable import MusaveraKit + +@Suite("Musavera analysis JSON") +struct MusaveraAnalysisCodableTests { + @Test("Encodes the native MusicUnderstanding result at the top level") + func encodesNativeResult() throws { + let analysis = try JSONDecoder().decode( + MusaveraAnalysis.self, + from: Data("{}".utf8) + ) + let data = try JSONEncoder().encode(analysis) + let object = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + #expect(object.isEmpty) + #expect(analysis.instrumentActivity == nil) + #expect(analysis.key == nil) + #expect(analysis.loudness == nil) + #expect(analysis.pace == nil) + #expect(analysis.rhythm == nil) + #expect(analysis.structure == nil) + } +}