diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd9ad9..f3a15cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and releases follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Added + +- Streaming PCM analysis through `MusaveraStreamingSession`, including + realtime loudness results and final aggregate analysis. + ## 0.1.0 - 2026-06-13 ### Added diff --git a/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj b/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj index 1d5ce90..ca879bc 100644 --- a/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj +++ b/Examples/MusaveraLab/MusaveraLab.xcodeproj/project.pbxproj @@ -7,39 +7,63 @@ objects = { /* Begin PBXBuildFile section */ + 21DA8B957D4C23AF4896CB30 /* LiveStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 773251A88F78F71EF655F53F /* LiveStreamView.swift */; }; 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 */; }; + 68E36C0E62FDBAC2EF3DF7F3 /* LiveStreamError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF70B3C1E29572AE441DE534 /* LiveStreamError.swift */; }; + 6E85767F9490B545C1D4EF21 /* AnalysisResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCFA482A340B55F54FCF30BC /* AnalysisResultsView.swift */; }; + 740A41F026B23CD3EC530064 /* LiveLoudnessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4B8CF7D3D8C20A2C53792E /* LiveLoudnessView.swift */; }; + 7B604FAF786BE94A91F85BE3 /* LabSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F57F6E67F40B939D10D2B323 /* LabSection.swift */; }; + 8C37156321C220D60A4BB470 /* Musavera Lab.icon in Resources */ = {isa = PBXBuildFile; fileRef = 8647D544CED10403821B344A /* Musavera Lab.icon */; }; + A02CD915DDE930A7BF199CB3 /* LiveStreamHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 701E55CEC44C91AC4EB8A3E9 /* LiveStreamHeader.swift */; }; + A7BDBEE7E6AF9943011DA9F2 /* LiveFinalAnalysisView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4496F1324C2312872AC5DA68 /* LiveFinalAnalysisView.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 */; }; + C244F797E1A8335CF09C3A8D /* LiveRecordingTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D33290F119D182479F1D5DBC /* LiveRecordingTransport.swift */; }; + C533DF268C534BC55CE1EB65 /* AnalyzeWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45240A93A424DC0458B95A59 /* AnalyzeWelcomeView.swift */; }; + CC1D83FF672A25C9BFC2D5C9 /* LiveStreamModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9563903791411B1D985ED35 /* LiveStreamModel.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 */; }; + F702E58D57C0246CCFE6821C /* LiveStreamState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C40DF98E80114E1B6B9D39 /* LiveStreamState.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 16850E396168613A82B4E05D /* MusaveraLab.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MusaveraLab.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 17C40DF98E80114E1B6B9D39 /* LiveStreamState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveStreamState.swift; sourceTree = ""; }; 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 = ""; }; + 4496F1324C2312872AC5DA68 /* LiveFinalAnalysisView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFinalAnalysisView.swift; sourceTree = ""; }; + 45240A93A424DC0458B95A59 /* AnalyzeWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyzeWelcomeView.swift; 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 = ""; }; + 701E55CEC44C91AC4EB8A3E9 /* LiveStreamHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveStreamHeader.swift; sourceTree = ""; }; 72FE5CB39CDA7B494D1DE3A4 /* AnalysisDashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalysisDashboard.swift; sourceTree = ""; }; + 773251A88F78F71EF655F53F /* LiveStreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveStreamView.swift; sourceTree = ""; }; + 7F4B8CF7D3D8C20A2C53792E /* LiveLoudnessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLoudnessView.swift; sourceTree = ""; }; + 8647D544CED10403821B344A /* Musavera Lab.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = "Musavera Lab.icon"; 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 = ""; }; + BF70B3C1E29572AE441DE534 /* LiveStreamError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveStreamError.swift; sourceTree = ""; }; C5DD336EA101AD39E6141936 /* MusicBrowserSidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicBrowserSidebar.swift; sourceTree = ""; }; + C9563903791411B1D985ED35 /* LiveStreamModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveStreamModel.swift; sourceTree = ""; }; + CCFA482A340B55F54FCF30BC /* AnalysisResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalysisResultsView.swift; sourceTree = ""; }; + D33290F119D182479F1D5DBC /* LiveRecordingTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveRecordingTransport.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 = ""; }; + F57F6E67F40B939D10D2B323 /* LabSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabSection.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -67,7 +91,14 @@ children = ( 28134147038B1E3872648893 /* AnalysisComponents.swift */, 72FE5CB39CDA7B494D1DE3A4 /* AnalysisDashboard.swift */, + CCFA482A340B55F54FCF30BC /* AnalysisResultsView.swift */, + 45240A93A424DC0458B95A59 /* AnalyzeWelcomeView.swift */, 5D7010759E3CE72A46D76413 /* ContentView.swift */, + 4496F1324C2312872AC5DA68 /* LiveFinalAnalysisView.swift */, + 7F4B8CF7D3D8C20A2C53792E /* LiveLoudnessView.swift */, + D33290F119D182479F1D5DBC /* LiveRecordingTransport.swift */, + 701E55CEC44C91AC4EB8A3E9 /* LiveStreamHeader.swift */, + 773251A88F78F71EF655F53F /* LiveStreamView.swift */, C5DD336EA101AD39E6141936 /* MusicBrowserSidebar.swift */, E2965F30D5C9A008DC2AFD30 /* MusicSelectionSheet.swift */, B6ECFFBEFCF66FA2E2FAB48D /* TransportBar.swift */, @@ -88,6 +119,9 @@ isa = PBXGroup; children = ( A018FE4324BF125ABD274B2D /* AudioFileStore.swift */, + BF70B3C1E29572AE441DE534 /* LiveStreamError.swift */, + C9563903791411B1D985ED35 /* LiveStreamModel.swift */, + 17C40DF98E80114E1B6B9D39 /* LiveStreamState.swift */, 4B3D4276646810C2DA45A3E8 /* PreviewPlayer.swift */, ); path = Services; @@ -112,6 +146,7 @@ 9F3BF4EA07744FF279BBA3D0 /* App */ = { isa = PBXGroup; children = ( + F57F6E67F40B939D10D2B323 /* LabSection.swift */, 6D0BE121349F66D59ED4BD2A /* MusaveraLabApp.swift */, E038E4851D68CBE647F8DF98 /* MusaveraLabModel.swift */, ); @@ -121,6 +156,7 @@ CD691D72EA74C87BF3D616F5 /* MusaveraLab */ = { isa = PBXGroup; children = ( + 8647D544CED10403821B344A /* Musavera Lab.icon */, 351EE86AE67DD541E20901CC /* MusaveraLab.entitlements */, 17F91F1BF6C6D59CC92807B9 /* Theme.swift */, 9F3BF4EA07744FF279BBA3D0 /* App */, @@ -148,6 +184,7 @@ buildConfigurationList = 4A95BEC55B4F4C2577B13035 /* Build configuration list for PBXNativeTarget "MusaveraLab" */; buildPhases = ( CE074CFB82092D3783CAFD50 /* Sources */, + E486B4177EC2EDE90291A53F /* Resources */, 977874206CB93E56BD73BA4D /* Frameworks */, ); buildRules = ( @@ -199,6 +236,17 @@ }; /* End PBXProject section */ +/* Begin PBXResourcesBuildPhase section */ + E486B4177EC2EDE90291A53F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8C37156321C220D60A4BB470 /* Musavera Lab.icon in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ CE074CFB82092D3783CAFD50 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -207,8 +255,19 @@ B0F5DFB080DD500DF9A8B5FF /* AnalysisComponents.swift in Sources */, 4E9EB78FBA07F3AED856D197 /* AnalysisDashboard.swift in Sources */, A91B6A70C878816A79A7C3AF /* AnalysisJSONDocument.swift in Sources */, + 6E85767F9490B545C1D4EF21 /* AnalysisResultsView.swift in Sources */, + C533DF268C534BC55CE1EB65 /* AnalyzeWelcomeView.swift in Sources */, EC67369138AEA5397F184A95 /* AudioFileStore.swift in Sources */, F17FAD96862797EAC998B10D /* ContentView.swift in Sources */, + 7B604FAF786BE94A91F85BE3 /* LabSection.swift in Sources */, + A7BDBEE7E6AF9943011DA9F2 /* LiveFinalAnalysisView.swift in Sources */, + 740A41F026B23CD3EC530064 /* LiveLoudnessView.swift in Sources */, + C244F797E1A8335CF09C3A8D /* LiveRecordingTransport.swift in Sources */, + 68E36C0E62FDBAC2EF3DF7F3 /* LiveStreamError.swift in Sources */, + A02CD915DDE930A7BF199CB3 /* LiveStreamHeader.swift in Sources */, + CC1D83FF672A25C9BFC2D5C9 /* LiveStreamModel.swift in Sources */, + F702E58D57C0246CCFE6821C /* LiveStreamState.swift in Sources */, + 21DA8B957D4C23AF4896CB30 /* LiveStreamView.swift in Sources */, 2BA84BC4C0402FADE8E84A6F /* MusaveraLabApp.swift in Sources */, 24785B8BAB77C0EBC9A28DA1 /* MusaveraLabModel.swift in Sources */, 2AD8EE2019F19C3DC7536C8E /* MusicBrowserSidebar.swift in Sources */, @@ -294,12 +353,14 @@ BA6F66BD215783BC54571F70 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "Musavera Lab"; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = MusaveraLab/MusaveraLab.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = YES; + ENABLE_AUDIO_INPUT = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; @@ -308,6 +369,7 @@ 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."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Musavera Lab uses the microphone to analyze live audio on this Mac."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -322,12 +384,14 @@ DC3D19AEF2B3314DB1BCCF7D /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "Musavera Lab"; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = MusaveraLab/MusaveraLab.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = YES; + ENABLE_AUDIO_INPUT = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; @@ -336,6 +400,7 @@ 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."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Musavera Lab uses the microphone to analyze live audio on this Mac."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", diff --git a/Examples/MusaveraLab/MusaveraLab/App/LabSection.swift b/Examples/MusaveraLab/MusaveraLab/App/LabSection.swift new file mode 100644 index 0000000..3747e71 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/App/LabSection.swift @@ -0,0 +1,35 @@ +import SwiftUI + +enum LabSection: String, CaseIterable, Identifiable { + case analyze + case live + + var id: Self { self } + + var title: String { + switch self { + case .analyze: + "Analyze Music" + case .live: + "Live Stream" + } + } + + var subtitle: String { + switch self { + case .analyze: + "Apple Music previews and audio files" + case .live: + "Microphone loudness and final analysis" + } + } + + var systemImage: String { + switch self { + case .analyze: + "waveform.path.ecg" + case .live: + "waveform.badge.microphone" + } + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabApp.swift b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabApp.swift index c5e06a5..b2fcaf4 100644 --- a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabApp.swift +++ b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabApp.swift @@ -3,14 +3,18 @@ import SwiftUI @main struct MusaveraLabApp: App { @State private var model = MusaveraLabModel() + @State private var liveStreamModel = LiveStreamModel() var body: some Scene { WindowGroup { ContentView() .environment(model) + .environment(liveStreamModel) .environment(model.previewPlayer) + .tint(LabTheme.accent) .frame(minWidth: 1_080, minHeight: 720) } .defaultSize(width: 1_320, height: 860) + .windowToolbarStyle(.unifiedCompact(showsTitle: false)) } } diff --git a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift index 6c35ded..ab4417e 100644 --- a/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift +++ b/Examples/MusaveraLab/MusaveraLab/App/MusaveraLabModel.swift @@ -55,17 +55,19 @@ final class MusaveraLabModel { var analysis: MusaveraAnalysis? var workState: WorkState = .idle var errorMessage: String? - private(set) var fullSongPlaybackStatus = ApplicationMusicPlayer.shared.state.playbackStatus + private(set) var fullSongPlaybackStatus: MusicKit.MusicPlayer.PlaybackStatus = .stopped private(set) var isStartingFullSong = false let previewPlayer = PreviewPlayer() @ObservationIgnored private let fileStore = AudioFileStore() @ObservationIgnored private let fullSongPlayer = ApplicationMusicPlayer.shared + @ObservationIgnored private let fullSongState = ApplicationMusicPlayer.shared.state @ObservationIgnored private var fullSongStateTask: Task? @ObservationIgnored private var pendingFullSongPlayID: UUID? init() { + fullSongPlaybackStatus = fullSongState.playbackStatus observeFullSongState() } @@ -203,6 +205,7 @@ final class MusaveraLabModel { do { fullSongPlayer.queue = ApplicationMusicPlayer.Queue(for: [selectedSong]) try await fullSongPlayer.play() + fullSongPlaybackStatus = fullSongState.playbackStatus guard pendingFullSongPlayID == requestID else { fullSongPlayer.pause() return @@ -213,6 +216,11 @@ final class MusaveraLabModel { } } + func pauseAllPlayback() { + previewPlayer.pause() + cancelFullSongPlayback() + } + func reset() { previewPlayer.unload() cancelFullSongPlayback() @@ -291,10 +299,10 @@ final class MusaveraLabModel { } private func observeFullSongState() { - let player = fullSongPlayer + let state = fullSongState fullSongStateTask = Task { @MainActor [weak self] in let statuses = Observations { - player.state.playbackStatus + state.playbackStatus } for await status in statuses { diff --git a/Examples/MusaveraLab/MusaveraLab/Musavera Lab.icon/Assets/Musavera-Lab-Icon-1024.png b/Examples/MusaveraLab/MusaveraLab/Musavera Lab.icon/Assets/Musavera-Lab-Icon-1024.png new file mode 100644 index 0000000..2b59285 Binary files /dev/null and b/Examples/MusaveraLab/MusaveraLab/Musavera Lab.icon/Assets/Musavera-Lab-Icon-1024.png differ diff --git a/Examples/MusaveraLab/MusaveraLab/Musavera Lab.icon/icon.json b/Examples/MusaveraLab/MusaveraLab/Musavera Lab.icon/icon.json new file mode 100644 index 0000000..f2f0e25 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Musavera Lab.icon/icon.json @@ -0,0 +1,30 @@ +{ + "fill" : { + "automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "Musavera-Lab-Icon-1024.png", + "name" : "Musavera Mark" + } + ], + "name" : "Artwork", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : false, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/Examples/MusaveraLab/MusaveraLab/MusaveraLab.entitlements b/Examples/MusaveraLab/MusaveraLab/MusaveraLab.entitlements index a046386..30d9cee 100644 --- a/Examples/MusaveraLab/MusaveraLab/MusaveraLab.entitlements +++ b/Examples/MusaveraLab/MusaveraLab/MusaveraLab.entitlements @@ -4,6 +4,8 @@ com.apple.security.app-sandbox + com.apple.security.device.audio-input + com.apple.security.files.user-selected.read-write com.apple.security.network.client diff --git a/Examples/MusaveraLab/MusaveraLab/Services/LiveStreamError.swift b/Examples/MusaveraLab/MusaveraLab/Services/LiveStreamError.swift new file mode 100644 index 0000000..4c083f5 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Services/LiveStreamError.swift @@ -0,0 +1,24 @@ +import Foundation + +enum LiveStreamError: LocalizedError { + case microphonePermissionDenied + case microphoneUnavailable + case invalidInputFormat + case noAudioCaptured + case captureFailed(String) + + var errorDescription: String? { + switch self { + case .microphonePermissionDenied: + "Microphone access is off. Allow Musavera Lab in System Settings > Privacy & Security > Microphone." + case .microphoneUnavailable: + "No microphone input is available on this Mac." + case .invalidInputFormat: + "The selected microphone did not provide a usable PCM audio format." + case .noAudioCaptured: + "No microphone audio reached the analyzer. Check the selected input device and try again." + case .captureFailed(let detail): + "Live audio capture could not start: \(detail)" + } + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Services/LiveStreamModel.swift b/Examples/MusaveraLab/MusaveraLab/Services/LiveStreamModel.swift new file mode 100644 index 0000000..110682e --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Services/LiveStreamModel.swift @@ -0,0 +1,541 @@ +@preconcurrency import AVFoundation +import Foundation +import MusicUnderstanding +import MusaveraKit +import Observation + +@Observable +@MainActor +final class LiveStreamModel { + typealias TimedFloat = MusicUnderstandingSession.TimedValue + + private(set) var state: LiveStreamState = .idle + private(set) var sampleRate: Double = 0 + private(set) var channelCount = 0 + private(set) var elapsedTime: TimeInterval = 0 + private(set) var bufferCount = 0 + private(set) var latestLoudness: LoudnessResult? + private(set) var momentaryHistory: [TimedFloat] = [] + private(set) var finalAnalysis: MusaveraAnalysis? + private(set) var recordingURL: URL? + var errorMessage: String? + + let recordingPlayer = PreviewPlayer() + + @ObservationIgnored private var audioEngine: AVAudioEngine? + @ObservationIgnored private var captureSink: LiveAudioBufferSink? + @ObservationIgnored private var recordingWriter: LiveRecordingWriter? + @ObservationIgnored private var streamingSession: MusaveraStreamingSession? + @ObservationIgnored private var analysisTask: Task? + @ObservationIgnored private var loudnessTask: Task? + @ObservationIgnored private var recordingLoadTask: Task? + @ObservationIgnored private var startRequestID: UUID? + @ObservationIgnored private var tapInstalled = false + @ObservationIgnored private var capturedFrameCount: Int64 = 0 + + isolated deinit { + analysisTask?.cancel() + loudnessTask?.cancel() + recordingLoadTask?.cancel() + captureSink?.finish() + recordingWriter?.discard() + + if tapInstalled { + audioEngine?.inputNode.removeTap(onBus: 0) + } + audioEngine?.stop() + + if let streamingSession { + Task { + await streamingSession.cancel() + } + } + + if let recordingURL { + try? FileManager.default.removeItem(at: recordingURL) + } + } + + var isListening: Bool { + state == .listening + } + + var isWorking: Bool { + state == .requestingPermission || state == .finalizing + } + + var canReset: Bool { + state != .idle + || finalAnalysis != nil + || latestLoudness != nil + || recordingURL != nil + || errorMessage != nil + } + + var isRecordingReady: Bool { + recordingURL != nil && recordingPlayer.duration > 0 + } + + var latestMomentaryValue: Float? { + latestLoudness?.momentary.last?.value + } + + var latestShortTermValue: Float? { + latestLoudness?.shortTerm.last?.value + } + + var integratedValue: Float? { + latestLoudness?.integrated.value + } + + var peakValue: Float? { + latestLoudness?.peak.value + } + + var microphonePermissionDenied: Bool { + let status = AVCaptureDevice.authorizationStatus(for: .audio) + return status == .denied || status == .restricted + } + + func start() async { + guard state == .idle || state == .complete else { return } + + prepareForNewCapture() + let requestID = UUID() + startRequestID = requestID + state = .requestingPermission + + let hasMicrophoneAccess = await requestMicrophoneAccess() + guard startRequestID == requestID, state == .requestingPermission else { + return + } + startRequestID = nil + + guard hasMicrophoneAccess else { + fail(with: LiveStreamError.microphonePermissionDenied) + return + } + + do { + try beginCapture() + } catch { + fail(with: error) + } + } + + func stop() { + guard state == .listening else { return } + + state = .finalizing + let progress = finishAudioProvider(retainRecording: true) + + guard progress.frameCount > 0 else { + fail(with: LiveStreamError.noAudioCaptured) + return + } + } + + func deactivate() { + pauseRecordingPlayback() + + switch state { + case .requestingPermission: + startRequestID = nil + state = .idle + case .listening: + stop() + default: + break + } + } + + func toggleRecordingPlayback() { + guard isRecordingReady else { return } + + if recordingPlayer.isPlaying { + recordingPlayer.pause() + } else { + recordingPlayer.play() + } + } + + func pauseRecordingPlayback() { + recordingPlayer.pause() + } + + func reset() { + cancelActiveSession(retainRecording: false) + clearResults() + state = .idle + errorMessage = nil + } + + private func requestMicrophoneAccess() async -> Bool { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + true + case .notDetermined: + await AVCaptureDevice.requestAccess(for: .audio) + case .denied, .restricted: + false + @unknown default: + false + } + } + + private func beginCapture() throws { + let engine = AVAudioEngine() + let inputNode = engine.inputNode + let format = inputNode.outputFormat(forBus: 0) + + guard format.sampleRate > 0, format.channelCount > 0 else { + throw LiveStreamError.invalidInputFormat + } + + let pair = AsyncStream.makeStream( + bufferingPolicy: .unbounded + ) + let sink = LiveAudioBufferSink(continuation: pair.continuation) + let session = MusaveraStreamingSession(audioProvider: pair.stream) + let writer = try LiveRecordingWriter(format: format) + + audioEngine = engine + captureSink = sink + recordingWriter = writer + streamingSession = session + sampleRate = format.sampleRate + channelCount = Int(format.channelCount) + + observeLoudness(from: session) + analyzeStream(with: session) + + let preferredFrameCount = Int(format.sampleRate * 0.12) + let bufferSize = AVAudioFrameCount(max(preferredFrameCount, 512)) + + do { + try inputNode.installAudioTap( + onBus: 0, + bufferSize: bufferSize, + format: format + ) { [weak self, sink, writer] buffer, _ in + writer.append(buffer) + guard let progress = sink.yield(buffer) else { return } + + Task { @MainActor [weak self, sink] in + self?.receive(captureProgress: progress, from: sink) + } + } + tapInstalled = true + + engine.prepare() + try engine.start() + state = .listening + } catch { + finishAudioProvider(retainRecording: false) + throw LiveStreamError.captureFailed(error.localizedDescription) + } + } + + private func observeLoudness(from session: MusaveraStreamingSession) { + loudnessTask = Task { [weak self] in + do { + for try await result in session.loudnessResults { + guard !Task.isCancelled else { return } + self?.receive(loudness: result) + } + } catch { + guard !Task.isCancelled else { return } + self?.handleLoudnessFailure(error) + } + } + } + + private func analyzeStream(with session: MusaveraStreamingSession) { + analysisTask = Task { [weak self] in + do { + let analysis = try await session.analyze() + guard !Task.isCancelled else { return } + self?.complete(with: analysis) + } catch { + guard !Task.isCancelled else { return } + self?.fail(with: error) + } + } + } + + private func receive( + captureProgress: LiveCaptureProgress, + from sink: LiveAudioBufferSink + ) { + guard captureSink === sink else { return } + apply(captureProgress: captureProgress) + } + + private func apply(captureProgress: LiveCaptureProgress) { + capturedFrameCount = captureProgress.frameCount + bufferCount = captureProgress.bufferCount + elapsedTime = Double(capturedFrameCount) / sampleRate + } + + private func receive(loudness: LoudnessResult) { + guard state == .listening || state == .finalizing else { return } + + latestLoudness = loudness + + let lastTime = momentaryHistory.last?.time.seconds ?? -.greatestFiniteMagnitude + momentaryHistory.append( + contentsOf: loudness.momentary.filter { $0.time.seconds > lastTime } + ) + + if momentaryHistory.count > 1_500 { + momentaryHistory.removeFirst(momentaryHistory.count - 1_500) + } + } + + private func complete(with analysis: MusaveraAnalysis) { + guard state == .listening || state == .finalizing else { return } + + if state == .listening { + finishAudioProvider(retainRecording: true) + } + + finalAnalysis = analysis + if let finalLoudness = analysis.loudness { + receive(loudness: finalLoudness) + } + state = .complete + streamingSession = nil + analysisTask = nil + loudnessTask = nil + } + + private func prepareForNewCapture() { + cancelActiveSession(retainRecording: false) + clearResults() + errorMessage = nil + state = .idle + } + + private func clearResults() { + sampleRate = 0 + channelCount = 0 + elapsedTime = 0 + bufferCount = 0 + capturedFrameCount = 0 + latestLoudness = nil + momentaryHistory = [] + finalAnalysis = nil + clearRecording() + } + + @discardableResult + private func finishAudioProvider(retainRecording: Bool) -> LiveCaptureProgress { + audioEngine?.stop() + + if tapInstalled { + audioEngine?.inputNode.removeTap(onBus: 0) + tapInstalled = false + } + + let progress = captureSink?.finish() + ?? LiveCaptureProgress( + frameCount: capturedFrameCount, + bufferCount: bufferCount + ) + captureSink = nil + audioEngine = nil + apply(captureProgress: progress) + finishRecording( + retainRecording: retainRecording && progress.frameCount > 0 + ) + return progress + } + + private func cancelActiveSession(retainRecording: Bool) { + let session = streamingSession + + startRequestID = nil + analysisTask?.cancel() + loudnessTask?.cancel() + analysisTask = nil + loudnessTask = nil + finishAudioProvider(retainRecording: retainRecording) + streamingSession = nil + + if let session { + Task { + await session.cancel() + } + } + } + + private func handleLoudnessFailure(_ error: any Error) { + loudnessTask = nil + + guard state == .listening else { return } + fail(with: error) + } + + private func fail(with error: any Error) { + let retainRecording = state == .listening || state == .finalizing + cancelActiveSession(retainRecording: retainRecording) + state = .idle + errorMessage = error.localizedDescription + } + + private func finishRecording(retainRecording: Bool) { + guard let writer = recordingWriter else { return } + recordingWriter = nil + + guard retainRecording else { + writer.discard() + return + } + + do { + prepareRecordingPlayback(at: try writer.finish()) + } catch { + writer.discard() + if errorMessage == nil { + errorMessage = "The microphone recording could not be saved: \(error.localizedDescription)" + } + } + } + + private func prepareRecordingPlayback(at url: URL) { + recordingLoadTask?.cancel() + recordingPlayer.unload() + + if let previousURL = recordingURL, previousURL != url { + try? FileManager.default.removeItem(at: previousURL) + } + + recordingURL = url + recordingLoadTask = Task { [weak self] in + guard let self else { return } + await recordingPlayer.load( + AVURLAsset( + url: url, + options: [AVURLAssetPreferPreciseDurationAndTimingKey: true] + ) + ) + guard !Task.isCancelled else { return } + recordingLoadTask = nil + } + } + + private func clearRecording() { + recordingLoadTask?.cancel() + recordingLoadTask = nil + recordingPlayer.unload() + recordingWriter?.discard() + recordingWriter = nil + + if let recordingURL { + try? FileManager.default.removeItem(at: recordingURL) + } + recordingURL = nil + } +} + +private nonisolated struct LiveCaptureProgress: Sendable { + var frameCount: Int64 = 0 + var bufferCount = 0 +} + +private nonisolated final class LiveAudioBufferSink: @unchecked Sendable { + private let lock = NSLock() + private let continuation: AsyncStream.Continuation + private var progress = LiveCaptureProgress() + private var isFinished = false + + init(continuation: AsyncStream.Continuation) { + self.continuation = continuation + } + + func yield(_ buffer: AVReadOnlyAudioPCMBuffer) -> LiveCaptureProgress? { + lock.lock() + defer { lock.unlock() } + + guard !isFinished else { return nil } + + switch continuation.yield(buffer) { + case .enqueued: + progress.frameCount += Int64(buffer.frameLength) + progress.bufferCount += 1 + return progress + case .dropped, .terminated: + return nil + @unknown default: + return nil + } + } + + @discardableResult + func finish() -> LiveCaptureProgress { + lock.lock() + defer { lock.unlock() } + + if !isFinished { + isFinished = true + continuation.finish() + } + + return progress + } +} + +private nonisolated final class LiveRecordingWriter: @unchecked Sendable { + let url: URL + + private let lock = NSLock() + private var audioFile: AVAudioFile? + private var writeError: (any Error)? + private var isFinished = false + + init(format: AVAudioFormat) throws { + url = FileManager.default.temporaryDirectory + .appendingPathComponent("Musavera Recording \(UUID().uuidString)") + .appendingPathExtension("caf") + audioFile = try AVAudioFile( + forWriting: url, + settings: format.settings + ) + } + + func append(_ buffer: AVReadOnlyAudioPCMBuffer) { + lock.lock() + defer { lock.unlock() } + + guard !isFinished, writeError == nil, let audioFile else { return } + + do { + try audioFile.write(from: buffer) + } catch { + writeError = error + self.audioFile = nil + } + } + + func finish() throws -> URL { + lock.lock() + isFinished = true + audioFile = nil + let error = writeError + lock.unlock() + + if let error { + throw error + } + return url + } + + func discard() { + lock.lock() + isFinished = true + audioFile = nil + lock.unlock() + + try? FileManager.default.removeItem(at: url) + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Services/LiveStreamState.swift b/Examples/MusaveraLab/MusaveraLab/Services/LiveStreamState.swift new file mode 100644 index 0000000..a3d20e4 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Services/LiveStreamState.swift @@ -0,0 +1,43 @@ +import SwiftUI + +enum LiveStreamState: Equatable { + case idle + case requestingPermission + case listening + case finalizing + case complete + + var title: String { + switch self { + case .idle: + "Ready" + case .requestingPermission: + "Requesting Access" + case .listening: + "Listening Live" + case .finalizing: + "Finishing Analysis" + case .complete: + "Analysis Complete" + } + } + + var systemImage: String { + switch self { + case .idle: + "mic" + case .requestingPermission: + "lock.open" + case .listening: + "waveform.badge.microphone" + case .finalizing: + "waveform.path.ecg" + case .complete: + "checkmark.circle.fill" + } + } + + var tint: Color { + LabTheme.accent + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Theme.swift b/Examples/MusaveraLab/MusaveraLab/Theme.swift index d99ba53..84d133d 100644 --- a/Examples/MusaveraLab/MusaveraLab/Theme.swift +++ b/Examples/MusaveraLab/MusaveraLab/Theme.swift @@ -3,12 +3,17 @@ 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 surface = Color(nsColor: .controlBackgroundColor).opacity(0.72) + static let raisedSurface = Color(nsColor: .textBackgroundColor).opacity(0.58) + static let separator = Color(nsColor: .separatorColor).opacity(0.34) + static let accent = Color(nsColor: .systemPink) 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 + static let structure = accent + static let segment = accent.opacity(0.72) + static let phrase = accent.opacity(0.44) + static let pace = accent + static let loudness = accent + + static let contentWidth: CGFloat = 1_360 + static let sectionRadius: CGFloat = 14 } diff --git a/Examples/MusaveraLab/MusaveraLab/Views/AnalysisComponents.swift b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisComponents.swift index 208f323..2baa26a 100644 --- a/Examples/MusaveraLab/MusaveraLab/Views/AnalysisComponents.swift +++ b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisComponents.swift @@ -16,7 +16,7 @@ struct ArtworkView: View { .scaledToFill() default: ZStack { - LabTheme.card + LabTheme.raisedSurface Image(systemName: "music.note") .font(.system(size: size * 0.34, weight: .semibold)) @@ -28,7 +28,7 @@ struct ArtworkView: View { .clipShape(RoundedRectangle(cornerRadius: size * 0.18, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: size * 0.18, style: .continuous) - .stroke(LabTheme.cardBorder) + .stroke(LabTheme.separator) } } } @@ -39,14 +39,18 @@ struct AnalysisTile: View { @ViewBuilder let content: Content var body: some View { - GroupBox { - content - .frame(maxWidth: .infinity) - .padding(.top, 6) - } label: { + VStack(alignment: .leading, spacing: 12) { Label(title, systemImage: systemImage) .font(.headline) + + content + .frame(maxWidth: .infinity) } + .padding(16) + .background( + LabTheme.surface, + in: RoundedRectangle(cornerRadius: LabTheme.sectionRadius, style: .continuous) + ) .accessibilityElement(children: .contain) } } diff --git a/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift index a0fb900..b2d38bd 100644 --- a/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift +++ b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisDashboard.swift @@ -10,86 +10,37 @@ struct AnalysisDashboard: View { let analysis: MusaveraAnalysis let onChooseMusic: () -> Void - private let summaryColumns = [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12) - ] - - private let activityColumns = [ - GridItem(.adaptive(minimum: 220, maximum: 360), spacing: 12) - ] - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 18) { - SourceHeader( - source: source, - onExport: exportAnalysis, - onChooseMusic: onChooseMusic - ) - - Text("Analysis") - .font(.title2.bold()) - .padding(.top, 4) - - LazyVGrid(columns: summaryColumns, spacing: 12) { - AnalysisTile(title: "Key", systemImage: "music.quarternote.3") { - KeySummaryView(key: analysis.key) + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 22) { + Color.clear + .frame(height: 0) + .id("analysis-top") + + SourceHeader( + source: source, + onExport: exportAnalysis, + onChooseMusic: onChooseMusic + ) + + VStack(alignment: .leading, spacing: 3) { + Text("Song Analysis") + .font(.title2.bold()) + + Text("Explore the musical details across the preview timeline.") + .foregroundStyle(.secondary) } - 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) - } + AnalysisResultsView(analysis: analysis) } + .frame(maxWidth: LabTheme.contentWidth, alignment: .leading) + .padding(32) + } + .task(id: source.localURL) { + await Task.yield() + proxy.scrollTo("analysis-top", anchor: .top) } - .frame(maxWidth: 1_400, alignment: .leading) - .padding(28) } .fileExporter( isPresented: $isExporting, @@ -120,58 +71,139 @@ private struct SourceHeader: View { let onChooseMusic: () -> Void var body: some View { - HStack(spacing: source.artworkURL == nil ? 20 : 24) { - ArtworkView( - url: source.artworkURL, - size: source.artworkURL == nil ? 116 : 152 + ViewThatFits(in: .horizontal) { + HStack(alignment: .bottom, spacing: 24) { + artwork + metadata + Spacer(minLength: 20) + actions + } + + VStack(alignment: .leading, spacing: 20) { + HStack(alignment: .bottom, spacing: 20) { + artwork + metadata + } + + actions + } + } + .padding(24) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 220, alignment: .bottomLeading) + .background { + SourceHeroBackground(artworkURL: source.artworkURL) + } + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .accessibilityElement(children: .contain) + } + + private var artwork: some View { + ArtworkView( + url: source.artworkURL, + size: source.artworkURL == nil ? 124 : 164 + ) + .shadow(color: .black.opacity(0.28), radius: 18, y: 9) + } + + private var metadata: some View { + VStack(alignment: .leading, spacing: 7) { + Label( + source.isAppleMusicPreview ? "APPLE MUSIC PREVIEW" : "LOCAL AUDIO", + systemImage: source.isAppleMusicPreview ? "music.note" : "internaldrive" ) - .shadow(color: .black.opacity(0.16), radius: 12, y: 6) + .font(.caption.weight(.bold)) + .foregroundStyle(.white.opacity(0.72)) + + Text(source.title) + .font(.largeTitle.bold()) + .lineLimit(2) + + Text(source.subtitle) + .font(.title3) + .foregroundStyle(.white.opacity(0.78)) + .lineLimit(2) + + if source.isAppleMusicPreview { + Label("30-second preview", systemImage: "clock") + .font(.callout) + .foregroundStyle(.white.opacity(0.7)) + } + } + } - VStack(alignment: .leading, spacing: 7) { - Text(source.title) - .font(.largeTitle.bold()) - .lineLimit(2) + private var actions: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 10) { + actionButtons + } - Text(source.subtitle) - .font(.title3) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 10) { + actionButtons + } + } + } + @ViewBuilder + private var actionButtons: some View { + if model.selectedSong != nil { + Button { + Task { + await model.toggleFullSongPlayback() + } + } label: { Label( - source.isAppleMusicPreview ? "30-second Apple Music preview" : "Local audio", - systemImage: source.isAppleMusicPreview ? "clock" : "internaldrive" + model.isFullSongPlaybackActive ? "Pause Full Song" : "Play Full Song", + systemImage: model.isFullSongPlaybackActive ? "pause.fill" : "play.fill" ) - .font(.callout) - .foregroundStyle(.secondary) } + .buttonStyle(.borderedProminent) + .tint(LabTheme.accent) + .controlSize(.large) + } - Spacer() - - VStack(alignment: .trailing, spacing: 10) { - if model.selectedSong != nil { - Button { - Task { - await model.toggleFullSongPlayback() - } - } label: { - Label( - model.isFullSongPlaybackActive ? "Pause Full Song" : "Play Full Song", - systemImage: model.isFullSongPlaybackActive ? "pause.fill" : "music.note" - ) - } - .buttonStyle(.borderedProminent) - } + Button("Find Music", systemImage: "magnifyingglass") { + onChooseMusic() + } + .buttonStyle(.bordered) + .tint(.white) + .controlSize(.large) + + Button("Export JSON", systemImage: "square.and.arrow.up", action: onExport) + .buttonStyle(.bordered) + .tint(.white) + .controlSize(.large) + } +} - Button("Export JSON", systemImage: "square.and.arrow.up", action: onExport) - .buttonStyle(.bordered) +private struct SourceHeroBackground: View { + let artworkURL: URL? - Button { - onChooseMusic() - } label: { - Label("Find Music", systemImage: "magnifyingglass") + var body: some View { + ZStack { + Color.black.opacity(0.86) + + if let artworkURL { + AsyncImage(url: artworkURL) { phase in + if case .success(let image) = phase { + image + .resizable() + .scaledToFill() + .scaleEffect(1.12) + .blur(radius: 28) + .opacity(0.68) + } } - .buttonStyle(.bordered) } + + LinearGradient( + colors: [ + .black.opacity(0.08), + .black.opacity(0.62) + ], + startPoint: .top, + endPoint: .bottom + ) } - .padding(.vertical, source.artworkURL == nil ? 2 : 8) } } diff --git a/Examples/MusaveraLab/MusaveraLab/Views/AnalysisResultsView.swift b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisResultsView.swift new file mode 100644 index 0000000..07e8399 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/AnalysisResultsView.swift @@ -0,0 +1,77 @@ +import MusaveraKit +import SwiftUI + +struct AnalysisResultsView: View { + let analysis: MusaveraAnalysis + + private let summaryColumns = [ + GridItem(.adaptive(minimum: 280), spacing: 12) + ] + + private let activityColumns = [ + GridItem(.adaptive(minimum: 330, maximum: 660), spacing: 12) + ] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + 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) + } + } + } + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Views/AnalyzeWelcomeView.swift b/Examples/MusaveraLab/MusaveraLab/Views/AnalyzeWelcomeView.swift new file mode 100644 index 0000000..0624f21 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/AnalyzeWelcomeView.swift @@ -0,0 +1,200 @@ +import SwiftUI + +struct AnalyzeWelcomeView: View { + let onChooseMusic: () -> Void + let onImportAudio: () -> Void + + private let capabilityColumns = [ + GridItem(.adaptive(minimum: 250), spacing: 12) + ] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 32) { + VStack(alignment: .leading, spacing: 7) { + Text("Analyze Music") + .font(.largeTitle.bold()) + + Text("Turn a song into a synchronized view of its key, rhythm, structure, instruments, and loudness.") + .font(.title3) + .foregroundStyle(.secondary) + .frame(maxWidth: 760, alignment: .leading) + } + + VStack(alignment: .leading, spacing: 14) { + Text("Choose a Source") + .font(.title2.bold()) + + ViewThatFits(in: .horizontal) { + HStack(spacing: 14) { + sourceButtons + } + + VStack(spacing: 14) { + sourceButtons + } + } + } + + VStack(alignment: .leading, spacing: 14) { + Text("Analysis Includes") + .font(.title2.bold()) + + LazyVGrid(columns: capabilityColumns, spacing: 12) { + AnalysisCapability( + title: "Key", + detail: "Tonic and mode over time", + systemImage: "music.quarternote.3" + ) + AnalysisCapability( + title: "Rhythm", + detail: "Tempo, beats, and bars", + systemImage: "metronome" + ) + AnalysisCapability( + title: "Structure", + detail: "Sections, segments, and phrases", + systemImage: "square.3.layers.3d" + ) + AnalysisCapability( + title: "Pace", + detail: "Perceived musical motion", + systemImage: "speedometer" + ) + AnalysisCapability( + title: "Instruments", + detail: "Vocal, drum, bass, and other activity", + systemImage: "pianokeys" + ) + AnalysisCapability( + title: "Loudness", + detail: "Peak, integrated, and momentary levels", + systemImage: "waveform" + ) + } + } + } + .frame(maxWidth: LabTheme.contentWidth, alignment: .leading) + .padding(32) + } + } + + @ViewBuilder + private var sourceButtons: some View { + SourceOptionButton( + title: "Search Apple Music", + detail: "Analyze a 30-second catalog preview", + systemImage: "music.note", + isPrimary: true, + action: onChooseMusic + ) + + SourceOptionButton( + title: "Open Audio File", + detail: "Analyze a song already on this Mac", + systemImage: "waveform", + isPrimary: false, + action: onImportAudio + ) + } +} + +private struct SourceOptionButton: View { + let title: String + let detail: String + let systemImage: String + let isPrimary: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + ZStack(alignment: .bottomLeading) { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(isPrimary ? LabTheme.accent.opacity(0.09) : LabTheme.surface) + + Image(systemName: systemImage) + .font(.system(size: 112, weight: .medium)) + .foregroundStyle( + isPrimary + ? LabTheme.accent.opacity(0.09) + : Color.secondary.opacity(0.06) + ) + .rotationEffect(.degrees(-9)) + .offset(x: 118, y: 24) + + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: systemImage) + .font(.title2.weight(.semibold)) + .foregroundStyle(isPrimary ? LabTheme.accent : .secondary) + + Spacer() + + Image(systemName: "arrow.up.right") + .font(.headline) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(title) + .font(.title2.bold()) + + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + } + .foregroundStyle(.primary) + .padding(20) + } + .frame(maxWidth: .infinity, minHeight: 168) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke( + isPrimary + ? LabTheme.accent.opacity(0.16) + : LabTheme.separator + ) + } + .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityHint(detail) + } +} + +private struct AnalysisCapability: View { + let title: String + let detail: String + let systemImage: String + + var body: some View { + HStack(spacing: 13) { + Image(systemName: systemImage) + .font(.title3.weight(.medium)) + .foregroundStyle(LabTheme.accent) + .frame(width: 36, height: 36) + .background(LabTheme.accent.opacity(0.08), in: Circle()) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.headline) + + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer(minLength: 0) + } + .padding(14) + .frame(maxWidth: .infinity, minHeight: 68, alignment: .leading) + .background( + LabTheme.surface, + in: RoundedRectangle(cornerRadius: LabTheme.sectionRadius, style: .continuous) + ) + .accessibilityElement(children: .combine) + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift b/Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift index 96e7d50..fbd7451 100644 --- a/Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift +++ b/Examples/MusaveraLab/MusaveraLab/Views/ContentView.swift @@ -3,12 +3,15 @@ import UniformTypeIdentifiers struct ContentView: View { @Environment(MusaveraLabModel.self) private var model + @Environment(LiveStreamModel.self) private var liveStreamModel + @State private var selection: LabSection = .analyze @State private var isImportingAudio = false @State private var isChoosingMusic = false var body: some View { NavigationSplitView { MusicBrowserSidebar( + selection: $selection, onChooseMusic: { isChoosingMusic = true }, @@ -16,18 +19,23 @@ struct ContentView: View { isImportingAudio = true } ) - .navigationSplitViewColumnWidth(min: 240, ideal: 270, max: 340) + .navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 280) } detail: { - DetailContentView( - onChooseMusic: { - isChoosingMusic = true - }, - onImportAudio: { - isImportingAudio = true - } - ) + switch selection { + case .analyze: + AnalysisDetailContentView( + onChooseMusic: { + isChoosingMusic = true + }, + onImportAudio: { + isImportingAudio = true + } + ) + case .live: + LiveStreamView() + } } - .navigationSplitViewStyle(.balanced) + .navigationSplitViewStyle(.prominentDetail) .sheet(isPresented: $isChoosingMusic) { MusicSelectionSheet() } @@ -40,23 +48,51 @@ struct ContentView: View { .alert( "Musavera Lab", isPresented: Binding( - get: { model.errorMessage != nil }, + get: { presentedErrorMessage != nil }, set: { isPresented in if !isPresented { - model.errorMessage = nil + dismissPresentedError() } } ) ) { Button("OK") { - model.errorMessage = nil + dismissPresentedError() } } message: { - Text(model.errorMessage ?? "") + Text(presentedErrorMessage ?? "") } .task { model.prepare() } + .onChange(of: selection) { oldValue, newValue in + if newValue == .live { + model.pauseAllPlayback() + } else if oldValue == .live { + liveStreamModel.deactivate() + } + } + .onDisappear { + liveStreamModel.deactivate() + } + } + + private var presentedErrorMessage: String? { + if let errorMessage = model.errorMessage { + errorMessage + } else if selection != .live { + liveStreamModel.errorMessage + } else { + nil + } + } + + private func dismissPresentedError() { + if model.errorMessage != nil { + model.errorMessage = nil + } else { + liveStreamModel.errorMessage = nil + } } private func importAudio(from result: Result) { @@ -74,7 +110,8 @@ struct ContentView: View { } } -private struct DetailContentView: View { +private struct AnalysisDetailContentView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(MusaveraLabModel.self) private var model let onChooseMusic: () -> Void @@ -91,17 +128,24 @@ private struct DetailContentView: View { analysis: analysis, onChooseMusic: onChooseMusic ) - .transition(.opacity.combined(with: .scale(scale: 0.98))) + .transition( + reduceMotion + ? .opacity + : .opacity.combined(with: .scale(scale: 0.985)) + ) } else if model.isBusy { WorkingView(state: model.workState) } else { - WelcomeDetailView( + AnalyzeWelcomeView( onChooseMusic: onChooseMusic, onImportAudio: onImportAudio ) } } - .animation(.smooth(duration: 0.35), value: model.analysis != nil) + .animation( + reduceMotion ? nil : .smooth(duration: 0.35), + value: model.analysis != nil + ) .safeAreaInset(edge: .bottom, spacing: 0) { if model.analysis != nil { TransportBar() @@ -110,134 +154,6 @@ private struct DetailContentView: View { } } -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 diff --git a/Examples/MusaveraLab/MusaveraLab/Views/LiveFinalAnalysisView.swift b/Examples/MusaveraLab/MusaveraLab/Views/LiveFinalAnalysisView.swift new file mode 100644 index 0000000..f1f3399 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/LiveFinalAnalysisView.swift @@ -0,0 +1,32 @@ +import MusaveraKit +import SwiftUI + +struct LiveFinalAnalysisView: View { + @Environment(LiveStreamModel.self) private var model + + let analysis: MusaveraAnalysis + let onExport: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Completed Musical Analysis") + .font(.title2.bold()) + + Text("These results become available after the incoming audio stream closes.") + .foregroundStyle(.secondary) + } + + Spacer() + + Button("Export JSON", systemImage: "square.and.arrow.up", action: onExport) + .buttonStyle(.bordered) + } + + AnalysisResultsView(analysis: analysis) + .environment(model.recordingPlayer) + } + .padding(.top, 4) + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Views/LiveLoudnessView.swift b/Examples/MusaveraLab/MusaveraLab/Views/LiveLoudnessView.swift new file mode 100644 index 0000000..a7c0fa1 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/LiveLoudnessView.swift @@ -0,0 +1,162 @@ +import Charts +import CoreMedia +import MusicUnderstanding +import SwiftUI + +struct LiveLoudnessView: View { + @Environment(LiveStreamModel.self) private var model + + private let metricColumns = [ + GridItem(.adaptive(minimum: 145), spacing: 18) + ] + + var body: some View { + AnalysisTile(title: "Realtime Loudness", systemImage: "waveform") { + VStack(alignment: .leading, spacing: 18) { + LazyVGrid(columns: metricColumns, spacing: 10) { + LiveLoudnessMetric( + title: "Momentary", + value: formatted(model.latestMomentaryValue), + unit: "LUFS" + ) + + LiveLoudnessMetric( + title: "Short-Term", + value: formatted(model.latestShortTermValue), + unit: "LUFS" + ) + + LiveLoudnessMetric( + title: "Integrated", + value: formatted(model.integratedValue), + unit: "LUFS" + ) + + LiveLoudnessMetric( + title: "Peak", + value: formatted(model.peakValue), + unit: "dB" + ) + } + + if model.momentaryHistory.isEmpty { + ContentUnavailableView { + Label("Waiting for Audio", systemImage: "waveform") + } description: { + Text("Start listening and play music or speak near the selected microphone.") + } + .frame(maxWidth: .infinity, minHeight: 220) + } else { + LiveLoudnessChart( + values: model.momentaryHistory, + elapsedTime: model.elapsedTime + ) + .frame(height: 250) + } + } + } + } + + private func formatted(_ value: Float?) -> String { + value.map { String(format: "%.1f", $0) } ?? "--" + } +} + +private struct LiveLoudnessMetric: View { + let title: String + let value: String + let unit: String + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(value) + .font(.system(.title2, design: .rounded, weight: .bold)) + .monospacedDigit() + + Text(unit) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + } +} + +private struct LiveLoudnessChart: View { + let values: [MusicUnderstandingSession.TimedValue] + let elapsedTime: TimeInterval + + var body: some View { + Chart(Array(values.enumerated()), id: \.offset) { _, point in + AreaMark( + x: .value("Time", point.time.seconds), + yStart: .value("Floor", -72), + yEnd: .value("Loudness", point.value) + ) + .foregroundStyle( + .linearGradient( + colors: [ + LabTheme.loudness.opacity(0.3), + LabTheme.loudness.opacity(0.02) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + + LineMark( + x: .value("Time", point.time.seconds), + y: .value("Loudness", point.value) + ) + .foregroundStyle(LabTheme.loudness) + .lineStyle(StrokeStyle(lineWidth: 2.2, lineCap: .round)) + .interpolationMethod(.linear) + } + .chartXScale(domain: visibleDomain) + .chartYScale(domain: -72...0) + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 6)) { value in + AxisGridLine() + .foregroundStyle(.secondary.opacity(0.12)) + AxisValueLabel { + if let seconds = value.as(Double.self) { + Text("\(Int(seconds))s") + } + } + } + } + .chartYAxis { + AxisMarks(position: .leading, values: [-72, -60, -48, -36, -24, -12, 0]) { + value in + AxisGridLine() + .foregroundStyle(.secondary.opacity(0.14)) + AxisValueLabel { + if let loudness = value.as(Int.self) { + Text("\(loudness)") + } + } + } + } + .accessibilityLabel("Live momentary loudness") + .accessibilityValue( + values.last.map { String(format: "%.1f LUFS", $0.value) } + ?? "No loudness values" + ) + } + + private var visibleDomain: ClosedRange { + let latestTime = max( + elapsedTime, + values.last?.time.seconds ?? 0 + ) + let upperBound = max(latestTime, 10) + return max(upperBound - 30, 0)...upperBound + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Views/LiveRecordingTransport.swift b/Examples/MusaveraLab/MusaveraLab/Views/LiveRecordingTransport.swift new file mode 100644 index 0000000..7de362d --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/LiveRecordingTransport.swift @@ -0,0 +1,121 @@ +import SwiftUI + +struct LiveRecordingTransport: View { + @Environment(MusaveraLabModel.self) private var analysisModel + @Environment(LiveStreamModel.self) private var model + + var body: some View { + @Bindable var player = model.recordingPlayer + + FloatingTransportSurface { + ViewThatFits(in: .horizontal) { + HStack(spacing: 16) { + recordingIdentity + + Divider() + .frame(height: 34) + + playbackControls(player: player) + + Divider() + .frame(height: 34) + + volumeControl(player: player) + } + + VStack(alignment: .leading, spacing: 14) { + recordingIdentity + playbackControls(player: player) + volumeControl(player: player) + } + } + } + } + + private var recordingIdentity: some View { + HStack(spacing: 12) { + Image(systemName: "waveform") + .font(.title3.weight(.medium)) + .foregroundStyle(LabTheme.accent) + .frame(width: 42, height: 42) + .background(LabTheme.accent.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 2) { + Text("Microphone Recording") + .font(.headline) + + Text("Captured during this live analysis") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(minWidth: 220, alignment: .leading) + } + + private func playbackControls(player: PreviewPlayer) -> some View { + HStack(spacing: 12) { + if player.duration > 0 { + Button { + if !player.isPlaying { + analysisModel.pauseAllPlayback() + } + model.toggleRecordingPlayback() + } label: { + Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") + .font(.callout.weight(.semibold)) + .frame(width: 20, height: 20) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.circle) + .keyboardShortcut(" ", modifiers: []) + .accessibilityLabel( + player.isPlaying ? "Pause microphone recording" : "Play microphone recording" + ) + } else { + ProgressView() + .controlSize(.small) + .frame(width: 34, height: 34) + .accessibilityLabel("Preparing microphone recording") + } + + Text(player.formattedCurrentTime) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 38, alignment: .trailing) + + Slider( + value: Binding( + get: { player.currentTime }, + set: { player.seek(to: $0) } + ), + in: 0...max(player.duration, 0.01) + ) + .disabled(player.duration <= 0) + .frame(minWidth: 180) + .accessibilityLabel("Recording position") + + Text(player.formattedDuration) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 38, alignment: .leading) + } + .frame(maxWidth: .infinity) + } + + private func volumeControl(player: PreviewPlayer) -> some View { + HStack(spacing: 8) { + Image(systemName: player.volume == 0 ? "speaker.slash.fill" : "speaker.fill") + .foregroundStyle(.secondary) + + Slider( + value: Binding( + get: { player.volume }, + set: { player.volume = $0 } + ), + in: 0...1 + ) + .frame(width: 96) + .accessibilityLabel("Recording volume") + } + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Views/LiveStreamHeader.swift b/Examples/MusaveraLab/MusaveraLab/Views/LiveStreamHeader.swift new file mode 100644 index 0000000..4c633a8 --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/LiveStreamHeader.swift @@ -0,0 +1,217 @@ +import SwiftUI + +struct LiveStreamHeader: View { + @Environment(MusaveraLabModel.self) private var analysisModel + @Environment(LiveStreamModel.self) private var model + + var body: some View { + VStack(alignment: .leading, spacing: 22) { + ViewThatFits(in: .horizontal) { + HStack(alignment: .center, spacing: 20) { + stateIcon + titleContent + Spacer(minLength: 24) + controls + } + + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .center, spacing: 18) { + stateIcon + titleContent + } + + controls + } + } + + Rectangle() + .fill(LabTheme.separator) + .frame(height: 1) + + HStack(spacing: 0) { + LiveStreamFact( + title: "Duration", + value: formattedDuration, + systemImage: "timer" + ) + + factDivider + + LiveStreamFact( + title: "Sample Rate", + value: formattedSampleRate, + systemImage: "waveform" + ) + + factDivider + + LiveStreamFact( + title: "Channels", + value: formattedChannelCount, + systemImage: "speaker.wave.2" + ) + + factDivider + + LiveStreamFact( + title: "Buffers", + value: model.bufferCount.formatted(), + systemImage: "square.stack.3d.up" + ) + } + } + .padding(24) + .frame(maxWidth: .infinity, minHeight: 232, alignment: .bottomLeading) + .background(LabTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(LabTheme.separator) + } + } + + private var stateIcon: some View { + Image(systemName: model.state.systemImage) + .font(.system(size: 38, weight: .medium)) + .foregroundStyle(model.state.tint) + .frame(width: 88, height: 88) + .background( + LabTheme.accent.opacity(0.08), + in: RoundedRectangle(cornerRadius: 19) + ) + .accessibilityHidden(true) + } + + private var titleContent: some View { + VStack(alignment: .leading, spacing: 7) { + Text("Live Stream") + .font(.largeTitle.bold()) + + Text("Hear loudness as audio reaches the microphone, then complete the musical analysis when capture ends.") + .font(.title3) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 690, alignment: .leading) + + Label(model.state.title, systemImage: model.state.systemImage) + .font(.callout.weight(.semibold)) + .foregroundStyle(model.state.tint) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(model.state.tint.opacity(0.08), in: Capsule()) + } + } + + @ViewBuilder + private var controls: some View { + if model.state == .listening { + Button { + model.stop() + } label: { + Label("Stop & Analyze", systemImage: "stop.fill") + } + .buttonStyle(.borderedProminent) + .tint(.red) + .controlSize(.large) + } else if model.isWorking { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + + Text( + model.state == .requestingPermission + ? "Waiting for microphone access" + : "Completing the final analysis" + ) + .font(.callout) + .foregroundStyle(.secondary) + } + } else { + HStack(spacing: 10) { + if model.canReset { + Button("Reset", systemImage: "arrow.counterclockwise") { + model.reset() + } + .buttonStyle(.bordered) + .controlSize(.large) + } + + Button { + analysisModel.pauseAllPlayback() + Task { + await model.start() + } + } label: { + Label( + model.state == .complete ? "Listen Again" : "Start Listening", + systemImage: "mic.fill" + ) + } + .buttonStyle(.borderedProminent) + .tint(LabTheme.accent) + .controlSize(.large) + } + } + } + + private var factDivider: some View { + Rectangle() + .fill(LabTheme.separator) + .frame(width: 1, height: 38) + } + + private var formattedDuration: String { + let totalSeconds = Int(model.elapsedTime.rounded(.down)) + return String( + format: "%02d:%02d", + totalSeconds / 60, + totalSeconds % 60 + ) + } + + private var formattedSampleRate: String { + guard model.sampleRate > 0 else { return "--" } + return String(format: "%.1f kHz", model.sampleRate / 1_000) + } + + private var formattedChannelCount: String { + switch model.channelCount { + case 0: + "--" + case 1: + "Mono" + case 2: + "Stereo" + default: + "\(model.channelCount)" + } + } +} + +private struct LiveStreamFact: View { + let title: String + let value: String + let systemImage: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: systemImage) + .foregroundStyle(.secondary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + + Text(value) + .font(.headline.monospacedDigit()) + } + + Spacer(minLength: 8) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 12) + .accessibilityElement(children: .combine) + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Views/LiveStreamView.swift b/Examples/MusaveraLab/MusaveraLab/Views/LiveStreamView.swift new file mode 100644 index 0000000..91c7d8e --- /dev/null +++ b/Examples/MusaveraLab/MusaveraLab/Views/LiveStreamView.swift @@ -0,0 +1,198 @@ +import AppKit +import MusaveraKit +import SwiftUI +import UniformTypeIdentifiers + +struct LiveStreamView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(LiveStreamModel.self) private var model + @State private var isExporting = false + + var body: some View { + ZStack { + LabTheme.background + .ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + LiveStreamHeader() + + if let errorMessage = model.errorMessage { + LiveStreamErrorBanner( + message: errorMessage, + showsSettingsButton: model.microphonePermissionDenied + ) + } + + LiveLoudnessView() + + LiveAnalysisBoundaryView() + + if let analysis = model.finalAnalysis { + LiveFinalAnalysisView( + analysis: analysis, + onExport: { + isExporting = true + } + ) + .transition( + reduceMotion + ? .opacity + : .opacity.combined(with: .move(edge: .bottom)) + ) + } + } + .frame(maxWidth: LabTheme.contentWidth, alignment: .leading) + .padding(32) + } + } + .animation( + reduceMotion ? nil : .smooth(duration: 0.3), + value: model.state + ) + .safeAreaInset(edge: .bottom, spacing: 0) { + if model.recordingURL != nil { + LiveRecordingTransport() + .transition( + reduceMotion + ? .opacity + : .opacity.combined(with: .move(edge: .bottom)) + ) + } + } + .fileExporter( + isPresented: $isExporting, + document: exportDocument, + contentType: .json, + defaultFilename: "Live Stream Analysis" + ) { result in + if case .failure(let error) = result { + model.errorMessage = "The live analysis could not be exported: \(error.localizedDescription)" + } + } + } + + private var exportDocument: AnalysisJSONDocument? { + model.finalAnalysis.map(AnalysisJSONDocument.init(analysis:)) + } +} + +private struct LiveStreamErrorBanner: View { + @Environment(LiveStreamModel.self) private var model + + let message: String + let showsSettingsButton: Bool + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.title3) + .foregroundStyle(.orange) + + VStack(alignment: .leading, spacing: 8) { + Text("Live Stream Needs Attention") + .font(.headline) + + Text(message) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if showsSettingsButton { + Button("Open Microphone Settings", systemImage: "gear") { + openMicrophoneSettings() + } + .buttonStyle(.bordered) + } + } + + Spacer() + + Button { + model.errorMessage = nil + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss") + } + .padding(16) + .background(.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 14)) + } + + private func openMicrophoneSettings() { + guard let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone" + ) else { + return + } + + NSWorkspace.shared.open(url) + } +} + +private struct LiveAnalysisBoundaryView: View { + private let columns = [ + GridItem(.adaptive(minimum: 250), spacing: 12) + ] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("How Streaming Works") + .font(.title2.bold()) + + LazyVGrid(columns: columns, alignment: .leading, spacing: 12) { + LiveCapabilityCard( + title: "While Listening", + detail: "Momentary, short-term, integrated, and peak loudness update from incoming PCM buffers.", + systemImage: "waveform" + ) + + LiveCapabilityCard( + title: "After You Stop", + detail: "Music Understanding finishes key, tempo, structure, pace, instruments, and final loudness.", + systemImage: "waveform.path.ecg" + ) + + LiveCapabilityCard( + title: "Audio Boundary", + detail: "This tab analyzes microphone PCM. MusicKit playback does not expose full-song PCM to apps.", + systemImage: "lock.shield" + ) + } + } + } +} + +private struct LiveCapabilityCard: View { + let title: String + let detail: String + let systemImage: String + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: systemImage) + .font(.title3.weight(.medium)) + .foregroundStyle(LabTheme.accent) + .frame(width: 36, height: 36) + .background(LabTheme.accent.opacity(0.08), in: Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + .padding(14) + .frame(maxWidth: .infinity, minHeight: 102, alignment: .topLeading) + .background( + LabTheme.surface, + in: RoundedRectangle(cornerRadius: LabTheme.sectionRadius, style: .continuous) + ) + } +} diff --git a/Examples/MusaveraLab/MusaveraLab/Views/MusicBrowserSidebar.swift b/Examples/MusaveraLab/MusaveraLab/Views/MusicBrowserSidebar.swift index 8eed107..ce55dee 100644 --- a/Examples/MusaveraLab/MusaveraLab/Views/MusicBrowserSidebar.swift +++ b/Examples/MusaveraLab/MusaveraLab/Views/MusicBrowserSidebar.swift @@ -3,31 +3,70 @@ import SwiftUI struct MusicBrowserSidebar: View { @Environment(MusaveraLabModel.self) private var model + @Environment(LiveStreamModel.self) private var liveStreamModel + @Binding var selection: LabSection 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() + List(selection: $selection) { + Section("Workspace") { + ForEach(LabSection.allCases) { section in + Label(section.title, systemImage: section.systemImage) + .tag(section) + } + } + + if selection == .analyze { + Section("Sources") { + Button(action: onChooseMusic) { + SidebarActionLabel( + title: "Search Apple Music", + systemImage: "magnifyingglass" + ) + } + .buttonStyle(.plain) + .disabled(model.isBusy) + + Button(action: onImportAudio) { + SidebarActionLabel( + title: "Open Audio File", + systemImage: "folder" + ) + } + .buttonStyle(.plain) + .disabled(model.isBusy) } - Button(action: onChooseMusic) { - Label("Choose Music", systemImage: "music.note.list") + Section("Apple Music") { + if model.isAuthorized { + Label("Connected", systemImage: "checkmark.circle.fill") + .foregroundStyle(.secondary) + } else { + Button { + Task { + await model.requestAuthorization() + } + } label: { + Label("Connect", systemImage: "person.crop.circle.badge.plus") + } + .disabled(model.isBusy) + } } - .disabled(model.isBusy) - } + } else { + Section("Microphone") { + Label( + liveStreamModel.state.title, + systemImage: liveStreamModel.state.systemImage + ) + .foregroundStyle(.secondary) - Section("On This Mac") { - Button(action: onImportAudio) { - Label("Open Audio File", systemImage: "folder") + Label("Loudness while listening", systemImage: "waveform") + .foregroundStyle(.secondary) + + Label("Full analysis after stop", systemImage: "checkmark.circle") + .foregroundStyle(.secondary) } - .buttonStyle(.plain) - .disabled(model.isBusy) } } .listStyle(.sidebar) @@ -35,27 +74,19 @@ struct MusicBrowserSidebar: View { } } -private struct AuthorizationRow: View { - @Environment(MusaveraLabModel.self) private var model +private struct SidebarActionLabel: View { + let title: String + let systemImage: String 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) + Label { + Text(title) + .foregroundStyle(.primary) + } icon: { + Image(systemName: systemImage) + .foregroundStyle(LabTheme.accent) } - .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } } diff --git a/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift b/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift index 2dc8853..1909eba 100644 --- a/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift +++ b/Examples/MusaveraLab/MusaveraLab/Views/MusicSelectionSheet.swift @@ -52,7 +52,7 @@ struct MusicSelectionSheet: View { .listStyle(.inset) } } - .navigationTitle("Choose Music") + .navigationTitle("Search Apple Music") .searchable( text: $query, placement: .toolbar, @@ -66,7 +66,7 @@ struct MusicSelectionSheet: View { } } } - .frame(minWidth: 680, minHeight: 540) + .frame(minWidth: 720, minHeight: 600) .task(id: SearchContext(query: query, isAuthorized: model.isAuthorized)) { await search() } @@ -140,7 +140,7 @@ private struct MusicSelectionRow: View { HStack(spacing: 14) { ArtworkView( url: song.artwork?.url(width: 144, height: 144), - size: 58 + size: 52 ) VStack(alignment: .leading, spacing: 3) { @@ -149,22 +149,24 @@ private struct MusicSelectionRow: View { .lineLimit(1) Text(song.artistName) + .font(.callout) .foregroundStyle(.secondary) .lineLimit(1) - Label( - hasPreview ? "Preview available" : "Preview unavailable", - systemImage: hasPreview ? "waveform" : "exclamationmark.circle" - ) - .font(.caption) - .foregroundStyle(.tertiary) + if !hasPreview { + Label("Preview unavailable", systemImage: "exclamationmark.circle") + .font(.caption) + .foregroundStyle(.tertiary) + } } Spacer() - Image(systemName: "chevron.right") + Label("Analyze", systemImage: "waveform") .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) + .foregroundStyle( + hasPreview ? LabTheme.accent : Color.secondary.opacity(0.5) + ) } .contentShape(Rectangle()) } diff --git a/Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift b/Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift index 29f5c49..1d84dbd 100644 --- a/Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift +++ b/Examples/MusaveraLab/MusaveraLab/Views/TransportBar.swift @@ -7,54 +7,79 @@ struct TransportBar: View { var body: some View { @Bindable var bindablePlayer = player - HStack(spacing: 16) { - Button { - model.togglePreviewPlayback() - } label: { - Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") - .font(.body.weight(.semibold)) - .frame(width: 24, height: 24) + FloatingTransportSurface { + HStack(spacing: 14) { + if let source = model.source { + ArtworkView(url: source.artworkURL, size: 42) + + VStack(alignment: .leading, spacing: 2) { + Text(source.title) + .font(.callout.weight(.semibold)) + .lineLimit(1) + + Text(source.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(width: 170, alignment: .leading) + } + + Button { + model.togglePreviewPlayback() + } label: { + Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") + .font(.callout.weight(.semibold)) + .frame(width: 20, height: 20) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.circle) + .keyboardShortcut(" ", modifiers: []) + .accessibilityLabel(player.isPlaying ? "Pause preview" : "Play preview") + + Text(player.formattedCurrentTime) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 38, alignment: .trailing) + + Slider( + value: Binding( + get: { player.currentTime }, + set: { player.seek(to: $0) } + ), + in: 0...max(player.duration, 0.01) + ) + + Text(player.formattedDuration) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 38, alignment: .leading) + + Divider() + .frame(height: 24) + + Image(systemName: "speaker.fill") + .foregroundStyle(.secondary) + + Slider(value: $bindablePlayer.volume, in: 0...1) + .frame(width: 92) + .accessibilityLabel("Preview volume") } - .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) } + } +} + +struct FloatingTransportSurface: View { + @ViewBuilder let content: Content + + var body: some View { + content + .frame(maxWidth: 980) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 15)) .shadow(color: .black.opacity(0.12), radius: 16, y: 8) .padding(.horizontal, 24) - .padding(.bottom, 14) + .padding(.bottom, 12) } } diff --git a/Examples/MusaveraLab/MusaveraLab/Visualizations/TimelineComponents.swift b/Examples/MusaveraLab/MusaveraLab/Visualizations/TimelineComponents.swift index 55cca2b..a274fe0 100644 --- a/Examples/MusaveraLab/MusaveraLab/Visualizations/TimelineComponents.swift +++ b/Examples/MusaveraLab/MusaveraLab/Visualizations/TimelineComponents.swift @@ -235,15 +235,15 @@ extension InstrumentActivityResult.Instrument { var labColor: Color { switch self { case .vocal: - .pink + LabTheme.accent case .drum: - .orange + LabTheme.accent.opacity(0.78) case .bass: - .blue + LabTheme.accent.opacity(0.58) case .other: - .purple + LabTheme.accent.opacity(0.38) default: - .purple + LabTheme.accent.opacity(0.38) } } diff --git a/Examples/MusaveraLab/README.md b/Examples/MusaveraLab/README.md index 24bd854..e84fece 100644 --- a/Examples/MusaveraLab/README.md +++ b/Examples/MusaveraLab/README.md @@ -7,6 +7,9 @@ Musavera Lab composes two Apple frameworks without coupling their wrapper librar 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. +6. A separate Live Stream workspace sends microphone PCM buffers to + `MusaveraStreamingSession`, updates loudness while listening, and completes + the full musical analysis after capture stops. 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. @@ -38,6 +41,9 @@ MusadoraKit is intentionally not a dependency. MusadoraKit and MusaveraKit remai - [x] Reject protected assets before analysis. - [x] Build `AVURLAsset` with precise timing enabled. - [x] Load the same asset into preview playback and MusaveraKit analysis. +- [x] Capture microphone input with the sendable macOS 27 audio tap. +- [x] Feed immutable PCM buffers into an asynchronous streaming provider. +- [x] Stop preview and full-song playback before live microphone analysis. - [ ] Exercise a preview with redirects and a non-`m4a` extension. ### Understanding @@ -45,6 +51,9 @@ MusadoraKit is intentionally not a dependency. MusadoraKit and MusaveraKit remai - [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. +- [x] Surface loudness results while microphone audio is still arriving. +- [x] Finish key, rhythm, structure, pace, instruments, and final loudness when + the live stream closes. - [ ] Add cancellation when a user chooses a different track mid-analysis. - [x] Export the complete native MusicUnderstanding result as formatted JSON. @@ -55,6 +64,9 @@ MusadoraKit is intentionally not a dependency. MusadoraKit and MusaveraKit remai - [x] Render pace, instrument ranges, activity curves, and loudness. - [x] Make the timeline seekable. - [x] Support local audio as a generic fallback. +- [x] Separate Analyze Music and Live Stream into native sidebar workspaces. +- [x] Show a rolling 30-second realtime loudness chart and input diagnostics. +- [x] Export completed live-stream analysis as formatted JSON. - [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. @@ -81,6 +93,21 @@ 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. +The Live Stream workspace requires the App Sandbox audio-input entitlement and +microphone usage description. macOS asks for permission the first time capture +starts. + +## Realtime Boundary + +Music Understanding accepts an asynchronous stream of read-only PCM buffers. +Its loudness sequence can update a meter while those buffers arrive. The +session's aggregate result, including key, rhythm, structure, pace, and +instrument activity, completes after the provider finishes. + +Musavera Lab uses the microphone for that live PCM path. MusicKit's +`ApplicationMusicPlayer` does not expose the decoded PCM of a full Apple Music +song, so the app does not claim to analyze protected full-song playback. + ## Verified Track The signed app was tested with Bruno Mars' "Grenade" from the Apple Music diff --git a/Examples/MusaveraLab/project.yml b/Examples/MusaveraLab/project.yml index 4ea9b25..7acd85f 100644 --- a/Examples/MusaveraLab/project.yml +++ b/Examples/MusaveraLab/project.yml @@ -30,10 +30,13 @@ targets: product: MusaveraKit settings: base: + ASSETCATALOG_COMPILER_APPICON_NAME: Musavera Lab + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: NO CODE_SIGN_ENTITLEMENTS: MusaveraLab/MusaveraLab.entitlements CODE_SIGN_STYLE: Automatic CURRENT_PROJECT_VERSION: 1 ENABLE_APP_SANDBOX: YES + ENABLE_AUDIO_INPUT: YES ENABLE_HARDENED_RUNTIME: YES ENABLE_OUTGOING_NETWORK_CONNECTIONS: YES ENABLE_PREVIEWS: YES @@ -42,6 +45,7 @@ targets: 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. + INFOPLIST_KEY_NSMicrophoneUsageDescription: Musavera Lab uses the microphone to analyze live audio on this Mac. MARKETING_VERSION: 1.0 PRODUCT_BUNDLE_IDENTIFIER: com.rudrankriyam.musaveralab PRODUCT_NAME: "$(TARGET_NAME)" diff --git a/README.md b/README.md index 1a07224..ace8958 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,27 @@ Passing an empty option set throws `MusaveraKitError.emptyAnalysisSet`. A focused helper throws `MusaveraKitError.missingResult` if the framework does not return its requested result. +## Analyze Streaming Audio + +Create a session from any nonthrowing asynchronous sequence of read-only PCM +buffers: + +```swift +let session = MusaveraStreamingSession(audioProvider: audioBuffers) + +async let analysis = session.analyze() + +for try await loudness in session.loudnessResults { + updateMeter(with: loudness) +} + +let completedAnalysis = try await analysis +``` + +Loudness results arrive while the provider yields audio. The complete +`MusaveraAnalysis`, including key, rhythm, structure, pace, and instrument +activity, becomes available after the provider finishes. + ## Timeline Helpers MusaveraKit adds small conveniences for playback-synchronized interfaces: @@ -154,7 +175,9 @@ that composes first-party MusicKit with MusaveraKit. It searches Apple Music, shows catalog artwork, downloads a song's 30-second preview for local analysis, offers separate full-song playback, and renders synchronized key, rhythm, structure, pace, instrument, and loudness views. The complete native -MusicUnderstanding result can also be exported as formatted JSON. +MusicUnderstanding result can also be exported as formatted JSON. Its Live +Stream workspace analyzes microphone PCM, draws realtime loudness, and +completes the remaining musical analysis when capture stops. The activity charts adapt from one to four columns, so a large window can show all four instrument activity timelines side by side. diff --git a/Sources/MusaveraKit/Documentation.docc/GettingStarted.md b/Sources/MusaveraKit/Documentation.docc/GettingStarted.md index 601c8b6..51b020b 100644 --- a/Sources/MusaveraKit/Documentation.docc/GettingStarted.md +++ b/Sources/MusaveraKit/Documentation.docc/GettingStarted.md @@ -9,7 +9,7 @@ Add MusaveraKit to your package dependencies: ```swift .package( url: "https://github.com/rryam/MusaveraKit.git", - branch: "main" + from: "0.1.0" ) ``` diff --git a/Sources/MusaveraKit/Documentation.docc/MusaveraKit.md b/Sources/MusaveraKit/Documentation.docc/MusaveraKit.md index b693f38..9c61e22 100644 --- a/Sources/MusaveraKit/Documentation.docc/MusaveraKit.md +++ b/Sources/MusaveraKit/Documentation.docc/MusaveraKit.md @@ -6,7 +6,8 @@ small API built on Music Understanding. ## Overview MusaveraKit creates and runs ``MusicUnderstandingSession`` instances for audio -assets, then presents the result through ``MusaveraAnalysis``. +assets and streaming PCM buffers, then presents the result through +``MusaveraAnalysis``. ```swift import AVFoundation @@ -32,12 +33,14 @@ instrument ranges. - - +- ### Starting Analysis - ``Musavera`` - ``Musavera/analyze(asset:)`` - ``Musavera/analyze(asset:options:)`` +- ``MusaveraStreamingSession`` - ``MusaveraAnalysisOptions`` ### Results and Errors diff --git a/Sources/MusaveraKit/Documentation.docc/StreamingAudio.md b/Sources/MusaveraKit/Documentation.docc/StreamingAudio.md new file mode 100644 index 0000000..374d7bf --- /dev/null +++ b/Sources/MusaveraKit/Documentation.docc/StreamingAudio.md @@ -0,0 +1,70 @@ +# Analyze Streaming Audio + +Feed live PCM buffers to Music Understanding and update loudness meters while +audio is still arriving. + +## Create a Streaming Session + +Create a nonthrowing asynchronous sequence of +`AVReadOnlyAudioPCMBuffer` values: + +```swift +import AVFoundation +import MusaveraKit + +let (buffers, continuation) = + AsyncStream.makeStream() + +let session = MusaveraStreamingSession(audioProvider: buffers) +``` + +Xcode 27 adds a sendable audio tap that produces the required read-only +buffers directly: + +```swift +let engine = AVAudioEngine() +let input = engine.inputNode +let format = input.outputFormat(forBus: 0) + +try input.installAudioTap( + onBus: 0, + bufferSize: 4_800, + format: format +) { buffer, _ in + continuation.yield(buffer) +} +``` + +## Observe Realtime Loudness + +Iterate ``MusaveraStreamingSession/loudnessResults`` while analysis runs: + +```swift +let loudnessTask = Task { + for try await loudness in session.loudnessResults { + updateMeter(with: loudness) + } +} + +let analysisTask = Task { + try await session.analyze() +} + +engine.prepare() +try engine.start() +``` + +Finish the provider when capture stops, then await the final analysis: + +```swift +engine.stop() +input.removeTap(onBus: 0) +continuation.finish() + +let analysis = try await analysisTask.value +loudnessTask.cancel() +``` + +Realtime delivery is currently available for loudness. Key, rhythm, pace, +structure, and instrument activity are returned in the complete +``MusaveraAnalysis`` after the audio sequence ends. diff --git a/Sources/MusaveraKit/Streaming/MusaveraStreamingSession.swift b/Sources/MusaveraKit/Streaming/MusaveraStreamingSession.swift new file mode 100644 index 0000000..9d927fe --- /dev/null +++ b/Sources/MusaveraKit/Streaming/MusaveraStreamingSession.swift @@ -0,0 +1,56 @@ +@preconcurrency import AVFoundation +import MusicUnderstanding + +/// A MusaveraKit session that analyzes an asynchronous stream of PCM buffers. +/// +/// Use ``loudnessResults`` for measurements that arrive while audio is +/// streaming. The complete ``MusaveraAnalysis`` becomes available after the +/// audio provider finishes. +public struct MusaveraStreamingSession: Sendable { + private let session: MusicUnderstandingSession + + /// Creates a streaming analysis session. + /// + /// - Parameter audioProvider: A nonthrowing asynchronous sequence of + /// read-only PCM buffers. + public init(audioProvider: sending Provider) + where Provider: AsyncSequence & Sendable, + Provider.Element == AVReadOnlyAudioPCMBuffer, + Provider.Failure == Never { + session = MusicUnderstandingSession(audioProvider: audioProvider) + } + + /// Loudness measurements that arrive while the provider yields audio. + public var loudnessResults: some Sendable & AsyncSequence { + session.loudnessResults + } + + /// Analyzes every supported musical dimension in the streamed audio. + /// + /// This method returns after the audio provider finishes and Music + /// Understanding completes its final analysis. + @discardableResult + public func analyze() async throws -> MusaveraAnalysis { + MusaveraAnalysis(result: try await session.analyze()) + } + + /// Analyzes selected musical dimensions in the streamed audio. + /// + /// - Parameter options: The musical dimensions to request. + /// - Throws: ``MusaveraKitError/emptyAnalysisSet`` when `options` is empty, + /// or an error from Music Understanding. + @discardableResult + public func analyze(options: MusaveraAnalysisOptions) async throws -> MusaveraAnalysis { + guard !options.isEmpty else { + throw MusaveraKitError.emptyAnalysisSet + } + + let result = try await session.analyze(for: options.musicUnderstandingTypes) + return MusaveraAnalysis(result: result) + } + + /// Cancels ongoing streaming analysis. + public func cancel() async { + await session.cancel() + } +} diff --git a/Tests/MusaveraKitTests/MusaveraStreamingSessionTests.swift b/Tests/MusaveraKitTests/MusaveraStreamingSessionTests.swift new file mode 100644 index 0000000..46ff3a2 --- /dev/null +++ b/Tests/MusaveraKitTests/MusaveraStreamingSessionTests.swift @@ -0,0 +1,18 @@ +import AVFoundation +import Testing +@testable import MusaveraKit + +@Suite("Musavera streaming session") +struct MusaveraStreamingSessionTests { + @Test("Rejects an empty focused analysis set before consuming audio") + func rejectsEmptyOptions() async { + let buffers = AsyncStream { continuation in + continuation.finish() + } + let session = MusaveraStreamingSession(audioProvider: buffers) + + await #expect(throws: MusaveraKitError.self) { + _ = try await session.analyze(options: []) + } + } +}