diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c9b70e..8021bec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ env: jobs: python-tests: - name: Python Helper Tests + name: Separator Helper Tests runs-on: macos-26 steps: - name: Checkout @@ -31,6 +31,9 @@ jobs: - name: Run Python helper tests run: python3 -m unittest JammLabSeparatorHelper/test_runner.py + - name: Run separator helper embed tests + run: bash scripts/test_embed_separator_helper.sh + swift-tests: name: Swift Tests runs-on: macos-26 diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a7ae4..4287ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased +- Moved audio and Stem playback preparation off the main thread, with cancellable progress, safer memory limits, and transactional project/mode switching that keeps the current audio available if preparation fails. +- Hardened the bundled Stem helper with a versioned v6 job protocol, startup capability checks, stale-helper detection, and one validated manifest for bundled models and compute modes. - Fixed automatic Stem transcription notation to show flats, naturals, and sharps consistently with the key signature and common-practice measure rules. - Improved Notation measure spacing to prevent late notes from stretching a single measure across the view, keep visible parts aligned, backfill the final page, and balance score systems without avoidable one-measure rows. - Added Backspace and Delete support for clearing selected Notation measures in the selected part while preserving harmony symbols and leaving default whole-measure rests. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 217bc68..938281a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,8 +68,8 @@ Useful paths and settings: - Model cache: `build/JammLabSeparatorHelper/model-cache`. - PyInstaller output: `build/JammLabSeparatorHelper/dist/JammLabSeparatorHelper`. - Python executable override: `PYTHON_BIN=/path/to/python3`. -- Default prefetched models: `htdemucs.yaml` and `UVR-MDX-NET-Inst_HQ_5.onnx`. -- Prefetched model list override: `SEPARATOR_MODELS="htdemucs.yaml UVR-MDX-NET-Inst_HQ_5.onnx other.yaml"`. +- Default prefetched models: `htdemucs.yaml`, `htdemucs_6s.yaml`, and `UVR-MDX-NET-Inst_HQ_5.onnx`. +- `JammLabSeparatorHelper/helper-manifest.json` is the single source of truth for supported models, required cache files, compute modes, and helper protocol compatibility. The `JammLab` target copies `build/JammLabSeparatorHelper/dist/JammLabSeparatorHelper` into diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 5dc199e..fbb757b 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -94,13 +94,22 @@ 9F8D01032C30000100112233 /* MultiTrackAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8D00032C30000100112233 /* MultiTrackAudioPlayer.swift */; }; 9F8D01042C30000100112233 /* AudioRenderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8D00042C30000100112233 /* AudioRenderState.swift */; }; 9F8D01052C30000100112233 /* ClickRenderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8D00052C30000100112233 /* ClickRenderState.swift */; }; + A14A01013000000100112233 /* AudioRenderAtomics.c in Sources */ = {isa = PBXBuildFile; fileRef = A14A00013000000100112233 /* AudioRenderAtomics.c */; }; 9F8E01012C40000100112233 /* StemSeparationJobModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8E00012C40000100112233 /* StemSeparationJobModels.swift */; }; 9F8E01022C40000100112233 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8E00032C40000100112233 /* main.swift */; }; + A14C01013000000100112233 /* StemHelperProcessSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A14C00013000000100112233 /* StemHelperProcessSupport.swift */; }; + A14D01013000000100112233 /* NotationTrackRenderScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = A14D00013000000100112233 /* NotationTrackRenderScene.swift */; }; + A14D01023000000100112233 /* NotationEditingPlanners.swift in Sources */ = {isa = PBXBuildFile; fileRef = A14D00023000000100112233 /* NotationEditingPlanners.swift */; }; + A14D01033000000100112233 /* NotationWindowToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A14D00033000000100112233 /* NotationWindowToolbar.swift */; }; + A14D01043000000100112233 /* AudioPlayerViewModel+NotationParts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A14D00043000000100112233 /* AudioPlayerViewModel+NotationParts.swift */; }; + A14D01053000000100112233 /* NotationTrackInteractionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A14D00053000000100112233 /* NotationTrackInteractionModels.swift */; }; 9F8E01032C40000100112233 /* StemModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8D00012C30000100112233 /* StemModels.swift */; }; 9F8E01042C40000100112233 /* StemSeparationJobModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8E00012C40000100112233 /* StemSeparationJobModels.swift */; }; 9F8F01012C50000100112233 /* AppDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8F00012C50000100112233 /* AppDefaults.swift */; }; 9F8F01022C50000100112233 /* AppDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8F00012C50000100112233 /* AppDefaults.swift */; }; 9F8F01032C50000100112233 /* AudioPlaybackControlling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8F00022C50000100112233 /* AudioPlaybackControlling.swift */; }; + A14B01013000000100112233 /* AudioPlaybackPreparation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A14B00013000000100112233 /* AudioPlaybackPreparation.swift */; }; + A14B01023000000100112233 /* AudioPreparationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A14B00023000000100112233 /* AudioPreparationCard.swift */; }; 9F9001012C60000100112233 /* TransportControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9000012C60000100112233 /* TransportControlsView.swift */; }; 9F9001032C60000100112233 /* InspectorSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9000032C60000100112233 /* InspectorSidebarView.swift */; }; 9F9101012C70000100112233 /* TempoGridCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9100012C70000100112233 /* TempoGridCalculator.swift */; }; @@ -397,11 +406,21 @@ 9F8D00032C30000100112233 /* MultiTrackAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiTrackAudioPlayer.swift; sourceTree = ""; }; 9F8D00042C30000100112233 /* AudioRenderState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRenderState.swift; sourceTree = ""; }; 9F8D00052C30000100112233 /* ClickRenderState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickRenderState.swift; sourceTree = ""; }; + A14A00013000000100112233 /* AudioRenderAtomics.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = AudioRenderAtomics.c; sourceTree = ""; }; + A14A00023000000100112233 /* AudioRenderAtomics.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AudioRenderAtomics.h; sourceTree = ""; }; 9F8E00012C40000100112233 /* StemSeparationJobModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemSeparationJobModels.swift; sourceTree = ""; }; 9F8E00022C40000100112233 /* JammLabStemHelper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = JammLabStemHelper; sourceTree = BUILT_PRODUCTS_DIR; }; 9F8E00032C40000100112233 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + A14C00013000000100112233 /* StemHelperProcessSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessSupport.swift; sourceTree = ""; }; + A14D00013000000100112233 /* NotationTrackRenderScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationTrackRenderScene.swift; sourceTree = ""; }; + A14D00023000000100112233 /* NotationEditingPlanners.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationEditingPlanners.swift; sourceTree = ""; }; + A14D00033000000100112233 /* NotationWindowToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationWindowToolbar.swift; sourceTree = ""; }; + A14D00043000000100112233 /* AudioPlayerViewModel+NotationParts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AudioPlayerViewModel+NotationParts.swift"; sourceTree = ""; }; + A14D00053000000100112233 /* NotationTrackInteractionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationTrackInteractionModels.swift; sourceTree = ""; }; 9F8F00012C50000100112233 /* AppDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDefaults.swift; sourceTree = ""; }; 9F8F00022C50000100112233 /* AudioPlaybackControlling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackControlling.swift; sourceTree = ""; }; + A14B00013000000100112233 /* AudioPlaybackPreparation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackPreparation.swift; sourceTree = ""; }; + A14B00023000000100112233 /* AudioPreparationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPreparationCard.swift; sourceTree = ""; }; 9F9000012C60000100112233 /* TransportControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportControlsView.swift; sourceTree = ""; }; 9F9000032C60000100112233 /* InspectorSidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorSidebarView.swift; sourceTree = ""; }; 9F9100012C70000100112233 /* TempoGridCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculator.swift; sourceTree = ""; }; @@ -642,6 +661,8 @@ 9F7003002F80000100112233 /* Transcription */, 9F8A000B2C00000100112233 /* Assets.xcassets */, 9F8A000C2C00000100112233 /* JammLab.entitlements */, + A14A00013000000100112233 /* AudioRenderAtomics.c */, + A14A00023000000100112233 /* AudioRenderAtomics.h */, 9F7000202F80000100112233 /* JammLab-Bridging-Header.h */, ); path = JammLab; @@ -657,6 +678,7 @@ 9F8A000A2C00000100112233 /* ImportedAudioFile.swift */, 9F8A00072C00000100112233 /* LoopRegion.swift */, 9FEA00022D70000100112233 /* NotationViewportState.swift */, + A14D00023000000100112233 /* NotationEditingPlanners.swift */, 9FBA00012D40000100112233 /* PitchDetection.swift */, 9F8A00132C00000100112233 /* JammLabProject.swift */, 9FEA00062D70000100112233 /* ProjectKeySelection.swift */, @@ -682,6 +704,7 @@ 9FB001002D06000100112233 /* AudioDeviceService.swift */, 9F8A00042C00000100112233 /* AudioFileImporter.swift */, 9F8F00022C50000100112233 /* AudioPlaybackControlling.swift */, + A14B00013000000100112233 /* AudioPlaybackPreparation.swift */, 9FCB00012D80000100112233 /* AudioSampleConverter.swift */, 9F8A00172C00000100112233 /* BeatGridCalculator.swift */, 9FBE00012F70000100112233 /* NotationBeamLayout.swift */, @@ -696,6 +719,7 @@ 9FC000012F30000100112233 /* NotationTieService.swift */, 9F2700012EA0000100112233 /* NotationSMuFL.swift */, 9FEA00032D70000100112233 /* NotationViewportFactory.swift */, + A14D00013000000100112233 /* NotationTrackRenderScene.swift */, 9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */, 9F8A00192C00000100112233 /* ProjectArtifactStore.swift */, 9F8A00142C00000100112233 /* ProjectDocumentService.swift */, @@ -757,6 +781,7 @@ 9FB201082D30000100112233 /* AudioPlayerViewModel+Markers.swift */, 9FB201092D30000100112233 /* AudioPlayerViewModel+LoopEditing.swift */, 9FB201052D30000100112233 /* AudioPlayerViewModel+Notation.swift */, + A14D00043000000100112233 /* AudioPlayerViewModel+NotationParts.swift */, 9FEE00022D90000100112233 /* AudioPlayerViewModel+NotationExport.swift */, 9FB201062D30000100112233 /* AudioPlayerViewModel+Video.swift */, 9FB201072D30000100112233 /* AudioPlayerViewModel+UndoDirty.swift */, @@ -811,6 +836,7 @@ 9FDD00042D60000100112233 /* NoteColorPresentation.swift */, 9FCB00072D80000100112233 /* NotationHarmonyInlineTextField.swift */, 9FD100002E00000100112233 /* NotationTrackLayoutItems.swift */, + A14D00053000000100112233 /* NotationTrackInteractionModels.swift */, 9FAD00012F40000100112233 /* MIDIPianoRollView.swift */, 9FDE00022F60000100112233 /* MIDIPianoRollLayout.swift */, 9FEA00042D70000100112233 /* NotationTrackView.swift */, @@ -991,6 +1017,7 @@ 9F9500012CB0000100112233 /* AbletonNumberField.swift */, 9F9600012CC0000100112233 /* JammValueSlider.swift */, 9FDE00032F60000100112233 /* AppControlButton.swift */, + A14B00023000000100112233 /* AudioPreparationCard.swift */, 9F8C00032C20000100112233 /* NotationControls.swift */, 9FCB00032D80000100112233 /* AppLetterToggleButton.swift */, 9FCB00052D80000100112233 /* AppPopoverDismissModifier.swift */, @@ -1004,6 +1031,7 @@ 9F9000032C60000100112233 /* InspectorSidebarView.swift */, 9FCB00042D80000100112233 /* JammModeToggleButton.swift */, 9F8C00052C20000100112233 /* NoteRowView.swift */, + A14D00033000000100112233 /* NotationWindowToolbar.swift */, 9F9800012CF0000100112233 /* RenameNoteDialog.swift */, 9FCB000B2D80000100112233 /* StemSeparationToolbarButton.swift */, 9F9700012CD0000100112233 /* TimelineViewportControlBar.swift */, @@ -1021,6 +1049,7 @@ isa = PBXGroup; children = ( 9F8E00032C40000100112233 /* main.swift */, + A14C00013000000100112233 /* StemHelperProcessSupport.swift */, ); path = JammLabStemHelper; sourceTree = ""; @@ -1226,6 +1255,7 @@ 9FB001012D06000100112233 /* AudioDeviceService.swift in Sources */, 9F8A01042C00000100112233 /* AudioFileImporter.swift in Sources */, 9F8F01032C50000100112233 /* AudioPlaybackControlling.swift in Sources */, + A14B01013000000100112233 /* AudioPlaybackPreparation.swift in Sources */, 9FCB01012D80000100112233 /* AudioSampleConverter.swift in Sources */, 9F8A01032C00000100112233 /* AudioPlayerViewModel.swift in Sources */, 9F7001052F80000100112233 /* AudioPlayerViewModel+StemTranscription.swift in Sources */, @@ -1238,10 +1268,12 @@ 9FB200082D30000100112233 /* AudioPlayerViewModel+Markers.swift in Sources */, 9FB200092D30000100112233 /* AudioPlayerViewModel+LoopEditing.swift in Sources */, 9FB200052D30000100112233 /* AudioPlayerViewModel+Notation.swift in Sources */, + A14D01043000000100112233 /* AudioPlayerViewModel+NotationParts.swift in Sources */, 9FEE01022D90000100112233 /* AudioPlayerViewModel+NotationExport.swift in Sources */, 9FB200062D30000100112233 /* AudioPlayerViewModel+Video.swift in Sources */, 9FB200072D30000100112233 /* AudioPlayerViewModel+UndoDirty.swift in Sources */, 9FDE01032F60000100112233 /* AppControlButton.swift in Sources */, + A14B01023000000100112233 /* AudioPreparationCard.swift in Sources */, 9F8C01032C20000100112233 /* NotationControls.swift in Sources */, 9FCB01032D80000100112233 /* AppLetterToggleButton.swift in Sources */, 9FCB01052D80000100112233 /* AppPopoverDismissModifier.swift in Sources */, @@ -1252,6 +1284,7 @@ 9F9601012CC0000100112233 /* JammValueSlider.swift in Sources */, 9FCB01042D80000100112233 /* JammModeToggleButton.swift in Sources */, 9F8C01022C20000100112233 /* AppPanel.swift in Sources */, + A14D01033000000100112233 /* NotationWindowToolbar.swift in Sources */, 9FCB01022D80000100112233 /* CompactValuePicker.swift in Sources */, 9F8C01012C20000100112233 /* AppTheme.swift in Sources */, 9F8A01152C00000100112233 /* BeatGridCalculator.swift in Sources */, @@ -1274,6 +1307,7 @@ 9FDE01022F60000100112233 /* MIDIPianoRollLayout.swift in Sources */, 9FCB01072D80000100112233 /* NotationHarmonyInlineTextField.swift in Sources */, 9FD100012E00000100112233 /* NotationTrackLayoutItems.swift in Sources */, + A14D01053000000100112233 /* NotationTrackInteractionModels.swift in Sources */, 9FBE01012F70000100112233 /* NotationBeamLayout.swift in Sources */, 9FBE01032F70000100112233 /* NotationBeamGrouping.swift in Sources */, 9FBE01042F70000100112233 /* NotationBeamGeometry.swift in Sources */, @@ -1288,8 +1322,10 @@ 9FAD01022F40000100112233 /* NotationNoteEditPlanner.swift in Sources */, 9FC001012F30000100112233 /* NotationTieService.swift in Sources */, 9FEA01032D70000100112233 /* NotationViewportFactory.swift in Sources */, + A14D01013000000100112233 /* NotationTrackRenderScene.swift in Sources */, 9FEA01052D70000100112233 /* NotationVisibleMeasureFitter.swift in Sources */, 9FEA01022D70000100112233 /* NotationViewportState.swift in Sources */, + A14D01023000000100112233 /* NotationEditingPlanners.swift in Sources */, 9F8B01032C10000100112233 /* PeakformTimelineView.swift in Sources */, 9FEA01062D70000100112233 /* ProjectKeySelection.swift in Sources */, 9F8A01192C00000100112233 /* ProjectArtifactStore.swift in Sources */, @@ -1304,6 +1340,7 @@ 9F2701012EA0000100112233 /* NotationSMuFL.swift in Sources */, 9F8D01042C30000100112233 /* AudioRenderState.swift in Sources */, 9F8D01052C30000100112233 /* ClickRenderState.swift in Sources */, + A14A01013000000100112233 /* AudioRenderAtomics.c in Sources */, 9F8D01032C30000100112233 /* MultiTrackAudioPlayer.swift in Sources */, 9F9301012C90000100112233 /* StemBackendResolver.swift in Sources */, 9F8D01012C30000100112233 /* StemModels.swift in Sources */, @@ -1488,6 +1525,7 @@ files = ( 9F8F01022C50000100112233 /* AppDefaults.swift in Sources */, 9F8E01022C40000100112233 /* main.swift in Sources */, + A14C01013000000100112233 /* StemHelperProcessSupport.swift in Sources */, 9F8E01032C40000100112233 /* StemModels.swift in Sources */, 9F8E01042C40000100112233 /* StemSeparationJobModels.swift in Sources */, 9F9301022C90000100112233 /* StemBackendResolver.swift in Sources */, diff --git a/JammLab/AudioRenderAtomics.c b/JammLab/AudioRenderAtomics.c new file mode 100644 index 0000000..9de1b93 --- /dev/null +++ b/JammLab/AudioRenderAtomics.c @@ -0,0 +1,37 @@ +#include "AudioRenderAtomics.h" + +#include +#include + +struct JammLabAtomicInt64 { + _Atomic int64_t value; +}; + +JammLabAtomicInt64 *JammLabAtomicInt64Create(int64_t initialValue) { + JammLabAtomicInt64 *storage = malloc(sizeof(JammLabAtomicInt64)); + if (storage == NULL) { + abort(); + } + atomic_init(&storage->value, initialValue); + return storage; +} + +void JammLabAtomicInt64Destroy(JammLabAtomicInt64 *storage) { + free(storage); +} + +int64_t JammLabAtomicInt64Load(const JammLabAtomicInt64 *storage) { + return atomic_load_explicit(&storage->value, memory_order_acquire); +} + +void JammLabAtomicInt64Store(JammLabAtomicInt64 *storage, int64_t value) { + atomic_store_explicit(&storage->value, value, memory_order_release); +} + +int64_t JammLabAtomicInt64Increment(JammLabAtomicInt64 *storage) { + return atomic_fetch_add_explicit(&storage->value, 1, memory_order_acq_rel) + 1; +} + +int64_t JammLabAtomicInt64Decrement(JammLabAtomicInt64 *storage) { + return atomic_fetch_sub_explicit(&storage->value, 1, memory_order_acq_rel) - 1; +} diff --git a/JammLab/AudioRenderAtomics.h b/JammLab/AudioRenderAtomics.h new file mode 100644 index 0000000..5370bbe --- /dev/null +++ b/JammLab/AudioRenderAtomics.h @@ -0,0 +1,15 @@ +#ifndef AudioRenderAtomics_h +#define AudioRenderAtomics_h + +#include + +typedef struct JammLabAtomicInt64 JammLabAtomicInt64; + +JammLabAtomicInt64 * _Nonnull JammLabAtomicInt64Create(int64_t initialValue); +void JammLabAtomicInt64Destroy(JammLabAtomicInt64 * _Nullable storage); +int64_t JammLabAtomicInt64Load(const JammLabAtomicInt64 * _Nonnull storage); +void JammLabAtomicInt64Store(JammLabAtomicInt64 * _Nonnull storage, int64_t value); +int64_t JammLabAtomicInt64Increment(JammLabAtomicInt64 * _Nonnull storage); +int64_t JammLabAtomicInt64Decrement(JammLabAtomicInt64 * _Nonnull storage); + +#endif diff --git a/JammLab/JammLab-Bridging-Header.h b/JammLab/JammLab-Bridging-Header.h index 973a41d..33be7c5 100644 --- a/JammLab/JammLab-Bridging-Header.h +++ b/JammLab/JammLab-Bridging-Header.h @@ -1 +1,2 @@ #import "Transcription/Native/JMBasicPitchBridge.h" +#import "AudioRenderAtomics.h" diff --git a/JammLab/Models/NotationEditingPlanners.swift b/JammLab/Models/NotationEditingPlanners.swift new file mode 100644 index 0000000..bc0eb29 --- /dev/null +++ b/JammLab/Models/NotationEditingPlanners.swift @@ -0,0 +1,124 @@ +import Foundation + +enum NotationPartStatePlanner { + static func knownStemTypes( + stemFiles: [StemFile], + notationItems: [NotationMeasureItem], + collapsedStemTypes: Set, + visiblePartIDs: Set + ) -> [StemType] { + let knownTypes = Set(stemFiles.map(\.type)) + .union(notationItems.compactMap(\.partID.stemType)) + .union(collapsedStemTypes) + .union(visiblePartIDs.compactMap(\.stemType)) + return StemType.allCases.filter { knownTypes.contains($0) } + } + + static func availableParts( + knownStemTypes: [StemType], + transcriptionTracks: [StemTranscriptionTrack] + ) -> [NotationPartDescriptor] { + let stemParts = knownStemTypes.map(NotationPartDescriptor.stem) + let additionalTranscriptions = StemType.allCases.flatMap { stemType in + transcriptionTracks + .filter { + $0.stemType == stemType + && $0.notationPartID != .stem(stemType) + } + .sorted { $0.createdAt < $1.createdAt } + .enumerated() + .map { index, track in + NotationPartDescriptor.stemTranscription( + stemType, + id: track.notationPartID, + sequence: index + 2 + ) + } + } + return [.main] + stemParts + additionalTranscriptions + } + + static func normalizedVisiblePartIDs( + _ rawPartIDs: Set, + availableParts: [NotationPartDescriptor] + ) -> Set { + let allowedPartIDs = Set(availableParts.map(\.id)) + var normalized = rawPartIDs.intersection(allowedPartIDs) + if normalized.isEmpty { + normalized = allowedPartIDs.contains(.main) ? [.main] : Set(allowedPartIDs.prefix(1)) + } + return normalized + } +} + +struct NotationAccidentalEditPlan { + var chainIDs: Set + var rootItemID: String + var alreadyApplied: Bool + var updatedItems: [NotationMeasureItem] +} + +enum NotationAccidentalPlanner { + static func plan( + accidental: NotationAccidental, + selectedItem: NotationMeasureItem, + measure: ScoreMeasure, + allItems: [NotationMeasureItem] + ) -> NotationAccidentalEditPlan? { + guard selectedItem.kind == .note, + selectedItem.pitch != nil, + measure.attributes.clef != .drums + else { + return nil + } + + let chainIDs = NotationNoteEditPlanner.logicalChainItemIDs( + in: allItems, + containing: selectedItem.id, + partID: selectedItem.partID + ) + guard !chainIDs.isEmpty else { return nil } + + let chainItems = allItems.filter { chainIDs.contains($0.id) } + let incomingTargetIDs = Set(chainItems.compactMap(\.tieTargetItemID)) + let rootItemID = chainItems.first(where: { !incomingTargetIDs.contains($0.id) })?.id + ?? selectedItem.id + + let hasCollision = chainItems.contains { chainItem in + guard var pitch = chainItem.pitch else { return true } + pitch.alter = accidental.alter + return allItems.contains { candidate in + !chainIDs.contains(candidate.id) + && candidate.partID == chainItem.partID + && candidate.kind == .note + && candidate.pitch?.midiNoteNumber == pitch.midiNoteNumber + && candidate.measureNumber == chainItem.measureNumber + && abs(candidate.measureStartTime - chainItem.measureStartTime) + < NotationMeasureTiming.timelineTolerance + && abs(candidate.offsetInQuarterNotes - chainItem.offsetInQuarterNotes) + < NotationMeasureTiming.timelineTolerance + } + } + guard !hasCollision else { return nil } + + let alreadyApplied = chainItems.allSatisfy { item in + item.pitch?.alter == accidental.alter + && item.explicitAccidental == (item.id == rootItemID ? accidental : nil) + } + let updatedItems = allItems.map { item -> NotationMeasureItem in + guard chainIDs.contains(item.id), var pitch = item.pitch else { return item } + pitch.alter = accidental.alter + var updated = item + updated.pitch = pitch + updated.explicitAccidental = item.id == rootItemID ? accidental : nil + return updated + } + + return NotationAccidentalEditPlan( + chainIDs: chainIDs, + rootItemID: rootItemID, + alreadyApplied: alreadyApplied, + updatedItems: updatedItems + ) + } +} diff --git a/JammLab/Models/StemBackendResolver.swift b/JammLab/Models/StemBackendResolver.swift index db30871..9307557 100644 --- a/JammLab/Models/StemBackendResolver.swift +++ b/JammLab/Models/StemBackendResolver.swift @@ -27,15 +27,11 @@ struct StemBackendResolver { StemBackendCandidate( executableURL: helperExecutableURL, argumentsPrefix: [], - displayName: "JammLabSeparatorHelper/\(StemBackendResolver.separatorVersion)" + displayName: StemBackendResolver.separatorExecutableName ) ] } - static var separatorVersion: String { - "1" - } - static func defaultBundledSeparatorExecutableURL( currentExecutableURL: URL = URL(fileURLWithPath: CommandLine.arguments.first ?? "") ) -> URL { diff --git a/JammLab/Models/StemSeparationJobModels.swift b/JammLab/Models/StemSeparationJobModels.swift index 892ca0c..a4abcc0 100644 --- a/JammLab/Models/StemSeparationJobModels.swift +++ b/JammLab/Models/StemSeparationJobModels.swift @@ -27,19 +27,18 @@ enum StemJobPhase: String, Codable, Equatable { } struct StemJobRequest: Codable, Equatable { + var protocolVersion: Int var jobID: String var audioPath: String var cacheKey: String var cacheDirectoryPath: String var modelDirectoryPath: String var sourceFingerprint: StemSourceFingerprint - var separationMethodID: String? = nil - var expectedStemTypes: [StemType]? = nil + var separationMethodID: String + var expectedStemTypes: [StemType] var modelName: String var settingsVersion: Int - var audioSeparatorPath: String? - var audioSeparatorBookmarkData: Data? - var computeMode: String? + var computeMode: String var createdAt: Date } @@ -73,7 +72,13 @@ struct StemJobResult: Codable, Equatable { } struct StemHelperHeartbeat: Codable, Equatable { + var protocolVersion: Int = StemJobFiles.protocolVersion var helperVersion: Int + var separatorVersion: String = "" + var executableIdentity: String = "" + var manifestSHA256: String = "" + var supportedModels: [String] = [] + var supportedComputeModes: [String] = [] var updatedAt: Date var activeJobID: String? @@ -82,8 +87,18 @@ struct StemHelperHeartbeat: Codable, Equatable { } } +struct StemHelperCapabilities: Codable, Equatable { + var protocolVersion: Int + var separatorVersion: String + var executableIdentity: String + var manifestSHA256: String + var supportedModels: [String] + var supportedComputeModes: [String] +} + enum StemJobFiles { - static let helperVersion = 5 + static let protocolVersion = 6 + static let helperVersion = 6 static let jobsDirectoryName = "StemJobs" static let currentJobsDirectoryName = "v\(helperVersion)" static let cacheDirectoryName = "StemCache" diff --git a/JammLab/Services/AudioPlaybackControlling.swift b/JammLab/Services/AudioPlaybackControlling.swift index 577ead5..8b5b881 100644 --- a/JammLab/Services/AudioPlaybackControlling.swift +++ b/JammLab/Services/AudioPlaybackControlling.swift @@ -2,6 +2,7 @@ import Foundation @MainActor protocol AudioPlaybackControlling: AnyObject { + var requiresPreparedPlayback: Bool { get } var isLoaded: Bool { get } var isPlaying: Bool { get } var currentTime: TimeInterval { get } @@ -17,6 +18,7 @@ protocol AudioPlaybackControlling: AnyObject { func setPitchShift(semitones: Float) func setMainVolume(_ volume: Float) func load(stems: [StemFile], mixState: StemMixState) throws + func install(prepared asset: PreparedPlaybackAsset) throws func applyMix(_ mixState: StemMixState) func setClickEnabled(_ isEnabled: Bool) func setClickVolume(_ volume: Float) @@ -28,6 +30,19 @@ protocol AudioPlaybackControlling: AnyObject { } extension AudioPlaybackControlling { + var requiresPreparedPlayback: Bool { false } + + func install(prepared asset: PreparedPlaybackAsset) throws { + switch asset.storage { + case .originalURL(let url): + try load(url: url) + case .stems(let stems, let mixState): + try load(stems: stems, mixState: mixState) + case .decoded: + throw MultiTrackAudioPlayerError.unsupportedPreparedAsset + } + } + func load(stems: [StemFile], mixState: StemMixState) throws { throw MultiTrackAudioPlayerError.unsupportedStemLoad } diff --git a/JammLab/Services/AudioPlaybackPreparation.swift b/JammLab/Services/AudioPlaybackPreparation.swift new file mode 100644 index 0000000..26ae360 --- /dev/null +++ b/JammLab/Services/AudioPlaybackPreparation.swift @@ -0,0 +1,260 @@ +@preconcurrency import AVFoundation +import Foundation + +enum SecurityScopedResourceLeaseError: LocalizedError { + case accessDenied(URL) + + var errorDescription: String? { + switch self { + case .accessDenied(let url): + return "JammLab cannot access \(url.lastPathComponent). Choose the file or project folder again." + } + } +} + +final class SecurityScopedResourceLease { + let url: URL + private var hasAccess: Bool + + init(url: URL, requiresAccess: Bool) throws { + self.url = url + hasAccess = url.startAccessingSecurityScopedResource() + if requiresAccess, !hasAccess { + throw SecurityScopedResourceLeaseError.accessDenied(url) + } + } + + deinit { + if hasAccess { + url.stopAccessingSecurityScopedResource() + } + } +} + +enum AudioPreparationKind: Equatable, Sendable { + case importing + case openingProject + case switchingMode +} + +enum AudioPreparationPhase: Equatable, Sendable { + case idle + case decoding + case installing + case cancelled + case failed +} + +struct AudioPreparationViewState: Equatable, Sendable { + var kind: AudioPreparationKind? + var phase: AudioPreparationPhase = .idle + var progress: Double? + var status = "" + var pendingPlaybackMode: PlaybackMode? + var isCancellable = false + + var isActive: Bool { + phase == .decoding || phase == .installing + } + + static let idle = AudioPreparationViewState() +} + +struct AudioPreparationProgress: Equatable, Sendable { + var completedUnitCount: Int + var totalUnitCount: Int + var status: String + + var fractionCompleted: Double? { + guard totalUnitCount > 0 else { return nil } + return min(1, max(0, Double(completedUnitCount) / Double(totalUnitCount))) + } +} + +struct AudioPreparationMemoryPolicy: Sendable { + var maximumCandidateBytes: UInt64 + + static var `default`: AudioPreparationMemoryPolicy { + let quarterOfPhysicalMemory = ProcessInfo.processInfo.physicalMemory / 4 + return AudioPreparationMemoryPolicy( + maximumCandidateBytes: min(2 * 1_024 * 1_024 * 1_024, quarterOfPhysicalMemory) + ) + } + + func validate(frameCount: AVAudioFramePosition, channelCount: AVAudioChannelCount) throws { + let frames = UInt64(max(0, frameCount)) + let channels = UInt64(max(1, channelCount)) + let finalPCMBytes = frames.multipliedReportingOverflow(by: channels * 4) + let workingSetBytes = finalPCMBytes.partialValue.multipliedReportingOverflow(by: 2) + guard !finalPCMBytes.overflow, + !workingSetBytes.overflow, + workingSetBytes.partialValue <= maximumCandidateBytes + else { + throw MultiTrackAudioPlayerError.preparationMemoryLimitExceeded( + requiredBytes: workingSetBytes.partialValue, + limitBytes: maximumCandidateBytes + ) + } + } +} + +struct PreparedAudioTrack: @unchecked Sendable { + var stemType: StemType? + var buffer: AVAudioPCMBuffer + var volume: Float +} + +struct PreparedPlaybackAsset: @unchecked Sendable { + enum Storage { + case decoded(outputFormat: AVAudioFormat, tracks: [PreparedAudioTrack]) + case originalURL(URL) + case stems([StemFile], StemMixState) + } + + var storage: Storage +} + +protocol AudioPlaybackPreparing: Sendable { + func prepareOriginal( + url: URL, + volume: Float, + progress: @escaping @Sendable (AudioPreparationProgress) -> Void + ) async throws -> PreparedPlaybackAsset + + func prepareStems( + _ stems: [StemFile], + mixState: StemMixState, + progress: @escaping @Sendable (AudioPreparationProgress) -> Void + ) async throws -> PreparedPlaybackAsset +} + +struct LegacyAudioPlaybackPreparer: AudioPlaybackPreparing { + func prepareOriginal( + url: URL, + volume: Float, + progress: @escaping @Sendable (AudioPreparationProgress) -> Void + ) async throws -> PreparedPlaybackAsset { + try Task.checkCancellation() + progress(AudioPreparationProgress(completedUnitCount: 1, totalUnitCount: 1, status: "Audio ready")) + return PreparedPlaybackAsset(storage: .originalURL(url)) + } + + func prepareStems( + _ stems: [StemFile], + mixState: StemMixState, + progress: @escaping @Sendable (AudioPreparationProgress) -> Void + ) async throws -> PreparedPlaybackAsset { + try Task.checkCancellation() + progress(AudioPreparationProgress(completedUnitCount: 1, totalUnitCount: 1, status: "Stems ready")) + return PreparedPlaybackAsset(storage: .stems(stems, mixState)) + } +} + +struct MultiTrackAudioPreparer: AudioPlaybackPreparing { + var memoryPolicy: AudioPreparationMemoryPolicy = .default + + func prepareOriginal( + url: URL, + volume: Float, + progress: @escaping @Sendable (AudioPreparationProgress) -> Void + ) async throws -> PreparedPlaybackAsset { + let memoryPolicy = memoryPolicy + let worker = Task.detached(priority: .userInitiated) { + try Task.checkCancellation() + progress(AudioPreparationProgress(completedUnitCount: 0, totalUnitCount: 1, status: "Decoding audio")) + let file = try AVAudioFile(forReading: url) + let outputFormat = try Self.renderFormat(for: file.processingFormat) + try memoryPolicy.validate(frameCount: file.length, channelCount: outputFormat.channelCount) + let buffer = try AudioFileBufferDecoder.decode( + file: file, + to: outputFormat, + cancellationCheck: Task.checkCancellation + ) + try Task.checkCancellation() + progress(AudioPreparationProgress(completedUnitCount: 1, totalUnitCount: 1, status: "Audio decoded")) + return PreparedPlaybackAsset( + storage: .decoded( + outputFormat: outputFormat, + tracks: [PreparedAudioTrack(stemType: nil, buffer: buffer, volume: volume)] + ) + ) + } + return try await withTaskCancellationHandler { + try await worker.value + } onCancel: { + worker.cancel() + } + } + + func prepareStems( + _ stems: [StemFile], + mixState: StemMixState, + progress: @escaping @Sendable (AudioPreparationProgress) -> Void + ) async throws -> PreparedPlaybackAsset { + guard !stems.isEmpty else { + throw MultiTrackAudioPlayerError.noStems + } + + let memoryPolicy = memoryPolicy + let worker = Task.detached(priority: .userInitiated) { + try Task.checkCancellation() + let firstFile = try AVAudioFile(forReading: stems[0].url) + let outputFormat = try Self.renderFormat(for: firstFile.processingFormat) + var preparedTracks: [PreparedAudioTrack] = [] + preparedTracks.reserveCapacity(stems.count) + + var estimatedFrames: AVAudioFramePosition = 0 + for stem in stems { + try Task.checkCancellation() + let file = try AVAudioFile(forReading: stem.url) + estimatedFrames += file.length + } + try memoryPolicy.validate(frameCount: estimatedFrames, channelCount: outputFormat.channelCount) + + for (index, stem) in stems.enumerated() { + try Task.checkCancellation() + progress(AudioPreparationProgress( + completedUnitCount: index, + totalUnitCount: stems.count, + status: "Decoding \(stem.type.title)" + )) + let file = try AVAudioFile(forReading: stem.url) + let buffer = try AudioFileBufferDecoder.decode( + file: file, + to: outputFormat, + cancellationCheck: Task.checkCancellation + ) + preparedTracks.append(PreparedAudioTrack( + stemType: stem.type, + buffer: buffer, + volume: mixState.effectiveVolume(for: stem.type) + )) + } + + try Task.checkCancellation() + progress(AudioPreparationProgress( + completedUnitCount: stems.count, + totalUnitCount: stems.count, + status: "Stems decoded" + )) + return PreparedPlaybackAsset(storage: .decoded(outputFormat: outputFormat, tracks: preparedTracks)) + } + return try await withTaskCancellationHandler { + try await worker.value + } onCancel: { + worker.cancel() + } + } + + private static func renderFormat(for sourceFormat: AVAudioFormat) throws -> AVAudioFormat { + guard let format = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: sourceFormat.sampleRate, + channels: sourceFormat.channelCount, + interleaved: false + ) else { + throw MultiTrackAudioPlayerError.unsupportedAudioFormat + } + return format + } +} diff --git a/JammLab/Services/AudioRenderState.swift b/JammLab/Services/AudioRenderState.swift index 74d8e2a..106721c 100644 --- a/JammLab/Services/AudioRenderState.swift +++ b/JammLab/Services/AudioRenderState.swift @@ -1,24 +1,94 @@ @preconcurrency import AVFoundation import Foundation -final class AudioRenderTrack { +final class AudioRenderAtomicInt64: @unchecked Sendable { + private let storage: OpaquePointer + + init(_ value: Int64 = 0) { + storage = JammLabAtomicInt64Create(value) + } + + deinit { + JammLabAtomicInt64Destroy(storage) + } + + var value: Int64 { + get { JammLabAtomicInt64Load(storage) } + set { JammLabAtomicInt64Store(storage, newValue) } + } + + @discardableResult + func increment() -> Int64 { + JammLabAtomicInt64Increment(storage) + } + + @discardableResult + func decrement() -> Int64 { + JammLabAtomicInt64Decrement(storage) + } +} + +final class AudioRenderAtomicFloat: @unchecked Sendable { + private let bits: AudioRenderAtomicInt64 + + init(_ value: Float) { + bits = AudioRenderAtomicInt64(Int64(value.bitPattern)) + } + + var value: Float { + get { Float(bitPattern: UInt32(truncatingIfNeeded: bits.value)) } + set { bits.value = Int64(newValue.bitPattern) } + } +} + +final class AudioRenderTrack: @unchecked Sendable { let id: MultiTrackAudioPlayer.TrackID let buffer: AVAudioPCMBuffer let frameLength: AVAudioFramePosition let channelCount: Int - var volume: Float + private let atomicVolume: AudioRenderAtomicFloat + + var volume: Float { + get { atomicVolume.value } + set { atomicVolume.value = newValue } + } init(id: MultiTrackAudioPlayer.TrackID, buffer: AVAudioPCMBuffer, volume: Float) { self.id = id self.buffer = buffer self.frameLength = AVAudioFramePosition(buffer.frameLength) self.channelCount = Int(buffer.format.channelCount) - self.volume = volume + atomicVolume = AudioRenderAtomicFloat(volume) } } -final class AudioTransportRenderState { - private(set) var sourceFrame: Double = 0 +final class AudioRenderGraphLease: @unchecked Sendable { + let tracks: [AudioRenderTrack] + private let activeCallbacks = AudioRenderAtomicInt64() + + init(tracks: [AudioRenderTrack]) { + self.tracks = tracks + } + + func beginRender() { + activeCallbacks.increment() + } + + func endRender() { + activeCallbacks.decrement() + } + + var isRenderInactive: Bool { + activeCallbacks.value == 0 + } + + var activeRenderCount: Int64 { + activeCallbacks.value + } +} + +final class AudioTransportRenderState: @unchecked Sendable { + private var sourceFrame: Double = 0 private var durationFrames: Double = 0 private var loopStartFrame: Double = 0 private var loopEndFrame: Double = 0 @@ -26,9 +96,10 @@ final class AudioTransportRenderState { private var loopArmed = false private var isPlaying = false private var didReachEnd = false + private let publishedFrame = AudioRenderAtomicInt64() var currentFrame: AVAudioFramePosition { - AVAudioFramePosition(max(0, min(durationFrames, sourceFrame)).rounded()) + AVAudioFramePosition(max(0, publishedFrame.value)) } func configure(durationFrames: AVAudioFramePosition) { @@ -38,6 +109,7 @@ final class AudioTransportRenderState { loopEndFrame = self.durationFrames loopArmed = false didReachEnd = false + publishCurrentFrame() } func setLoop(enabled: Bool, startFrame: AVAudioFramePosition, endFrame: AVAudioFramePosition) { @@ -48,10 +120,8 @@ final class AudioTransportRenderState { loopEndFrame = end if !isLoopEnabled { loopArmed = false - } else if sourceFrame < loopEndFrame { - loopArmed = true } else { - loopArmed = false + loopArmed = sourceFrame < loopEndFrame } } @@ -62,10 +132,12 @@ final class AudioTransportRenderState { didReachEnd = false loopArmed = isLoopEnabled && sourceFrame < loopEndFrame isPlaying = true + publishCurrentFrame() } func pause() { isPlaying = false + publishCurrentFrame() } func stop() { @@ -73,12 +145,14 @@ final class AudioTransportRenderState { isPlaying = false didReachEnd = false loopArmed = isLoopEnabled && sourceFrame < loopEndFrame + publishCurrentFrame() } func seek(to frame: AVAudioFramePosition) { sourceFrame = max(0, min(durationFrames, Double(frame))) didReachEnd = false loopArmed = isLoopEnabled && sourceFrame < loopEndFrame + publishCurrentFrame() } func nextSourceFrame() -> AVAudioFramePosition? { @@ -87,6 +161,7 @@ final class AudioTransportRenderState { if sourceFrame >= durationFrames { didReachEnd = true isPlaying = false + publishCurrentFrame() return nil } @@ -104,6 +179,12 @@ final class AudioTransportRenderState { isPlaying = false } + publishCurrentFrame() return frame } + + private func publishCurrentFrame() { + let frame = AVAudioFramePosition(max(0, min(durationFrames, sourceFrame)).rounded()) + publishedFrame.value = Int64(frame) + } } diff --git a/JammLab/Services/MultiTrackAudioPlayer.swift b/JammLab/Services/MultiTrackAudioPlayer.swift index 46619ce..2d8e1b7 100644 --- a/JammLab/Services/MultiTrackAudioPlayer.swift +++ b/JammLab/Services/MultiTrackAudioPlayer.swift @@ -21,8 +21,22 @@ private final class SingleUseAudioBufferProvider: @unchecked Sendable { } enum AudioFileBufferDecoder { - static func decode(file: AVAudioFile, to outputFormat: AVAudioFormat) throws -> AVAudioPCMBuffer { + static func decode( + file: AVAudioFile, + to outputFormat: AVAudioFormat, + cancellationCheck: () throws -> Void = {} + ) throws -> AVAudioPCMBuffer { let inputFormat = file.processingFormat + try cancellationCheck() + + if formatsMatch(inputFormat, outputFormat) { + return try readMatchingFormat( + file: file, + format: inputFormat, + cancellationCheck: cancellationCheck + ) + } + guard let inputBuffer = AVAudioPCMBuffer( pcmFormat: inputFormat, frameCapacity: AVAudioFrameCount(file.length) @@ -31,9 +45,7 @@ enum AudioFileBufferDecoder { } try file.read(into: inputBuffer) - if formatsMatch(inputFormat, outputFormat), inputBuffer.floatChannelData != nil { - return inputBuffer - } + try cancellationCheck() guard let converter = AVAudioConverter(from: inputFormat, to: outputFormat) else { throw MultiTrackAudioPlayerError.audioConversionFailed @@ -60,6 +72,48 @@ enum AudioFileBufferDecoder { if conversionError != nil || outputBuffer.floatChannelData == nil { throw MultiTrackAudioPlayerError.audioConversionFailed } + try cancellationCheck() + return outputBuffer + } + + private static func readMatchingFormat( + file: AVAudioFile, + format: AVAudioFormat, + cancellationCheck: () throws -> Void + ) throws -> AVAudioPCMBuffer { + guard + let outputBuffer = AVAudioPCMBuffer( + pcmFormat: format, + frameCapacity: AVAudioFrameCount(file.length) + ), + let outputChannels = outputBuffer.floatChannelData + else { + throw MultiTrackAudioPlayerError.audioConversionFailed + } + + let chunkCapacity: AVAudioFrameCount = 16_384 + while file.framePosition < file.length { + try cancellationCheck() + let remaining = AVAudioFrameCount(file.length - file.framePosition) + let requestedFrames = min(chunkCapacity, remaining) + guard + let chunk = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: requestedFrames), + let chunkChannels = chunk.floatChannelData + else { + throw MultiTrackAudioPlayerError.audioConversionFailed + } + + try file.read(into: chunk, frameCount: requestedFrames) + guard chunk.frameLength > 0 else { break } + let destinationOffset = Int(outputBuffer.frameLength) + let byteCount = Int(chunk.frameLength) * MemoryLayout.size + for channel in 0.. AVAudioSourceNode { + private func makeTrackSourceNode( + format: AVAudioFormat, + lease: AudioRenderGraphLease + ) -> AVAudioSourceNode { let transportState = self.transportState - let tracks = self.trackOrder + let tracks = lease.tracks let channelCount = Int(format.channelCount) return AVAudioSourceNode(format: format) { _, _, frameCount, outputData in + lease.beginRender() + defer { lease.endRender() } let outputBuffers = UnsafeMutableAudioBufferListPointer(outputData) for buffer in outputBuffers { memset(buffer.mData, 0, Int(buffer.mDataByteSize)) @@ -463,14 +584,15 @@ final class MultiTrackAudioPlayer: AudioPlaybackControlling { } private func resetEngine() { + engine.stop() + Self.waitForRenderQuiescence(renderLease) sourceNode = nil clickNode = nil - engine.stop() + renderLease = nil engine = AVAudioEngine() timePitch = AVAudioUnitTimePitch() renderTracks = [:] - trackOrder = [] outputFormat = nil duration = 0 durationFrames = 0 @@ -479,4 +601,31 @@ final class MultiTrackAudioPlayer: AudioPlaybackControlling { isLoaded = false isPlaying = false } + + private func mutateRenderState(_ mutation: () -> Void) { + let shouldRestart = engine.isRunning && isPlaying + if engine.isRunning { + engine.stop() + Self.waitForRenderQuiescence(renderLease) + } + + mutation() + + if shouldRestart { + do { + try engine.start() + } catch { + transportState.pause() + clickState.pause(at: transportState.currentFrame) + isPlaying = false + } + } + } + + nonisolated static func waitForRenderQuiescence(_ renderLease: AudioRenderGraphLease?) { + guard let renderLease else { return } + while !renderLease.isRenderInactive { + Thread.sleep(forTimeInterval: 0.000_1) + } + } } diff --git a/JammLab/Services/NotationTrackRenderScene.swift b/JammLab/Services/NotationTrackRenderScene.swift new file mode 100644 index 0000000..380c000 --- /dev/null +++ b/JammLab/Services/NotationTrackRenderScene.swift @@ -0,0 +1,78 @@ +import CoreGraphics +import Foundation + +struct NotationTrackRenderScene: Equatable { + struct Input: Equatable { + var visibleMeasures: [ScoreMeasure] + var measureLayout: NotationSystemMeasureLayout? + var renderedMeasureCount: Int + var width: CGFloat + var attributeDisplays: [NotationAttributeDisplay] + var attributeReserveWidths: [CGFloat] + } + + var input: Input + var geometries: [NotationMeasureCanvasGeometry] + var barlines: [NotationBarlineGeometry] + var barlineHitTargets: [NotationBarlineHitTarget] + var notationItems: [NotationItemLayoutItem] + var harmonies: [HarmonyLayoutItem] + var regionLabels: [RegionLabelLayoutItem] + + static func make(input: Input) -> NotationTrackRenderScene { + let geometries: [NotationMeasureCanvasGeometry] + if let measureLayout = input.measureLayout, + measureLayout.matches(input.visibleMeasures) { + geometries = measureLayout.geometries(totalWidth: input.width) + } else { + geometries = NotationMeasureLayout.canvasGeometries( + measureCount: max(1, input.renderedMeasureCount), + totalWidth: input.width, + attributeReserveWidths: input.attributeReserveWidths + ) + } + + return NotationTrackRenderScene( + input: input, + geometries: geometries, + barlines: NotationMeasureLayout.barlineGeometries(for: geometries), + barlineHitTargets: NotationMeasureLayout.barlineHitTargets( + for: geometries, + measures: input.visibleMeasures + ), + notationItems: NotationTrackLayoutItems.notationItems( + visibleMeasures: input.visibleMeasures, + geometries: geometries + ), + harmonies: NotationTrackLayoutItems.harmonies( + visibleMeasures: input.visibleMeasures, + geometries: geometries + ), + regionLabels: NotationTrackLayoutItems.regionLabels( + visibleMeasures: input.visibleMeasures, + geometries: geometries + ) + ) + } +} + +final class NotationTrackRenderSceneCache { + private var cachedScene: NotationTrackRenderScene? + private(set) var buildCount = 0 + + func scene(input: NotationTrackRenderScene.Input) -> NotationTrackRenderScene { + if let cachedScene, cachedScene.input == input { + return cachedScene + } + + let scene = NotationTrackRenderScene.make(input: input) + cachedScene = scene + buildCount += 1 + return scene + } + + func scene(matching geometries: [NotationMeasureCanvasGeometry]) -> NotationTrackRenderScene? { + guard cachedScene?.geometries == geometries else { return nil } + return cachedScene + } +} diff --git a/JammLab/Services/StemHelperProcessController.swift b/JammLab/Services/StemHelperProcessController.swift index 7e6c0e9..6b93c5c 100644 --- a/JammLab/Services/StemHelperProcessController.swift +++ b/JammLab/Services/StemHelperProcessController.swift @@ -33,6 +33,17 @@ enum StemHelperLaunchError: LocalizedError { } } +enum StemHelperCapabilityError: LocalizedError { + case incompatibleHeartbeat(String) + + var errorDescription: String? { + switch self { + case .incompatibleHeartbeat(let details): + return details + } + } +} + protocol StemHelperLaunchedProcess: AnyObject { var isRunning: Bool { get } func terminate() @@ -76,14 +87,13 @@ private final class FoundationStemHelperProcess: StemHelperLaunchedProcess { } } -final class StemHelperProcessController { +actor StemHelperProcessController { private let helperExecutableURL: URL private let heartbeatURL: URL private let expectedHelperVersion: Int private let fileManager: FileManager private let launcher: StemHelperProcessLaunching private let pollInterval: UInt64 = 200_000_000 - private let lock = NSLock() private var launchedProcess: StemHelperLaunchedProcess? init( @@ -101,11 +111,7 @@ final class StemHelperProcessController { } deinit { - lock.lock() - let process = launchedProcess - launchedProcess = nil - lock.unlock() - process?.terminate() + launchedProcess?.terminate() } static func defaultHelperExecutableURL(bundle: Bundle = .main) -> URL { @@ -115,23 +121,36 @@ final class StemHelperProcessController { .appendingPathComponent("JammLabStemHelper") } - func ensureRunning(timeout: TimeInterval = 3) async throws { - if isHeartbeatFresh { + func ensureRunning( + timeout: TimeInterval = 3, + requiredModel: String? = nil, + computeMode: String? = nil + ) async throws { + if let heartbeat = readHeartbeat(), heartbeat.isFresh { + if let mismatch = capabilityMismatch( + heartbeat, + requiredModel: requiredModel, + computeMode: computeMode + ) { + throw StemHelperCapabilityError.incompatibleHeartbeat(mismatch) + } return } + try? fileManager.removeItem(at: heartbeatURL) try launchIfNeeded() - try await waitForFreshHeartbeat(timeout: timeout) + try await waitForFreshHeartbeat( + timeout: timeout, + requiredModel: requiredModel, + computeMode: computeMode + ) } private func launchIfNeeded() throws { - lock.lock() if let launchedProcess, launchedProcess.isRunning { - lock.unlock() return } launchedProcess = nil - lock.unlock() guard fileManager.fileExists(atPath: helperExecutableURL.path) else { throw StemHelperLaunchError.missingExecutable(helperExecutableURL) @@ -142,32 +161,74 @@ final class StemHelperProcessController { } let process = try launcher.launchStemHelper(at: helperExecutableURL) - - lock.lock() launchedProcess = process - lock.unlock() } - private func waitForFreshHeartbeat(timeout: TimeInterval) async throws { + private func waitForFreshHeartbeat( + timeout: TimeInterval, + requiredModel: String?, + computeMode: String? + ) async throws { let startedAt = Date() + var lastMismatch: String? while Date().timeIntervalSince(startedAt) < timeout { - if isHeartbeatFresh { - return + if let heartbeat = readHeartbeat(), heartbeat.isFresh { + if let mismatch = capabilityMismatch( + heartbeat, + requiredModel: requiredModel, + computeMode: computeMode + ) { + lastMismatch = mismatch + } else { + return + } } try await Task.sleep(nanoseconds: pollInterval) } + if let lastMismatch { + throw StemHelperCapabilityError.incompatibleHeartbeat(lastMismatch) + } throw StemHelperLaunchError.heartbeatTimedOut("heartbeat: \(heartbeatURL.path)") } - private var isHeartbeatFresh: Bool { + private func readHeartbeat() -> StemHelperHeartbeat? { guard let data = try? Data(contentsOf: heartbeatURL), let heartbeat = try? JSONDecoder().decode(StemHelperHeartbeat.self, from: data) else { - return false + return nil + } + return heartbeat + } + + private func capabilityMismatch( + _ heartbeat: StemHelperHeartbeat, + requiredModel: String?, + computeMode: String? + ) -> String? { + guard heartbeat.protocolVersion == StemJobFiles.protocolVersion, + heartbeat.helperVersion == expectedHelperVersion + else { + return "Expected protocol/helper v\(StemJobFiles.protocolVersion)/v\(expectedHelperVersion), got v\(heartbeat.protocolVersion)/v\(heartbeat.helperVersion)." } - return heartbeat.helperVersion == expectedHelperVersion && heartbeat.isFresh + let expectedIdentity = helperExecutableURL + .standardizedFileURL + .resolvingSymlinksInPath() + .path + guard heartbeat.executableIdentity == expectedIdentity else { + return "A stale Stem helper is running from \(heartbeat.executableIdentity.isEmpty ? "an unknown path" : heartbeat.executableIdentity)." + } + guard !heartbeat.separatorVersion.isEmpty, !heartbeat.manifestSHA256.isEmpty else { + return "The helper did not publish its separator version or manifest identity." + } + if let requiredModel, !heartbeat.supportedModels.contains(requiredModel) { + return "Model \(requiredModel) is not bundled." + } + if let computeMode, !heartbeat.supportedComputeModes.contains(computeMode) { + return "Compute mode \(computeMode) is not supported." + } + return nil } } diff --git a/JammLab/Services/StemSeparationService.swift b/JammLab/Services/StemSeparationService.swift index e5cfd90..96c7b18 100644 --- a/JammLab/Services/StemSeparationService.swift +++ b/JammLab/Services/StemSeparationService.swift @@ -9,6 +9,7 @@ enum StemSeparationError: LocalizedError { case helperJobTimedOut(String) case incompleteOutput([StemType]) case invalidStemDuration(StemType) + case helperCapabilityMismatch(String) case cancelled var errorDescription: String? { @@ -26,6 +27,8 @@ enum StemSeparationError: LocalizedError { return "Stem helper finished, but these stems are missing: \(names)." case .invalidStemDuration(let type): return "\(type.title) stem duration does not match the original track." + case .helperCapabilityMismatch(let details): + return "The bundled Stem helper is incompatible with this separation request. \(details)" case .cancelled: return "Stem separation was cancelled." } @@ -45,6 +48,8 @@ enum StemSeparationError: LocalizedError { return "Incomplete helper output. Missing stems: \(missingTypes.map(\.rawValue).joined(separator: ", "))" case .invalidStemDuration(let type): return "Invalid stem duration for \(type.rawValue)" + case .helperCapabilityMismatch(let details): + return "Stem helper capability mismatch\n\(details)" } } } @@ -145,10 +150,18 @@ final class StemSeparationService { progress(StemSeparationProgress(phase: .checkingBackend, progress: nil, status: "Waiting for helper")) do { - try await helperProcessController.ensureRunning() + try await helperProcessController.ensureRunning( + requiredModel: method.modelName, + computeMode: appSettingsStore.stemBackendComputeMode.helperArgument + ) + } catch let error as StemHelperCapabilityError { + try Task.checkCancellation() + throw StemSeparationError.helperCapabilityMismatch(error.localizedDescription) } catch let error as StemHelperLaunchError { + try Task.checkCancellation() throw StemSeparationError.helperNotRunning(error.diagnostics) } catch { + try Task.checkCancellation() throw StemSeparationError.helperNotRunning(error.localizedDescription) } @@ -273,6 +286,7 @@ final class StemSeparationService { let input = try jobInput(for: audioURL, jobDirectory: jobDirectory, mode: inputMode) let request = StemJobRequest( + protocolVersion: StemJobFiles.protocolVersion, jobID: jobDirectory.lastPathComponent, audioPath: input.audioPath, cacheKey: cacheKey, @@ -283,8 +297,6 @@ final class StemSeparationService { expectedStemTypes: method.stemTypes, modelName: method.modelName, settingsVersion: Self.settingsVersion, - audioSeparatorPath: nil, - audioSeparatorBookmarkData: nil, computeMode: appSettingsStore.stemBackendComputeMode.helperArgument, createdAt: Date() ) diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift index 73f6d7d..acb19bb 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift @@ -11,9 +11,8 @@ private struct NotationAccidentalEditTarget { var selectedItemID: String var partID: NotationPartID var clef: Clef - var chainIDs: Set - var rootItemID: String var alreadyApplied: Bool + var updatedItems: [NotationMeasureItem] } private enum TiedNotationNoteCommandResolution { @@ -34,160 +33,6 @@ private enum TiedNotationNoteCommandResolution { } extension AudioPlayerViewModel { - var canShowNotationWindow: Bool { - duration > 0 - } - - var availableNotationParts: [NotationPartDescriptor] { - let stemParts = knownStemNotationPartTypes().map(NotationPartDescriptor.stem) - let additionalTranscriptions = StemType.allCases.flatMap { stemType in - stemTranscriptionTracks - .filter { - $0.stemType == stemType - && $0.notationPartID != .stem(stemType) - } - .sorted { $0.createdAt < $1.createdAt } - .enumerated() - .map { index, track in - NotationPartDescriptor.stemTranscription( - stemType, - id: track.notationPartID, - sequence: index + 2 - ) - } - } - return [.main] + stemParts + additionalTranscriptions - } - - var visibleNotationParts: [NotationPartDescriptor] { - let visibleIDs = normalizedVisibleNotationPartIDs() - return availableNotationParts.filter { visibleIDs.contains($0.id) } - } - - func isStemNotationTrackCollapsed(_ stemType: StemType) -> Bool { - stemNotationTrackCollapsed[stemType] ?? true - } - - func stemNoteDisplayMode(for stemType: StemType) -> StemNoteDisplayMode { - stemNoteDisplayModes[stemType] ?? .notation - } - - func toggleStemNoteDisplayMode(_ stemType: StemType) { - let nextMode: StemNoteDisplayMode = stemNoteDisplayMode(for: stemType) == .notation - ? .midi - : .notation - if nextMode == .midi { - stemNoteDisplayModes[stemType] = .midi - } else { - stemNoteDisplayModes.removeValue(forKey: stemType) - } - stemNotationTrackCollapsed[stemType] = false - refreshProjectModifiedState() - } - - func toggleNotationWindowPartVisibility(_ partID: NotationPartID) { - var next = normalizedVisibleNotationPartIDs() - if next.contains(partID) { - next.remove(partID) - } else { - next.insert(partID) - } - - let normalized = normalizedVisibleNotationPartIDs(from: next) - guard normalized != visibleNotationPartIDs else { return } - visibleNotationPartIDs = normalized - refreshProjectModifiedState() - } - - func normalizedVisibleNotationPartIDs(from rawPartIDs: Set? = nil) -> Set { - let allowedPartIDs = Set(availableNotationParts.map(\.id)) - var normalized = (rawPartIDs ?? visibleNotationPartIDs).intersection(allowedPartIDs) - if normalized.isEmpty { - normalized = allowedPartIDs.contains(.main) ? [.main] : Set(allowedPartIDs.prefix(1)) - } - return normalized - } - - func notationClef(for partID: NotationPartID) -> Clef { - NotationPartClefOverrides.clef(for: partID, in: notationPartClefs) - } - - func setNotationClef(_ clef: Clef, for partID: NotationPartID) { - let sourceClef = notationClef(for: partID) - guard sourceClef != clef else { return } - - let octaveDelta = sourceClef == .drums || clef == .drums - ? 0 - : clef.notationMetrics.storedPitchOctaveOffset - - sourceClef.notationMetrics.storedPitchOctaveOffset - let candidateItems = notationItems.map { item -> NotationMeasureItem in - guard item.partID == partID, - !item.isSynthesized, - item.kind == .note, - var pitch = item.pitch - else { - return item - } - - pitch.octave += octaveDelta - var transposed = item - transposed.pitch = pitch - if clef == .drums { - transposed.explicitAccidental = nil - } - return transposed - } - let invalidPitch = candidateItems.first { item in - item.partID == partID - && item.kind == .note - && item.pitch.map { !NotationInputPolicy.isEditable($0, in: clef) } == true - }?.pitch - guard invalidPitch == nil else { - errorMessage = "Cannot change to \(clef.displayName): the part contains notes outside the supported input map." - return - } - - errorMessage = nil - performUndoableEdit("Change Notation Clef") { - if clef == NotationPartClefOverrides.defaultClef(for: partID) { - notationPartClefs.removeValue(forKey: partID) - } else { - notationPartClefs[partID] = clef - } - - notationItems = candidateItems - notationItems = ProjectStateNormalizer.normalizedNotationItems( - notationItems, - duration: duration, - notationPartClefs: notationPartClefs - ) - refreshNotationSelections(for: partID) - } - } - - func selectDrumInstrument(midiNoteNumber: Int) { - guard DrumInstrumentMap.allowedMIDINoteNumbers.contains(midiNoteNumber) else { return } - selectedDrumInstrumentMIDINoteNumber = midiNoteNumber - let pitch = NotationPitchMapper.pitch( - forMIDINoteNumber: midiNoteNumber, - keySignature: .cMajor - ) - auditionNotationNotePitch(pitch, clef: .drums) - } - - private func knownStemNotationPartTypes() -> [StemType] { - let stemTypes = Set(stemFiles.map(\.type)) - let notationStemTypes = Set(notationItems.compactMap(\.partID.stemType)) - let collapsedStemTypes = Set(stemNotationTrackCollapsed.keys) - let visibleStemTypes = Set(visibleNotationPartIDs.compactMap(\.stemType)) - let knownTypes = stemTypes - .union(notationStemTypes) - .union(collapsedStemTypes) - .union(visibleStemTypes) - - return StemType.allCases.filter { knownTypes.contains($0) } - } - var canChangeNotationDuration: Bool { duration > 0 && (isNotationEntryModeEnabled || canEditSelectedNotationItem) } @@ -376,14 +221,7 @@ extension AudioPlayerViewModel { guard !target.alreadyApplied else { return true } performUndoableEdit("Set \(accidental.displayName) Accidental") { - notationItems = notationItems.map { item in - guard target.chainIDs.contains(item.id), var pitch = item.pitch else { return item } - pitch.alter = accidental.alter - var updated = item - updated.pitch = pitch - updated.explicitAccidental = item.id == target.rootItemID ? accidental : nil - return updated - } + notationItems = target.updatedItems notationItems = ProjectStateNormalizer.normalizedNotationItems( notationItems, duration: duration, @@ -409,46 +247,13 @@ extension AudioPlayerViewModel { accidental: NotationAccidental ) -> NotationAccidentalEditTarget? { guard let match = notationItemMatch(for: selection), - match.item.kind == .note, - match.item.pitch != nil, - match.measure.attributes.clef != .drums - else { - return nil - } - - let chainIDs = NotationNoteEditPlanner.logicalChainItemIDs( - in: notationItems, - containing: match.item.id, - partID: match.item.partID - ) - guard !chainIDs.isEmpty else { return nil } - - let chainItems = notationItems.filter { chainIDs.contains($0.id) } - let incomingTargetIDs = Set(chainItems.compactMap(\.tieTargetItemID)) - let rootItemID = chainItems.first(where: { !incomingTargetIDs.contains($0.id) })?.id - ?? match.item.id - - let hasCollision = chainItems.contains { chainItem in - guard var pitch = chainItem.pitch else { return true } - pitch.alter = accidental.alter - return notationItems.contains { candidate in - !chainIDs.contains(candidate.id) - && candidate.partID == chainItem.partID - && candidate.kind == .note - && candidate.pitch?.midiNoteNumber == pitch.midiNoteNumber - && candidate.measureNumber == chainItem.measureNumber - && abs(candidate.measureStartTime - chainItem.measureStartTime) - < NotationMeasureTiming.timelineTolerance - && abs(candidate.offsetInQuarterNotes - chainItem.offsetInQuarterNotes) - < NotationMeasureTiming.timelineTolerance - } - } - guard !hasCollision else { return nil } - - let alreadyApplied = chainItems.allSatisfy { item in - item.pitch?.alter == accidental.alter - && item.explicitAccidental == (item.id == rootItemID ? accidental : nil) - } + let plan = NotationAccidentalPlanner.plan( + accidental: accidental, + selectedItem: match.item, + measure: match.measure, + allItems: notationItems + ) + else { return nil } return NotationAccidentalEditTarget( measureNumber: match.measure.number, @@ -456,9 +261,8 @@ extension AudioPlayerViewModel { selectedItemID: match.item.id, partID: match.item.partID, clef: match.measure.attributes.clef, - chainIDs: chainIDs, - rootItemID: rootItemID, - alreadyApplied: alreadyApplied + alreadyApplied: plan.alreadyApplied, + updatedItems: plan.updatedItems ) } @@ -1679,7 +1483,7 @@ extension AudioPlayerViewModel { ) } - private func refreshNotationSelections(for partID: NotationPartID) { + func refreshNotationSelections(for partID: NotationPartID) { let measures = currentNotationScoreMeasures(partID: partID) if let selection = selectedNotationItem, selection.partID == partID { diff --git a/JammLab/ViewModels/AudioPlayerViewModel+NotationParts.swift b/JammLab/ViewModels/AudioPlayerViewModel+NotationParts.swift new file mode 100644 index 0000000..3bb08f9 --- /dev/null +++ b/JammLab/ViewModels/AudioPlayerViewModel+NotationParts.swift @@ -0,0 +1,139 @@ +import Foundation + +extension AudioPlayerViewModel { + var canShowNotationWindow: Bool { + duration > 0 + } + + var availableNotationParts: [NotationPartDescriptor] { + NotationPartStatePlanner.availableParts( + knownStemTypes: knownStemNotationPartTypes(), + transcriptionTracks: stemTranscriptionTracks + ) + } + + var visibleNotationParts: [NotationPartDescriptor] { + let visibleIDs = normalizedVisibleNotationPartIDs() + return availableNotationParts.filter { visibleIDs.contains($0.id) } + } + + func isStemNotationTrackCollapsed(_ stemType: StemType) -> Bool { + stemNotationTrackCollapsed[stemType] ?? true + } + + func stemNoteDisplayMode(for stemType: StemType) -> StemNoteDisplayMode { + stemNoteDisplayModes[stemType] ?? .notation + } + + func toggleStemNoteDisplayMode(_ stemType: StemType) { + let nextMode: StemNoteDisplayMode = stemNoteDisplayMode(for: stemType) == .notation + ? .midi + : .notation + if nextMode == .midi { + stemNoteDisplayModes[stemType] = .midi + } else { + stemNoteDisplayModes.removeValue(forKey: stemType) + } + stemNotationTrackCollapsed[stemType] = false + refreshProjectModifiedState() + } + + func toggleNotationWindowPartVisibility(_ partID: NotationPartID) { + var next = normalizedVisibleNotationPartIDs() + if next.contains(partID) { + next.remove(partID) + } else { + next.insert(partID) + } + + let normalized = normalizedVisibleNotationPartIDs(from: next) + guard normalized != visibleNotationPartIDs else { return } + visibleNotationPartIDs = normalized + refreshProjectModifiedState() + } + + func normalizedVisibleNotationPartIDs( + from rawPartIDs: Set? = nil + ) -> Set { + NotationPartStatePlanner.normalizedVisiblePartIDs( + rawPartIDs ?? visibleNotationPartIDs, + availableParts: availableNotationParts + ) + } + + func notationClef(for partID: NotationPartID) -> Clef { + NotationPartClefOverrides.clef(for: partID, in: notationPartClefs) + } + + func setNotationClef(_ clef: Clef, for partID: NotationPartID) { + let sourceClef = notationClef(for: partID) + guard sourceClef != clef else { return } + + let octaveDelta = sourceClef == .drums || clef == .drums + ? 0 + : clef.notationMetrics.storedPitchOctaveOffset + - sourceClef.notationMetrics.storedPitchOctaveOffset + let candidateItems = notationItems.map { item -> NotationMeasureItem in + guard item.partID == partID, + !item.isSynthesized, + item.kind == .note, + var pitch = item.pitch + else { + return item + } + + pitch.octave += octaveDelta + var transposed = item + transposed.pitch = pitch + if clef == .drums { + transposed.explicitAccidental = nil + } + return transposed + } + let invalidPitch = candidateItems.first { item in + item.partID == partID + && item.kind == .note + && item.pitch.map { !NotationInputPolicy.isEditable($0, in: clef) } == true + }?.pitch + guard invalidPitch == nil else { + errorMessage = "Cannot change to \(clef.displayName): the part contains notes outside the supported input map." + return + } + + errorMessage = nil + performUndoableEdit("Change Notation Clef") { + if clef == NotationPartClefOverrides.defaultClef(for: partID) { + notationPartClefs.removeValue(forKey: partID) + } else { + notationPartClefs[partID] = clef + } + + notationItems = candidateItems + notationItems = ProjectStateNormalizer.normalizedNotationItems( + notationItems, + duration: duration, + notationPartClefs: notationPartClefs + ) + refreshNotationSelections(for: partID) + } + } + + func selectDrumInstrument(midiNoteNumber: Int) { + guard DrumInstrumentMap.allowedMIDINoteNumbers.contains(midiNoteNumber) else { return } + selectedDrumInstrumentMIDINoteNumber = midiNoteNumber + let pitch = NotationPitchMapper.pitch( + forMIDINoteNumber: midiNoteNumber, + keySignature: .cMajor + ) + auditionNotationNotePitch(pitch, clef: .drums) + } + + private func knownStemNotationPartTypes() -> [StemType] { + NotationPartStatePlanner.knownStemTypes( + stemFiles: stemFiles, + notationItems: notationItems, + collapsedStemTypes: Set(stemNotationTrackCollapsed.keys), + visiblePartIDs: visibleNotationPartIDs + ) + } +} diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Playback.swift b/JammLab/ViewModels/AudioPlayerViewModel+Playback.swift index e64d98d..853ec30 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Playback.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Playback.swift @@ -1,6 +1,116 @@ import Foundation +struct PreparedAudioPreparation { + var runID: UUID + var asset: PreparedPlaybackAsset +} + +struct AudioPlaybackTransactionFailure: LocalizedError { + var primaryDescription: String + var recoveryDescription: String + + var errorDescription: String? { + "\(primaryDescription) Previous playback could not be restored: \(recoveryDescription)" + } +} + extension AudioPlayerViewModel { + func prepareOriginalPlayback( + for file: ImportedAudioFile, + kind: AudioPreparationKind, + volume: Float? = nil + ) async throws -> PreparedAudioPreparation { + audioPreparationTask?.cancel() + let runID = UUID() + audioPreparationRunID = runID + audioPreparationState = AudioPreparationViewState( + kind: kind, + phase: .decoding, + progress: 0, + status: "Preparing audio", + pendingPlaybackMode: .original, + isCancellable: true + ) + + let preparer = playbackPreparer + let volume = volume ?? mainTrackVolume + let task = Task { + try await preparer.prepareOriginal(url: file.url, volume: volume) { [weak self] progress in + Task { @MainActor in + guard self?.audioPreparationRunID == runID else { return } + self?.audioPreparationState = AudioPreparationViewState( + kind: kind, + phase: .decoding, + progress: progress.fractionCompleted, + status: progress.status, + pendingPlaybackMode: .original, + isCancellable: true + ) + } + } + } + audioPreparationTask = task + + do { + let asset = try await task.value + try Task.checkCancellation() + guard audioPreparationRunID == runID else { throw CancellationError() } + audioPreparationState = AudioPreparationViewState( + kind: kind, + phase: .installing, + progress: 1, + status: "Installing audio", + pendingPlaybackMode: .original, + isCancellable: false + ) + return PreparedAudioPreparation(runID: runID, asset: asset) + } catch { + if audioPreparationRunID == runID { + audioPreparationTask = nil + audioPreparationRunID = nil + audioPreparationState = AudioPreparationViewState( + kind: kind, + phase: error is CancellationError ? .cancelled : .failed, + progress: nil, + status: error is CancellationError ? "Audio preparation cancelled" : error.localizedDescription, + pendingPlaybackMode: nil, + isCancellable: false + ) + } + throw error + } + } + + func finishAudioPreparation(runID: UUID) { + guard audioPreparationRunID == runID else { return } + audioPreparationTask = nil + audioPreparationRunID = nil + audioPreparationState = .idle + } + + func failAudioPreparation(runID: UUID, error: Error, kind: AudioPreparationKind) { + guard audioPreparationRunID == runID else { return } + audioPreparationTask = nil + audioPreparationRunID = nil + audioPreparationState = AudioPreparationViewState( + kind: kind, + phase: error is CancellationError ? .cancelled : .failed, + progress: nil, + status: error is CancellationError ? "Audio preparation cancelled" : error.localizedDescription, + pendingPlaybackMode: nil, + isCancellable: false + ) + } + + func cancelAudioPreparation() { + guard audioPreparationState.isCancellable else { return } + audioPreparationTask?.cancel() + audioPreparationState.phase = .cancelled + audioPreparationState.progress = nil + audioPreparationState.status = "Cancelling audio preparation" + audioPreparationState.isCancellable = false + } + var clickVolumeText: String { "\(Int((clickVolume * 100).rounded()))%" } @@ -194,9 +304,180 @@ extension AudioPlayerViewModel { } func restorePlaybackMode(_ mode: PlaybackMode, preservedTime: TimeInterval) { + if playbackEngine.requiresPreparedPlayback { + beginPreparedPlaybackModeSwitch( + mode, + preservedTime: preservedTime, + errorPrefix: "Playback mode restore failed", + registersUndo: false + ) + return + } switchPlaybackMode(mode, preservedTime: preservedTime, errorPrefix: "Playback mode restore failed") } + func beginPreparedPlaybackModeSwitch( + _ mode: PlaybackMode, + preservedTime: TimeInterval, + errorPrefix: String, + registersUndo: Bool, + failureRollbackMode: PlaybackMode? = nil + ) { + let targetMode: PlaybackMode = mode == .stems && canUseStemsPlayback ? .stems : .original + guard targetMode != playbackMode || preparedPlaybackAssets[targetMode] == nil else { return } + guard let importedFile else { return } + + audioPreparationTask?.cancel() + let runID = UUID() + let previousMode = playbackMode + let rollbackMode = failureRollbackMode ?? previousMode + let cachedRollbackAsset = preparedPlaybackAssets[rollbackMode] + let rollbackStems = stemFiles + let rollbackMixState = stemMixState + let wasPlaying = playbackState == .playing + audioPreparationRunID = runID + activePlaybackEngine.pause() + videoFollower.pause() + if wasPlaying { + playbackState = .paused + } + + audioPreparationState = AudioPreparationViewState( + kind: .switchingMode, + phase: .decoding, + progress: 0, + status: targetMode == .stems ? "Preparing stems" : "Preparing original audio", + pendingPlaybackMode: targetMode, + isCancellable: true + ) + + let preparer = playbackPreparer + let stems = stemFiles + let mixState = stemMixState + let volume = mainTrackVolume + let progressHandler: @Sendable (AudioPreparationProgress) -> Void = { [weak self] progress in + Task { @MainActor in + guard self?.audioPreparationRunID == runID else { return } + self?.audioPreparationState = AudioPreparationViewState( + kind: .switchingMode, + phase: .decoding, + progress: progress.fractionCompleted, + status: progress.status, + pendingPlaybackMode: targetMode, + isCancellable: true + ) + } + } + + let task = Task { + if let cached = preparedPlaybackAssets[targetMode] { + return cached + } + if targetMode == .stems { + return try await preparer.prepareStems(stems, mixState: mixState, progress: progressHandler) + } + return try await preparer.prepareOriginal( + url: importedFile.url, + volume: volume, + progress: progressHandler + ) + } + audioPreparationTask = task + + Task { [weak self] in + guard let self else { return } + do { + let asset = try await task.value + try Task.checkCancellation() + guard audioPreparationRunID == runID else { return } + audioPreparationState = AudioPreparationViewState( + kind: .switchingMode, + phase: .installing, + progress: 1, + status: "Installing \(targetMode.title)", + pendingPlaybackMode: targetMode, + isCancellable: false + ) + try configurePlayer(with: asset) + if targetMode == .stems { + playbackEngine.applyMix(stemMixState) + } + seekExactly(to: preservedTime) + + if wasPlaying { + try activePlaybackEngine.play() + videoFollower.play(rate: playbackRate) + playbackState = .playing + } + + preparedPlaybackAssets[targetMode] = asset + playbackMode = targetMode + if registersUndo, previousMode != targetMode { + undoManager?.registerUndo(withTarget: self) { target in + target.restorePlaybackMode(previousMode, preservedTime: preservedTime) + } + undoManager?.setActionName("Change Playback Mode") + refreshUndoAvailability() + refreshProjectModifiedState() + } + finishAudioPreparation(runID: runID) + } catch { + guard audioPreparationRunID == runID else { return } + var recoveryError: Error? + do { + let rollbackAsset: PreparedPlaybackAsset + if let cachedRollbackAsset { + rollbackAsset = cachedRollbackAsset + } else if rollbackMode == .stems { + rollbackAsset = try await preparer.prepareStems( + rollbackStems, + mixState: rollbackMixState, + progress: { _ in } + ) + } else { + rollbackAsset = try await preparer.prepareOriginal( + url: importedFile.url, + volume: volume, + progress: { _ in } + ) + } + guard audioPreparationRunID == runID else { return } + try configurePlayer(with: rollbackAsset) + if rollbackMode == .stems { + playbackEngine.applyMix(rollbackMixState) + } + seekExactly(to: preservedTime) + if wasPlaying { + try activePlaybackEngine.play() + videoFollower.play(rate: playbackRate) + playbackState = .playing + } + preparedPlaybackAssets[rollbackMode] = rollbackAsset + playbackMode = rollbackMode + } catch { + recoveryError = error + playbackState = .paused + } + audioPreparationTask = nil + audioPreparationRunID = nil + audioPreparationState = AudioPreparationViewState( + kind: .switchingMode, + phase: error is CancellationError ? .cancelled : .failed, + progress: nil, + status: error is CancellationError ? "Playback preparation cancelled" : error.localizedDescription, + pendingPlaybackMode: nil, + isCancellable: false + ) + if !(error is CancellationError) { + let recoverySuffix = recoveryError.map { + " Previous playback could not be restored: \($0.localizedDescription)" + } ?? "" + errorMessage = "\(errorPrefix): \(error.localizedDescription)\(recoverySuffix)" + } + } + } + } + func switchPlaybackMode( _ mode: PlaybackMode, preservedTime: TimeInterval, @@ -260,6 +541,11 @@ extension AudioPlayerViewModel { applyPlaybackConfiguration() } + func configurePlayer(with preparedAsset: PreparedPlaybackAsset) throws { + try playbackEngine.install(prepared: preparedAsset) + applyPlaybackConfiguration() + } + func applyPlaybackConfiguration() { playbackEngine.setPlaybackRate(playbackRate) videoFollower.setPlaybackRate(playbackRate) diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift index f5f1470..a11137b 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift @@ -2,32 +2,78 @@ import Foundation extension AudioPlayerViewModel { func importAudio() async { + let operationID = UUID() + mediaLoadRunID = operationID + audioPreparationTask?.cancel() errorMessage = nil isImporting = true do { guard let file = try await importer.importFile() else { + guard mediaLoadRunID == operationID else { return } + mediaLoadRunID = nil isImporting = false return } + guard mediaLoadRunID == operationID else { return } - try loadImportedAudio(file) + let candidateMediaLease = try SecurityScopedResourceLease( + url: file.sourceMediaURL, + requiresAccess: isSandboxed() + ) + let preparation = try await prepareOriginalPlayback(for: file, kind: .importing) + guard mediaLoadRunID == operationID else { return } + _ = candidateMediaLease + do { + try loadImportedAudio(file, preparedAsset: preparation.asset) + finishAudioPreparation(runID: preparation.runID) + } catch { + failAudioPreparation(runID: preparation.runID, error: error, kind: .importing) + throw error + } + mediaLoadRunID = nil } catch { + guard mediaLoadRunID == operationID else { return } + mediaLoadRunID = nil isImporting = false - errorMessage = error.localizedDescription + if !(error is CancellationError) { + errorMessage = error.localizedDescription + } } } func importAudio(from url: URL) async { + let operationID = UUID() + mediaLoadRunID = operationID + audioPreparationTask?.cancel() errorMessage = nil isImporting = true do { let file = try await importer.importFile(from: url) - try loadImportedAudio(file) + guard mediaLoadRunID == operationID else { return } + let candidateMediaLease = try SecurityScopedResourceLease( + url: file.sourceMediaURL, + requiresAccess: isSandboxed() + ) + let preparation = try await prepareOriginalPlayback(for: file, kind: .importing) + guard mediaLoadRunID == operationID else { return } + _ = candidateMediaLease + do { + try loadImportedAudio(file, preparedAsset: preparation.asset) + finishAudioPreparation(runID: preparation.runID) + } catch { + failAudioPreparation(runID: preparation.runID, error: error, kind: .importing) + throw error + } + mediaLoadRunID = nil } catch { + guard mediaLoadRunID == operationID else { return } + mediaLoadRunID = nil isImporting = false - errorMessage = error.localizedDescription + if !(error is CancellationError) { + errorMessage = error.localizedDescription + } } } @@ -81,6 +127,9 @@ extension AudioPlayerViewModel { } func newProject() { + mediaLoadRunID = nil + cancelAudioPreparation() + audioPreparationTask?.cancel() stopPlaybackClock() playbackEngine.unload() performWithoutVideoWindowDirtyTracking { @@ -99,6 +148,7 @@ extension AudioPlayerViewModel { } private func resetForNewProject() { + preparedPlaybackAssets = [:] importedFile = nil analysisResult = nil peakformData = nil @@ -126,7 +176,9 @@ extension AudioPlayerViewModel { timelineVisibleRange = 0...0 userTimelineVisibleRange = 0...0 currentProjectURL = nil + mediaLoadRunID = nil isImporting = false + audioPreparationState = .idle isAnalyzing = false isBuildingWaveform = false resetStemState() @@ -167,6 +219,73 @@ extension AudioPlayerViewModel { analyze(file: file, includesTempo: true, includesKey: true) } + func loadImportedAudio( + _ file: ImportedAudioFile, + preparedAsset: PreparedPlaybackAsset + ) throws { + let previousMode = playbackMode + let previousAsset = preparedPlaybackAssets[previousMode] ?? { + if previousMode == .stems, !stemFiles.isEmpty { + return PreparedPlaybackAsset(storage: .stems(stemFiles, stemMixState)) + } + return importedFile.map { PreparedPlaybackAsset(storage: .originalURL($0.url)) } + }() + let previousTime = currentTime + let previousPlaybackState = playbackState + stopPlaybackClock() + playbackEngine.stop() + videoFollower.stop() + cancelBackgroundWork() + + do { + try configurePlayer(with: preparedAsset) + preparedPlaybackAssets = [.original: preparedAsset] + } catch { + let installError = error + if let previousAsset { + do { + try configurePlayer(with: previousAsset) + guard playbackEngine.isLoaded else { + throw MultiTrackAudioPlayerError.unsupportedPreparedAsset + } + if previousMode == .stems { + playbackEngine.applyMix(stemMixState) + } + playbackEngine.seek(to: previousTime) + if previousPlaybackState == .playing { + try playbackEngine.play() + videoFollower.play(rate: playbackRate) + startPlaybackClock() + } + } catch { + playbackState = .paused + throw AudioPlaybackTransactionFailure( + primaryDescription: installError.localizedDescription, + recoveryDescription: error.localizedDescription + ) + } + } else if previousPlaybackState == .playing { + playbackState = .paused + } + throw installError + } + + beginSecurityScopedAccess(for: file.sourceMediaURL) + importedFile = file + performWithoutVideoWindowDirtyTracking { + videoFollower.load(videoURL: file.videoURL) + } + resetForImportedFile(file) + clearUndoHistory() + + _ = restoreCachedStems(for: file.url) + restoreVideoWindowOpenState(file.mediaKind == .video) + markProjectClean() + + buildPeakform(file: file) + analyze(file: file, includesTempo: true, includesKey: true) + } + private func resetForImportedFile(_ file: ImportedAudioFile) { currentProjectURL = nil duration = file.duration @@ -196,28 +315,103 @@ extension AudioPlayerViewModel { } func openProject(at url: URL) async { + let operationID = UUID() + mediaLoadRunID = operationID + audioPreparationTask?.cancel() errorMessage = nil isImporting = true - stopPlaybackClock() - var didAdoptProject = false do { - beginProjectSecurityScopedAccess(for: url) + let candidateProjectLease = try SecurityScopedResourceLease( + url: url, + requiresAccess: isSandboxed() + ) let project = try projectService.load(from: url) + var candidateArtifactLease: SecurityScopedResourceLease? if let artifactRootURL = try? project.resolvedArtifactRootURL() { - beginProjectSecurityScopedAccess(for: artifactRootURL) + candidateArtifactLease = try SecurityScopedResourceLease( + url: artifactRootURL, + requiresAccess: isSandboxed() + ) } let mediaResult = try await projectPersistenceCoordinator.resolveProjectMedia(project: project, projectURL: url) + guard mediaLoadRunID == operationID else { return } + var candidateMediaLease: SecurityScopedResourceLease? if let resolvedMediaURL = mediaResult.resolvedMediaURL { - beginSecurityScopedAccess(for: resolvedMediaURL) + candidateMediaLease = try SecurityScopedResourceLease( + url: resolvedMediaURL, + requiresAccess: isSandboxed() + ) } let projectDuration = mediaResult.projectDuration - + let file = mediaResult.file + let nextMainTrackVolume = clampedVolume( + project.mainTrackVolume ?? AppSliderDefaults.mainTrackVolume + ) + let preparation = try await prepareOriginalPlayback( + for: file, + kind: .openingProject, + volume: nextMainTrackVolume + ) + guard mediaLoadRunID == operationID else { return } + _ = candidateProjectLease + _ = candidateArtifactLease + _ = candidateMediaLease + + let previousMode = playbackMode + let previousAsset = preparedPlaybackAssets[previousMode] ?? { + if previousMode == .stems, !stemFiles.isEmpty { + return PreparedPlaybackAsset(storage: .stems(stemFiles, stemMixState)) + } + return importedFile.map { PreparedPlaybackAsset(storage: .originalURL($0.url)) } + }() + let previousTime = currentTime + let previousPlaybackState = playbackState + stopPlaybackClock() playbackEngine.stop() videoFollower.stop() + do { + try configurePlayer(with: preparation.asset) + } catch { + let installError = error + if let previousAsset { + do { + try configurePlayer(with: previousAsset) + guard playbackEngine.isLoaded else { + throw MultiTrackAudioPlayerError.unsupportedPreparedAsset + } + if previousMode == .stems { + playbackEngine.applyMix(stemMixState) + } + playbackEngine.seek(to: previousTime) + if previousPlaybackState == .playing { + try playbackEngine.play() + videoFollower.play(rate: playbackRate) + startPlaybackClock() + } + } catch { + playbackState = .paused + let transactionError = AudioPlaybackTransactionFailure( + primaryDescription: installError.localizedDescription, + recoveryDescription: error.localizedDescription + ) + failAudioPreparation( + runID: preparation.runID, + error: transactionError, + kind: .openingProject + ) + throw transactionError + } + } else if previousPlaybackState == .playing { + playbackState = .paused + } + failAudioPreparation(runID: preparation.runID, error: installError, kind: .openingProject) + throw installError + } + playbackRate = ProjectStateNormalizer.normalizedPlaybackRate(project.playbackRate) pitchShiftSemitones = ProjectStateNormalizer.normalizedPitchShift(project.pitchShiftSemitones) - mainTrackVolume = clampedVolume(project.mainTrackVolume ?? AppSliderDefaults.mainTrackVolume) + mainTrackVolume = nextMainTrackVolume clickVolume = clampedVolume(project.clickVolume ?? AppSliderDefaults.clickVolume) isSnapEnabled = project.isSnapEnabled ?? false beatGridSettings = ProjectStateNormalizer.normalizedBeatGridSettings( @@ -232,18 +426,24 @@ extension AudioPlayerViewModel { shouldAcceptAnalyzedTempo = mediaResult.shouldAnalyzeTempo isClickEnabled = (project.isClickEnabled ?? false) && beatGridSettings.bpm != nil let restoredPlaybackMode = project.playbackMode ?? project.stemState?.playbackMode ?? .original - let file = mediaResult.file let resolvedProjectDuration = mediaResult.projectDuration beatGridSettings = beatGridSettings.clamped(to: resolvedProjectDuration) synchronizeTempoBPM(tempoBPM) - try configurePlayer(with: file) + applyPlaybackConfiguration() + preparedPlaybackAssets = [.original: preparation.asset] + finishAudioPreparation(runID: preparation.runID) importedFile = file + beginSecurityScopedAccess(for: mediaResult.resolvedMediaURL ?? file.sourceMediaURL) + if let artifactRootURL = try? project.resolvedArtifactRootURL() { + _ = beginProjectSecurityScopedAccess(for: artifactRootURL) + } else { + _ = beginProjectSecurityScopedAccess(for: url) + } performWithoutVideoWindowDirtyTracking { videoFollower.load(videoURL: file.videoURL) } currentProjectURL = url - didAdoptProject = true duration = resolvedProjectDuration let restoredPlaybackMarkerTime = ProjectStateNormalizer.normalizedTimelineTime( project.playbackMarkerTime, @@ -294,6 +494,7 @@ extension AudioPlayerViewModel { stemNoteDisplayModes = StemNoteDisplayModes.normalized(project.stemNoteDisplayModes) visibleNotationPartIDs = normalizedVisibleNotationPartIDs(from: project.visibleNotationPartIDs) isImporting = false + mediaLoadRunID = nil clearUndoHistory() markProjectClean() if let warningMessage = mediaResult.warningMessage { @@ -310,10 +511,11 @@ extension AudioPlayerViewModel { marksProjectModifiedForAutoKey: shouldAnalyzeKey ) } catch { + guard mediaLoadRunID == operationID else { return } + mediaLoadRunID = nil isImporting = false - if !didAdoptProject { - endSecurityScopedAccess() - endProjectSecurityScopedAccess() + if error is CancellationError { + return } errorMessage = "Project open failed: \(error.localizedDescription)" } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Stems.swift b/JammLab/ViewModels/AudioPlayerViewModel+Stems.swift index 9cea1d2..14b3819 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Stems.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Stems.swift @@ -58,7 +58,7 @@ extension AudioPlayerViewModel { guard stemSeparationRunID == runID else { return } let message = error.localizedDescription let diagnostics = (error as? StemSeparationError)?.diagnostics - let isCancellation = isStemSeparationCancellation(error) + let isCancellation = Task.isCancelled || isStemSeparationCancellation(error) stemSeparationState = StemSeparationViewState( phase: isCancellation ? .cancelled : .failed(message), progress: nil, @@ -104,6 +104,16 @@ extension AudioPlayerViewModel { } func setPlaybackMode(_ mode: PlaybackMode) { + if playbackEngine.requiresPreparedPlayback { + beginPreparedPlaybackModeSwitch( + mode, + preservedTime: currentTime, + errorPrefix: "Playback mode switch failed", + registersUndo: true + ) + return + } + performUndoableEdit("Change Playback Mode") { switchPlaybackMode(mode, preservedTime: currentTime, errorPrefix: "Playback mode switch failed") } @@ -151,6 +161,9 @@ extension AudioPlayerViewModel { func registerStemMetadata(_ metadata: StemCacheMetadata, activatePlayback: Bool = false) { + let replacesActiveMultiTrackStems = playbackEngine.requiresPreparedPlayback + && playbackMode == .stems + preparedPlaybackAssets[.stems] = nil stemCacheMetadata = metadata stemFiles = metadata.stems stemMixState.setAvailability(from: metadata.stems) @@ -162,12 +175,22 @@ extension AudioPlayerViewModel { ) if activatePlayback || playbackMode == .stems { - switchPlaybackMode( - .stems, - preservedTime: currentTime, - errorPrefix: "Stem playback failed", - reloadIfCurrentMode: true - ) + if playbackEngine.requiresPreparedPlayback { + beginPreparedPlaybackModeSwitch( + .stems, + preservedTime: currentTime, + errorPrefix: "Stem playback failed", + registersUndo: false, + failureRollbackMode: replacesActiveMultiTrackStems ? .original : nil + ) + } else { + switchPlaybackMode( + .stems, + preservedTime: currentTime, + errorPrefix: "Stem playback failed", + reloadIfCurrentMode: true + ) + } } } @@ -185,9 +208,6 @@ extension AudioPlayerViewModel { stemMixState = preferredMixState } - if preferredPlaybackMode == .stems { - playbackMode = .stems - } registerStemMetadata(metadata) return true @@ -220,9 +240,6 @@ extension AudioPlayerViewModel { expectedFingerprint: currentFingerprint, fallbackFingerprint: projectStemState?.sourceFingerprint ) { - if projectStemState?.playbackMode == .stems { - playbackMode = .stems - } registerStemMetadata(metadata) return } @@ -282,9 +299,6 @@ extension AudioPlayerViewModel { return } - if projectStemState.playbackMode == .stems { - playbackMode = .stems - } registerStemMetadata(metadata) } catch { stemSeparationState = StemSeparationViewState( @@ -314,6 +328,7 @@ extension AudioPlayerViewModel { func resetStemState(mixState: StemMixState = StemMixState()) { playbackMode = .original + preparedPlaybackAssets[.stems] = nil stemFiles = [] clearStemPeakforms() stemMixState = mixState diff --git a/JammLab/ViewModels/AudioPlayerViewModel.swift b/JammLab/ViewModels/AudioPlayerViewModel.swift index d5ede75..5d26055 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel.swift @@ -62,6 +62,7 @@ final class AudioPlayerViewModel: ObservableObject { @Published var timelineVisibleRange: ClosedRange = 0...0 @Published var currentProjectURL: URL? @Published var isImporting = false + @Published var audioPreparationState: AudioPreparationViewState = .idle @Published var isAnalyzing = false @Published var isBuildingWaveform = false @Published var playbackMode: PlaybackMode = .original @@ -97,6 +98,7 @@ final class AudioPlayerViewModel: ObservableObject { let analyzer: AudioAnalyzing let peakformProvider: PeakformProvider let playbackEngine: AudioPlaybackControlling + let playbackPreparer: any AudioPlaybackPreparing let videoFollower: VideoFollowerControlling let appSettingsStore: AppSettingsStore let stemSeparationService: StemSeparationService @@ -113,6 +115,10 @@ final class AudioPlayerViewModel: ObservableObject { var clockTask: Task? var analysisTask: Task? var waveformTask: Task? + var audioPreparationTask: Task? + var audioPreparationRunID: UUID? + var mediaLoadRunID: UUID? + var preparedPlaybackAssets: [PlaybackMode: PreparedPlaybackAsset] = [:] var notationMeasureSelectionAnchor: NotationMeasureSelection? var stemSeparationTask: Task? var stemSeparationRunID: UUID? @@ -176,6 +182,7 @@ final class AudioPlayerViewModel: ObservableObject { analyzer: AudioAnalyzing = AudioAnalyzer(), peakformProvider: PeakformProvider = CachedPeakformProvider(), playbackEngine: AudioPlaybackControlling? = nil, + playbackPreparer: (any AudioPlaybackPreparing)? = nil, videoFollower: VideoFollowerControlling? = nil, appSettingsStore: AppSettingsStore = AppSettingsStore(), stemSeparationService: StemSeparationService? = nil, @@ -192,10 +199,15 @@ final class AudioPlayerViewModel: ObservableObject { isSandboxed: @escaping () -> Bool = AudioPlayerViewModel.defaultSandboxDetection ) { let resolvedStemSeparationService = stemSeparationService ?? StemSeparationService(appSettingsStore: appSettingsStore) + let resolvedPlaybackEngine = playbackEngine ?? MultiTrackAudioPlayer() self.importer = importer self.analyzer = analyzer self.peakformProvider = peakformProvider - self.playbackEngine = playbackEngine ?? MultiTrackAudioPlayer() + self.playbackEngine = resolvedPlaybackEngine + self.playbackPreparer = playbackPreparer + ?? (resolvedPlaybackEngine.requiresPreparedPlayback + ? MultiTrackAudioPreparer() + : LegacyAudioPlaybackPreparer()) self.videoFollower = videoFollower ?? VideoFollowerController() self.appSettingsStore = appSettingsStore self.stemSeparationService = resolvedStemSeparationService @@ -251,6 +263,7 @@ final class AudioPlayerViewModel: ObservableObject { clockTask?.cancel() analysisTask?.cancel() waveformTask?.cancel() + audioPreparationTask?.cancel() stemSeparationTask?.cancel() stemPeakformTask?.cancel() stemSeparationService.cancel() diff --git a/JammLab/Views/Components/AudioPreparationCard.swift b/JammLab/Views/Components/AudioPreparationCard.swift new file mode 100644 index 0000000..1b58def --- /dev/null +++ b/JammLab/Views/Components/AudioPreparationCard.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct AudioPreparationCard: View { + let state: AudioPreparationViewState + let onCancel: () -> Void + + var body: some View { + AppPanel { + HStack(spacing: AppTheme.Spacing.md) { + progressIndicator + + VStack(alignment: .leading, spacing: AppTheme.Spacing.xs) { + AppSectionTitle(title: title) + Text(state.status) + .font(AppTheme.Typography.tileTitle) + .lineLimit(2) + } + + Spacer(minLength: AppTheme.Spacing.md) + + if state.isCancellable { + Button("Cancel", role: .cancel, action: onCancel) + .help("Cancel audio preparation") + .accessibilityLabel("Cancel audio preparation") + } + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(title) + .accessibilityValue(state.status) + } + + @ViewBuilder + private var progressIndicator: some View { + if let progress = state.progress { + ProgressView(value: progress) + .progressViewStyle(.circular) + .accessibilityValue("\(Int((progress * 100).rounded())) percent") + } else { + ProgressView() + .progressViewStyle(.circular) + } + } + + private var title: String { + switch state.kind { + case .importing: + return "Importing Audio" + case .openingProject: + return "Opening Project" + case .switchingMode: + return "Preparing Playback" + case nil: + return "Preparing Audio" + } + } +} diff --git a/JammLab/Views/Components/NotationWindowToolbar.swift b/JammLab/Views/Components/NotationWindowToolbar.swift new file mode 100644 index 0000000..72a3e25 --- /dev/null +++ b/JammLab/Views/Components/NotationWindowToolbar.swift @@ -0,0 +1,122 @@ +import SwiftUI + +struct NotationWindowToolbar: View { + @ObservedObject var viewModel: AudioPlayerViewModel + + var body: some View { + HStack(spacing: AppTheme.Spacing.md) { + NotationEntryModeButton( + mode: .note, + isActive: viewModel.isNotationNoteEntryModeEnabled + ) { + viewModel.toggleNotationNoteEntryMode() + } + .disabled(viewModel.duration <= 0) + .help("Add notes to Notation (N)") + .accessibilityLabel("Notation Note Entry") + .accessibilityValue(viewModel.isNotationNoteEntryModeEnabled ? "Enabled" : "Disabled") + + NotationDurationControl( + denominator: Binding( + get: { viewModel.notationDurationDenominator }, + set: { viewModel.setNotationDurationDenominator($0) } + ), + isEnabled: viewModel.canChangeNotationDuration + ) + + NotationAugmentationDotButton( + isActive: viewModel.notationDurationIsDotted + ) { + viewModel.toggleNotationDurationDot() + } + .disabled(!viewModel.canChangeNotationDuration) + + if hasVisibleTonalPart { + HStack(spacing: AppTheme.Spacing.xs) { + ForEach(NotationAccidental.allCases, id: \.self) { accidental in + NotationAccidentalButton( + accidental: accidental, + isActive: viewModel.pendingNotationAccidental == accidental + ) { + viewModel.handleNotationAccidentalCommand(accidental) + } + .disabled(viewModel.duration <= 0) + } + } + } + + NotationTieButton(status: viewModel.tieCommandStatus) { + viewModel.handleAddTiedNotationNoteCommand() + } + + if hasVisibleDrumPart { + DrumInstrumentPaletteButton( + selectedMIDINoteNumber: viewModel.selectedDrumInstrumentMIDINoteNumber, + selectInstrument: { viewModel.selectDrumInstrument(midiNoteNumber: $0) } + ) + } + + NotationEntryModeButton( + mode: .rest, + isActive: viewModel.isNotationRestEntryModeEnabled + ) { + viewModel.toggleNotationRestEntryMode() + } + .disabled(viewModel.duration <= 0) + .help("Add rests to Notation") + .accessibilityLabel("Notation Rest Entry") + .accessibilityValue(viewModel.isNotationRestEntryModeEnabled ? "Enabled" : "Disabled") + + partVisibilityMenu + + Spacer(minLength: AppTheme.Spacing.md) + + AppControlButton( + title: "Export MusicXML", + systemImage: "square.and.arrow.up" + ) { + Task { + await viewModel.exportNotationAsMusicXML() + } + } + .disabled(!viewModel.canExportNotation) + .help(ControlHelpText.exportNotationMusicXML) + .accessibilityLabel(ControlHelpText.exportNotationMusicXML) + } + .padding(.horizontal, AppTheme.Spacing.panelPadding) + .padding(.vertical, AppTheme.Spacing.md) + } + + private var partVisibilityMenu: some View { + Menu { + ForEach(viewModel.availableNotationParts) { part in + Button { + viewModel.toggleNotationWindowPartVisibility(part.id) + } label: { + if viewModel.normalizedVisibleNotationPartIDs().contains(part.id) { + Label(part.title, systemImage: "checkmark") + } else { + Text(part.title) + } + } + } + } label: { + Label("Parts", systemImage: "rectangle.stack") + } + .disabled(viewModel.availableNotationParts.count <= 1) + .help("Choose visible Notation parts") + .accessibilityLabel("Visible Notation Parts") + } + + private var hasVisibleDrumPart: Bool { + viewModel.visibleNotationParts.contains { + viewModel.notationClef(for: $0.id) == .drums + } + } + + private var hasVisibleTonalPart: Bool { + viewModel.visibleNotationParts.contains { + viewModel.notationClef(for: $0.id) != .drums + } + } +} diff --git a/JammLab/Views/MainWorkspacePanels.swift b/JammLab/Views/MainWorkspacePanels.swift index 92f332f..a9364e9 100644 --- a/JammLab/Views/MainWorkspacePanels.swift +++ b/JammLab/Views/MainWorkspacePanels.swift @@ -112,6 +112,15 @@ extension ContentView { } .frame(maxWidth: .infinity, alignment: .topLeading) .frame(height: height, alignment: .topLeading) + + if viewModel.audioPreparationState.isActive { + AudioPreparationCard( + state: viewModel.audioPreparationState, + onCancel: viewModel.cancelAudioPreparation + ) + .padding(AppTheme.Spacing.md) + .transition(.opacity) + } } .frame(maxWidth: .infinity, alignment: .topLeading) .frame(height: height, alignment: .topLeading) diff --git a/JammLab/Views/NotationTrackInteractionModels.swift b/JammLab/Views/NotationTrackInteractionModels.swift new file mode 100644 index 0000000..71b7df9 --- /dev/null +++ b/JammLab/Views/NotationTrackInteractionModels.swift @@ -0,0 +1,73 @@ +import Foundation + +struct NotationTrackActions { + var selectHarmony: (HarmonySymbol.ID?) -> Void + var selectMeasure: (ScoreMeasure?, Bool, NotationPartID) -> Void + var selectItem: (NotationItemSelection?, Bool) -> Void + var canInsertNotationNote: (NotationNotePlacement) -> Bool + var insertNotationNote: (NotationNotePlacement) -> Bool + var insertNotationRest: (NotationRestPlacement) -> Bool + var changeSelectedNotePitch: (NotationPitch, Bool) -> Bool + var changeClef: (NotationPartID, Clef) -> Void + var auditionNotePitch: (NotationPitch, Clef) -> Void + var deleteSelectedNotationMeasureContents: () -> Bool + var deleteSelectedNotationNote: () -> Bool + var locatePlaybackMarkerExactly: (TimeInterval) -> Void + var saveHarmony: (HarmonySymbol) -> Void + var deleteHarmony: (HarmonySymbol.ID) -> Void + var adjacentHarmonyPlacement: (TimeInterval, HarmonyNavigationDirection) -> HarmonyPlacement? + + static var noop: NotationTrackActions { + NotationTrackActions( + selectHarmony: { _ in }, + selectMeasure: { _, _, _ in }, + selectItem: { _, _ in }, + canInsertNotationNote: { _ in false }, + insertNotationNote: { _ in false }, + insertNotationRest: { _ in false }, + changeSelectedNotePitch: { _, _ in false }, + changeClef: { _, _ in }, + auditionNotePitch: { _, _ in }, + deleteSelectedNotationMeasureContents: { false }, + deleteSelectedNotationNote: { false }, + locatePlaybackMarkerExactly: { _ in }, + saveHarmony: { _ in }, + deleteHarmony: { _ in }, + adjacentHarmonyPlacement: { _, _ in nil } + ) + } +} + +struct HarmonyEditorDraft: Equatable { + var id: HarmonySymbol.ID + var time: TimeInterval + var measureNumber: Int + var offsetInQuarterNotes: Double + var text: String + var isNew: Bool +} + +struct NotationHarmonyPlacement: Equatable { + var measureIndex: Int + var time: TimeInterval + var measureNumber: Int + var offsetInQuarterNotes: Double + + var harmonyPlacement: HarmonyPlacement { + HarmonyPlacement( + time: time, + measureNumber: measureNumber, + offsetInQuarterNotes: offsetInQuarterNotes + ) + } +} + +struct NotationDraggedNotePitchPreview: Equatable { + var selection: NotationItemSelection + var pitch: NotationPitch + var didAudition: Bool + + func matches(_ selection: NotationItemSelection) -> Bool { + self.selection == selection + } +} diff --git a/JammLab/Views/NotationTrackView.swift b/JammLab/Views/NotationTrackView.swift index 4631e23..ecb5354 100644 --- a/JammLab/Views/NotationTrackView.swift +++ b/JammLab/Views/NotationTrackView.swift @@ -3,25 +3,8 @@ import SwiftUI private let notationTrackCoordinateSpaceName = "NotationTrackCoordinateSpace" -struct NotationTrackActions { - var selectHarmony: (HarmonySymbol.ID?) -> Void - var selectMeasure: (ScoreMeasure?, Bool, NotationPartID) -> Void - var selectItem: (NotationItemSelection?, Bool) -> Void - var canInsertNotationNote: (NotationNotePlacement) -> Bool - var insertNotationNote: (NotationNotePlacement) -> Bool - var insertNotationRest: (NotationRestPlacement) -> Bool - var changeSelectedNotePitch: (NotationPitch, Bool) -> Bool - var changeClef: (NotationPartID, Clef) -> Void - var auditionNotePitch: (NotationPitch, Clef) -> Void - var deleteSelectedNotationMeasureContents: () -> Bool - var deleteSelectedNotationNote: () -> Bool - var locatePlaybackMarkerExactly: (TimeInterval) -> Void - var saveHarmony: (HarmonySymbol) -> Void - var deleteHarmony: (HarmonySymbol.ID) -> Void - var adjacentHarmonyPlacement: (TimeInterval, HarmonyNavigationDirection) -> HarmonyPlacement? -} - struct NotationTrackView: View { + @State private var renderSceneCache = NotationTrackRenderSceneCache() let state: NotationViewportState let measureLayout: NotationSystemMeasureLayout? let partID: NotationPartID @@ -270,17 +253,18 @@ struct NotationTrackView: View { width: CGFloat, attributeDisplays: [NotationAttributeDisplay] ) -> [NotationMeasureCanvasGeometry] { - if let measureLayout, - measureLayout.matches(state.visibleMeasures) { - return measureLayout.geometries(totalWidth: width) - } - - let safeMeasureCount = max(1, measureCount) - return NotationMeasureLayout.canvasGeometries( - measureCount: safeMeasureCount, - totalWidth: width, - attributeReserveWidths: measureAttributeReserveWidths(attributeDisplays: attributeDisplays) - ) + renderSceneCache.scene( + input: NotationTrackRenderScene.Input( + visibleMeasures: state.visibleMeasures, + measureLayout: measureLayout, + renderedMeasureCount: max(1, measureCount), + width: width, + attributeDisplays: attributeDisplays, + attributeReserveWidths: measureAttributeReserveWidths( + attributeDisplays: attributeDisplays + ) + ) + ).geometries } private func drawStaffLines( @@ -309,7 +293,9 @@ struct NotationTrackView: View { staffBottom: CGFloat, in context: inout GraphicsContext ) { - for barline in NotationMeasureLayout.barlineGeometries(for: geometries) { + let barlines = renderSceneCache.scene(matching: geometries)?.barlines + ?? NotationMeasureLayout.barlineGeometries(for: geometries) + for barline in barlines { drawBarline( x: barline.x, isOuterBoundary: barline.isOuterBoundary, @@ -1448,10 +1434,11 @@ struct NotationTrackView: View { width: width, attributeDisplays: attributeDisplays ) - let targets = NotationMeasureLayout.barlineHitTargets( - for: geometries, - measures: state.visibleMeasures - ) + let targets = renderSceneCache.scene(matching: geometries)?.barlineHitTargets + ?? NotationMeasureLayout.barlineHitTargets( + for: geometries, + measures: state.visibleMeasures + ) let staffTop = staffTop(in: height) let hitY = max(0, staffTop - AppTheme.Spacing.xs) let hitHeight = AppTheme.Timeline.notationStaffLineSpacing * 4 + AppTheme.Spacing.sm @@ -1832,7 +1819,10 @@ struct NotationTrackView: View { private func regionLabelLayoutItems( geometries: [NotationMeasureCanvasGeometry] ) -> [RegionLabelLayoutItem] { - NotationTrackLayoutItems.regionLabels( + if let items = renderSceneCache.scene(matching: geometries)?.regionLabels { + return items + } + return NotationTrackLayoutItems.regionLabels( visibleMeasures: state.visibleMeasures, geometries: geometries ) @@ -1841,7 +1831,10 @@ struct NotationTrackView: View { private func harmonyLayoutItems( geometries: [NotationMeasureCanvasGeometry] ) -> [HarmonyLayoutItem] { - NotationTrackLayoutItems.harmonies( + if let items = renderSceneCache.scene(matching: geometries)?.harmonies { + return items + } + return NotationTrackLayoutItems.harmonies( visibleMeasures: state.visibleMeasures, geometries: geometries ) @@ -1850,7 +1843,10 @@ struct NotationTrackView: View { private func notationItemLayoutItems( geometries: [NotationMeasureCanvasGeometry] ) -> [NotationItemLayoutItem] { - NotationTrackLayoutItems.notationItems( + if let items = renderSceneCache.scene(matching: geometries)?.notationItems { + return items + } + return NotationTrackLayoutItems.notationItems( visibleMeasures: state.visibleMeasures, geometries: geometries ) @@ -2264,62 +2260,6 @@ private struct KeySignatureAccidentalsView: View { } } -private struct HarmonyEditorDraft: Equatable { - var id: HarmonySymbol.ID - var time: TimeInterval - var measureNumber: Int - var offsetInQuarterNotes: Double - var text: String - var isNew: Bool -} - -private struct NotationHarmonyPlacement: Equatable { - var measureIndex: Int - var time: TimeInterval - var measureNumber: Int - var offsetInQuarterNotes: Double - - var harmonyPlacement: HarmonyPlacement { - HarmonyPlacement( - time: time, - measureNumber: measureNumber, - offsetInQuarterNotes: offsetInQuarterNotes - ) - } -} - -private struct NotationDraggedNotePitchPreview: Equatable { - var selection: NotationItemSelection - var pitch: NotationPitch - var didAudition: Bool - - func matches(_ selection: NotationItemSelection) -> Bool { - self.selection == selection - } -} - -private extension NotationTrackActions { - static var noop: NotationTrackActions { - NotationTrackActions( - selectHarmony: { _ in }, - selectMeasure: { _, _, _ in }, - selectItem: { _, _ in }, - canInsertNotationNote: { _ in false }, - insertNotationNote: { _ in false }, - insertNotationRest: { _ in false }, - changeSelectedNotePitch: { _, _ in false }, - changeClef: { _, _ in }, - auditionNotePitch: { _, _ in }, - deleteSelectedNotationMeasureContents: { false }, - deleteSelectedNotationNote: { false }, - locatePlaybackMarkerExactly: { _ in }, - saveHarmony: { _ in }, - deleteHarmony: { _ in }, - adjacentHarmonyPlacement: { _, _ in nil } - ) - } -} - #Preview { let settings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) let tempoMap = TempoMap(baseSettings: settings, markers: [], duration: 120) diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index d67ef60..4011a6f 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -21,7 +21,7 @@ struct NotationWindowView: View { let scoreLayout = notationScoreLayout(contentWidth: contentWidth) VStack(spacing: AppTheme.Spacing.none) { - header + NotationWindowToolbar(viewModel: viewModel) Divider() @@ -50,90 +50,6 @@ struct NotationWindowView: View { } } - private var header: some View { - HStack(spacing: AppTheme.Spacing.md) { - NotationEntryModeButton( - mode: .note, - isActive: viewModel.isNotationNoteEntryModeEnabled - ) { - viewModel.toggleNotationNoteEntryMode() - } - .disabled(viewModel.duration <= 0) - .help("Add notes to Notation (N)") - .accessibilityLabel("Notation Note Entry") - .accessibilityValue(viewModel.isNotationNoteEntryModeEnabled ? "Enabled" : "Disabled") - - NotationDurationControl( - denominator: Binding( - get: { viewModel.notationDurationDenominator }, - set: { viewModel.setNotationDurationDenominator($0) } - ), - isEnabled: viewModel.canChangeNotationDuration - ) - - NotationAugmentationDotButton( - isActive: viewModel.notationDurationIsDotted - ) { - viewModel.toggleNotationDurationDot() - } - .disabled(!viewModel.canChangeNotationDuration) - - if hasVisibleTonalPart { - HStack(spacing: AppTheme.Spacing.xs) { - ForEach(NotationAccidental.allCases, id: \.self) { accidental in - NotationAccidentalButton( - accidental: accidental, - isActive: viewModel.pendingNotationAccidental == accidental - ) { - viewModel.handleNotationAccidentalCommand(accidental) - } - .disabled(viewModel.duration <= 0) - } - } - } - - NotationTieButton(status: viewModel.tieCommandStatus) { - viewModel.handleAddTiedNotationNoteCommand() - } - - if hasVisibleDrumPart { - DrumInstrumentPaletteButton( - selectedMIDINoteNumber: viewModel.selectedDrumInstrumentMIDINoteNumber, - selectInstrument: { viewModel.selectDrumInstrument(midiNoteNumber: $0) } - ) - } - - NotationEntryModeButton( - mode: .rest, - isActive: viewModel.isNotationRestEntryModeEnabled - ) { - viewModel.toggleNotationRestEntryMode() - } - .disabled(viewModel.duration <= 0) - .help("Add rests to Notation") - .accessibilityLabel("Notation Rest Entry") - .accessibilityValue(viewModel.isNotationRestEntryModeEnabled ? "Enabled" : "Disabled") - - partVisibilityMenu - - Spacer(minLength: AppTheme.Spacing.md) - - AppControlButton( - title: "Export MusicXML", - systemImage: "square.and.arrow.up" - ) { - Task { - await viewModel.exportNotationAsMusicXML() - } - } - .disabled(!viewModel.canExportNotation) - .help(ControlHelpText.exportNotationMusicXML) - .accessibilityLabel(ControlHelpText.exportNotationMusicXML) - } - .padding(.horizontal, AppTheme.Spacing.panelPadding) - .padding(.vertical, AppTheme.Spacing.md) - } - @ViewBuilder private func scoreBody( layout: NotationWindowScoreLayout @@ -253,27 +169,6 @@ struct NotationWindowView: View { .frame(maxWidth: .infinity, alignment: .leading) } - private var partVisibilityMenu: some View { - Menu { - ForEach(viewModel.availableNotationParts) { part in - Button { - viewModel.toggleNotationWindowPartVisibility(part.id) - } label: { - if viewModel.normalizedVisibleNotationPartIDs().contains(part.id) { - Label(part.title, systemImage: "checkmark") - } else { - Text(part.title) - } - } - } - } label: { - Label("Parts", systemImage: "rectangle.stack") - } - .disabled(viewModel.availableNotationParts.count <= 1) - .help("Choose visible Notation parts") - .accessibilityLabel("Visible Notation Parts") - } - private func notationScoreLayout(contentWidth: CGFloat) -> NotationWindowScoreLayout { let partStates = viewModel.visibleNotationParts.map { part in let scoreState = notationScoreState(partID: part.id) diff --git a/JammLabSeparatorHelper/JammLabSeparatorHelper.spec b/JammLabSeparatorHelper/JammLabSeparatorHelper.spec index 33588a0..9b65d12 100644 --- a/JammLabSeparatorHelper/JammLabSeparatorHelper.spec +++ b/JammLabSeparatorHelper/JammLabSeparatorHelper.spec @@ -10,6 +10,8 @@ datas = [] binaries = [] hiddenimports = [] +datas += [("helper-manifest.json", ".")] + for package in [ "audio_separator", "imageio_ffmpeg", diff --git a/JammLabSeparatorHelper/helper-manifest.json b/JammLabSeparatorHelper/helper-manifest.json new file mode 100644 index 0000000..168b0ed --- /dev/null +++ b/JammLabSeparatorHelper/helper-manifest.json @@ -0,0 +1,30 @@ +{ + "protocolVersion": 6, + "separatorVersion": "2", + "supportedComputeModes": [ + "cpu", + "auto" + ], + "models": [ + { + "name": "htdemucs.yaml", + "requiredFiles": [ + "htdemucs.yaml", + "955717e8-8726e21a.th" + ] + }, + { + "name": "htdemucs_6s.yaml", + "requiredFiles": [ + "htdemucs_6s.yaml", + "5c90dfd2-34c22ccb.th" + ] + }, + { + "name": "UVR-MDX-NET-Inst_HQ_5.onnx", + "requiredFiles": [ + "UVR-MDX-NET-Inst_HQ_5.onnx" + ] + } + ] +} diff --git a/JammLabSeparatorHelper/runner.py b/JammLabSeparatorHelper/runner.py index 6df8831..73ae52e 100644 --- a/JammLabSeparatorHelper/runner.py +++ b/JammLabSeparatorHelper/runner.py @@ -10,7 +10,9 @@ from __future__ import annotations import argparse +import hashlib import importlib.metadata +import json import logging import os import platform @@ -21,15 +23,10 @@ from pathlib import Path -HELPER_VERSION = "1" +HELPER_VERSION = "2" BUNDLED_MODEL_CACHE_DIR_NAME = "bundled-model-cache" RUNTIME_DIR_NAME = "JammLabSeparatorHelper" DEFAULT_COMPUTE_DEVICE = "cpu" -REQUIRED_MODEL_CACHE_FILES = { - "htdemucs.yaml": ["htdemucs.yaml", "955717e8-8726e21a.th"], - "htdemucs_6s.yaml": ["htdemucs_6s.yaml", "5c90dfd2-34c22ccb.th"], - "UVR-MDX-NET-Inst_HQ_5.onnx": ["UVR-MDX-NET-Inst_HQ_5.onnx"], -} def _package_version(name: str) -> str: @@ -49,6 +46,21 @@ def bundled_model_cache_dir() -> Path: return bundled_resource_root() / BUNDLED_MODEL_CACHE_DIR_NAME +def helper_manifest_bytes() -> bytes: + return (bundled_resource_root() / "helper-manifest.json").read_bytes() + + +def helper_manifest() -> dict: + return json.loads(helper_manifest_bytes()) + + +def required_model_cache_files() -> dict[str, list[str]]: + return { + model["name"]: list(model["requiredFiles"]) + for model in helper_manifest()["models"] + } + + def bundled_ffmpeg_path() -> Path | None: try: import imageio_ffmpeg @@ -139,7 +151,7 @@ def copy_seed_model_cache(model_dir: Path, seed_dir: Path | None = None) -> int: def missing_required_model_files(model_dir: Path, model_filename: str) -> list[str]: - required_files = REQUIRED_MODEL_CACHE_FILES.get(model_filename, [model_filename]) + required_files = required_model_cache_files().get(model_filename, [model_filename]) return [filename for filename in required_files if not (model_dir / filename).is_file()] @@ -187,12 +199,27 @@ def print_env_info() -> int: return 0 +def print_capabilities_json() -> int: + manifest = helper_manifest() + capabilities = { + "protocolVersion": manifest["protocolVersion"], + "separatorVersion": manifest["separatorVersion"], + "executableIdentity": str(Path(sys.executable).resolve()), + "manifestSHA256": hashlib.sha256(helper_manifest_bytes()).hexdigest(), + "supportedModels": [model["name"] for model in manifest["models"]], + "supportedComputeModes": manifest["supportedComputeModes"], + } + print(json.dumps(capabilities, sort_keys=True, separators=(",", ":"))) + return 0 + + def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( prog="JammLabSeparatorHelper", description="Bundled JammLab stem separator backend.", ) parser.add_argument("--env_info", action="store_true", help="Print bundled backend diagnostics and exit.") + parser.add_argument("--capabilities_json", action="store_true", help="Print the bundled capability manifest as JSON and exit.") parser.add_argument("--prefetch_model", help="Download/cache a model into --model_file_dir and exit.") parser.add_argument("--validate_model_cache", help="Validate that --model_file_dir contains required files for a model and exit.") parser.add_argument("audio_path", nargs="?", help="Audio file to separate.") @@ -297,6 +324,8 @@ def main(argv: list[str] | None = None) -> int: args = parse_args(list(sys.argv[1:] if argv is None else argv)) if args.env_info: return print_env_info() + if args.capabilities_json: + return print_capabilities_json() if args.validate_model_cache: return validate_model_cache(args) if args.prefetch_model: diff --git a/JammLabSeparatorHelper/test_runner.py b/JammLabSeparatorHelper/test_runner.py index b5cce87..2d0d69e 100644 --- a/JammLabSeparatorHelper/test_runner.py +++ b/JammLabSeparatorHelper/test_runner.py @@ -11,6 +11,21 @@ class RunnerTests(unittest.TestCase): + def test_capabilities_json_comes_from_checked_in_manifest(self): + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(runner.print_capabilities_json(), 0) + + capabilities = __import__("json").loads(output.getvalue()) + self.assertEqual(capabilities["protocolVersion"], 6) + self.assertEqual(capabilities["separatorVersion"], "2") + self.assertEqual( + capabilities["supportedModels"], + ["htdemucs.yaml", "htdemucs_6s.yaml", "UVR-MDX-NET-Inst_HQ_5.onnx"], + ) + self.assertEqual(capabilities["supportedComputeModes"], ["cpu", "auto"]) + self.assertEqual(len(capabilities["manifestSHA256"]), 64) + def test_parse_log_level_accepts_named_levels(self): self.assertEqual(runner.parse_log_level("INFO"), logging.INFO) self.assertEqual(runner.parse_log_level("debug"), logging.DEBUG) diff --git a/JammLabStemHelper/StemHelperProcessSupport.swift b/JammLabStemHelper/StemHelperProcessSupport.swift new file mode 100644 index 0000000..a50850d --- /dev/null +++ b/JammLabStemHelper/StemHelperProcessSupport.swift @@ -0,0 +1,149 @@ +import Foundation + +struct HelperBackend { + var executableURL: URL + var argumentsPrefix: [String] + var displayName: String + + init(candidate: StemBackendCandidate) { + executableURL = candidate.executableURL + argumentsPrefix = candidate.argumentsPrefix + displayName = candidate.displayName + } + + func commandDescription(extraArguments: [String]) -> String { + commandDescription(executableURL: executableURL, extraArguments: extraArguments) + } + + func commandDescription(executableURL: URL, extraArguments: [String]) -> String { + ([executableURL.path] + argumentsPrefix + extraArguments) + .map { $0.contains(" ") ? "\"\($0)\"" : $0 } + .joined(separator: " ") + } + + func identifier(separatorVersion: String) -> String { + "\(executableURL.lastPathComponent)/\(separatorVersion)" + } +} + +struct ProcessResult { + var exitCode: Int32 + var output: String +} + +enum HelperError: LocalizedError { + case backendNotFound(String) + case backendFailed(String) + case incompleteOutput(String) + case protocolMismatch(expected: Int, actual: Int) + case unsupportedCapability(model: String, computeMode: String) + case cancelled + + var errorDescription: String? { + switch self { + case .backendNotFound(let details): + return "Bundled stem separator was not found or failed capability probing.\n\(details)" + case .backendFailed(let details): + return "Stem backend failed.\n\(details)" + case .incompleteOutput(let stem): + return "Stem backend did not produce \(stem)." + case .protocolMismatch(let expected, let actual): + return "Stem job protocol mismatch: expected v\(expected), got v\(actual)." + case .unsupportedCapability(let model, let computeMode): + return "Bundled separator does not support model \(model) with compute mode \(computeMode)." + case .cancelled: + return "Stem helper job cancelled." + } + } +} + +final class HeartbeatThread { + private let lock = NSLock() + private var isStopped = false + private let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + func start() { + Thread.detachNewThread { [weak self] in + while self?.stopped == false { + self?.action() + Thread.sleep(forTimeInterval: 2) + } + } + } + + func stop() { + lock.lock() + isStopped = true + lock.unlock() + } + + private var stopped: Bool { + lock.lock() + defer { lock.unlock() } + return isStopped + } +} + +final class CancellationWatcherThread { + private let lock = NSLock() + private var isStopped = false + private let jobDirectory: URL + private let action: () -> Void + + init(jobDirectory: URL, action: @escaping () -> Void) { + self.jobDirectory = jobDirectory + self.action = action + } + + func start() { + Thread.detachNewThread { [weak self] in + while self?.stopped == false { + if self?.isCancelled == true { + self?.action() + self?.stop() + return + } + Thread.sleep(forTimeInterval: 0.5) + } + } + } + + func stop() { + lock.lock() + isStopped = true + lock.unlock() + } + + private var stopped: Bool { + lock.lock() + defer { lock.unlock() } + return isStopped + } + + private var isCancelled: Bool { + FileManager.default.fileExists( + atPath: jobDirectory.appendingPathComponent(StemJobFiles.cancelFilename).path + ) + } +} + +final class ProcessOutputBuffer: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + var stringValue: String { + lock.lock() + defer { lock.unlock() } + return String(data: data, encoding: .utf8) ?? "" + } + + func append(_ newData: Data) { + lock.lock() + data.append(newData) + lock.unlock() + } +} diff --git a/JammLabStemHelper/main.swift b/JammLabStemHelper/main.swift index 3d788dd..7763b22 100644 --- a/JammLabStemHelper/main.swift +++ b/JammLabStemHelper/main.swift @@ -5,11 +5,20 @@ StemHelperRunner().run() private final class StemHelperRunner { private let fileManager = FileManager.default private var activeProcess: Process? + private var resolvedBackend: HelperBackend? + private var capabilities: StemHelperCapabilities? func run() { print("JammLabStemHelper started. Watching \(jobsDirectory().path)") try? fileManager.createDirectory(at: jobsDirectory(), withIntermediateDirectories: true) try? fileManager.createDirectory(at: modelDirectory(), withIntermediateDirectories: true) + do { + let resolution = try resolveBackend(jobDirectory: jobsDirectory()) + resolvedBackend = resolution.backend + capabilities = resolution.capabilities + } catch { + print("JammLabStemHelper capability probe failed: \(error.localizedDescription)") + } while true { writeHeartbeat(activeJobID: nil) @@ -49,10 +58,32 @@ private final class StemHelperRunner { private func process(_ jobDirectory: URL) { do { let request: StemJobRequest = try readJSON(from: jobDirectory.appendingPathComponent(StemJobFiles.requestFilename)) + guard request.protocolVersion == StemJobFiles.protocolVersion else { + throw HelperError.protocolMismatch( + expected: StemJobFiles.protocolVersion, + actual: request.protocolVersion + ) + } writeHeartbeat(activeJobID: request.jobID) try updateStatus(.checkingBackend, request: request, jobDirectory: jobDirectory, message: "Checking backend") - let backend = try resolveBackend(jobDirectory: jobDirectory) + let resolution: (backend: HelperBackend, capabilities: StemHelperCapabilities) + if let resolvedBackend, let capabilities { + resolution = (resolvedBackend, capabilities) + } else { + resolution = try resolveBackend(jobDirectory: jobDirectory) + resolvedBackend = resolution.backend + capabilities = resolution.capabilities + } + let backend = resolution.backend + guard resolution.capabilities.supportedModels.contains(request.modelName), + resolution.capabilities.supportedComputeModes.contains(request.computeMode) + else { + throw HelperError.unsupportedCapability( + model: request.modelName, + computeMode: request.computeMode + ) + } try ensureNotCancelled(jobDirectory) try updateStatus( .processing, @@ -89,12 +120,14 @@ private final class StemHelperRunner { let stems = try normalizeStems( from: workDirectory, cacheDirectory: URL(fileURLWithPath: request.cacheDirectoryPath), - expectedTypes: request.expectedStemTypes ?? StemSeparationMethod.defaultValue.stemTypes + expectedTypes: request.expectedStemTypes ) let metadata = StemCacheMetadata( cacheKey: request.cacheKey, sourceFingerprint: request.sourceFingerprint, - backendIdentifier: backend.displayName, + backendIdentifier: backend.identifier( + separatorVersion: resolution.capabilities.separatorVersion + ), separationMethodID: request.separationMethodID, modelName: request.modelName, settingsVersion: request.settingsVersion, @@ -113,7 +146,9 @@ private final class StemHelperRunner { } } - private func resolveBackend(jobDirectory: URL) throws -> HelperBackend { + private func resolveBackend( + jobDirectory: URL + ) throws -> (backend: HelperBackend, capabilities: StemHelperCapabilities) { let candidates = StemBackendResolver().bundledSeparatorCandidates.map(HelperBackend.init(candidate:)) var diagnostics: [String] = [] @@ -121,18 +156,30 @@ private final class StemHelperRunner { do { let result = try runBackend( backend, - arguments: ["--env_info"], + arguments: ["--capabilities_json"], jobDirectory: jobDirectory, heartbeatJobID: nil ) guard result.exitCode == 0 else { - diagnostics.append("\(backend.commandDescription(extraArguments: ["--env_info"])) failed with \(result.exitCode)") + diagnostics.append("\(backend.commandDescription(extraArguments: ["--capabilities_json"])) failed with \(result.exitCode)") continue } - if !result.output.localizedCaseInsensitiveContains("JammLabSeparatorHelper") { - diagnostics.append("\(backend.displayName) did not report bundled helper identity") + guard let jsonLine = result.output + .split(separator: "\n") + .last(where: { $0.trimmingCharacters(in: .whitespaces).hasPrefix("{") }), + let data = String(jsonLine).data(using: .utf8), + let capabilities = try? JSONDecoder().decode(StemHelperCapabilities.self, from: data) + else { + diagnostics.append("\(backend.displayName) did not report valid capability JSON") + continue + } + guard capabilities.protocolVersion == StemJobFiles.protocolVersion else { + diagnostics.append( + "\(backend.displayName) uses protocol \(capabilities.protocolVersion), expected \(StemJobFiles.protocolVersion)" + ) + continue } - return backend + return (backend, capabilities) } catch { diagnostics.append("\(backend.displayName): \(error.localizedDescription)") } @@ -154,7 +201,7 @@ private final class StemHelperRunner { "--model_file_dir", request.modelDirectoryPath, "--compute_device", - request.computeMode ?? "cpu" + request.computeMode ] } @@ -189,9 +236,8 @@ private final class StemHelperRunner { let heartbeatThread = HeartbeatThread { [weak self] in self?.writeHeartbeat(activeJobID: heartbeatJobID) } - let cancellationWatcher = CancellationWatcherThread(jobDirectory: jobDirectory) { [weak self, weak process] in + let cancellationWatcher = CancellationWatcherThread(jobDirectory: jobDirectory) { [weak process] in process?.terminate() - self?.activeProcess = nil } heartbeatThread.start() cancellationWatcher.start() @@ -324,8 +370,18 @@ private final class StemHelperRunner { } private func writeHeartbeat(activeJobID: String?) { + let executableIdentity = URL(fileURLWithPath: CommandLine.arguments[0]) + .standardizedFileURL + .resolvingSymlinksInPath() + .path let heartbeat = StemHelperHeartbeat( + protocolVersion: StemJobFiles.protocolVersion, helperVersion: StemJobFiles.helperVersion, + separatorVersion: capabilities?.separatorVersion ?? "", + executableIdentity: executableIdentity, + manifestSHA256: capabilities?.manifestSHA256 ?? "", + supportedModels: capabilities?.supportedModels ?? [], + supportedComputeModes: capabilities?.supportedComputeModes ?? [], updatedAt: Date(), activeJobID: activeJobID ) @@ -380,140 +436,3 @@ private final class StemHelperRunner { } } } - -private struct HelperBackend { - var executableURL: URL - var argumentsPrefix: [String] - var displayName: String - - init(candidate: StemBackendCandidate) { - executableURL = candidate.executableURL - argumentsPrefix = candidate.argumentsPrefix - displayName = candidate.displayName - } - - func commandDescription(extraArguments: [String]) -> String { - commandDescription(executableURL: executableURL, extraArguments: extraArguments) - } - - func commandDescription(executableURL: URL, extraArguments: [String]) -> String { - ([executableURL.path] + argumentsPrefix + extraArguments) - .map { $0.contains(" ") ? "\"\($0)\"" : $0 } - .joined(separator: " ") - } - -} - -private struct ProcessResult { - var exitCode: Int32 - var output: String -} - -private enum HelperError: LocalizedError { - case backendNotFound(String) - case backendFailed(String) - case incompleteOutput(String) - case cancelled - - var errorDescription: String? { - switch self { - case .backendNotFound(let details): - return "Bundled stem separator was not found or failed --env_info.\n\(details)" - case .backendFailed(let details): - return "Stem backend failed.\n\(details)" - case .incompleteOutput(let stem): - return "Stem backend did not produce \(stem)." - case .cancelled: - return "Stem helper job cancelled." - } - } -} - -private final class HeartbeatThread { - private let lock = NSLock() - private var isStopped = false - private let action: () -> Void - - init(action: @escaping () -> Void) { - self.action = action - } - - func start() { - Thread.detachNewThread { [weak self] in - while self?.stopped == false { - self?.action() - Thread.sleep(forTimeInterval: 2) - } - } - } - - func stop() { - lock.lock() - isStopped = true - lock.unlock() - } - - private var stopped: Bool { - lock.lock() - defer { lock.unlock() } - return isStopped - } -} - -private final class CancellationWatcherThread { - private let lock = NSLock() - private var isStopped = false - private let jobDirectory: URL - private let action: () -> Void - - init(jobDirectory: URL, action: @escaping () -> Void) { - self.jobDirectory = jobDirectory - self.action = action - } - - func start() { - Thread.detachNewThread { [weak self] in - while self?.stopped == false { - if self?.isCancelled == true { - self?.action() - self?.stop() - return - } - Thread.sleep(forTimeInterval: 0.5) - } - } - } - - func stop() { - lock.lock() - isStopped = true - lock.unlock() - } - - private var stopped: Bool { - lock.lock() - defer { lock.unlock() } - return isStopped - } - - private var isCancelled: Bool { - FileManager.default.fileExists(atPath: jobDirectory.appendingPathComponent(StemJobFiles.cancelFilename).path) - } -} - -private final class ProcessOutputBuffer: @unchecked Sendable { - private let lock = NSLock() - private var data = Data() - - var stringValue: String { - lock.lock() - defer { lock.unlock() } - return String(data: data, encoding: .utf8) ?? "" - } - - func append(_ newData: Data) { - lock.lock() - data.append(newData) - lock.unlock() - } -} diff --git a/JammLabTests/ClickRenderStateTests.swift b/JammLabTests/ClickRenderStateTests.swift index 5814934..016872c 100644 --- a/JammLabTests/ClickRenderStateTests.swift +++ b/JammLabTests/ClickRenderStateTests.swift @@ -219,3 +219,72 @@ final class ClickRenderStateTests: XCTestCase { return frames } } + +final class AudioTransportRenderStateTests: XCTestCase { + func testPublishedFrameTracksRenderProgressAndSeek() { + let state = AudioTransportRenderState() + state.configure(durationFrames: 8) + state.seek(to: 3) + + XCTAssertEqual(state.currentFrame, 3) + + state.play() + XCTAssertEqual(state.nextSourceFrame(), 3) + XCTAssertEqual(state.currentFrame, 4) + + state.pause() + XCTAssertEqual(state.currentFrame, 4) + } + + func testLoopPublishesWrappedFrameWithoutTornTransportState() { + let state = AudioTransportRenderState() + state.configure(durationFrames: 10) + state.setLoop(enabled: true, startFrame: 2, endFrame: 5) + state.seek(to: 4) + state.play() + + XCTAssertEqual(state.nextSourceFrame(), 4) + XCTAssertEqual(state.currentFrame, 2) + XCTAssertEqual(state.nextSourceFrame(), 2) + XCTAssertEqual(state.currentFrame, 3) + } + + func testAtomicRenderCounterProducesExactConcurrentTotal() { + let value = AudioRenderAtomicInt64() + DispatchQueue.concurrentPerform(iterations: 10_000) { _ in + value.increment() + } + + XCTAssertEqual(value.value, 10_000) + + DispatchQueue.concurrentPerform(iterations: 10_000) { _ in + value.decrement() + } + + XCTAssertEqual(value.value, 0) + } + + func testRenderQuiescenceWaitsForEveryActiveLease() { + let lease = AudioRenderGraphLease(tracks: []) + lease.beginRender() + lease.beginRender() + XCTAssertEqual(lease.activeRenderCount, 2) + + let waitStarted = DispatchSemaphore(value: 0) + let waitFinished = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + waitStarted.signal() + MultiTrackAudioPlayer.waitForRenderQuiescence(lease) + waitFinished.signal() + } + + XCTAssertEqual(waitStarted.wait(timeout: .now() + 1), .success) + lease.endRender() + XCTAssertEqual(lease.activeRenderCount, 1) + XCTAssertEqual(waitFinished.wait(timeout: .now() + 0.02), .timedOut) + + lease.endRender() + XCTAssertEqual(waitFinished.wait(timeout: .now() + 1), .success) + XCTAssertTrue(lease.isRenderInactive) + } +} diff --git a/JammLabTests/MultiTrackAudioPlayerConversionTests.swift b/JammLabTests/MultiTrackAudioPlayerConversionTests.swift index 8ac2b76..e7dbec1 100644 --- a/JammLabTests/MultiTrackAudioPlayerConversionTests.swift +++ b/JammLabTests/MultiTrackAudioPlayerConversionTests.swift @@ -3,6 +3,88 @@ import XCTest @testable import JammLab final class MultiTrackAudioPlayerConversionTests: XCTestCase { + func testBackgroundPreparerDecodesOriginalAndReportsCompletion() async throws { + let url = try temporaryAudioFile(duration: 0.1, namePrefix: "prepared") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let completed = expectation(description: "Preparation completed") + + let asset = try await MultiTrackAudioPreparer().prepareOriginal( + url: url, + volume: 0.75 + ) { progress in + if progress.fractionCompleted == 1 { + completed.fulfill() + } + } + await fulfillment(of: [completed], timeout: 2) + + guard case .decoded(let format, let tracks) = asset.storage else { + return XCTFail("Expected decoded playback asset") + } + XCTAssertEqual(format.commonFormat, .pcmFormatFloat32) + XCTAssertEqual(tracks.count, 1) + XCTAssertNil(tracks[0].stemType) + XCTAssertEqual(tracks[0].volume, 0.75) + XCTAssertGreaterThan(tracks[0].buffer.frameLength, 0) + } + + func testPreparationMemoryPolicyRejectsOversizedCandidate() { + let policy = AudioPreparationMemoryPolicy(maximumCandidateBytes: 1_024) + + XCTAssertThrowsError( + try policy.validate(frameCount: 10_000, channelCount: 2) + ) { error in + guard case MultiTrackAudioPlayerError.preparationMemoryLimitExceeded = error else { + return XCTFail("Unexpected error: \(error)") + } + } + } + + func testMatchingFormatDecodeChecksCancellationBetweenChunks() throws { + let url = try temporaryAudioFile(duration: 2, namePrefix: "cancelled-decode") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let file = try AVAudioFile(forReading: url) + let outputFormat = file.processingFormat + var cancellationChecks = 0 + + XCTAssertThrowsError( + try AudioFileBufferDecoder.decode( + file: file, + to: outputFormat, + cancellationCheck: { + cancellationChecks += 1 + if cancellationChecks == 3 { + throw CancellationError() + } + } + ) + ) { error in + XCTAssertTrue(error is CancellationError) + } + XCTAssertEqual(cancellationChecks, 3) + XCTAssertLessThan(file.framePosition, file.length) + } + + func testCancellingPreparationPropagatesToDetachedDecodeWorker() async throws { + let url = try temporaryAudioFile(duration: 2, namePrefix: "cancelled-preparation") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let task = Task { + try await MultiTrackAudioPreparer().prepareOriginal( + url: url, + volume: 1 + ) { _ in } + } + + task.cancel() + + do { + _ = try await task.value + XCTFail("Expected cancellation") + } catch { + XCTAssertTrue(error is CancellationError) + } + } + func testDecodeConvertsIntegerPCMWithoutTruncatingSamples() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -55,3 +137,94 @@ final class MultiTrackAudioPlayerConversionTests: XCTestCase { try file.write(from: buffer) } } + +final class AudioPlaybackTransactionTests: XCTestCase { + @MainActor + func testImportedAudioInstallFailureRestoresPreviousPlayback() throws { + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + let oldFile = ImportedAudioFile( + url: URL(fileURLWithPath: "/tmp/old.wav"), + displayName: "old.wav", + duration: 30 + ) + let newFile = ImportedAudioFile( + url: URL(fileURLWithPath: "/tmp/new.wav"), + displayName: "new.wav", + duration: 45 + ) + try viewModel.loadImportedAudio(oldFile) + viewModel.preparedPlaybackAssets[.original] = PreparedPlaybackAsset(storage: .originalURL(oldFile.url)) + viewModel.currentTime = 12 + viewModel.playbackState = .playing + engine.currentTime = 12 + engine.isPlaying = true + engine.queuedLoadErrors = [TestPlaybackTransactionError.installFailed] + + XCTAssertThrowsError( + try viewModel.loadImportedAudio( + newFile, + preparedAsset: PreparedPlaybackAsset(storage: .originalURL(newFile.url)) + ) + ) + + XCTAssertEqual(viewModel.importedFile, oldFile) + XCTAssertEqual(viewModel.playbackState, .playing) + XCTAssertTrue(engine.isLoaded) + XCTAssertTrue(engine.isPlaying) + XCTAssertEqual(engine.currentTime, 12, accuracy: 0.0001) + } + + @MainActor + func testImportedAudioInstallAndRecoveryFailureCannotRemainGhostPlaying() throws { + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + let oldFile = ImportedAudioFile( + url: URL(fileURLWithPath: "/tmp/old.wav"), + displayName: "old.wav", + duration: 30 + ) + let newFile = ImportedAudioFile( + url: URL(fileURLWithPath: "/tmp/new.wav"), + displayName: "new.wav", + duration: 45 + ) + try viewModel.loadImportedAudio(oldFile) + viewModel.preparedPlaybackAssets[.original] = PreparedPlaybackAsset(storage: .originalURL(oldFile.url)) + viewModel.playbackState = .playing + engine.isPlaying = true + engine.queuedLoadErrors = [ + TestPlaybackTransactionError.installFailed, + TestPlaybackTransactionError.recoveryFailed + ] + + XCTAssertThrowsError( + try viewModel.loadImportedAudio( + newFile, + preparedAsset: PreparedPlaybackAsset(storage: .originalURL(newFile.url)) + ) + ) { error in + XCTAssertTrue(error is AudioPlaybackTransactionFailure) + } + + XCTAssertEqual(viewModel.importedFile, oldFile) + XCTAssertEqual(viewModel.playbackState, .paused) + XCTAssertFalse(engine.isLoaded) + XCTAssertFalse(engine.isPlaying) + XCTAssertNil(viewModel.clockTask) + } +} + +private enum TestPlaybackTransactionError: LocalizedError { + case installFailed + case recoveryFailed + + var errorDescription: String? { + switch self { + case .installFailed: + return "install failed" + case .recoveryFailed: + return "recovery failed" + } + } +} diff --git a/JammLabTests/NotationMeasureGeometryTests.swift b/JammLabTests/NotationMeasureGeometryTests.swift index 228f038..5d658bd 100644 --- a/JammLabTests/NotationMeasureGeometryTests.swift +++ b/JammLabTests/NotationMeasureGeometryTests.swift @@ -229,6 +229,70 @@ final class NotationMeasureGeometryTests: XCTestCase { XCTAssertNotEqual(retimedTargets.map(\.targetTime), originalTargets.map(\.targetTime)) } + func testNotationRenderSceneCacheReusesPureGeometrySnapshot() { + let measures = [ + scoreMeasure(number: 1, start: 0, end: 2), + scoreMeasure(number: 2, start: 2, end: 4) + ] + let input = NotationTrackRenderScene.Input( + visibleMeasures: measures, + measureLayout: nil, + renderedMeasureCount: 2, + width: 600, + attributeDisplays: [.full, .none], + attributeReserveWidths: [80, 0] + ) + let cache = NotationTrackRenderSceneCache() + + let first = cache.scene(input: input) + let second = cache.scene(input: input) + + XCTAssertEqual(first, second) + XCTAssertEqual(cache.buildCount, 1) + XCTAssertEqual(first.geometries.count, 2) + XCTAssertEqual(first.barlineHitTargets.map(\.targetTime), [0, 2, 4]) + } + + func testNotationRenderSceneCacheInvalidatesForWidthOrScoreChanges() { + let measure = scoreMeasure(number: 1, start: 0, end: 2) + let input = NotationTrackRenderScene.Input( + visibleMeasures: [measure], + measureLayout: nil, + renderedMeasureCount: 1, + width: 300, + attributeDisplays: [.full], + attributeReserveWidths: [80] + ) + let cache = NotationTrackRenderSceneCache() + _ = cache.scene(input: input) + var resizedInput = input + resizedInput.width = 500 + _ = cache.scene(input: resizedInput) + var changedScoreInput = resizedInput + changedScoreInput.visibleMeasures = [ + scoreMeasure(number: 1, start: 0, end: 3) + ] + _ = cache.scene(input: changedScoreInput) + + XCTAssertEqual(cache.buildCount, 3) + } + + func testNotationPartPlannerAlwaysKeepsAnAvailablePartVisible() { + let available: [NotationPartDescriptor] = [.main, .stem(.vocals)] + + XCTAssertEqual( + NotationPartStatePlanner.normalizedVisiblePartIDs([], availableParts: available), + [.main] + ) + XCTAssertEqual( + NotationPartStatePlanner.normalizedVisiblePartIDs( + [.stem(.vocals), .stem(.drums)], + availableParts: available + ), + [.stem(.vocals)] + ) + } + private func scoreMeasure( number: Int, start: TimeInterval, diff --git a/JammLabTests/StemHelperProcessControllerTests.swift b/JammLabTests/StemHelperProcessControllerTests.swift index dd95c3d..b09351f 100644 --- a/JammLabTests/StemHelperProcessControllerTests.swift +++ b/JammLabTests/StemHelperProcessControllerTests.swift @@ -20,25 +20,27 @@ final class StemHelperProcessControllerTests: XCTestCase { XCTAssertEqual(launcher.launchCount, 0) } - func testStemHelperControllerIgnoresWrongVersionHeartbeat() async throws { + func testStemHelperControllerRejectsFreshWrongVersionHeartbeatWithoutLaunchingSecondHelper() async throws { let directory = temporaryDirectory() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let helperURL = try executableHelperFile(in: directory) let heartbeatURL = directory.appendingPathComponent(StemJobFiles.heartbeatFilename) try writeHeartbeat(to: heartbeatURL, helperVersion: StemJobFiles.helperVersion - 1, updatedAt: Date()) let launcher = MockStemHelperLauncher() - launcher.onLaunch = { _ in - try self.writeHeartbeat(to: heartbeatURL, updatedAt: Date()) - } let controller = StemHelperProcessController( helperExecutableURL: helperURL, heartbeatURL: heartbeatURL, launcher: launcher ) - try await controller.ensureRunning(timeout: 0.5) - - XCTAssertEqual(launcher.launchCount, 1) + do { + try await controller.ensureRunning(timeout: 0.5) + XCTFail("Expected capability mismatch") + } catch is StemHelperCapabilityError { + XCTAssertEqual(launcher.launchCount, 0) + } catch { + XCTFail("Unexpected error: \(error)") + } } func testStemHelperControllerLaunchesWhenHeartbeatIsMissing() async throws { @@ -127,4 +129,44 @@ final class StemHelperProcessControllerTests: XCTestCase { XCTAssertTrue(process.didTerminate) } + func testStemHelperControllerRejectsPersistentCapabilityMismatch() async throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let helperURL = try executableHelperFile(in: directory) + let heartbeatURL = directory.appendingPathComponent(StemJobFiles.heartbeatFilename) + let launcher = MockStemHelperLauncher() + launcher.onLaunch = { _ in + let heartbeat = StemHelperHeartbeat( + protocolVersion: StemJobFiles.protocolVersion, + helperVersion: StemJobFiles.helperVersion, + separatorVersion: "test-separator", + executableIdentity: helperURL.standardizedFileURL.resolvingSymlinksInPath().path, + manifestSHA256: "test-manifest", + supportedModels: ["htdemucs.yaml"], + supportedComputeModes: ["cpu"], + updatedAt: Date(), + activeJobID: nil + ) + try JSONEncoder().encode(heartbeat).write(to: heartbeatURL, options: .atomic) + } + let controller = StemHelperProcessController( + helperExecutableURL: helperURL, + heartbeatURL: heartbeatURL, + launcher: launcher + ) + + do { + try await controller.ensureRunning( + timeout: 0.3, + requiredModel: "UVR-MDX-NET-Inst_HQ_5.onnx", + computeMode: "cpu" + ) + XCTFail("Expected capability mismatch") + } catch is StemHelperCapabilityError { + XCTAssertEqual(launcher.launchCount, 1) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + } diff --git a/JammLabTests/StemJobWorkflowTests.swift b/JammLabTests/StemJobWorkflowTests.swift index fdd66c8..ca71d3a 100644 --- a/JammLabTests/StemJobWorkflowTests.swift +++ b/JammLabTests/StemJobWorkflowTests.swift @@ -4,6 +4,7 @@ import XCTest final class StemJobWorkflowTests: XCTestCase { func testStemJobModelsRoundTrip() throws { let request = StemJobRequest( + protocolVersion: StemJobFiles.protocolVersion, jobID: "job-1", audioPath: "/tmp/song.mp3", cacheKey: "cache", @@ -14,8 +15,6 @@ final class StemJobWorkflowTests: XCTestCase { expectedStemTypes: StemSeparationMethod.vocalInstrumental.stemTypes, modelName: StemSeparationMethod.vocalInstrumental.modelName, settingsVersion: 2, - audioSeparatorPath: nil, - audioSeparatorBookmarkData: nil, computeMode: "auto", createdAt: Date(timeIntervalSince1970: 100) ) @@ -45,7 +44,7 @@ final class StemJobWorkflowTests: XCTestCase { XCTAssertEqual(try JSONDecoder().decode(StemJobResult.self, from: JSONEncoder().encode(result)), result) } - func testLegacyStemJobRequestWithoutAudioSeparatorPathDecodes() throws { + func testV6StemJobRequestRejectsLegacyPayload() throws { let json = """ { "jobID": "job-legacy", @@ -64,25 +63,21 @@ final class StemJobWorkflowTests: XCTestCase { } """ - let request = try JSONDecoder().decode(StemJobRequest.self, from: Data(json.utf8)) - - XCTAssertEqual(request.jobID, "job-legacy") - XCTAssertNil(request.audioSeparatorPath) - XCTAssertNil(request.audioSeparatorBookmarkData) - XCTAssertNil(request.computeMode) - XCTAssertNil(request.separationMethodID) - XCTAssertNil(request.expectedStemTypes) + XCTAssertThrowsError( + try JSONDecoder().decode(StemJobRequest.self, from: Data(json.utf8)) + ) } func testStemJobFilesUseVersionedCurrentJobsDirectory() { let appSupport = URL(fileURLWithPath: "/tmp/JammLab", isDirectory: true) let jobsDirectory = StemJobFiles.currentJobsDirectory(in: appSupport) - XCTAssertEqual(StemJobFiles.helperVersion, 5) - XCTAssertEqual(jobsDirectory.path, "/tmp/JammLab/\(StemJobFiles.jobsDirectoryName)/v5") + XCTAssertEqual(StemJobFiles.helperVersion, 6) + XCTAssertEqual(StemJobFiles.protocolVersion, 6) + XCTAssertEqual(jobsDirectory.path, "/tmp/JammLab/\(StemJobFiles.jobsDirectoryName)/v6") XCTAssertEqual( jobsDirectory.appendingPathComponent(StemJobFiles.heartbeatFilename).path, - "/tmp/JammLab/\(StemJobFiles.jobsDirectoryName)/v5/\(StemJobFiles.heartbeatFilename)" + "/tmp/JammLab/\(StemJobFiles.jobsDirectoryName)/v6/\(StemJobFiles.heartbeatFilename)" ) } diff --git a/JammLabTests/TestSupport.swift b/JammLabTests/TestSupport.swift index 9c7a09c..9d77a5c 100644 --- a/JammLabTests/TestSupport.swift +++ b/JammLabTests/TestSupport.swift @@ -78,8 +78,15 @@ extension XCTestCase { } func writeHeartbeat(to url: URL, helperVersion: Int, updatedAt: Date) throws { + let helperURL = url.deletingLastPathComponent().appendingPathComponent("JammLabStemHelper") let heartbeat = StemHelperHeartbeat( + protocolVersion: StemJobFiles.protocolVersion, helperVersion: helperVersion, + separatorVersion: "test-separator", + executableIdentity: helperURL.standardizedFileURL.resolvingSymlinksInPath().path, + manifestSHA256: "test-manifest", + supportedModels: StemSeparationMethod.allCases.map(\.modelName), + supportedComputeModes: ["cpu", "auto"], updatedAt: updatedAt, activeJobID: nil ) @@ -164,6 +171,7 @@ struct MockPeakformProvider: PeakformProvider { @MainActor final class MockPlaybackEngine: AudioPlaybackControlling { + var requiresPreparedPlayback = false var isLoaded = false var isPlaying = false var currentTime: TimeInterval = 0 @@ -182,12 +190,21 @@ final class MockPlaybackEngine: AudioPlaybackControlling { var seekCount = 0 var loopEnabled = false var loopRegion = LoopRegion.empty + var queuedLoadErrors: [Error] = [] + var queuedPlayErrors: [Error] = [] func load(url: URL) throws { + isLoaded = false + if !queuedLoadErrors.isEmpty { + throw queuedLoadErrors.removeFirst() + } isLoaded = true } func play() throws { + if !queuedPlayErrors.isEmpty { + throw queuedPlayErrors.removeFirst() + } isPlaying = true } @@ -230,6 +247,10 @@ final class MockPlaybackEngine: AudioPlaybackControlling { } func load(stems: [StemFile], mixState: StemMixState) throws { + isLoaded = false + if !queuedLoadErrors.isEmpty { + throw queuedLoadErrors.removeFirst() + } isLoaded = true self.mixState = mixState } diff --git a/JammLabTests/ViewModelStemPlaybackTests.swift b/JammLabTests/ViewModelStemPlaybackTests.swift index 614f5e0..f59f980 100644 --- a/JammLabTests/ViewModelStemPlaybackTests.swift +++ b/JammLabTests/ViewModelStemPlaybackTests.swift @@ -89,6 +89,163 @@ final class ViewModelStemPlaybackTests: XCTestCase { XCTAssertEqual(engine.currentTime, 12, accuracy: 0.0001) } + @MainActor + func testRegisterStemMetadataPreparesMultiTrackPlaybackAsynchronously() async { + let engine = MultiTrackAudioPlayer() + let viewModel = AudioPlayerViewModel( + peakformProvider: MockPeakformProvider(), + playbackEngine: engine, + playbackPreparer: SuspendedStemPlaybackPreparer() + ) + viewModel.duration = 30 + viewModel.currentTime = 12 + viewModel.importedFile = ImportedAudioFile( + url: URL(fileURLWithPath: "/tmp/original.wav"), + displayName: "original.wav", + duration: 30 + ) + + viewModel.registerStemMetadata(testStemMetadata(), activatePlayback: true) + await Task.yield() + + XCTAssertEqual(viewModel.playbackMode, .original) + XCTAssertNotNil(viewModel.audioPreparationTask) + XCTAssertEqual(viewModel.audioPreparationState.kind, .switchingMode) + XCTAssertEqual(viewModel.audioPreparationState.pendingPlaybackMode, .stems) + XCTAssertFalse(engine.isLoaded) + + viewModel.cancelAudioPreparation() + let didCancel = await waitForMainActorCondition { + viewModel.audioPreparationTask == nil + } + XCTAssertTrue(didCancel) + } + + @MainActor + func testPreparedModeInstallFailureRestoresOriginalPlayback() async { + let engine = MockPlaybackEngine() + engine.requiresPreparedPlayback = true + let viewModel = AudioPlayerViewModel( + peakformProvider: MockPeakformProvider(), + playbackEngine: engine, + playbackPreparer: ImmediatePlaybackPreparer() + ) + let originalURL = URL(fileURLWithPath: "/tmp/original.wav") + viewModel.importedFile = ImportedAudioFile( + url: originalURL, + displayName: "original.wav", + duration: 30 + ) + viewModel.duration = 30 + viewModel.currentTime = 12 + viewModel.playbackState = .playing + viewModel.registerStemMetadata(testStemMetadata()) + viewModel.preparedPlaybackAssets[.original] = PreparedPlaybackAsset(storage: .originalURL(originalURL)) + engine.isLoaded = true + engine.isPlaying = true + engine.currentTime = 12 + engine.queuedLoadErrors = [TestStemPlaybackError.installFailed] + + viewModel.setPlaybackMode(.stems) + let didFinish = await waitForMainActorCondition { + viewModel.audioPreparationTask == nil + } + + XCTAssertTrue(didFinish) + XCTAssertEqual(viewModel.playbackMode, .original) + XCTAssertEqual(viewModel.playbackState, .playing) + XCTAssertTrue(engine.isLoaded) + XCTAssertTrue(engine.isPlaying) + XCTAssertEqual(engine.currentTime, 12, accuracy: 0.0001) + XCTAssertFalse(viewModel.canUndo) + } + + @MainActor + func testReplacingActiveStemsFallsBackToOriginalWhenInstallFails() async { + let engine = MockPlaybackEngine() + engine.requiresPreparedPlayback = true + let viewModel = AudioPlayerViewModel( + peakformProvider: MockPeakformProvider(), + playbackEngine: engine, + playbackPreparer: ImmediatePlaybackPreparer() + ) + let originalURL = URL(fileURLWithPath: "/tmp/original.wav") + viewModel.importedFile = ImportedAudioFile( + url: originalURL, + displayName: "original.wav", + duration: 30 + ) + viewModel.duration = 30 + viewModel.currentTime = 9 + viewModel.registerStemMetadata(testStemMetadata()) + viewModel.playbackMode = .stems + viewModel.preparedPlaybackAssets[.original] = PreparedPlaybackAsset(storage: .originalURL(originalURL)) + viewModel.preparedPlaybackAssets[.stems] = PreparedPlaybackAsset( + storage: .stems(viewModel.stemFiles, viewModel.stemMixState) + ) + viewModel.playbackState = .playing + engine.isLoaded = true + engine.isPlaying = true + engine.currentTime = 9 + engine.queuedLoadErrors = [TestStemPlaybackError.installFailed] + + viewModel.registerStemMetadata(testStemMetadata(), activatePlayback: true) + let didFinish = await waitForMainActorCondition { + viewModel.audioPreparationTask == nil + } + + XCTAssertTrue(didFinish) + XCTAssertEqual(viewModel.playbackMode, .original) + XCTAssertEqual(viewModel.playbackState, .playing) + XCTAssertTrue(engine.isLoaded) + XCTAssertTrue(engine.isPlaying) + XCTAssertEqual(engine.currentTime, 9, accuracy: 0.0001) + XCTAssertEqual(viewModel.stemSeparationState.phase, .completed) + } + + @MainActor + func testPreparedModeInstallAndRecoveryFailurePausesPlaybackWithDiagnostics() async { + let engine = MockPlaybackEngine() + engine.requiresPreparedPlayback = true + let viewModel = AudioPlayerViewModel( + peakformProvider: MockPeakformProvider(), + playbackEngine: engine, + playbackPreparer: ImmediatePlaybackPreparer() + ) + let originalURL = URL(fileURLWithPath: "/tmp/original.wav") + viewModel.importedFile = ImportedAudioFile( + url: originalURL, + displayName: "original.wav", + duration: 30 + ) + viewModel.duration = 30 + viewModel.currentTime = 7 + viewModel.playbackState = .playing + viewModel.registerStemMetadata(testStemMetadata()) + viewModel.preparedPlaybackAssets[.original] = PreparedPlaybackAsset(storage: .originalURL(originalURL)) + engine.isLoaded = true + engine.isPlaying = true + engine.queuedLoadErrors = [ + TestStemPlaybackError.installFailed, + TestStemPlaybackError.recoveryFailed + ] + + viewModel.setPlaybackMode(.stems) + let didFinish = await waitForMainActorCondition { + viewModel.audioPreparationTask == nil + } + + XCTAssertTrue(didFinish) + XCTAssertEqual(viewModel.playbackMode, .original) + XCTAssertEqual(viewModel.playbackState, .paused) + XCTAssertFalse(engine.isLoaded) + XCTAssertFalse(engine.isPlaying) + XCTAssertTrue( + viewModel.errorMessage?.contains("Previous playback could not be restored") == true, + viewModel.errorMessage ?? "Expected recovery diagnostics, got nil" + ) + } + private func testStemMetadata() -> StemCacheMetadata { StemCacheMetadata( cacheKey: "test-cache", @@ -108,3 +265,54 @@ final class ViewModelStemPlaybackTests: XCTestCase { ) } } + +private struct SuspendedStemPlaybackPreparer: AudioPlaybackPreparing { + func prepareOriginal( + url: URL, + volume: Float, + progress: @escaping @Sendable (AudioPreparationProgress) -> Void + ) async throws -> PreparedPlaybackAsset { + PreparedPlaybackAsset(storage: .originalURL(url)) + } + + func prepareStems( + _ stems: [StemFile], + mixState: StemMixState, + progress: @escaping @Sendable (AudioPreparationProgress) -> Void + ) async throws -> PreparedPlaybackAsset { + try await Task.sleep(nanoseconds: 10_000_000_000) + return PreparedPlaybackAsset(storage: .stems(stems, mixState)) + } +} + +private struct ImmediatePlaybackPreparer: AudioPlaybackPreparing { + func prepareOriginal( + url: URL, + volume: Float, + progress: @escaping @Sendable (AudioPreparationProgress) -> Void + ) async throws -> PreparedPlaybackAsset { + PreparedPlaybackAsset(storage: .originalURL(url)) + } + + func prepareStems( + _ stems: [StemFile], + mixState: StemMixState, + progress: @escaping @Sendable (AudioPreparationProgress) -> Void + ) async throws -> PreparedPlaybackAsset { + PreparedPlaybackAsset(storage: .stems(stems, mixState)) + } +} + +private enum TestStemPlaybackError: LocalizedError { + case installFailed + case recoveryFailed + + var errorDescription: String? { + switch self { + case .installFailed: + return "install failed" + case .recoveryFailed: + return "recovery failed" + } + } +} diff --git a/scripts/build_separator_helper.sh b/scripts/build_separator_helper.sh index daf2661..f001a1a 100755 --- a/scripts/build_separator_helper.sh +++ b/scripts/build_separator_helper.sh @@ -7,10 +7,9 @@ BUILD_DIR="$ROOT_DIR/build/JammLabSeparatorHelper" VENV_DIR="$BUILD_DIR/venv" DIST_DIR="$BUILD_DIR/dist" MODEL_CACHE_DIR="$BUILD_DIR/model-cache" -MODEL_MANIFEST="$MODEL_CACHE_DIR/jammlab-models.txt" +HELPER_MANIFEST="$HELPER_DIR/helper-manifest.json" PYINSTALLER_CONFIG_DIR="$BUILD_DIR/pyinstaller-config" PYTHON_BIN="${PYTHON_BIN:-python3}" -SEPARATOR_MODELS="${SEPARATOR_MODELS:-htdemucs.yaml htdemucs_6s.yaml UVR-MDX-NET-Inst_HQ_5.onnx}" mkdir -p "$BUILD_DIR" "$PYINSTALLER_CONFIG_DIR" export PYINSTALLER_CONFIG_DIR @@ -23,6 +22,7 @@ fi "$VENV_DIR/bin/python" -m pip install -r "$HELPER_DIR/requirements-build.txt" mkdir -p "$MODEL_CACHE_DIR" +SEPARATOR_MODELS="$("$PYTHON_BIN" -c 'import json,sys; print(" ".join(model["name"] for model in json.load(open(sys.argv[1]))["models"]))' "$HELPER_MANIFEST")" for model in $SEPARATOR_MODELS; do "$VENV_DIR/bin/python" "$HELPER_DIR/runner.py" \ --prefetch_model "$model" \ @@ -31,7 +31,6 @@ for model in $SEPARATOR_MODELS; do --validate_model_cache "$model" \ --model_file_dir "$MODEL_CACHE_DIR" done -printf "%s\n" $SEPARATOR_MODELS > "$MODEL_MANIFEST" rm -rf "$DIST_DIR" "$BUILD_DIR/work" ( @@ -54,4 +53,5 @@ find "$DIST_DIR/JammLabSeparatorHelper" -type f -name "*.py" \ -exec perl -i -0pe 's/\A#![^\n]*\n//' {} + "$HELPER_EXEC" --env_info +"$HELPER_EXEC" --capabilities_json echo "Built $DIST_DIR/JammLabSeparatorHelper" diff --git a/scripts/embed_separator_helper.sh b/scripts/embed_separator_helper.sh index 72ce30a..4f0919e 100755 --- a/scripts/embed_separator_helper.sh +++ b/scripts/embed_separator_helper.sh @@ -9,25 +9,25 @@ fi SOURCE_DIR="$SRCROOT/build/JammLabSeparatorHelper/dist/JammLabSeparatorHelper" DEST_DIR="$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH/Resources/JammLabSeparatorHelper" LEGACY_DEST_DIR="$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH/Helpers/JammLabSeparatorHelper" -REQUIRED_MODELS="htdemucs.yaml htdemucs_6s.yaml UVR-MDX-NET-Inst_HQ_5.onnx" -MANIFEST="$SOURCE_DIR/_internal/bundled-model-cache/jammlab-models.txt" if [[ ! -x "$SOURCE_DIR/JammLabSeparatorHelper" ]]; then echo "error: missing bundled separator helper at $SOURCE_DIR. Run scripts/build_separator_helper.sh before building JammLab." >&2 exit 1 fi -if [[ ! -f "$MANIFEST" ]]; then - echo "error: bundled separator helper model manifest is missing. Run scripts/build_separator_helper.sh before building JammLab." >&2 +if ! CAPABILITIES_JSON="$("$SOURCE_DIR/JammLabSeparatorHelper" --capabilities_json 2>&1)"; then + echo "error: bundled separator helper is stale or incompatible and cannot publish protocol capabilities." >&2 + echo "error: rebuild it with scripts/build_separator_helper.sh before building JammLab." >&2 + printf '%s\n' "$CAPABILITIES_JSON" >&2 exit 1 fi -for model in $REQUIRED_MODELS; do - if ! grep -qx "$model" "$MANIFEST"; then - echo "error: bundled separator helper is missing required model $model. Run scripts/build_separator_helper.sh before building JammLab." >&2 - exit 1 - fi -done +if [[ -z "$CAPABILITIES_JSON" || "$CAPABILITIES_JSON" != \{* ]] || + ! printf '%s' "$CAPABILITIES_JSON" | /usr/bin/plutil -convert json -o /dev/null - >/dev/null 2>&1; then + echo "error: bundled separator helper returned invalid capability JSON. Run scripts/build_separator_helper.sh before building JammLab." >&2 + printf '%s\n' "$CAPABILITIES_JSON" >&2 + exit 1 +fi rm -rf "$DEST_DIR" "$LEGACY_DEST_DIR" mkdir -p "$(dirname "$DEST_DIR")" diff --git a/scripts/test_embed_separator_helper.sh b/scripts/test_embed_separator_helper.sh new file mode 100644 index 0000000..c752d36 --- /dev/null +++ b/scripts/test_embed_separator_helper.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEST_ROOT="$(mktemp -d)" + +cleanup() { + if [[ -n "${TEST_ROOT:-}" && -d "$TEST_ROOT" ]]; then + rm -rf -- "$TEST_ROOT" + fi +} +trap cleanup EXIT + +HELPER_DIR="$TEST_ROOT/build/JammLabSeparatorHelper/dist/JammLabSeparatorHelper" +DEST_DIR="$TEST_ROOT/target/JammLab.app/Contents/Resources/JammLabSeparatorHelper" +mkdir -p "$HELPER_DIR" + +FAKE_HELPER="$HELPER_DIR/JammLabSeparatorHelper" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + 'case "${FAKE_HELPER_MODE:?}" in' \ + ' stale)' \ + ' printf "%s\n" "JammLabSeparatorHelper: error: unrecognized arguments: --capabilities_json" >&2' \ + ' exit 2' \ + ' ;;' \ + ' invalid)' \ + ' printf "%s\n" "{broken"' \ + ' ;;' \ + ' valid)' \ + ' printf "%s\n" "{\"protocolVersion\":6}"' \ + ' ;;' \ + 'esac' > "$FAKE_HELPER" +chmod +x "$FAKE_HELPER" + +fail() { + printf 'error: %s\n' "$1" >&2 + exit 1 +} + +assert_contains() { + local output="$1" + local expected="$2" + [[ "$output" == *"$expected"* ]] || fail "expected output to contain: $expected" +} + +run_failure_case() { + local mode="$1" + local output + local status + local sentinel="$DEST_DIR/existing-helper" + + mkdir -p "$DEST_DIR" + printf 'preserve me\n' > "$sentinel" + + set +e + output="$( + SRCROOT="$TEST_ROOT" \ + TARGET_BUILD_DIR="$TEST_ROOT/target" \ + CONTENTS_FOLDER_PATH="JammLab.app/Contents" \ + FAKE_HELPER_MODE="$mode" \ + SKIP_BUNDLED_SEPARATOR_HELPER=0 \ + bash "$ROOT_DIR/scripts/embed_separator_helper.sh" 2>&1 + )" + status=$? + set -e + + [[ $status -ne 0 ]] || fail "$mode helper unexpectedly succeeded" + [[ -f "$sentinel" ]] || fail "$mode helper removed the existing destination" + [[ "$(< "$sentinel")" == "preserve me" ]] || fail "$mode helper mutated the existing destination" + printf '%s' "$output" +} + +stale_output="$(run_failure_case stale)" +assert_contains "$stale_output" "bundled separator helper is stale or incompatible" +assert_contains "$stale_output" "scripts/build_separator_helper.sh" +assert_contains "$stale_output" "unrecognized arguments: --capabilities_json" + +invalid_output="$(run_failure_case invalid)" +assert_contains "$invalid_output" "bundled separator helper returned invalid capability JSON" +assert_contains "$invalid_output" "scripts/build_separator_helper.sh" +assert_contains "$invalid_output" "{broken" + +SRCROOT="$TEST_ROOT" \ + TARGET_BUILD_DIR="$TEST_ROOT/target" \ + CONTENTS_FOLDER_PATH="JammLab.app/Contents" \ + FAKE_HELPER_MODE=valid \ + SKIP_BUNDLED_SEPARATOR_HELPER=0 \ + bash "$ROOT_DIR/scripts/embed_separator_helper.sh" +[[ -x "$DEST_DIR/JammLabSeparatorHelper" ]] || fail "valid helper was not embedded" + +printf 'embed_separator_helper tests passed\n'