From aaac8e4b42ab56a6541ccde55e10db8119a91ebf Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 14:10:01 -0700 Subject: [PATCH 01/25] Add Apple sample license --- Examples/MusaveraLab/APPLE_SAMPLE_LICENSE.txt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 Examples/MusaveraLab/APPLE_SAMPLE_LICENSE.txt diff --git a/Examples/MusaveraLab/APPLE_SAMPLE_LICENSE.txt b/Examples/MusaveraLab/APPLE_SAMPLE_LICENSE.txt new file mode 100644 index 0000000..34acdb0 --- /dev/null +++ b/Examples/MusaveraLab/APPLE_SAMPLE_LICENSE.txt @@ -0,0 +1,20 @@ +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. + From 599bd819a8360bdf7781f4ae0766328d230e56ed Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 14:10:21 -0700 Subject: [PATCH 02/25] Scaffold Musavera Lab project --- .../MusaveraLab.xcodeproj/project.pbxproj | 437 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcschemes/MusaveraLab.xcscheme | 91 ++++ Examples/MusaveraLab/project.yml | 58 +++ 4 files changed, 593 insertions(+) create mode 100644 Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj create mode 100644 Examples/MusaveraLab/MusaveraLab.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/MusaveraLab/MusaveraLab.xcodeproj/xcshareddata/xcschemes/MusaveraLab.xcscheme create mode 100644 Examples/MusaveraLab/project.yml diff --git a/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj b/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj new file mode 100644 index 0000000..3fbb037 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj @@ -0,0 +1,437 @@ +// !$*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 */; }; + 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; }; + 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 */, + 5D907943F997C17C6829F95A /* Services */, + 29800BCA3A37D96D264A5266 /* Views */, + 01615905EABEE2D5C93439D3 /* Visualizations */, + ); + path = MusaveraLab; + 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 */, + 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/project.yml b/Examples/MusaveraLab/project.yml new file mode 100644 index 0000000..31c22f6 --- /dev/null +++ b/Examples/MusaveraLab/project.yml @@ -0,0 +1,58 @@ +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 + From 2ee0af666208dba970c659f99678a365703cd05b Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 14:10:43 -0700 Subject: [PATCH 03/25] Configure Musavera Lab application --- .../MusaveraLab/App/MusaveraLabApp.swift | 16 ++++++++++++++++ .../MusaveraLab/MusaveraLab.entitlements | 12 ++++++++++++ Examples/MusaveraLab/MusaveraLab/Theme.swift | 14 ++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 Examples/MusaveraLab/MusaveraLab/App/MusaveraLabApp.swift create mode 100644 Examples/MusaveraLab/MusaveraLab/MusaveraLab.entitlements create mode 100644 Examples/MusaveraLab/MusaveraLab/Theme.swift 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/MusaveraLab.entitlements b/Examples/MusaveraLab/MusaveraLab/MusaveraLab.entitlements new file mode 100644 index 0000000..625af03 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/MusaveraLab.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + + + 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 +} From 9a4b895201b2a243dbd87765330aa4dcf413bf72 Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 14:11:29 -0700 Subject: [PATCH 04/25] Add preview audio file cache --- .../MusaveraLab/Services/AudioFileStore.swift | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift diff --git a/Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift b/Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift new file mode 100644 index 0000000..6e1ac49 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift @@ -0,0 +1,96 @@ +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 isAccessing = sourceURL.startAccessingSecurityScopedResource() + defer { + if isAccessing { + sourceURL.stopAccessingSecurityScopedResource() + } + } + + 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) + } + } +} + From 578188961bcedfd57bfd6170454a920a6c23d6f3 Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 14:11:53 -0700 Subject: [PATCH 05/25] Add synchronized preview player --- .../MusaveraLab/Services/PreviewPlayer.swift | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift diff --git a/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift b/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift new file mode 100644 index 0000000..af06ce5 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift @@ -0,0 +1,124 @@ +@preconcurrency import AVFoundation +import CoreMedia +import Foundation +import Observation + +@Observable +@MainActor +final class PreviewPlayer { + let player = AVPlayer() + + private(set) var isPlaying = false + private(set) var currentTime: Double = 0 + private(set) var duration: Double = 0 + private(set) var sampleRate: CMTimeScale = 44_100 + + 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 { + stop() + + 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 play() { + player.play() + isPlaying = true + } + + func pause() { + player.pause() + isPlaying = false + } + + func togglePlayback() { + isPlaying ? pause() : play() + } + + 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) + } +} + From c3b558614c29ca8ef7173a9abcf7c3ca602ad0d9 Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 14:12:12 -0700 Subject: [PATCH 06/25] Add MusicKit analysis model --- .../MusaveraLab/App/MusaveraLabModel.swift | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift diff --git a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift new file mode 100644 index 0000000..1dc6967 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift @@ -0,0 +1,223 @@ +@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 + } + + 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? + + let previewPlayer = PreviewPlayer() + + @ObservationIgnored private let fileStore = AudioFileStore() + @ObservationIgnored private let fullSongPlayer = ApplicationMusicPlayer.shared + + var isBusy: Bool { + workState != .idle + } + + var isAuthorized: Bool { + authorizationStatus == .authorized + } + + func prepare() { + authorizationStatus = MusicAuthorization.currentStatus + } + + func requestAuthorization() async { + guard workState == .idle else { return } + + 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 } + + selectedSong = song + analysis = nil + source = nil + previewPlayer.stop() + fullSongPlayer.pause() + + 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 } + + selectedSong = nil + analysis = nil + source = nil + previewPlayer.stop() + fullSongPlayer.pause() + 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 playFullSong() async { + guard let selectedSong else { return } + + previewPlayer.pause() + + do { + fullSongPlayer.queue = ApplicationMusicPlayer.Queue(for: [selectedSong]) + try await fullSongPlayer.play() + } catch { + errorMessage = "Full-song playback could not start: \(error.localizedDescription)" + } + } + + func reset() { + previewPlayer.stop() + fullSongPlayer.pause() + selectedSong = nil + source = nil + analysis = nil + workState = .idle + } + + 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 = try await Musavera.analyze(asset: asset) + 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.stop() + workState = .idle + errorMessage = error.localizedDescription + } +} + +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." + } + } +} From 6ab0796e9fa8805ec50d2e3727dd41cddb1aad6e Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 14:12:31 -0700 Subject: [PATCH 07/25] Add native macOS app shell --- .../MusaveraLab/Views/ContentView.swift | 249 ++++++++++++++++++ .../Views/MusicBrowserSidebar.swift | 61 +++++ 2 files changed, 310 insertions(+) create mode 100644 Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift create mode 100644 Examples/MusaveraLab/MusaveraLab/Views/MusicBrowserSidebar.swift diff --git a/Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift b/Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift new file mode 100644 index 0000000..28a4223 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift @@ -0,0 +1,249 @@ +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 + guard case .success(let url) = result else { return } + Task { + await model.analyzeLocalFile(at: url) + } + } + .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 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) + } +} From 2202742a8442fc6dbf2f7216c1a96ffcc1f50a8e Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 14:12:51 -0700 Subject: [PATCH 08/25] Add Apple Music catalog picker --- .../Views/MusicSelectionSheet.swift | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift diff --git a/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift b/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift new file mode 100644 index 0000000..6e5ff94 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift @@ -0,0 +1,170 @@ +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? + + 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) { + dismiss() + Task { + await model.analyze(song: song) + } + } + } + .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 { + if !model.isAuthorized { + await model.requestAuthorization() + } + } + .task(id: SearchContext(query: query, isAuthorized: model.isAuthorized)) { + await search() + } + } + + private func search() async { + let term = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard model.isAuthorized, !term.isEmpty else { + songs = [] + searchMessage = nil + isSearching = false + 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() + + songs = Array(response.songs) + searchMessage = songs.isEmpty ? "Nothing matched “\(term)”." : nil + isSearching = false + } catch is CancellationError { + return + } catch { + songs = [] + searchMessage = error.localizedDescription + isSearching = false + } + } +} + +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" + ) + } +} From e401b4bbb2eac3171733812201b3510e19fb0e44 Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 14:13:11 -0700 Subject: [PATCH 09/25] Add analysis summary components --- .../Views/AnalysisComponents.swift | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 Examples/MusaveraLab/MusaveraLab/Views/AnalysisComponents.swift 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 + } +} From 9550f522c20f2255f069c16e0de9cc3c733577ab Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 14:13:29 -0700 Subject: [PATCH 10/25] Add synchronized timeline visualizations --- .../Visualizations/TimelineComponents.swift | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 Examples/MusaveraLab/MusaveraLab/Visualizations/TimelineComponents.swift 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" + } + } +} From e842c8a584813fbfda5cce805a4e8dd0cda91b55 Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 14:13:59 -0700 Subject: [PATCH 11/25] Add responsive analysis dashboard --- .../MusaveraLab/Views/AnalysisDashboard.swift | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift diff --git a/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift new file mode 100644 index 0000000..10d6c7d --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift @@ -0,0 +1,149 @@ +import MusaveraKit +import SwiftUI + +struct AnalysisDashboard: View { + @Environment(MusaveraLabModel.self) private var model + + 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, + 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) + } + } +} + +private struct SourceHeader: View { + @Environment(MusaveraLabModel.self) private var model + + let source: MusaveraLabModel.AudioSource + 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.playFullSong() + } + } label: { + Label("Play Full Song", systemImage: "music.note") + } + .buttonStyle(.borderedProminent) + } + + Button { + onChooseMusic() + } label: { + Label("Find Music", systemImage: "magnifyingglass") + } + .buttonStyle(.bordered) + } + } + .padding(.vertical, source.artworkURL == nil ? 2 : 8) + } +} From 5b127ad75769d5080032a4ffa8f014e832b7b56f Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 14:14:17 -0700 Subject: [PATCH 12/25] Add preview transport controls --- .../MusaveraLab/Views/TransportBar.swift | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift diff --git a/Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift b/Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift new file mode 100644 index 0000000..40435e8 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct TransportBar: View { + @Environment(PreviewPlayer.self) private var player + + var body: some View { + @Bindable var bindablePlayer = player + + HStack(spacing: 16) { + Button { + player.togglePlayback() + } 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) + } +} From c8f879aa8f8d99a329c590fbd3bb9d588e95c12c Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 14:14:47 -0700 Subject: [PATCH 13/25] Document Musavera Lab workflow --- Examples/MusaveraLab/README.md | 95 ++++++++++++++++++++++++++++++++++ README.md | 11 ++++ 2 files changed, 106 insertions(+) create mode 100644 Examples/MusaveraLab/README.md diff --git a/Examples/MusaveraLab/README.md b/Examples/MusaveraLab/README.md new file mode 100644 index 0000000..52a1053 --- /dev/null +++ b/Examples/MusaveraLab/README.md @@ -0,0 +1,95 @@ +# 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. +- [ ] Add JSON export for sharing analysis results. + +### 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. + +## 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/README.md b/README.md index 77dfab6..8157e7a 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,17 @@ dependencies: [ .product(name: "MusaveraKit", package: "MusaveraKit") ``` +## 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 activity charts adapt from one to four columns, so a large window can show +all four instrument activity timelines side by side. + ## Current Status This is a beta SDK package. It is intentionally small and compiler-first while Apple finishes documenting the MusicUnderstanding framework. From e3162423258bc8ea11fda20bf6fa82954dbf94af Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 14:16:53 -0700 Subject: [PATCH 14/25] Clean up sample file endings --- Examples/MusaveraLab/APPLE_SAMPLE_LICENSE.txt | 1 - Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift | 1 - Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift | 1 - Examples/MusaveraLab/project.yml | 1 - 4 files changed, 4 deletions(-) diff --git a/Examples/MusaveraLab/APPLE_SAMPLE_LICENSE.txt b/Examples/MusaveraLab/APPLE_SAMPLE_LICENSE.txt index 34acdb0..1ecede7 100644 --- a/Examples/MusaveraLab/APPLE_SAMPLE_LICENSE.txt +++ b/Examples/MusaveraLab/APPLE_SAMPLE_LICENSE.txt @@ -17,4 +17,3 @@ 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/Services/AudioFileStore.swift b/Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift index 6e1ac49..b139bea 100644 --- a/Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift +++ b/Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift @@ -93,4 +93,3 @@ actor AudioFileStore { } } } - diff --git a/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift b/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift index af06ce5..0327c8d 100644 --- a/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift +++ b/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift @@ -121,4 +121,3 @@ final class PreviewPlayer { return String(format: "%02d:%02d", Int(seconds) / 60, Int(seconds) % 60) } } - diff --git a/Examples/MusaveraLab/project.yml b/Examples/MusaveraLab/project.yml index 31c22f6..4ea9b25 100644 --- a/Examples/MusaveraLab/project.yml +++ b/Examples/MusaveraLab/project.yml @@ -55,4 +55,3 @@ schemes: config: Debug archive: config: Release - From afbef79c014980c7374014d9e72257160d37319b Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 15:17:05 -0700 Subject: [PATCH 15/25] Add Codable analysis results --- .../MusaveraKit/Models/MusaveraAnalysis.swift | 13 +++++++++- .../MusaveraAnalysisCodableTests.swift | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 Tests/MusaveraKitTests/MusaveraAnalysisCodableTests.swift diff --git a/Sources/MusaveraKit/Models/MusaveraAnalysis.swift b/Sources/MusaveraKit/Models/MusaveraAnalysis.swift index 2e086a0..d89e0ca 100644 --- a/Sources/MusaveraKit/Models/MusaveraAnalysis.swift +++ b/Sources/MusaveraKit/Models/MusaveraAnalysis.swift @@ -3,13 +3,24 @@ 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 { public let result: MusicUnderstandingSession.SessionResult public init(result: MusicUnderstandingSession.SessionResult) { 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) + } + 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) + } +} From 72321db26b3783f2f71a1e80178e28b57943e9e6 Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 15:17:21 -0700 Subject: [PATCH 16/25] Add native JSON export --- .../MusaveraLab.xcodeproj/project.pbxproj | 12 +++++++ .../MusaveraLab/App/MusaveraLabModel.swift | 8 +++++ .../Documents/AnalysisJSONDocument.swift | 32 +++++++++++++++++++ .../MusaveraLab/MusaveraLab.entitlements | 2 +- .../MusaveraLab/Views/AnalysisDashboard.swift | 21 ++++++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 Examples/MusaveraLab/MusaveraLab/Documents/AnalysisJSONDocument.swift diff --git a/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj b/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj index 3fbb037..1d5ce90 100644 --- a/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj +++ b/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 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 */; }; @@ -36,6 +37,7 @@ 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 */ @@ -122,6 +124,7 @@ 351EE86AE67DD541E20901CC /* MusaveraLab.entitlements */, 17F91F1BF6C6D59CC92807B9 /* Theme.swift */, 9F3BF4EA07744FF279BBA3D0 /* App */, + F7F79764B4E0D5C905588239 /* Documents */, 5D907943F997C17C6829F95A /* Services */, 29800BCA3A37D96D264A5266 /* Views */, 01615905EABEE2D5C93439D3 /* Visualizations */, @@ -129,6 +132,14 @@ path = MusaveraLab; sourceTree = ""; }; + F7F79764B4E0D5C905588239 /* Documents */ = { + isa = PBXGroup; + children = ( + DE7DEA2C229BDA2A4B85CAAA /* AnalysisJSONDocument.swift */, + ); + path = Documents; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -195,6 +206,7 @@ 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 */, diff --git a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift index 1dc6967..3b825e1 100644 --- a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift +++ b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift @@ -13,6 +13,14 @@ final class MusaveraLabModel { 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 { 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 index 625af03..a046386 100644 --- a/Examples/MusaveraLab/MusaveraLab/MusaveraLab.entitlements +++ b/Examples/MusaveraLab/MusaveraLab/MusaveraLab.entitlements @@ -4,7 +4,7 @@ com.apple.security.app-sandbox - com.apple.security.files.user-selected.read-only + com.apple.security.files.user-selected.read-write com.apple.security.network.client diff --git a/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift index 10d6c7d..a327816 100644 --- a/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift +++ b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift @@ -1,8 +1,10 @@ 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 @@ -22,6 +24,7 @@ struct AnalysisDashboard: View { VStack(alignment: .leading, spacing: 18) { SourceHeader( source: source, + onExport: exportAnalysis, onChooseMusic: onChooseMusic ) @@ -88,6 +91,20 @@ struct AnalysisDashboard: View { .frame(maxWidth: 1_400, alignment: .leading) .padding(28) } + .fileExporter( + isPresented: $isExporting, + document: AnalysisJSONDocument(analysis: analysis), + contentType: .json, + defaultFilename: source.exportFilename + ) { result in + if case .failure(let error) = result { + model.errorMessage = "The analysis could not be exported: \(error.localizedDescription)" + } + } + } + + private func exportAnalysis() { + isExporting = true } } @@ -95,6 +112,7 @@ private struct SourceHeader: View { @Environment(MusaveraLabModel.self) private var model let source: MusaveraLabModel.AudioSource + let onExport: () -> Void let onChooseMusic: () -> Void var body: some View { @@ -136,6 +154,9 @@ private struct SourceHeader: View { .buttonStyle(.borderedProminent) } + Button("Export JSON", systemImage: "square.and.arrow.up", action: onExport) + .buttonStyle(.bordered) + Button { onChooseMusic() } label: { From eb57bf7cfde02bf9a825ebd759eb9f9215193885 Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 15:17:37 -0700 Subject: [PATCH 17/25] Document analysis export --- Examples/MusaveraLab/README.md | 5 +++-- README.md | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Examples/MusaveraLab/README.md b/Examples/MusaveraLab/README.md index 52a1053..24bd854 100644 --- a/Examples/MusaveraLab/README.md +++ b/Examples/MusaveraLab/README.md @@ -46,7 +46,7 @@ MusadoraKit is intentionally not a dependency. MusadoraKit and MusaveraKit remai - [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. -- [ ] Add JSON export for sharing analysis results. +- [x] Export the complete native MusicUnderstanding result as formatted JSON. ### Visuals @@ -86,7 +86,8 @@ add a MusicKit key to the app's code-signing entitlements. 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. +110 BPM for that preview excerpt. The result can be exported through the native +macOS save panel as formatted JSON. ## Apple Sample Attribution diff --git a/README.md b/README.md index 8157e7a..560e5b2 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,8 @@ dependencies: [ 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. +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. From dad30bc112761277344d73b33fd2a83114929a75 Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 15:51:57 -0700 Subject: [PATCH 18/25] Reset preview metadata between assets --- .../MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift b/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift index 0327c8d..6f36045 100644 --- a/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift +++ b/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift @@ -6,12 +6,14 @@ 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: CMTimeScale = 44_100 + private(set) var sampleRate = defaultSampleRate var volume: Float = 1 { didSet { @@ -35,6 +37,8 @@ final class PreviewPlayer { func load(_ asset: AVURLAsset) async { stop() + duration = 0 + sampleRate = Self.defaultSampleRate let item = AVPlayerItem(asset: asset) player.replaceCurrentItem(with: item) From 2dc3e63718ec87eb7395f81a791fba61d049fce8 Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 15:52:17 -0700 Subject: [PATCH 19/25] Keep security scope active during imports --- .../MusaveraLab/Services/AudioFileStore.swift | 7 ------- .../MusaveraLab/Views/ContentView.swift | 19 +++++++++++++++---- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift b/Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift index b139bea..4a646c1 100644 --- a/Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift +++ b/Examples/MusaveraLab/MusaveraLab/Services/AudioFileStore.swift @@ -41,13 +41,6 @@ actor AudioFileStore { } func importFile(_ sourceURL: URL) throws -> URL { - let isAccessing = sourceURL.startAccessingSecurityScopedResource() - defer { - if isAccessing { - sourceURL.stopAccessingSecurityScopedResource() - } - } - let destination = try destinationURL( identifier: UUID().uuidString, sourceURL: sourceURL, diff --git a/Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift b/Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift index 28a4223..96e7d50 100644 --- a/Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift +++ b/Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift @@ -35,10 +35,7 @@ struct ContentView: View { isPresented: $isImportingAudio, allowedContentTypes: [.audio] ) { result in - guard case .success(let url) = result else { return } - Task { - await model.analyzeLocalFile(at: url) - } + importAudio(from: result) } .alert( "Musavera Lab", @@ -61,6 +58,20 @@ struct ContentView: View { 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 { From bb1e0127837be34607934b58ebd85223a2f5077d Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 15:52:25 -0700 Subject: [PATCH 20/25] Stabilize Apple Music search state --- .../Views/MusicSelectionSheet.swift | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift b/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift index 6e5ff94..44b7929 100644 --- a/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift +++ b/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift @@ -9,6 +9,7 @@ struct MusicSelectionSheet: View { @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 { @@ -68,22 +69,24 @@ struct MusicSelectionSheet: View { } } .frame(minWidth: 680, minHeight: 540) - .task { - if !model.isAuthorized { - await model.requestAuthorization() - } - } .task(id: SearchContext(query: query, isAuthorized: model.isAuthorized)) { await search() } } 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 - isSearching = false return } @@ -99,15 +102,15 @@ struct MusicSelectionSheet: View { 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 - isSearching = false } catch is CancellationError { return } catch { + guard activeSearchID == searchID else { return } songs = [] searchMessage = error.localizedDescription - isSearching = false } } } From 87a5c1c5fadc64cd31b926fcee7bb694a2adf429 Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 15:52:36 -0700 Subject: [PATCH 21/25] Clear stale state after analysis failures --- Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift index 3b825e1..b397df4 100644 --- a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift +++ b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift @@ -211,6 +211,10 @@ final class MusaveraLabModel { private func fail(with error: Error) { previewPlayer.stop() + fullSongPlayer.pause() + selectedSong = nil + source = nil + analysis = nil workState = .idle errorMessage = error.localizedDescription } From 18a974ffae78dad8d7d688a9c8b5b7ff9190e624 Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 16:05:50 -0700 Subject: [PATCH 22/25] Unload previews after failed analysis --- .../MusaveraLab/App/MusaveraLabModel.swift | 12 +++++++++--- .../MusaveraLab/Services/PreviewPlayer.swift | 13 ++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift index b397df4..32cdf1b 100644 --- a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift +++ b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift @@ -156,7 +156,7 @@ final class MusaveraLabModel { } func reset() { - previewPlayer.stop() + previewPlayer.unload() fullSongPlayer.pause() selectedSong = nil source = nil @@ -191,7 +191,13 @@ final class MusaveraLabModel { workState = .analyzing async let playerLoad: Void = previewPlayer.load(asset) - let result = try await Musavera.analyze(asset: asset) + let result: MusaveraAnalysis + do { + result = try await Musavera.analyze(asset: asset) + } catch { + await playerLoad + throw error + } await playerLoad analysis = result @@ -210,7 +216,7 @@ final class MusaveraLabModel { } private func fail(with error: Error) { - previewPlayer.stop() + previewPlayer.unload() fullSongPlayer.pause() selectedSong = nil source = nil diff --git a/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift b/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift index 6f36045..615e38f 100644 --- a/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift +++ b/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift @@ -36,9 +36,7 @@ final class PreviewPlayer { } func load(_ asset: AVURLAsset) async { - stop() - duration = 0 - sampleRate = Self.defaultSampleRate + unload() let item = AVPlayerItem(asset: asset) player.replaceCurrentItem(with: item) @@ -58,6 +56,15 @@ final class PreviewPlayer { } } + func unload() { + stop() + endOfPlaybackTask?.cancel() + endOfPlaybackTask = nil + player.replaceCurrentItem(with: nil) + duration = 0 + sampleRate = Self.defaultSampleRate + } + func play() { player.play() isPlaying = true From 675638e8509cfaaf473c7c3ed5ba437793c4fd7c Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 16:09:52 -0700 Subject: [PATCH 23/25] Clear stale operation errors --- Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift | 5 +++++ .../MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift index 32cdf1b..46514c5 100644 --- a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift +++ b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift @@ -76,6 +76,7 @@ final class MusaveraLabModel { func requestAuthorization() async { guard workState == .idle else { return } + errorMessage = nil workState = .authorizing authorizationStatus = await MusicAuthorization.request() workState = .idle @@ -89,6 +90,7 @@ final class MusaveraLabModel { func analyze(song: Song) async { guard workState == .idle else { return } + errorMessage = nil selectedSong = song analysis = nil source = nil @@ -121,6 +123,7 @@ final class MusaveraLabModel { func analyzeLocalFile(at sourceURL: URL) async { guard workState == .idle else { return } + errorMessage = nil selectedSong = nil analysis = nil source = nil @@ -145,6 +148,7 @@ final class MusaveraLabModel { func playFullSong() async { guard let selectedSong else { return } + errorMessage = nil previewPlayer.pause() do { @@ -162,6 +166,7 @@ final class MusaveraLabModel { source = nil analysis = nil workState = .idle + errorMessage = nil } private func analyzeAudio( diff --git a/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift index a327816..cd516f4 100644 --- a/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift +++ b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift @@ -97,13 +97,17 @@ struct AnalysisDashboard: View { contentType: .json, defaultFilename: source.exportFilename ) { result in - if case .failure(let error) = result { + 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 } } From fbaa312f5d41999f67c5439e37f2973b216e30ab Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 16:20:25 -0700 Subject: [PATCH 24/25] Coordinate preview and full-song playback --- .../MusaveraLab/App/MusaveraLabModel.swift | 91 ++++++++++++++++++- .../MusaveraLab/Services/PreviewPlayer.swift | 4 - .../MusaveraLab/Views/AnalysisDashboard.swift | 7 +- .../MusaveraLab/Views/TransportBar.swift | 3 +- 4 files changed, 93 insertions(+), 12 deletions(-) diff --git a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift index 46514c5..6c35ded 100644 --- a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift +++ b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift @@ -55,11 +55,23 @@ final class MusaveraLabModel { 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 @@ -69,6 +81,19 @@ final class MusaveraLabModel { authorizationStatus == .authorized } + var isFullSongPlaybackActive: Bool { + if isStartingFullSong { + true + } else { + switch fullSongPlaybackStatus { + case .playing, .seekingForward, .seekingBackward: + true + default: + false + } + } + } + func prepare() { authorizationStatus = MusicAuthorization.currentStatus } @@ -95,7 +120,7 @@ final class MusaveraLabModel { analysis = nil source = nil previewPlayer.stop() - fullSongPlayer.pause() + cancelFullSongPlayback() do { guard let previewURL = song.previewAssets?.compactMap(\.url).first else { @@ -128,7 +153,7 @@ final class MusaveraLabModel { analysis = nil source = nil previewPlayer.stop() - fullSongPlayer.pause() + cancelFullSongPlayback() workState = .importing do { @@ -145,23 +170,52 @@ final class MusaveraLabModel { } } - func playFullSong() async { + 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() - fullSongPlayer.pause() + cancelFullSongPlayback() selectedSong = nil source = nil analysis = nil @@ -222,13 +276,40 @@ final class MusaveraLabModel { private func fail(with error: Error) { previewPlayer.unload() - fullSongPlayer.pause() + 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 { diff --git a/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift b/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift index 615e38f..5b6668c 100644 --- a/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift +++ b/Examples/MusaveraLab/MusaveraLab/Services/PreviewPlayer.swift @@ -75,10 +75,6 @@ final class PreviewPlayer { isPlaying = false } - func togglePlayback() { - isPlaying ? pause() : play() - } - func stop() { pause() player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero) diff --git a/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift index cd516f4..a0fb900 100644 --- a/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift +++ b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift @@ -150,10 +150,13 @@ private struct SourceHeader: View { if model.selectedSong != nil { Button { Task { - await model.playFullSong() + await model.toggleFullSongPlayback() } } label: { - Label("Play Full Song", systemImage: "music.note") + Label( + model.isFullSongPlaybackActive ? "Pause Full Song" : "Play Full Song", + systemImage: model.isFullSongPlaybackActive ? "pause.fill" : "music.note" + ) } .buttonStyle(.borderedProminent) } diff --git a/Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift b/Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift index 40435e8..29f5c49 100644 --- a/Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift +++ b/Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift @@ -1,6 +1,7 @@ import SwiftUI struct TransportBar: View { + @Environment(MusaveraLabModel.self) private var model @Environment(PreviewPlayer.self) private var player var body: some View { @@ -8,7 +9,7 @@ struct TransportBar: View { HStack(spacing: 16) { Button { - player.togglePlayback() + model.togglePreviewPlayback() } label: { Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") .font(.body.weight(.semibold)) From d0f03e106820df6589854bbd4ed9f9030009a623 Mon Sep 17 00:00:00 2001 From: rudrankriyam Date: Sat, 13 Jun 2026 16:31:58 -0700 Subject: [PATCH 25/25] Keep busy song selections in place --- .../MusaveraLab/Views/MusicSelectionSheet.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift b/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift index 44b7929..2dc8853 100644 --- a/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift +++ b/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift @@ -45,11 +45,9 @@ struct MusicSelectionSheet: View { } else { List(songs) { song in MusicSelectionRow(song: song) { - dismiss() - Task { - await model.analyze(song: song) - } + select(song) } + .disabled(model.isBusy) } .listStyle(.inset) } @@ -74,6 +72,15 @@ struct MusicSelectionSheet: View { } } + 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