diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..9a7395f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,46 @@ +name: Bug report +description: Something in Isolate doesn't work as expected +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a problem. If macOS won't open Isolate, see [First launch](https://github.com/neokumar1/Isolate#first-launch); many other problems are covered in [Troubleshooting](https://github.com/neokumar1/Isolate/blob/main/TROUBLESHOOTING.md). + - type: input + id: version + attributes: + label: Isolate version + description: Shown in About Isolate. + placeholder: "1.3.0" + validations: + required: true + - type: input + id: macos + attributes: + label: macOS version and Mac model + placeholder: "macOS 15.6, MacBook Air M2" + validations: + required: true + - type: textarea + id: steps + attributes: + label: What did you do? + description: The steps that lead to the problem. + placeholder: | + 1. Imported a 4-minute FLAC file with ⌘O + 2. ... + validations: + required: true + - type: textarea + id: result + attributes: + label: What happened, and what did you expect? + description: Include the exact message Isolate showed, if any. + validations: + required: true + - type: input + id: audio + attributes: + label: Audio file details + description: Format, sample rate, channels and length, if the problem involves a song. Please don't attach copyrighted or private audio. + placeholder: "FLAC, 48 kHz, stereo, 4:05" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..bd01b6a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: macOS won't open Isolate + url: https://github.com/neokumar1/Isolate#first-launch + about: Releases are ad-hoc signed; approve the first launch in System Settings › Privacy & Security. + - name: Troubleshooting guide + url: https://github.com/neokumar1/Isolate/blob/main/TROUBLESHOOTING.md + about: Model errors, iCloud Drive files, disk space, stuck imports, library backups and exports. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a2cc38e..7bba004 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: permissions: contents: read @@ -15,26 +16,60 @@ concurrency: jobs: build: - runs-on: macos-15 - timeout-minutes: 30 + name: Build & Test (${{ matrix.runner }}) + strategy: + fail-fast: false + matrix: + runner: [macos-15, macos-26] + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + env: + # Pull requests from forks get no repository variables; they build and + # test without the model, and the inference tests skip. + ISOLATE_MODEL_ARCHIVE_URL: ${{ vars.ISOLATE_MODEL_ARCHIVE_URL }} + ISOLATE_MODEL_ARCHIVE_SHA256: ${{ vars.ISOLATE_MODEL_ARCHIVE_SHA256 }} + # macOS 15 checks safe refusal of its known-bad Core ML runtime. macOS 26 + # must actually separate; an incompatible model there fails the build. + TEST_RUNNER_ISOLATE_ALLOW_INCOMPATIBLE_MODEL: ${{ matrix.runner == 'macos-15' && '1' || '0' }} steps: - uses: actions/checkout@v5 - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable - run: brew install xcodegen - - run: xcodegen generate - - name: Check shell scripts and whitespace + - name: Generate Xcode project run: | - bash -n install.sh scripts/package_release.sh scripts/fetch_release_model.sh + if [[ "${{ matrix.runner }}" == macos-15 ]]; then + ruby scripts/ci_legacy_icon.rb + xcodegen generate --spec project.ci-legacy.yml + else + xcodegen generate + fi + - name: Check scripts, version metadata and whitespace + run: | + for script in install.sh scripts/*.sh; do bash -n "$script"; done + ruby -c Casks/isolate.rb + bash scripts/check_version.sh + git diff --exit-code -- Info.plist git diff --check + - name: Fetch and validate the pinned model + if: env.ISOLATE_MODEL_ARCHIVE_URL != '' && env.ISOLATE_MODEL_ARCHIVE_SHA256 != '' + run: | + bash scripts/fetch_release_model.sh + # Xcode forwards TEST_RUNNER_ variables to the tests, which then fail + # instead of skipping when the model cannot run. + echo "TEST_RUNNER_ISOLATE_REQUIRE_MODEL=1" >> "$GITHUB_ENV" + - name: Note skipped inference + if: env.ISOLATE_MODEL_ARCHIVE_URL == '' || env.ISOLATE_MODEL_ARCHIVE_SHA256 == '' + run: echo "::notice::The model variables are not available to this run, so Core ML inference tests will skip." - name: Build Apple Silicon release run: xcodebuild build -project Isolate.xcodeproj -scheme Isolate -configuration Release -destination 'platform=macOS,arch=arm64' -derivedDataPath build/DerivedData CODE_SIGNING_ALLOWED=NO - name: Run unit and audio regression tests - run: xcodebuild test -project Isolate.xcodeproj -scheme Isolate -destination 'platform=macOS,arch=arm64' -derivedDataPath build/DerivedData -only-testing:IsolateTests CODE_SIGNING_ALLOWED=NO + # Exercise the release configuration already built above. + run: xcodebuild test -project Isolate.xcodeproj -scheme Isolate -configuration Release -destination 'platform=macOS,arch=arm64' -derivedDataPath build/DerivedData -only-testing:IsolateTests CODE_SIGNING_ALLOWED=NO ENABLE_TESTABILITY=YES - name: Preserve test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: - name: test-results + name: test-results-${{ matrix.runner }} path: build/DerivedData/Logs/Test/*.xcresult diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f1eaaf..e071bc2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,18 +14,30 @@ concurrency: jobs: release: - runs-on: macos-15 - timeout-minutes: 45 + # Core ML computes the model incorrectly on hosted macOS 14 and 15 (Isolate refuses to + # separate there), so the inference gate must run where separation is verified. + runs-on: macos-26 + timeout-minutes: 60 env: ISOLATE_MODEL_ARCHIVE_URL: ${{ vars.ISOLATE_MODEL_ARCHIVE_URL }} ISOLATE_MODEL_ARCHIVE_SHA256: ${{ vars.ISOLATE_MODEL_ARCHIVE_SHA256 }} TEST_RUNNER_ISOLATE_REQUIRE_MODEL: '1' + TEST_RUNNER_ISOLATE_ALLOW_INCOMPATIBLE_MODEL: '0' + RELEASE_TAG: ${{ github.ref_name }} steps: - uses: actions/checkout@v5 - - name: Require a release tag + # Fail in seconds, before the model download and test run, when the tag, + # project.yml, Info.plist and CHANGELOG.md do not agree. + - name: Require a version tag that matches the app env: REF_TYPE: ${{ github.ref_type }} - run: test "$REF_TYPE" = tag + run: | + if [[ "$REF_TYPE" != tag ]]; then + echo "::error::Run this workflow from a version tag such as v1.3.0, not a branch." + exit 1 + fi + bash scripts/check_version.sh "$RELEASE_TAG" + bash scripts/release_notes.sh "$RELEASE_TAG" > /dev/null - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable @@ -36,31 +48,34 @@ jobs: - name: Require passing tests including actual model inference run: xcodebuild test -project Isolate.xcodeproj -scheme Isolate -destination 'platform=macOS,arch=arm64' -derivedDataPath build/DerivedData -only-testing:IsolateTests CODE_SIGNING_ALLOWED=NO - name: Package release with bundled model - env: - RELEASE_TAG: ${{ github.ref_name }} run: bash scripts/package_release.sh "$RELEASE_TAG" + - name: Write release notes + run: bash scripts/release_notes.sh "$RELEASE_TAG" > dist/RELEASE_NOTES.md - name: Create draft for manual review - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3.0.3 with: + name: Isolate ${{ github.ref_name }} + body_path: dist/RELEASE_NOTES.md files: | dist/Isolate.dmg - dist/Isolate-${{ github.ref_name }}.dmg dist/Isolate-${{ github.ref_name }}-macOS.zip dist/SHA256SUMS.txt fail_on_unmatched_files: true - generate_release_notes: true draft: true - body: | - Apple Silicon · macOS 14+ - Includes the validated Core ML separation model. - - These CI artifacts are ad-hoc signed and are not notarized. Before publishing, - complete the platform and distribution checks in RELEASE.md. Download the DMG, - drag Isolate into Applications, and follow Apple's Privacy & Security guidance - if macOS requests explicit approval. + - name: Summarize maintainer checks + run: | + { + echo "### Draft release ${RELEASE_TAG} created" + echo "The artifacts are ad-hoc signed and not notarized. Before publishing, work through the publication checks in RELEASE.md," + echo "then set the Homebrew cask's version and sha256 from the published Isolate.dmg." + echo + echo '```' + cat dist/SHA256SUMS.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" - name: Preserve test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: release-test-results path: build/DerivedData/Logs/Test/*.xcresult diff --git a/.gitignore b/.gitignore index cbc39b0..ff5753a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,13 +8,14 @@ build/ !default.mode2v3 *.perspectivev3 !default.perspectivev3 -xcuserdata +xcuserdata/ *.xccheckout *.moved-aside DerivedData *.hmap *.ipa *.xcuserstate +*.xcresult # SwiftPackageManager .build/ @@ -28,14 +29,14 @@ Manifest.lock *.swp *~.nib -# Other -xcuserdata/ - -# CoreML Model Binaries (>100MB) +# CoreML Model Binaries (>100MB) and archives made from them *.mlmodelc *.mlpackage *.mlmodel +/HTDemucs* # Distribution Artifacts dist/ +*.dmg +*.zip Assets/AppIcon.iconset/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9af431a..95f3b57 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,41 +6,54 @@ Isolate is an Apple Silicon macOS app using SwiftUI, SwiftData, AVFoundation, Co | Component | Responsibility | | --- | --- | -| `IsolateApp` / `ContentView` | Main window, commands, shared engine, modal state, SwiftData context | -| `ImportCoordinator` | File selection, ordered drops, sequential batch imports, persistence | -| `DemucsEngine` actor | Exclusive separation, model loading, inference, overlap reconstruction | -| `StreamingAudio` | Bounded decode/resampling, normalization statistics, reflected windows | -| `StemCache` | Content keys, cache validation, staged publication and ownership checks | -| `AudioEngineManager` (`@MainActor`) | Playback graph, controls, metadata tasks, export snapshots | +| `IsolateApp` / `ContentView` | Main window, commands, shared engine, modal state, SwiftData container | +| `IsolateAppDelegate` | Confirms quitting during a separation or export; waits for a cancelled separation to clean up | +| `ImportCoordinator` | File and folder selection, ordered drops, sequential batch imports, batch progress, failure summaries, persistence | +| `DemucsEngine` actor | Exclusive separation, shared model load and idle release, inference, overlap reconstruction | +| `StreamingAudio` | Bounded decode, downmix and resampling, length checks, normalization statistics, reflected windows, iCloud Drive downloads | +| `StemCache` | Content keys, cache validation, disk-space preflight, staged publication, abandoned-staging sweep, ownership checks | +| `AudioEngineManager` (`@MainActor`) | Playback graph, synchronized starts, controls, metadata tasks, export snapshots | | `AudioMeterProcessor` | Tap-local FFT, spectrum, waveform and peak readings | -| `AudioExporter` | Independent offline graph, encoding, ZIP, destination replacement | -| `TrackModel` | SwiftData record for source identity, user title, date and stem paths | -| `ThemeManager` / `AppSettings` | Persisted appearance and application preferences | +| `AudioExporter` | Independent offline graph, peak-safe stem encoding, ZIP, destination replacement | +| `TrackModel` / `LibraryStore` | SwiftData record for source identity, user title, date and stem paths; the library store, its recovery and the legacy import | +| `ThemeManager` / `AppSettings` | Appearance tokens, Increase Contrast, persisted preferences | | `NowPlayingManager` / `MenuBarManager` | System media controls, metadata, optional status item | +| `AppMoveHelper` | Offers to move a copy opened from a disk image, including a translocated one, into Applications | ## Import transaction -1. Reserve import state before suspending; one batch processes files sequentially. -2. Hash source bytes plus a pipeline version. Reuse only complete, consistent caches. -3. Decode a float WAV original into a temporary cache directory, computing mono mean and standard deviation in a streaming pass. -4. Process reflected ten-second windows at five-second hops. Accumulate only the active overlap; write finished hops immediately. -5. Close and validate the four stems and original, then publish the directory. Cancellation/failure removes the unfinished generation. -6. Install validated audio handles into the player; insert/save the SwiftData record. Save errors remain visible. +1. Reserve import state before suspending. Dropped or chosen folders expand to supported audio files off the main actor, in Finder order and without duplicates, and files are processed one at a time. While one separates, iCloud Drive is asked only for the next file, so a cancelled batch does not leave the rest of a folder downloading. +2. Take the single separation slot and sweep `.partial-*` and `.backup-*` folders left in the cache by an interrupted run. The same sweep runs at launch, and is skipped while a separation is running. +3. If the source is an evicted iCloud Drive file, request it and wait, cancellably, with no invented percentage. Without a network connection the import fails after about two seconds with a clear message. +4. Hash the source bytes with the pipeline version (`Isolate-streaming-v4`). Reuse only complete, consistent caches. +5. Before loading the model, compare the space the result needs (frames × 8 bytes × 5 files, plus 64 MiB) with the volume's available capacity, and fail with the numbers if it is short. The check is skipped when the length or capacity is unknown. +6. Wait for the shared model load. Cancelling the import ends the wait at once; the load finishes in the background and the next import reuses it. +7. Decode a float WAV original into a `.partial-` staging directory, computing the mono mean and standard deviation in the same streaming pass. +8. Process reflected ten-second windows at five-second hops. Accumulate only the active overlap and write finished hops immediately. +9. Close and validate the four stems and the original, then publish the directory, moving any invalid existing cache aside first. Cancellation or failure removes the unfinished generation. +10. Install the validated audio into the player and insert or update the SwiftData record. A reimport deletes the stem folder it replaced when the cache owns it and no other entry references it. Failures are collected into one summary for the batch; save errors take priority. +11. Release the model 60 seconds after the last separation. A batch keeps it loaded, because each file starts before the timer fires. -Memory for input/output audio is bounded by chunk size rather than track length. Core ML memory use is separate and depends on the model/runtime. Temporary and final disk usage still scales with track length. +Memory for input and output audio is bounded by chunk size rather than track length. Core ML memory is separate and depends on the model and runtime; on the development Mac it measured 1.6–2.7 GB after inference. Temporary and final disk use still scale with track length. ## Playback and UI safety -The audio graph and observable UI state belong to the main actor. Each meter tap owns its mutable FFT state; value snapshots cross back to the UI. Playback completion and metadata tasks carry generation IDs so cancelled or replaced tracks cannot update current state. Remote media callbacks enqueue main-actor work. +The audio graph and observable UI state belong to the main actor. Each meter tap owns its mutable FFT state; value snapshots cross back to the UI. Playback completion and metadata tasks carry generation IDs so cancelled or replaced tracks cannot update current state. Artwork decoding and the source-format probe run off the main actor. Remote media callbacks enqueue main-actor work. -Exports snapshot the selected files and controls and use an independent graph on a worker task. Library deletion is blocked while exporting. Existing output files are replaced only after the rendered file/archive has been created successfully. +Exports snapshot the selected files and controls before the save panel opens, check afterwards that the same track is still loaded, and render on an independent graph in a worker task that can be cancelled. Library deletion is blocked while exporting or separating. Existing output files are replaced only after the rendered file or archive is complete. -The app uses one logical main window. Hosted tests and `-ui-testing` launches use separate preferences, an in-memory library, and a temporary stem cache. +Quitting asks for confirmation while a separation or export runs. Confirming during a separation cancels it and waits up to 15 seconds for its temporary files to be removed; confirming during an export cancels it and waits up to 5 seconds for its temporary renders to be removed. The exporter stages its output in the volume's item-replacement directory, so nothing partial is left in the destination folder. Separations and exports hold an activity that keeps the Mac from idle-sleeping until they finish. -Track renaming uses a native sheet so its text field can accept keyboard focus while the player is blocked. Decorative corner overlays do not receive pointer events; disabled mixer controls stop participating in keyboard focus. +The app uses one logical main window; automatic window tabbing is disabled. The menu bar item reopens a closed window through the scene's `openWindow`. Hosted tests and `-ui-testing` launches use separate preferences, an in-memory library, and a temporary stem cache. + +Track renaming uses a native sheet so its text field can accept keyboard focus while the player is blocked. Decorative corner overlays do not receive pointer events; disabled mixer controls stop participating in keyboard focus. While About, Settings or the delete card covers the separation progress, its cancel button has no keyboard shortcut, so Escape closes the card on top. ## Files and persistence -Source paths identify library entries; identical bytes at different paths may share a cache. Deletion checks both cache ownership and remaining library references. External source audio is never deleted. The app is not App Sandbox enabled; security-scoped access is still balanced for URLs provided by system pickers. +The library is an explicit SwiftData store at `~/Library/Application Support/Isolate/Library.store`. Builds up to 1.2 used SwiftData's default configuration, which for this unsandboxed app is the shared `~/Library/Application Support/default.store` that other apps also open and migrate. On first launch, `LibraryStore` copies `default.store` and its `-wal`/`-shm` files to a temporary folder, checks with SQLite that the copy has a `ZTRACKMODEL` table with all eight columns, opens only the copy, and inserts rows whose IDs are not already present. The shared file is never opened in place, modified or deleted. Completion is recorded under `didImportLegacyLibraryStore` only after an import succeeds or finds no old library; a failed copy or read is reported and retried at up to three launches. + +If `Library.store` cannot be opened and SQLite reports the file damaged (not a database, corrupt, or a failed `quick_check`), it and its sidecar files are moved, never deleted, into `Library Backups//` beside it and a new store is opened; the main window shows where the backup went at every launch until the notice is closed. If no new store can be created, the originals are moved back. Any other failure (a full disk, permissions, a lock, a store from a newer version) leaves `Library.store` untouched and runs that session in memory with a notice that changes will not be saved and the library will be tried again next launch. Any new `TrackModel` attribute must still be optional or have a default, or ship with a `SchemaMigrationPlan`, or upgraded stores will not open. + +Source paths identify library entries; identical bytes at different paths share a cache. Deletion checks both cache ownership and remaining library references. External source audio is never deleted. The app is not App Sandbox enabled; security-scoped access is still balanced for URLs provided by system pickers. Imported sources stay in place and are reread by later launches for metadata and stem rebuilds, so `Info.plist` carries purpose strings for the Desktop, Documents, Downloads, removable-volume and network-volume prompts. See [AUDIO_ENGINE.md](AUDIO_ENGINE.md), [MODEL.md](MODEL.md), and [RELEASE.md](RELEASE.md) for detailed contracts and verification. diff --git a/AUDIO_ENGINE.md b/AUDIO_ENGINE.md index 4959c5c..cfe6359 100644 --- a/AUDIO_ENGINE.md +++ b/AUDIO_ENGINE.md @@ -2,11 +2,13 @@ ## Separation -`ExtAudioFile` decodes supported input into stereo Float32 at 44.1 kHz in 16,384-frame blocks. Normalization uses a mono reference mean and standard deviation. Reflection repeats correctly at both ends even for sources shorter than one model window. +`ExtAudioFile` decodes supported input into stereo Float32 at 44.1 kHz in 16,384-frame blocks. Sources with more than two channels are decoded at their own channel count with an explicit client channel layout (ALAC and AAC otherwise decode in codec-native order) and downmixed by speaker position with `AVAudioConverter`. Files that declare no usable layout get the WAV/FLAC default order, `WAVE_3_0` through `WAVE_7_1`; more than eight channels is refused. For PCM, FLAC and ALAC, whose declared length is exact, a decode that ends more than 1% and more than one second short is refused as damaged; MP3 and AAC lengths can be estimates and are not checked. Core Audio errors are reported in plain language with the four-character code. -HTDemucs accepts 441,000 frames. Isolate runs sequential predictions with a 220,500-frame hop, applies a Hann window, divides overlap sums by accumulated weights, and writes completed hops to four Float32 WAV files. Returned tensor strides and Float16/Float32 types are respected. Non-finite source/model samples fail the import. The decoded original is preserved for comparison; no automatic filtering or limiting is applied to cached source audio. +Normalization uses a mono reference mean and standard deviation, with stereo energy as a fallback when opposite-phase channels cancel the mono reference. Denormalization distributes the removed mean equally across the four stems so their sum restores it once. Interior windows read directly into the reused buffer. Reflection repeats correctly at both ends even for sources shorter than one model window. -Model order is vocals, drums, bass, other. See [MODEL.md](MODEL.md) before replacing the model. +HTDemucs accepts 441,000 frames. Isolate runs sequential predictions with a 220,500-frame hop, applies a Hann window, divides overlap sums by accumulated weights, and writes completed hops to four Float32 WAV files. Returned tensor strides and Float16/Float32 types are respected, and the output buffer is read once per chunk. Non-finite source or model samples fail the import. The decoded original is preserved for comparison; no automatic filtering or limiting is applied to cached audio. On test songs, the four stems summed back to the decoded original at 28–33 dB signal-to-residual, and cached stems peaked at 1.3–1.5 times full scale, which is why stem export applies a shared gain (see Exports). + +Model order is vocals, drums, bass, other. See [MODEL.md](MODEL.md) before replacing the model and [ARCHITECTURE.md](ARCHITECTURE.md) for the import transaction around this loop. ## Playback graph @@ -17,25 +19,38 @@ Bass → EQ → gain/pan mixer ┤ ├→ time/pitch → master EQ Other → EQ → gain/pan mixer ┘ Original ┘ ``` -All five players schedule against the same host time. The original and stem sum enter the shared effects path, keeping comparison playback under the same tempo/pitch controls. Original comparison mutes the stem sum. Each channel's audible gain is determined by mute/solo state; solo selection takes priority when any solo is active. +The original and the stem sum meet before the shared effects path, so Compare Original plays under the same speed, pitch and master EQ. Compare Original mutes the stem sum. Each channel's audible gain comes from mute/solo state; solo wins when any solo is active. + +Every start from a stopped state (play, seek, loop wrap, autoplay, restart at the end and resume after an output-device change) starts all five players on one frame of their shared 44.1 kHz render timeline: `play(at:)` receives a sample time three output IO cycles past the newest render (scaled up at playback rates above 1×), and after the calls the newest render must still be at least two cycles short of that frame, otherwise the players are rescheduled from the same frame and started again. A host-time start made each `play(at:)` block for about one IO cycle and, measured on a real Mac, still started roughly one seek in five out of sync; the sample-time calls return immediately, and a polarity null test stayed aligned on every seek on macOS 15, 26 and 27. At 512 frames and 48 kHz a loop wrap now leaves a 32–43 ms gap (about 100 ms with the earlier start). After `engine.start()` the start waits for the first new render, so it never uses a timestamp from before a pause. If no render timestamp arrives, it falls back to a host-time lead sized from the IO buffer and the measured duration of the calls. A watchdog confirms the players moved once the start has passed and otherwise restarts from the same frame on the host-time path for the rest of the session. Seeks that jump also reset the time/pitch unit, which refills 4,096 frames (about 93 ms) with silence so no audio from the old position is heard. + +Pause calls `engine.pause()`, so every player freezes on the same render cycle, and resume is only `engine.start()`: instant, with no rescheduling. The engine also pauses, releasing the output device, when a track is unloaded, when a seek lands exactly on the end, and 0.5 s after a track finishes so the limiter and time/pitch tails play out. Do not replace this with `engine.pause(); player.play(); engine.start()`: on macOS 27, `play()` on a stopped engine is silently ignored. -Seeking clamps to the source range, invalidates old completion callbacks, and reschedules all players. Seeking to exactly the end does not schedule a zero-frame segment. Playback stops at completion unless looping is enabled. Playback progress uses the player clock; the UI timer does not generate audio timing. +Seeking clamps to the source range, invalidates old completion callbacks, stops the players, resets the time/pitch node so no audio from the old position leaks through, and reschedules. Seeking to exactly the end does not schedule a zero-frame segment; with looping on it wraps to the loop start, and with looping off it stops. Playback stops at completion unless looping is enabled. Playback progress uses the player clock; the UI timer does not generate audio timing. Reselecting the loaded track keeps its mix, loop, speed, pitch and position. -A–B looping reschedules at the loop start when the player clock reaches the end marker. It is a practice feature, with scheduling latency at the boundary; no seamless/sample-accurate looping guarantee is made. +A–B looping reschedules at the loop start when the player clock reaches the end marker, keeping the time/pitch tail so the end of the region stays audible. The shortest region is 0.5 s (at most half the track). Setting A at or after B resets B to the end, and setting B at or before A resets A to the start, so a new region begins instead of clamping back into the old one; ⌥L clears both. Looping is a practice feature with scheduling latency at the boundary; no seamless or sample-accurate looping is claimed. + +Engine teardown cancels pending work, removes its configuration observer, and releases taps and the graph on the main thread. The playback clock owns its timer on the main actor. This avoids the isolated-deinitializer back-deployment runtime that crashed synchronous tests on macOS 15. ## Controls and metering - Faders: −60…+6 dB logarithmic scale, exact unity tick, zero amplitude at the bottom. -- EQ: 100 Hz shelf / 1 kHz parametric / 10 kHz shelf, ±12 dB. Flat EQ and unchanged time/pitch bypass their DSP nodes. -- Pitch: −12…+12 semitones. UI speed presets: 0.5×…1.5×. +- EQ: 100 Hz low shelf / 1 kHz parametric / 10 kHz high shelf, ±12 dB, on each stem and the master bus. Flat EQ and unchanged time/pitch bypass their DSP nodes. ⌘E bypasses every stem and master EQ at once. +- Pitch: −12…+12 semitones. UI speed presets: 0.5, 0.75, 0.85, 1, 1.15, 1.25 and 1.5×. - Master output uses Apple's peak limiter. It is not a loudness normalizer or a true-peak mastering guarantee. -- Per-tap processors reuse FFT working buffers and throttle UI readings. Spectrum and waveform readings include both stereo channels, including right-only and opposite-phase signals. The display is a live level visualization, not a stored full-track waveform. +- Meters tap the main mixer (32 bands) and each stem mixer (7 bands). macOS delivers tap buffers of roughly 100 ms; each processor analyses every 1,024-frame FFT hop in the buffer, newest first, taking the per-bin maximum across hops and both channels, so recent audio and short transients register. Processors reuse their FFT working buffers, and readings cross to the main actor as value snapshots. Stem readings are dropped while Compare Original is on. Because pause stops the engine, meters do no work while paused. The display is a live level visualization, not a stored full-track waveform. ## Exports | Export | Container | Included controls | | --- | --- | --- | -| Individual stems | ZIP containing four 24-bit WAV or FLAC files | Optional per-stem EQ; unity gain, centered pan, original timing | -| Current mix | Stereo 44.1 kHz 24-bit WAV | Mute/solo, gain, pan, channel/master EQ, tempo, pitch, limiter; original comparison when selected | +| Individual stems | ZIP (entries stored, non-ASCII names flagged as UTF-8) of four 24-bit WAV or FLAC files, stereo 44.1 kHz | Channel EQ unless that channel or all EQ is bypassed; unity gain unless one shared gain is needed to keep the loudest stem at −0.1 dBFS; centered pan; original timing | +| Current mix | Stereo 44.1 kHz 24-bit WAV | Mute/solo, gain, pan, channel and master EQ, speed, pitch, limiter. With Compare Original on: the original with master EQ, speed and pitch, named `_Original.wav` | + +Exports snapshot the sources, title, format, EQ, speed and pitch before the save panel opens, and refuse with a message if the loaded track changed while it was open. A fresh `AVAudioEngine` renders offline on a worker task in blocks of up to 4,096 frames, checks for cancellation on every block, and reports progress at most once per whole percent. Mix exports cover the full track; loop markers do not trim them. + +- **Stem gain.** Each stem is rendered with its EQ to a temporary Float32 WAV and its peak measured. All four are then encoded with one gain, `min(1, 0.98855 / peak)`, so their balance and sum are kept; stems already below −0.1 dBFS are bit-identical to an ungained export. Each temporary file is deleted after it is encoded, so peak temporary use in the system temporary folder is about four Float32 stems plus the encoded files and the ZIP. +- **Mix timing.** With the limiter on, the render runs for the limiter's latency (0.002 s, 88 frames) longer and discards the look-ahead, so a 1× mix is sample-aligned with the source. When speed or pitch is changed, a fixed 4,096-frame (about 93 ms) tail is rendered because the time/pitch unit spreads audio past the stretched length while reporting no latency; such files are `ceil(length / rate) + 4096` frames long. +- **Archive and publication.** `/usr/bin/zip` stores the entries uncompressed and is terminated if the export is cancelled. The finished file is staged in the destination volume's item-replacement directory, then moved or swapped into place, so a cancelled, failed or interrupted export leaves the destination untouched and nothing partial in its folder. On a volume that cannot provide an item-replacement directory, the staging copy is a hidden `.isolate-` file beside the destination, which a forced quit can leave behind. +- **File names.** Names come from the library title (keeping its capitalization), with `/ : \ ? * " < > |` and control characters replaced by `_`, leading and trailing dots and spaces trimmed, and the length bounded to 200 UTF-8 bytes on character boundaries. -Mix export covers the full track. A fresh AVAudioEngine renders in blocks of up to 4,096 frames with bounded retry handling. Expected duration follows playback rate. The regression suite checks encoding headers, length, audible signal, final transients, mute/pan behavior, and failed-export preservation. No MP3 encoder is implemented. +The regression suite checks encoding headers, lengths, audible signal, final transients with time/pitch, mute and pan, shared stem gain, sample alignment, cancellation, UTF-8 ZIP flags and failed-export preservation. No MP3 encoder is implemented. diff --git a/Assets/AppIcon-macOS27.png b/Assets/AppIcon-macOS27.png new file mode 100644 index 0000000..71ba585 Binary files /dev/null and b/Assets/AppIcon-macOS27.png differ diff --git a/Assets/AppIcon.icns b/Assets/AppIcon.icns index 389aa54..1af4798 100644 Binary files a/Assets/AppIcon.icns and b/Assets/AppIcon.icns differ diff --git a/Assets/dmg_background.png b/Assets/dmg_background.png index a81f46c..1370a68 100644 Binary files a/Assets/dmg_background.png and b/Assets/dmg_background.png differ diff --git a/Assets/screenshot-dark.png b/Assets/screenshot-dark.png new file mode 100644 index 0000000..a9550fc Binary files /dev/null and b/Assets/screenshot-dark.png differ diff --git a/Assets/screenshot-light.png b/Assets/screenshot-light.png new file mode 100644 index 0000000..c3a13e2 Binary files /dev/null and b/Assets/screenshot-light.png differ diff --git a/Assets/screenshot-separating.png b/Assets/screenshot-separating.png new file mode 100644 index 0000000..7eb8c67 Binary files /dev/null and b/Assets/screenshot-separating.png differ diff --git a/Assets/social_preview.png b/Assets/social_preview.png index af751e1..a9550fc 100644 Binary files a/Assets/social_preview.png and b/Assets/social_preview.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e2f6d93 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,97 @@ +# Changelog + +User-visible changes to Isolate, newest first. Release downloads are on [GitHub Releases](https://github.com/neokumar1/Isolate/releases). + +## [1.3.0] + +This is the first release since v1.2.7 and the first public build that includes the separation model; the v1.2.7 download did not contain it, so it could not separate songs. Your existing library carries over (see Library). + +New since v1.2.7: three-band EQ on every stem and on the master bus, with presets and bypass; **Export Mix** (⇧⌘M) for the whole track as a 24-bit WAV; light and match-system themes; a peak limiter on the master output; folder import; and ⌥L to clear loop markers. The rest of this release is a broad hardening pass, listed below. + +### Playback + +- All five players (four stems and the original) now start on the same audio frame after play, seek, loop wrap, song changes and output-device changes. Previously stems could start 9–42 ms apart, and about one seek in five came out of sync. Starting also no longer stalls the window for about 50 ms, and the silence at each A–B loop wrap is down to roughly 30–40 ms. +- Selecting a song that has finished plays it again from the start (or from the loop start), keeping its mix. +- Pause and resume keep the stems aligned and resume instantly. Pausing, unloading a song and the end of a song (after 0.5 s) release the audio device. +- Seeking no longer plays a moment of audio from the old position through the speed and pitch processor. +- The shortest A–B loop is now 0.5 seconds rather than 2% of the song, so short phrases in long songs can be looped. Setting A after B, or B before A, starts a new loop instead of snapping back into the old one. +- Seeking to the end while looping jumps back to A. Releasing a scrub at the very end stops playback instead of restarting from 0:00. +- The loop controls are disabled until a song is loaded. +- Selecting the song that is already playing, or pressing Next or Previous with one song in the library, no longer resets its mix, loop, speed, pitch and position. +- A library entry whose files are missing no longer stops a different song that is playing. +- Importing a song now pauses the one that is playing. + +### Separation & import + +- Isolate now checks the separation model on a built-in test signal before using it. Core ML in macOS 14 and 15 computes the model incorrectly on some compute paths; Isolate picks one that passes, or asks you to update to macOS 26 instead of producing broken stems. +- Invalid model output is refused without crashing. If one compute path fails to load or predict, Isolate tries the remaining paths before reporting an error. +- Files with unreadable audio headers report an error before loading the separation model. +- Recordings with a DC offset now keep the original mix's offset when the four stems are summed. Previously normalization restored that offset four times. Reimport a track separated by an older build to regenerate its stems with this fix. + +- Import whole folders by dragging them onto the window or choosing them with ⌘O. Files are added in Finder order, a file is never imported twice in one batch, and AIFC files are accepted. +- Batch imports show "N OF M" with the file name, can be cancelled as a whole, and end with one summary of the files that failed and why (the first three are named, the rest are counted). +- iCloud Drive files that are not on your Mac yet are downloaded first. If you are offline, Isolate says so instead of waiting. +- Surround files (up to 7.1) are downmixed to stereo by speaker position. Before, only their first two channels were used. +- Truncated or damaged WAV, FLAC and ALAC files are refused with how much of them could be read, instead of producing shortened stems. +- Isolate checks free disk space before separating and tells you how much it needs. +- Errors are in plain language: damaged or mislabelled files, an unusable model (with its location and the Core ML reason), and missing disk space. +- Cancelling while the model is loading now stops at once. +- New songs keep their Finder name: "AC/DC" no longer becomes "AC:DC", and a title such as "Song v1.2" keeps its version number. +- Reimporting a song removes the stems it replaced, and leftover temporary folders from an interrupted separation are cleaned up at launch. + +### Library + +- The library now has its own file at `~/Library/Application Support/Isolate/Library.store`. Earlier versions kept it in the shared `default.store`, which other apps can open and rewrite, emptying the library. On first launch, 1.3.0 copies your songs from a temporary copy of the old file; the old file itself is never changed or deleted. +- If the library file is damaged, it is moved into `Library Backups` with a timestamp, you are told where it went until you dismiss the notice, and a new library starts. It is never deleted. Other open failures leave the file untouched and retry at the next launch. +- Search matches the title, the file name and the folders shown in the sidebar (such as artist and album folders), but no longer the parts of the path every song shares, so common words don't match every song. +- Folder headers show enough of the path to tell folders with the same name apart. +- Next and Previous, from media keys or the menu bar, follow the sidebar's folder order. +- Songs cannot be deleted while a separation is running. +- Renamed titles are limited to 200 characters, and long titles no longer push the buttons out of the delete dialog. +- Now Playing and the menu bar keep titles that begin with numbers, such as "7-Eleven" and "99 Problems". + +### Export + +- 24-bit stem exports no longer clip. If any stem would go over full scale, all four are lowered by the same amount to −0.1 dBFS, which keeps their balance and their sum. Stems that don't need it are exported unchanged. +- Mix exports line up sample for sample with the source, and keep their full ending when speed or pitch is changed. +- Exports show their progress and can be cancelled from the EXPORT button, its menu or File › Cancel Export. A cancelled export leaves the destination untouched, and quitting mid-export no longer leaves partial files in the export folder. +- File names keep the library title's capitalization. Names that begin with dots are no longer hidden, characters that Windows cannot extract are replaced, and non-ASCII names in stem ZIPs extract correctly on Windows. +- The save panel says whether channel EQ is included. With Compare Original on, a mix export is named `_Original.wav` and says it contains the original recording. +- If the song changes while the save panel is open, nothing is exported and Isolate says why. +- Stem ZIPs are stored uncompressed, so the archiving step is quick. + +### Interface & accessibility + +- A layered Nothing-inspired app icon uses macOS 27's system lighting and appearance treatments, with an automatically generated icon for older macOS versions. +- The disk image now labels the drag-to-Applications action and shows the Mac, macOS and bundled-model requirements beside the app. +- Red is reserved for active and interrupting states such as solo, mute, loop, play, clipping, export progress and errors. Resting controls are neutral, and Compare Original is amber. +- Text colors meet at least 4.5:1 contrast in both themes, and Increase Contrast is supported. +- The header, studio display and transport no longer truncate in smaller windows, and the seek bar stays usable down to the minimum window size. ⌘1 to ⌘5 hide the library when the display would not fit beside it. +- VoiceOver announces errors, and the mixer, EQ, macros, tabs, album art and BYPASS have labels, values and actions. The menu bar item reports whether Isolate is playing. Reduce Motion is respected. +- Escape closes About, Settings and the shortcut card without cancelling an import underneath. +- Settings › Shortcuts lists every shortcut and scrolls. +- Double-click resets work on pan controls, EQ knobs and EQ nodes. The pan readout rounds correctly, and keyboard steps land exactly on center. +- With all EQ bypassed (⌘E), every channel shows ALL EQ OFF; click it to turn EQ back on. +- Open Isolate in the menu bar reopens a closed window, and the window no longer opens as tabs. +- The menu bar icon follows the menu bar's appearance, so it no longer disappears when the app theme differs. +- Switches, tabs, steppers and the error toast's close button respond across their full area. + +### Reliability + +- Separating and exporting keep the Mac from idle-sleeping until they finish. +- Quitting during a separation or an export asks first. Quitting during a separation cleans up its temporary files before Isolate closes. +- Opening Isolate from the downloaded disk image offers to move it to Applications, including when macOS runs it from a temporary location. Replacing an installed copy asks first and moves the old copy to the Trash. +- Decoding artwork and reading a song's format no longer freeze the window on large images or sleeping network drives. Oversized artwork is skipped. +- The meters show the newest audio and short transients, and the stem meters go blank during Compare Original. +- Release builds include the separation model, are signed with the hardened runtime, and report 1.3.0 in About. + +### Performance + +- Separation reads each chunk of model output in one pass. This optimization preserves the arithmetic; the DC-offset correction described above changes denormalization separately. +- Mix exports use the peak limiter's measured delay, so they stay sample-aligned with the source on every macOS version. +- Isolate releases the separation model after 60 seconds without a separation, and reloads it when needed. +- The meters and the player do less drawing work for each audio update, and Now Playing is no longer republished every second. + +## Earlier versions + +Notes for v1.2.7 and earlier are on the [Releases page](https://github.com/neokumar1/Isolate/releases). diff --git a/Casks/isolate.rb b/Casks/isolate.rb index 6317678..1d4da31 100644 --- a/Casks/isolate.rb +++ b/Casks/isolate.rb @@ -4,17 +4,36 @@ url "https://github.com/neokumar1/Isolate/releases/download/v#{version}/Isolate.dmg" name "Isolate" - desc "Four-stem audio separation and mixing with Core ML" + desc "Stem player that splits songs into vocals, drums, bass and other" homepage "https://github.com/neokumar1/Isolate" - depends_on macos: ">= :sonoma" + livecheck do + url :url + strategy :github_latest + end + depends_on arch: :arm64 + depends_on macos: ">= :sonoma" app "Isolate.app" + # The library lives in Application Support/Isolate. Never add the shared + # ~/Library/Application Support/default.store, which other apps also use. zap trash: [ + "~/Library/Application Scripts/com.isolate.Isolate", "~/Library/Application Support/Isolate", - "~/Library/Preferences/com.isolate.Isolate.plist", "~/Library/Caches/com.isolate.Isolate", + "~/Library/Preferences/com.isolate.Isolate.plist", + "~/Library/Saved Application State/com.isolate.Isolate.savedState", ] + + caveats <<~EOS + Isolate is ad-hoc signed and not notarized by Apple, so macOS asks you to + approve its first launch: + macOS 15 or later: open Isolate and click Done. In System Settings > + Privacy & Security, click Open Anyway next to the Isolate message, + authenticate, then click Open. + macOS 14: Control-click Isolate in Applications, choose Open, then Open. + Details: https://github.com/neokumar1/Isolate#first-launch + EOS end diff --git a/DESIGN.md b/DESIGN.md index 1f8bf0d..0981a80 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,21 +1,62 @@ # Isolate - Design System -## Core Aesthetic: The "Nothing" Brand -The app embraces a literal translation of the "Nothing" brand aesthetic, moving away from standard Apple HIG to create a highly stylized, hardware-inspired, utilitarian interface. +## Core aesthetic -### 1. Typography -- **Primary Font**: Dot-matrix style (e.g., NDot or a similar custom font) for all headings, numbers, and key interactive elements. -- **Secondary Font**: A clean, technical sans-serif (like Space Grotesk, Inter, or native SF Pro in a rigid weight) for readable body text and secondary labels. +Isolate looks like a piece of studio hardware: a rigid grid, hairline dividers, dot-matrix type and displays, and custom faders and knobs instead of native sliders. The style is inspired by Nothing's industrial design; Isolate is not affiliated with Nothing. The hardware metaphor overrides standard HIG styling, but never accessibility: contrast, keyboard access, VoiceOver and the system's accessibility settings come first. -### 2. Color Palette -- **Backgrounds**: Deep blacks and dark grays, or stark whites (depending on dark/light mode), mimicking physical hardware casings. -- **Accents**: Stark, highly saturated Red (e.g., `#FF0000`) for active states, recording, and critical actions. -- **Materials**: Use of dotted grids, subtle noise textures, and glassmorphism (translucency) to simulate looking "inside" the hardware. +## Typography -### 3. UI Components (Stem Player UX) -- **Sliders & Knobs**: Custom-built controls that look physical and tactile, eschewing standard native sliders. The 4 stem controls (Vocals, Bass, Drums, Other) should dominate the playback view. -- **Buttons**: Pill-shaped or perfectly circular buttons with solid borders and dot-matrix iconography. -- **Layout**: Rigid, grid-based, and symmetrical, heavily utilizing borders and dividers to separate functional zones (Library vs. Mixer vs. Effects). +- **DotGothic16** (Fontworks, SIL Open Font License 1.1) is bundled and registered through `ATSApplicationFontsPath`. It is used for nearly all text: titles, readouts, labels and buttons, usually in capitals. +- SF Symbols and the system font appear only for a few icons and small auxiliary labels. +- Titles that overflow scroll as a marquee. With Reduce Motion on, or when the overflow is small, they truncate with an ellipsis instead and show the full title as a tooltip. -### 4. Interactive Feedback -- Incorporate subtle haptic feedback (where possible on Mac trackpads) and sharp, snappy animations (no slow, floaty Apple springs—use rigid, fast easing). +## Color tokens + +Every color comes from a semantic token on `ThemeManager`; views do not use literal colors. The themes are **MATCH SYSTEM**, **NOTHING DARK** and **NOTHING LIGHT**. + +| Token | Use | +| --- | --- | +| `background`, `surface`, `surfaceSecondary`, `surfaceHover` | Window, cards and modals, control and channel-strip fills, hover | +| `textPrimary`, `textSecondary`, `textMuted` | All readable text, from most to least prominent | +| `textDisabled` | Disabled controls and purely decorative marks; never information | +| `accentRed` | The single accent, reserved for active and interrupting states (see below) | +| `onAccent` | Text and glyphs drawn on an `accentRed` fill | +| `warning` | Amber for comparison and caution: Compare Original, the [A]/[B] loop markers, ALL EQ OFF | +| `hairline`, `border`, `cardBorder` | Dividers and outlines | +| `modalBackdrop`, `modalBackground` | Modal scrim and card | +| `knobFace`, `knobArcTrack`, `faderTrack`, `faderThumb`, `faderThumbStroke`, `faderThumbKnurling`, `spectrumBarDefault` | Hardware controls and displays | + +`textPrimary`, `textSecondary`, `textMuted`, `accentRed` and `warning` each measure at least 4.5:1 (the lowest is 4.55:1) against the background, surface, modal and channel-strip fills of their mode, and `onAccent` measures at least 5.9:1 on `accentRed`. Keep new text on these tokens; `textDisabled` and translucent strokes are not for text. + +**Increase Contrast.** `ThemeManager` follows System Settings › Accessibility › Display › Increase contrast and updates live. `textSecondary` becomes `textPrimary`, `textMuted` becomes `textSecondary`, hairlines, borders and knob tracks become much more opaque, and fader tick marks switch to the border token. + +## Red policy + +Red means something is active or needs attention. It is used for engaged solo and mute, LOOP ON and the loop region, the play button, the lit clip LED and signal peaks, values changed from their default (such as pitch or speed), export progress, [RESET], destructive delete, the error toast, the drop target, and separation progress and its cancel button. + +Everything at rest is neutral: titles, the clock, resting meters and fader fills, idle buttons, headers and icons. Selected tabs, chips and presets are shown inverted in neutral colors (a `textPrimary` fill with background-colored text), not in red. Compare Original is amber (`warning`), because it marks a comparison rather than an alert. + +## Components + +- **Channel strips:** four identical strips in model order (vocals, drums, bass, other), each with a meter, a bipolar pan bar, three EQ knobs, a fader with a dB and percentage readout, and M and S buttons. +- **Faders:** −60 to +6 dB on a logarithmic scale with an exact unity tick; the bottom is silence. +- **Knobs and EQ nodes:** ±12 dB with a detent at 0 dB. The trackpad gives an alignment tap only when the gain enters the ±0.25 dB detent. +- **Pan:** moves in 5% steps from the keyboard and snaps exactly to center. +- **Buttons and chips:** small 3–4 pt corner radii, 1 pt outlines, and fills that follow the outline shape. +- **Dot matrix:** the album art renders as a dot matrix (click for full resolution), progress bars are rows of blocks, and the spectrum is 32 block columns. +- **Studio display:** five modes (32-band FFT, stem macros, stem balance, telemetry, equalizer). As space shrinks it drops metadata first, then switches to short tab names, then drops its title. +- **Layout:** a 270 pt library sidebar, the header and studio display, the mixer, and a transport pinned to the bottom that switches between regular, compact and tight sizes so the seek bar stays usable. The minimum window is 960 × 580 pt. +- **Corner brackets** are decorative and never take clicks. + +## Motion and feedback + +- Animations are short ease-outs of about 0.1 to 0.18 s; the library sidebar snaps without animating. There are no slow springs. +- Reduce Motion stops the title marquee, fades the error toast instead of moving it, and switches the album art without animation. +- Clicks, detents and resets give a trackpad haptic on Macs with a Force Touch trackpad. **Tactile Haptics** in Settings turns them off. + +## Accessibility + +- Custom controls have VoiceOver labels and values. Faders, knobs, pan bars, the seek bar and EQ nodes are adjustable. Several resets also exist as a named action or a context-menu item: Reset on EQ nodes, Center Pan on pan bars, Reset EQ Gain on knobs, and Clear Loop Markers on LOOP. +- Tabs, chips and presets report their selected state, and errors are announced as well as shown. +- Hit areas are expanded without changing the layout, so small controls respond across their full visible area. +- Every main action has a keyboard shortcut. Settings › Shortcuts lists them all; the shortcut card (? or /) shows the most-used ones. diff --git a/INSTALL.md b/INSTALL.md index 2f90a64..14b77cf 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,25 +1,46 @@ # Install and build Isolate -## Release app +Isolate needs a Mac with Apple silicon (M1 or later) and macOS 14 Sonoma or later. Check both in **Apple menu › About This Mac**. macOS 26 or later is recommended: on macOS 14 and 15, Core ML computes the separation model incorrectly on some compute paths, so Isolate checks the model first and may ask you to update (see [TROUBLESHOOTING.md](TROUBLESHOOTING.md)). The v1.3.0+ disk image includes the model; you do not need Xcode, Python, Homebrew, an account or a separate model download. -The public v1.2.7 DMG inspected on September 21, 2026 does **not** include the separation model or app version metadata. The v1.2.8 work in this checkout fixes packaging, but has not been published. Use the source-build instructions and [MODEL.md](MODEL.md) until a complete release is available. The terminal installer intentionally rejects releases missing checksums or the model. +## Disk image (recommended) -1. Use an Apple Silicon Mac running macOS 14 or later. -2. Download `Isolate.dmg` from the project's [Releases](https://github.com/neokumar1/Isolate/releases). -3. Open the disk image and drag Isolate into Applications. Quit a running copy before replacing it. -4. Launch the app from Applications. Release packages must contain `Contents/Resources/HTDemucs.mlmodelc`. +1. On the [releases page](https://github.com/neokumar1/Isolate/releases), choose **v1.3.0 or newer** and download `Isolate.dmg` (about 160 MB). Earlier public releases do not bundle the separation model; if v1.3.0 is not listed yet, the complete installer has not been published. +2. Open the disk image and drag **Isolate.app** onto its **Applications** shortcut. Quit a running copy before replacing it. +3. Eject the disk image, open Isolate from Applications and follow the README's [First launch](README.md#first-launch) steps if macOS blocks it. +4. Drag an MP3, WAV, FLAC, M4A, AAC, AIFF or CAF file or a folder onto the app window, or press **⌘O** to browse. Files with DRM, including Apple Music subscription downloads, are not supported. -If macOS blocks an unsigned or unnotarized build, verify where it came from and review **System Settings → Privacy & Security → Open Anyway**. Do not disable Gatekeeper or recursively clear quarantine. Signing and notarization status belongs in each release's notes. +The app takes about 313 MB once installed, because the separation model is inside it (`Isolate.app/Contents/Resources/HTDemucs.mlmodelc`). Allow about 106 MB more per minute of audio you separate. Releases are ad-hoc signed and are not notarized by Apple. Approve Isolate in **System Settings › Privacy & Security** rather than disabling Gatekeeper or clearing quarantine attributes. -The Homebrew cask in `Casks/isolate.rb` describes the existing 1.2.5 release. Its version and checksum must be updated together only after a new artifact has been published and verified. +To check a download, compare `shasum -a 256 Isolate.dmg` with the value in the release's `SHA256SUMS.txt`. A checksum published in the same release confirms the file arrived intact; it does not prove who published it. -## Optional terminal installer +If you open Isolate straight from the disk image, it offers to move itself to Applications. If Applications already has a copy, it asks before replacing it and moves the old copy to the Trash. -Download and inspect `install.sh` before running it. It pins the latest release version for the duration of installation, checks the DMG against its release checksum, verifies the app signature, and requires the bundled model. It stages the app before replacing an existing installation. A checksum fetched from the same release checks integrity, not independent publisher identity. +## Homebrew + +The cask is still pinned to an older public release. **Wait until its version is v1.3.0 or newer** before using it for separation. The cask lives in this repository rather than in Homebrew's main tap, so tap it by URL: + +```sh +brew tap neokumar1/isolate https://github.com/neokumar1/Isolate +brew install --cask neokumar1/isolate/isolate +``` + +Homebrew quarantines the app like a browser download, so the first launch needs the same approval. `brew uninstall --zap --cask neokumar1/isolate/isolate` also removes the library, the separated stems and the preferences. + +## Terminal installer + +`install.sh` installs the public Latest release into `/Applications`. **Use it only after Latest is v1.3.0 or newer**; the currently published v1.2.7 app does not bundle the model. Download and read the installer before running it: + +```sh +curl -fsSLO https://raw.githubusercontent.com/neokumar1/Isolate/main/install.sh +less install.sh +bash install.sh +``` + +It resolves the latest version once, downloads `SHA256SUMS.txt` before the disk image and stops if the checksum is missing or does not match. It also checks the app's code signature and that the model is bundled, and it stages the new copy before replacing an existing one. Run it from an administrator account; it will not install into a folder it cannot write. Because curl does not mark downloads as quarantined, macOS usually does not ask you to approve the first launch of an app installed this way. ## Build from source -Install Xcode 26.2 or newer, select it with Xcode's Locations preferences, and install XcodeGen. The deployment target remains macOS 14. +Install Xcode 26.2 or later and [XcodeGen](https://github.com/yonaskolb/XcodeGen). The deployment target stays macOS 14. ```sh brew install xcodegen @@ -29,13 +50,24 @@ xcodebuild build -project Isolate.xcodeproj -scheme Isolate \ open build/DerivedData/Build/Products/Debug/Isolate.app ``` -No Python runtime or third-party Swift dependencies are needed by the app. The Core ML model is a separate build input; follow [MODEL.md](MODEL.md). Without it, the UI builds and existing valid cached stems can play, but new separation cannot run. +The app needs no Python runtime and no third-party Swift packages. The Core ML model is not in the repository; follow [MODEL.md](MODEL.md) to install the validated model. Without it the app builds and plays songs that were already separated, but it cannot separate new ones. + +## Where Isolate keeps its data + +| Data | Location | +| --- | --- | +| Library | `~/Library/Application Support/Isolate/Library.store` | +| Library backups | `~/Library/Application Support/Isolate/Library Backups//` | +| Separated stems | `~/Library/Application Support/Isolate/Stems/` | +| Model for source builds | `~/Library/Application Support/Isolate/HTDemucs.mlmodelc` (the bundled model is used first) | +| Preferences | the `com.isolate.Isolate` user defaults domain | + +Your original audio files stay where they are; Isolate only reads them. Each separated song uses about 106 MB per minute of audio in the stems folder: 32-bit float copies of the four stems and of the decoded original. + +Versions before 1.3 kept the library in the shared `~/Library/Application Support/default.store`. On its first launch, 1.3 copies your songs out of a temporary copy of that file into `Library.store`. It never opens, changes or deletes `default.store` itself, because other apps can use the same file. If `Library.store` is ever damaged, Isolate moves it into `Library Backups` and starts a new library; if it cannot be opened for another reason, Isolate leaves it untouched and tries again at the next launch; see [TROUBLESHOOTING.md](TROUBLESHOOTING.md#the-library-is-empty-or-was-moved-to-library-backups). -## Local data +Deleting a song in Isolate removes only the stem folder it owns, and only when no other library entry uses it. Quit Isolate and back up `~/Library/Application Support/Isolate` before changing anything in it by hand. -- Library: SwiftData's application store under the app's Application Support location. -- Model: bundled resources first, then `~/Library/Application Support/Isolate/HTDemucs.mlmodelc`. -- Audio cache: `~/Library/Application Support/Isolate/Stems/`. -- Preferences: `com.isolate.Isolate` user defaults. +## Uninstall -Removing a library entry removes only its owned, unshared stem cache. Original source files are not deleted. Keep original files available if cached stems need rebuilding. Quit Isolate and back up the library/cache before manually changing app data. +Quit Isolate and move `/Applications/Isolate.app` to the Trash. To remove your library and separated stems too, delete `~/Library/Application Support/Isolate`. Your original audio files are not affected. diff --git a/Info.plist b/Info.plist index 2f61097..7cec0d8 100644 --- a/Info.plist +++ b/Info.plist @@ -10,6 +10,8 @@ $(EXECUTABLE_NAME) CFBundleIconFile AppIcon + CFBundleIconName + AppIcon CFBundleIdentifier com.isolate.Isolate CFBundleInfoDictionaryVersion @@ -19,12 +21,26 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.2.8 + 1.3.0 CFBundleVersion - 1.2.8 + 1.3.0 + LSApplicationCategoryType + public.app-category.music LSMinimumSystemVersion 14.0 + NSDesktopFolderUsageDescription + Isolate reads songs you imported from your Desktop folder to show their details and rebuild their stems. + NSDocumentsFolderUsageDescription + Isolate reads songs you imported from your Documents folder to show their details and rebuild their stems. + NSDownloadsFolderUsageDescription + Isolate reads songs you imported from your Downloads folder to show their details and rebuild their stems. NSHighResolutionCapable + NSHumanReadableCopyright + Copyright © 2026 Neo Kumar. MIT License. + NSNetworkVolumesUsageDescription + Isolate reads songs you imported from network drives to show their details and rebuild their stems. + NSRemovableVolumesUsageDescription + Isolate reads songs you imported from external drives to show their details and rebuild their stems. diff --git a/Isolate.xcodeproj/project.pbxproj b/Isolate.xcodeproj/project.pbxproj index 39e763c..6b221cf 100644 --- a/Isolate.xcodeproj/project.pbxproj +++ b/Isolate.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 01DBA77A67ADC621DEA46203 /* FinalSeparationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5609077229DD52FB45EAE0A /* FinalSeparationTests.swift */; }; + 078304508565C9F89E4D57F6 /* FinalLibraryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE2E169E5C0E362D36A132D9 /* FinalLibraryTests.swift */; }; + 0A8C997F9F6A2F2BDDA081B4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 290303E3A6563B30E391B1E0 /* AppIcon.icon */; }; + 1419844DB45A5A83122232D3 /* SeparationFixTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2C50DD1D3E61F3C453C02B /* SeparationFixTests.swift */; }; 161B67E13457763752062DE1 /* ProductionRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B807A91D83D445402D9CAF99 /* ProductionRegressionTests.swift */; }; 161EC9862A7208CA69D028EC /* IsolateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC1A0F01EE97D0951555E052 /* IsolateApp.swift */; }; 1879BCDE487BDB9B7C3761CC /* AudioMeterProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB33D51AF84015B27BDB60D /* AudioMeterProcessor.swift */; }; @@ -20,21 +24,35 @@ 64DAA5BF14FB75131FF30C78 /* TrackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E404CB7871E1A45DE1242859 /* TrackModel.swift */; }; 669642CCDE25B6636D369197 /* AppMoveHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8902F75890A1D02F662A2C4 /* AppMoveHelper.swift */; }; 6CBEC0DDE108EA06515F8B93 /* AudioEngineManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F162949F185EF2FA9835DE2C /* AudioEngineManager.swift */; }; + 6E2D4D823F3A717CBBB8088D /* ImportSeparationRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2EBC811F849DA5036CFC1B /* ImportSeparationRegressionTests.swift */; }; + 724C0E0BA859FFBA0CB4212E /* ShellFixTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09046DA9C244F85B332F5756 /* ShellFixTests.swift */; }; + 7482AE6E5AF793D0C1211EB7 /* PerfFixTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5A0B54BC270C469A336E6F /* PerfFixTests.swift */; }; 792E9F6C4E56359D456DDA0F /* MenuBarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF938C23122BA151FB3DFCE /* MenuBarManager.swift */; }; + 81071D25E7CBF4FDD195C50F /* WindowSizingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E53390C1141BAFC60BD554E9 /* WindowSizingTests.swift */; }; 8795659D14EB49E0487CF201 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59B0F1BF366285DF7ED96856 /* PlayerView.swift */; }; + 8BD92F3394B42C1A0AD566FC /* PlaybackClock.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF8F78D393965984025C15BF /* PlaybackClock.swift */; }; + 9567D8734665373069E224DF /* FinalUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8FA41F8C246C64D13774AA1 /* FinalUITests.swift */; }; 95C2E3DA7EFB59D971F2F6E1 /* DotGothic16-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 65D4E52007621CC6578509E0 /* DotGothic16-Regular.ttf */; }; + 96CEBDABD970A3C881DD01E9 /* EngineFixTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A4285D899C4A5EEEC9EC719 /* EngineFixTests.swift */; }; + 96E80690607437453B6037E1 /* RealMusicSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C70B0ECC18309C897B93F11C /* RealMusicSmokeTests.swift */; }; 9B785A6BE884B7DEB4B68D29 /* FaderScale.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F482B1CF7D3861CF9CB7BE9 /* FaderScale.swift */; }; A07EF38BF93598B2A4702404 /* ImportCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85EA9B80B1D07CE0BC012A6 /* ImportCoordinator.swift */; }; A424654DA3598C87FECBA99E /* IsolateUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51723840AA00CB3D59039A2 /* IsolateUITests.swift */; }; A58E8B618E774A7BB42E88EB /* StreamingAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80C4F1BEA2CBC91B8FB4637C /* StreamingAudio.swift */; }; A835E32888406F90B405C409 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E25FF2F9713A5963BB3BD4 /* ThemeManager.swift */; }; + AFEB288E772C93176F922D29 /* UIFixTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D721629222D7EC4EFE4580D2 /* UIFixTests.swift */; }; + B4B2686CB28F09D6CD78E8D8 /* SuiteHardeningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C57BDBBA1E4730D9A3DBEA /* SuiteHardeningTests.swift */; }; + BA4FE14242EEF97C53744DA3 /* ModelSelfTestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED9249DA0AFAB419C7252B18 /* ModelSelfTestTests.swift */; }; BD13B32036CBA957B6A8FB50 /* NowPlayingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A696B7978AB11ACA46D7592E /* NowPlayingManager.swift */; }; BF1A95B3725BFC8C84B3AA92 /* DemucsEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FFF484DB9B227FEA9BFA72 /* DemucsEngine.swift */; }; + CB9B464C3969BCC6ADD31B4D /* FinalEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868D3EA947238BC8C0808689 /* FinalEngineTests.swift */; }; CCA10CCFD58840F597E21121 /* IsolateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B54412E9E3F71B00BF34FB0 /* IsolateTests.swift */; }; + DB4F240607006163A014D28D /* ExporterFixTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1AE7FC2C60C05B9AB636C6F /* ExporterFixTests.swift */; }; EF40587C88F3A21DCBED178E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D39417E3CC1D1DF6CF0E4656 /* SettingsView.swift */; }; F0F7BC284EA97E34B007342C /* FFTAnalyzer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49A8F54BCDFECE3713F6E918 /* FFTAnalyzer.swift */; }; F26A92AD18BE6E579AC5EC40 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C71DE2440945DF04ED48AA /* Haptics.swift */; }; - F27D574EC6856B022932FB9D /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 7BEEC1EF4DD1E71AAEA9F300 /* AppIcon.icns */; }; + F8064489140C6F4984AC86AF /* HardeningModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 715F9102B1C7078BEEF841DA /* HardeningModelTests.swift */; }; + FB955598DE92CA5F0B6C9E17 /* HardeningSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52F3D53A092043D43C33C7C2 /* HardeningSupport.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -57,17 +75,26 @@ /* Begin PBXFileReference section */ 00E25FF2F9713A5963BB3BD4 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; 08F2813A8F193935CAA240FC /* StemCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemCache.swift; sourceTree = ""; }; + 09046DA9C244F85B332F5756 /* ShellFixTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellFixTests.swift; sourceTree = ""; }; 0CA8A3AD0C499BC470D746C2 /* AudioExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioExporter.swift; sourceTree = ""; }; + 1A4285D899C4A5EEEC9EC719 /* EngineFixTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngineFixTests.swift; sourceTree = ""; }; + 290303E3A6563B30E391B1E0 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; + 2C2C50DD1D3E61F3C453C02B /* SeparationFixTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparationFixTests.swift; sourceTree = ""; }; 3B32BB7E1AA2EC3946E2671E /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; + 3C2EBC811F849DA5036CFC1B /* ImportSeparationRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportSeparationRegressionTests.swift; sourceTree = ""; }; 49A8F54BCDFECE3713F6E918 /* FFTAnalyzer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FFTAnalyzer.swift; sourceTree = ""; }; 4CF938C23122BA151FB3DFCE /* MenuBarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarManager.swift; sourceTree = ""; }; 4F482B1CF7D3861CF9CB7BE9 /* FaderScale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaderScale.swift; sourceTree = ""; }; + 52F3D53A092043D43C33C7C2 /* HardeningSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardeningSupport.swift; sourceTree = ""; }; 53FFF484DB9B227FEA9BFA72 /* DemucsEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemucsEngine.swift; sourceTree = ""; }; 59B0F1BF366285DF7ED96856 /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; + 5E5A0B54BC270C469A336E6F /* PerfFixTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerfFixTests.swift; sourceTree = ""; }; 65D4E52007621CC6578509E0 /* DotGothic16-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "DotGothic16-Regular.ttf"; sourceTree = ""; }; - 7BEEC1EF4DD1E71AAEA9F300 /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = ""; }; + 715F9102B1C7078BEEF841DA /* HardeningModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardeningModelTests.swift; sourceTree = ""; }; 80C4F1BEA2CBC91B8FB4637C /* StreamingAudio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingAudio.swift; sourceTree = ""; }; 825F29C7C08E71499FA30D2B /* DotGothic16-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "DotGothic16-LICENSE.txt"; sourceTree = ""; }; + 868D3EA947238BC8C0808689 /* FinalEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinalEngineTests.swift; sourceTree = ""; }; + 87C57BDBBA1E4730D9A3DBEA /* SuiteHardeningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuiteHardeningTests.swift; sourceTree = ""; }; 8CEABFC996FE6B255FC50AD8 /* IsolateTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IsolateTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9735698E807C603F5A017AF1 /* Demucs-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Demucs-LICENSE.txt"; sourceTree = ""; }; 9B54412E9E3F71B00BF34FB0 /* IsolateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsolateTests.swift; sourceTree = ""; }; @@ -77,14 +104,23 @@ A85EA9B80B1D07CE0BC012A6 /* ImportCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportCoordinator.swift; sourceTree = ""; }; A8902F75890A1D02F662A2C4 /* AppMoveHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMoveHelper.swift; sourceTree = ""; }; AC1A0F01EE97D0951555E052 /* IsolateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsolateApp.swift; sourceTree = ""; }; + B1AE7FC2C60C05B9AB636C6F /* ExporterFixTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExporterFixTests.swift; sourceTree = ""; }; B807A91D83D445402D9CAF99 /* ProductionRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductionRegressionTests.swift; sourceTree = ""; }; BDB33D51AF84015B27BDB60D /* AudioMeterProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMeterProcessor.swift; sourceTree = ""; }; + C5609077229DD52FB45EAE0A /* FinalSeparationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinalSeparationTests.swift; sourceTree = ""; }; + C70B0ECC18309C897B93F11C /* RealMusicSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealMusicSmokeTests.swift; sourceTree = ""; }; + C8FA41F8C246C64D13774AA1 /* FinalUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinalUITests.swift; sourceTree = ""; }; + CF8F78D393965984025C15BF /* PlaybackClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackClock.swift; sourceTree = ""; }; D39417E3CC1D1DF6CF0E4656 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + D721629222D7EC4EFE4580D2 /* UIFixTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFixTests.swift; sourceTree = ""; }; D7C71DE2440945DF04ED48AA /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; D9F18A0F03B33726F0C3D41F /* Isolate.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Isolate.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DE2E169E5C0E362D36A132D9 /* FinalLibraryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinalLibraryTests.swift; sourceTree = ""; }; E404CB7871E1A45DE1242859 /* TrackModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackModel.swift; sourceTree = ""; }; E51723840AA00CB3D59039A2 /* IsolateUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsolateUITests.swift; sourceTree = ""; }; + E53390C1141BAFC60BD554E9 /* WindowSizingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowSizingTests.swift; sourceTree = ""; }; EB48C41F9AED4EED81D5AECF /* IsolateUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IsolateUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + ED9249DA0AFAB419C7252B18 /* ModelSelfTestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelSelfTestTests.swift; sourceTree = ""; }; F162949F185EF2FA9835DE2C /* AudioEngineManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngineManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -92,7 +128,7 @@ 3D73573D4100B5C10FE03C91 /* Resources */ = { isa = PBXGroup; children = ( - 7BEEC1EF4DD1E71AAEA9F300 /* AppIcon.icns */, + 290303E3A6563B30E391B1E0 /* AppIcon.icon */, 9735698E807C603F5A017AF1 /* Demucs-LICENSE.txt */, 825F29C7C08E71499FA30D2B /* DotGothic16-LICENSE.txt */, 65D4E52007621CC6578509E0 /* DotGothic16-Regular.ttf */, @@ -124,6 +160,7 @@ AC1A0F01EE97D0951555E052 /* IsolateApp.swift */, 4CF938C23122BA151FB3DFCE /* MenuBarManager.swift */, A696B7978AB11ACA46D7592E /* NowPlayingManager.swift */, + CF8F78D393965984025C15BF /* PlaybackClock.swift */, D39417E3CC1D1DF6CF0E4656 /* SettingsView.swift */, 08F2813A8F193935CAA240FC /* StemCache.swift */, 80C4F1BEA2CBC91B8FB4637C /* StreamingAudio.swift */, @@ -166,8 +203,25 @@ B1EA22E3D283A7BD3EAC64FC /* IsolateTests */ = { isa = PBXGroup; children = ( + 1A4285D899C4A5EEEC9EC719 /* EngineFixTests.swift */, + B1AE7FC2C60C05B9AB636C6F /* ExporterFixTests.swift */, + 868D3EA947238BC8C0808689 /* FinalEngineTests.swift */, + DE2E169E5C0E362D36A132D9 /* FinalLibraryTests.swift */, + C5609077229DD52FB45EAE0A /* FinalSeparationTests.swift */, + C8FA41F8C246C64D13774AA1 /* FinalUITests.swift */, + 715F9102B1C7078BEEF841DA /* HardeningModelTests.swift */, + 52F3D53A092043D43C33C7C2 /* HardeningSupport.swift */, + 3C2EBC811F849DA5036CFC1B /* ImportSeparationRegressionTests.swift */, 9B54412E9E3F71B00BF34FB0 /* IsolateTests.swift */, + ED9249DA0AFAB419C7252B18 /* ModelSelfTestTests.swift */, + 5E5A0B54BC270C469A336E6F /* PerfFixTests.swift */, B807A91D83D445402D9CAF99 /* ProductionRegressionTests.swift */, + C70B0ECC18309C897B93F11C /* RealMusicSmokeTests.swift */, + 2C2C50DD1D3E61F3C453C02B /* SeparationFixTests.swift */, + 09046DA9C244F85B332F5756 /* ShellFixTests.swift */, + 87C57BDBBA1E4730D9A3DBEA /* SuiteHardeningTests.swift */, + D721629222D7EC4EFE4580D2 /* UIFixTests.swift */, + E53390C1141BAFC60BD554E9 /* WindowSizingTests.swift */, ); name = IsolateTests; path = Tests/IsolateTests; @@ -289,7 +343,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - F27D574EC6856B022932FB9D /* AppIcon.icns in Resources */, + 0A8C997F9F6A2F2BDDA081B4 /* AppIcon.icon in Resources */, 1BE789984D5C997C570F807B /* Demucs-LICENSE.txt in Resources */, 228DC4E4977582F3460AEC60 /* DotGothic16-LICENSE.txt in Resources */, 95C2E3DA7EFB59D971F2F6E1 /* DotGothic16-Regular.ttf in Resources */, @@ -318,6 +372,7 @@ 49F7C5966BB4966F7B02CE38 /* LibraryView.swift in Sources */, 792E9F6C4E56359D456DDA0F /* MenuBarManager.swift in Sources */, BD13B32036CBA957B6A8FB50 /* NowPlayingManager.swift in Sources */, + 8BD92F3394B42C1A0AD566FC /* PlaybackClock.swift in Sources */, 8795659D14EB49E0487CF201 /* PlayerView.swift in Sources */, EF40587C88F3A21DCBED178E /* SettingsView.swift in Sources */, 1CA50B3B9305F340E86C09ED /* StemCache.swift in Sources */, @@ -331,8 +386,25 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 96CEBDABD970A3C881DD01E9 /* EngineFixTests.swift in Sources */, + DB4F240607006163A014D28D /* ExporterFixTests.swift in Sources */, + CB9B464C3969BCC6ADD31B4D /* FinalEngineTests.swift in Sources */, + 078304508565C9F89E4D57F6 /* FinalLibraryTests.swift in Sources */, + 01DBA77A67ADC621DEA46203 /* FinalSeparationTests.swift in Sources */, + 9567D8734665373069E224DF /* FinalUITests.swift in Sources */, + F8064489140C6F4984AC86AF /* HardeningModelTests.swift in Sources */, + FB955598DE92CA5F0B6C9E17 /* HardeningSupport.swift in Sources */, + 6E2D4D823F3A717CBBB8088D /* ImportSeparationRegressionTests.swift in Sources */, CCA10CCFD58840F597E21121 /* IsolateTests.swift in Sources */, + BA4FE14242EEF97C53744DA3 /* ModelSelfTestTests.swift in Sources */, + 7482AE6E5AF793D0C1211EB7 /* PerfFixTests.swift in Sources */, 161B67E13457763752062DE1 /* ProductionRegressionTests.swift in Sources */, + 96E80690607437453B6037E1 /* RealMusicSmokeTests.swift in Sources */, + 1419844DB45A5A83122232D3 /* SeparationFixTests.swift in Sources */, + 724C0E0BA859FFBA0CB4212E /* ShellFixTests.swift in Sources */, + B4B2686CB28F09D6CD78E8D8 /* SuiteHardeningTests.swift in Sources */, + AFEB288E772C93176F922D29 /* UIFixTests.swift in Sources */, + 81071D25E7CBF4FDD195C50F /* WindowSizingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -375,7 +447,6 @@ "$(inherited)", "@executable_path/../Frameworks", ); - OTHER_SWIFT_FLAGS = "-enable-experimental-feature IsolatedDeinit"; PRODUCT_BUNDLE_IDENTIFIER = com.isolate.Isolate; SDKROOT = macosx; SWIFT_VERSION = 5.0; @@ -504,7 +575,6 @@ "$(inherited)", "@executable_path/../Frameworks", ); - OTHER_SWIFT_FLAGS = "-enable-experimental-feature IsolatedDeinit"; PRODUCT_BUNDLE_IDENTIFIER = com.isolate.Isolate; SDKROOT = macosx; SWIFT_VERSION = 5.0; diff --git a/Isolate.xcodeproj/xcuserdata/neokumar.xcuserdatad/xcschemes/xcschememanagement.plist b/Isolate.xcodeproj/xcuserdata/neokumar.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index e8744c9..0000000 --- a/Isolate.xcodeproj/xcuserdata/neokumar.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - Isolate.xcscheme_^#shared#^_ - - orderHint - 0 - - - - diff --git a/LAUNCH.md b/LAUNCH.md new file mode 100644 index 0000000..a0e9fed --- /dev/null +++ b/LAUNCH.md @@ -0,0 +1,50 @@ +# Isolate 1.3.0 launch kit + +Prepared September 26, 2026. This is launch copy for review, not a publication record. + +## Before announcing + +The public Latest release currently points to **v1.2.7**, which does not bundle the separation model. Publish the verified v1.3.0 artifacts and complete the publication checks in [RELEASE.md](RELEASE.md) before using the launch copy below. Verify the final download link after publication. The Homebrew cask still points to v1.2.5; update its version and checksum from the actual published DMG before recommending Homebrew. + +The app targets Apple silicon and macOS 14+, with macOS 26+ recommended for separation. Some older macOS compute paths fail the model's built-in check. Distribution is ad-hoc signed and requires first-launch approval; no Developer ID signing or notarization is claimed. Remaining verification limits are recorded in [QUALITY_REPORT.md](QUALITY_REPORT.md). + +## LinkedIn post + +I built Isolate, a native Mac app for taking songs apart and practicing what you hear. + +Drop in an audio file and split it into vocals, drums, bass and other. Solo a bass line, mute the vocals, loop a difficult phrase, or slow it down without changing the pitch. Then export the stems or your own mix. + +The separation runs locally on your Mac with HTDemucs through Core ML. No account. No upload. + +I wanted it to feel like a piece of studio hardware: dot-matrix type, four channel strips, tactile controls, and Nothing-inspired dark and light themes. + +Isolate 1.3.0 is free and open source. Apple silicon required; macOS 26 or later recommended. The download includes the model. This independent project is not affiliated with Nothing or Apple. + +Download and first-launch instructions: https://github.com/neokumar1/Isolate + +I'd love to hear what you use it to practice. + +## Short post + +I built Isolate for Mac: split songs into vocals, drums, bass and other, then solo, loop, slow down and export. Runs locally. Free and open source. Apple silicon; macOS 26+ recommended. + +https://github.com/neokumar1/Isolate + +## Images and alt text + +- Lead image: [full dark mixer](Assets/screenshot-dark.png). The matching [social preview](Assets/social_preview.png) now preserves the whole window, including the transport and export controls. +- Second image: [full light mixer](Assets/screenshot-light.png). +- Optional third image: [separation progress](Assets/screenshot-separating.png). +- App icon: [native macOS 27 preview](Assets/AppIcon-macOS27.png), rendered from the bundled layered icon. +- Alt text: “Isolate's four-channel audio mixer on macOS, with vocals, drums, bass and other stems, EQ knobs, level faders, a spectrum display and playback controls.” + +The screenshots show the synthetic Isolate Demo. Keep personal libraries and copyrighted song artwork out of public captures. For a video, use audio you own or have permission to share. + +## 25-second demo outline + +1. 0–5 seconds: show the loaded demo and start playback. +2. 5–12 seconds: solo drums, then bass, then restore the full mix. +3. 12–18 seconds: set a loop and reduce speed to 0.75×. +4. 18–25 seconds: show Export Mix and the light theme; end on the repository URL. + +Use a real screen recording. Do not imply that the separation happens instantly, that stems are artifact-free, or that all Macs separate at the same speed. diff --git a/MODEL.md b/MODEL.md index af92491..4855e4a 100644 --- a/MODEL.md +++ b/MODEL.md @@ -13,38 +13,61 @@ The reference model's final gather uses `[3, 0, 1, 2]` to reorder Demucs's origi ## Reference artifact -The locally available artifact audited for this checkout has these SHA-256 values: +The reference artifact has these SHA-256 values: ```text 8e321470c16930183821c9b63ab058a2b3adc332d7974dc4aabbab15fbbd4ce0 model.mil efab790ad07d93faeb5a19b6e1eedad8c37ad351563a891a153fce307811c099 weights/weight.bin ``` -`scripts/validate_model.swift` requires these hashes and loads the model to check its contract before packaging. These hashes identify the reference artifact; they are not a reproducible training/conversion provenance record. This repository does not yet contain a conversion pipeline or a verified public download for that exact artifact. Document and publish that model input before relying on public release CI. +`scripts/validate_model.swift` requires these hashes and loads the model to check its contract before packaging. These hashes identify the reference artifact; they are not a reproducible training or conversion provenance record, and this repository does not contain a conversion pipeline. + +The artifact is published as a ZIP with `HTDemucs.mlmodelc` at its root: + +| | | +| --- | --- | +| Release | [`model-htdemucs-v1`](https://github.com/neokumar1/Isolate/releases/tag/model-htdemucs-v1) (prerelease) | +| Asset | `HTDemucs-reference.zip`, 144,236,351 bytes | +| URL | `https://github.com/neokumar1/Isolate/releases/download/model-htdemucs-v1/HTDemucs-reference.zip` | +| SHA-256 | `c497133349d2396a2e865827255d9ceeecc8ce0bee6e25febcac7b04187adc37` | + +Its tag does not start with `v`, so it never starts the release workflow, and it is a prerelease, so it never becomes the repository's Latest release (which `install.sh` and the README's download link resolve). Keep both properties if the archive is ever replaced, and publish a new tag rather than replacing the asset. ## Source builds -Place the validated directory at: +The app looks for the model in its bundle first, then at: ```text ~/Library/Application Support/Isolate/HTDemucs.mlmodelc ``` -Then validate it from the repository: +The simplest way to install it there is the same script CI uses. It downloads the pinned archive, checks its checksum and the reference hashes, and refuses to overwrite an existing model: + +```sh +ISOLATE_MODEL_ARCHIVE_URL=https://github.com/neokumar1/Isolate/releases/download/model-htdemucs-v1/HTDemucs-reference.zip \ +ISOLATE_MODEL_ARCHIVE_SHA256=c497133349d2396a2e865827255d9ceeecc8ce0bee6e25febcac7b04187adc37 \ + bash scripts/fetch_release_model.sh +``` + +Set `ISOLATE_MODEL_DESTINATION` to check an archive into another folder without touching an installed model. To validate a model you placed by hand: ```sh swift scripts/validate_model.swift "$HOME/Library/Application Support/Isolate/HTDemucs.mlmodelc" ``` -The app first looks in its bundle, then Application Support. A compatible `HTDemucs_CoreML_FP16.mlpackage` in either location can be compiled on demand. Failed model loading is reported during import; no network download occurs inside the app. Runtime loading validates tensor shapes; release validation also pins the reference hashes. +A compatible `HTDemucs_CoreML_FP16.mlpackage` in either location can be compiled on demand. The app tries `computeUnits = .all` first, shares the verified model between imports, and releases it 60 seconds after the last separation. The next import reloads and verifies it. On the development Mac, the first load by a newly built or updated app took about 17 seconds while Core ML prepared the model; later loads from its compiled cache took 3.2–3.7 seconds. No network download happens inside the app. + +Runtime loading validates tensor shapes and then separates a built-in ten-second test signal, requiring the four stems to add back up to it within 20 dB (correct runs measure 36–43 dB). Core ML in macOS 14 and 15 computes this model incorrectly on some compute paths — about 3 dB on the CPU path of both, the same for the shipped model and one compiled on the machine — so each path (`all`, CPU and GPU, CPU and Neural Engine, CPU) is tried until one passes. If none does, imports report that this version of macOS computes the model incorrectly and suggest macOS 26 or later. Release validation also pins the reference hashes. When no model exists, imports report **CoreML Model Not Found**. When a model exists but cannot be used, imports report **Model Could Not Be Loaded** with the model's path and the reason Core ML gave, so a damaged or incompatible model is not mistaken for a missing one. ## CI provisioning -Create a ZIP with `HTDemucs.mlmodelc` at its root using `ditto -c -k --keepParent`. Host that immutable artifact and set repository variables: +The repository variables `ISOLATE_MODEL_ARCHIVE_URL` (HTTPS) and `ISOLATE_MODEL_ARCHIVE_SHA256` hold the values above. `scripts/fetch_release_model.sh` accepts the checksum in either case, verifies the archive and the reference hashes, and installs the model into the runner's Application Support directory. + +- **Build & Test** fetches the model whenever the variables are available, which includes pushes and pull requests from branches of this repository, and sets `TEST_RUNNER_ISOLATE_REQUIRE_MODEL=1`. Pull requests from forks receive no repository variables, so they build and test without the model and the inference tests skip with a notice. +- **Prepare Release Draft** always requires the model and successful inference. Only the macOS 15 compatibility job sets `TEST_RUNNER_ISOLATE_ALLOW_INCOMPATIBLE_MODEL=1` to verify safe refusal; the macOS 26 build and release jobs fail if the model is incompatible. The opt-in real-music test also fails when separation fails. -- `ISOLATE_MODEL_ARCHIVE_URL`: HTTPS download URL. -- `ISOLATE_MODEL_ARCHIVE_SHA256`: lowercase SHA-256 of the ZIP. +Xcode forwards `TEST_RUNNER_ISOLATE_REQUIRE_MODEL=1` to the test process as `ISOLATE_REQUIRE_MODEL=1`, so missing-model inference fails instead of silently skipping. Setting only `ISOLATE_REQUIRE_MODEL` in the invoking shell does not enforce this. -`scripts/fetch_release_model.sh` checks the archive checksum and reference model hashes before installing it into the runner's Application Support directory. It refuses to overwrite an existing model. Release CI sets `TEST_RUNNER_ISOLATE_REQUIRE_MODEL=1`; Xcode forwards it as `ISOLATE_REQUIRE_MODEL=1` to the test process, so missing-model inference cannot silently skip. +To publish a new archive, create the ZIP with `ditto -c -k --keepParent HTDemucs.mlmodelc HTDemucs-reference.zip` outside the repository (`.gitignore` excludes model folders and archives, and GitHub rejects files over 100 MB), attach it to a new non-`v` prerelease, confirm the URL downloads anonymously, and update both variables together. -When changing model weights, conversion, or output order: review provenance and licenses, update hash validation, increment the cache pipeline version in `StemCache.swift`, and test real inference and source identity before release. +When changing model weights, conversion, output order or audio processing: review provenance and licenses, update hash validation when the model changes, increment the cache pipeline version in `StemCache.swift` (currently `Isolate-streaming-v5`), and test real inference and source identity before release. Version 5 corrects restoration of the source's DC offset. Existing library entries remain playable; reimport them to regenerate their stems with the corrected processing. diff --git a/QUALITY_REPORT.md b/QUALITY_REPORT.md index 02412da..f6a8a07 100644 --- a/QUALITY_REPORT.md +++ b/QUALITY_REPORT.md @@ -1,51 +1,112 @@ -# Production readiness verification — v1.2.8 +# Verification report — v1.3.0 -Verified September 21, 2026 on Apple Silicon, macOS 27.0 (26A428), Xcode 27.0 (27A266a). This report covers the local working tree based on `4e0a222`, including the hardening work already present when this session resumed. Changes and packages remain local. +Verified September 25–26, 2026. Local results are from an Apple silicon MacBook Pro running macOS 27.0 with Xcode 27.0. Hosted results are from GitHub Actions `macos-15` (macOS 15.7, Xcode 26.3) and `macos-26` (macOS 26.6, Xcode 26.6) runners, which fetch the pinned model (`model-htdemucs-v1`, SHA-256 `c497133349d2396a2e865827255d9ceeecc8ce0bee6e25febcac7b04187adc37`). The historical audit notes below are retained from the previous handoff. [Run 36275537518](https://github.com/neokumar1/Isolate/actions/runs/36275537518) passed both hosted jobs after the audio fixes; the installer-artwork and instructions were then checked locally. -## Results +## September 26 continuation + +The continuation found and fixed three additional production defects: nonfinite model-quality diagnostics could trap while converting to `Int`; a failed model load or prediction prevented fallback to another compute path; and unreadable audio headers were detected only after a costly model load. The model release gate now fails incompatible inference unless the older-OS compatibility job explicitly permits safe refusal. + +Testing three additional real songs found a fourth defect: denormalization added the mix's DC offset to each of the four stems. The sum therefore contained four times the original offset. Restoring one quarter to each stem improved the affected MP3's reconstruction from **5.6 dB to 28.5 dB**. The cache key advances to v5; existing library stems remain playable, and reimporting a track regenerates them with the corrected algorithm. Analytical Float16/Float32 overlap tests and a real-inference DC-offset test cover the change. The old overlap fixture was corrected to model a four-way split instead of expecting a full copy of the source in every stem. + +The app now uses a native `AppIcon.icon` package with four SVG stem layers. Xcode 27 compiles light, dark and tintable icon stacks into `Assets.car`, plus the compatibility `AppIcon.icns`. The default, dark and tinted previews were inspected, as were 16 px and 32 px renders. The standalone ICNS contains all ten standard 16–1024 px representations. macOS's system icon service successfully rendered the packaged app's icon, and its compiled compatibility ICNS was extracted and inspected. The DMG background was regenerated at 1320 × 800 px / 144 dpi with a drag arrow and system requirements. A mounted Finder-window inspection caught footer text hidden by Finder's status bar; the final image shows both requirements lines unobstructed. Xcode 26.3's asset agent crashed on the layered icon on the hosted macOS 15 image, so only that CI job builds with the committed compatibility ICNS; macOS 26 and the release package compile the layered icon. Design references: [Apple Icon Composer](https://developer.apple.com/icon-composer/) and [app-icon integration](https://developer.apple.com/documentation/xcode/creating-your-app-icon-using-icon-composer). + +Current release artifacts were built from base commit `13472da629de9edff9f722fe9f3e70496752c9c7` plus the v1.3.0 release-branch changes. They are local candidates, not published releases. + +| Current-checkout check | Result | +| --- | --- | +| Release build and packaging | Passed; arm64, version 1.3.0, bundled model and licenses | +| DMG / ZIP contents | Identical app contents; valid `/Applications` symlink, visible install artwork, bundled model; image verified and mounted read-only; both app signatures passed | +| Signature | `codesign --verify --deep --strict` passed; ad hoc with hardened runtime; **not notarized** | +| Bundled model | Reference hashes and input/output tensor contract passed | +| Icon resources | Four vectors; Aqua, Dark Aqua and tintable icon stacks; compatibility ICNS present | +| Shell, Ruby, YAML, plist, version and whitespace checks | Passed | +| Final model-required unit/audio suite | **200 tests, 0 failures, 0 skips**, including real-music separation; 476.3 seconds | +| Final desktop UI suite | **6 tests, 0 failures, 0 skips**; 157.2 seconds | +| Hosted CI at `de11929` | macOS 15 and 26 Release builds and unit/audio jobs both passed; macOS 26 required real model inference | + +Final local installer candidate sizes: app **312,954,459 bytes**, DMG **160,881,848 bytes**, ZIP **148,388,620 bytes**. SHA-256: + +```text +b79fc710bda28c37edd178d3bcfb512ca5dd1d6c1843078f7cafacff1e63cc88 Isolate.dmg +c8fdcf291b4ee8bbf567f819de728c0b9e5a9594f469e124af14f8a5bf39b638 Isolate-v1.3.0-macOS.zip +``` + +The build emits Xcode's unrelated App Intents metadata notice and an outdated iOS simulator-service diagnostic on this Mac; native macOS builds succeed. Negative tests deliberately emit decoder and library-open errors while checking safe recovery. The unit result records 16 internal thread-priority inversion warnings during audio tests, and the UI result records one. These checks establish passing behavior, not a silent console or a proof that every scheduling path is optimal. + +An earlier UI attempt closed the Save dialog before its button could be queried; the WAV had actually exported and appeared in Finder. The export workflow then passed in isolation, followed by a clean pass of all six UI tests. The final result bundle is `/private/tmp/IsolateLaunchReview-ui-verified.xcresult`; the unit bundle is `/private/tmp/IsolateLaunchReview-final-verified.xcresult`. + +## Current real-music results + +Three additional sources were copied from the owner's music library into a temporary test folder; the originals were only read. Every output contained four finite stereo stems at 44.1 kHz, with matching source lengths and nonzero energy in all four stems. + +| Source | Length | Separation time | Speed | Stems → mix reconstruction | +| --- | --- | --- | --- | --- | +| MP3 with measurable DC offset and punctuation in its filename | 3:37 | 71.1 s | 3.1× realtime | 28.5 dB | +| M4A | 2:13 | 43.2 s | 3.1× realtime | 22.9 dB | +| Long MP3 | 9:08 | 170.8 s | 3.2× realtime | 30.3 dB | + +These values measure reconstruction and processing time, not perceptual stem isolation. The largest stem peak was 1.389; floating-point caches preserve it, and the separately tested export path prevents 24-bit clipping. The real-music verifier still checks every sample for finiteness, but now calls XCTest only for failures rather than millions of successful per-sample assertions. + +## Earlier branch results (before this continuation) | Check | Result | | --- | --- | -| Unit and audio regression suite, with the model required | **36 passed, 0 failures** | -| Desktop UI suite | **4 passed, 0 failures** | -| Actual Core ML separation | Passed; four finite, stereo stems with the original frame count, including overlap boundaries and cache reuse | -| Release arm64 build | Passed | -| Reference model hashes and tensor contract | Passed | -| Final DMG integrity and all package checksums | Passed | -| Extracted ZIP app signature, version, model hashes and bundled licenses | Passed; ad-hoc signature, version 1.2.8, macOS 14.0 deployment target | -| Shell syntax, cask Ruby syntax, whitespace checks | Passed | +| Unit and audio regression suite, model required (local, macOS 27) | **192 tests, 0 failures**; 1 skipped: the opt-in real-music test | +| Same suite on hosted macOS 26 | **192 tests, 0 failures**; 4 skipped: the real-music test, and 3 double-click tests because this host does not deliver synthetic mouse events | +| Same suite on hosted macOS 15 | **See CI on the release pull request.** The model self-test refuses the model here (3.3 dB on every compute path), so inference tests skip with that reason | +| Desktop UI suite (local) | **5 tests, 0 failures** | +| Real music, three songs (local) | **All separated**; stems reconstruct each mix at 28.6–32.9 dB (table below) | +| Release build (arm64) | Passed with no warnings in `Sources/` | +| Local package, `scripts/package_release.sh v1.3.0` | Passed: `Isolate.dmg` 160,101,439 bytes, app 312,945,036 bytes, version 1.3.0, ad-hoc signature with the hardened runtime, `codesign --verify --deep --strict` passes, model and licenses bundled | +| Reference model hashes and tensor contract | Passed for the installed model and the hosted archive (downloaded and re-checked) | + +The UI workflow imports synthetic audio through the system file picker and waits for real Core ML separation. It then plays and pauses, solos and mutes every stem, and checks that typing in the library search does not trigger mixer shortcuts. It also checks that Escape over Settings and over About leaves a running separation alone, renames through the native sheet, closes and reopens the main window, exports a 24-bit WAV mix, and deletes the entry while confirming that the source file's bytes are unchanged. + +## Earlier real-music separation + +The previous handoff recorded these three songs, copied read-only from the owner's library and run through `RealMusicSmokeTests` before this continuation: + +| Source | Length | Separation time | Speed | Stems → mix reconstruction | +| --- | --- | --- | --- | --- | +| ALAC `.m4a` (24-bit source) | 4:37 | 1:33 | 3.0× realtime | 32.9 dB | +| MP3 with quotes in its file name | 3:53 | 1:15 | 3.1× realtime | 28.6 dB | +| MP3 | 8:24 | 2:34 | 3.3× realtime | 29.2 dB | + +The same songs measured 2.5–2.6× realtime before the model-output loop was rewritten, with **identical** reconstruction and per-stem levels, which confirms the rewrite is bit-exact. Cached stems can peak above full scale (1.34–1.52 here); stem exports lower all four stems together when needed so 24-bit files do not clip. Speed depends on the Mac and on Core ML's scheduling; no fixed speed is claimed. + +## Core ML compatibility -The UI workflow imports synthetic audio through the system picker, waits for separation, plays/pauses, uses mute/reset shortcuts, renames through the native sheet, closes/reopens the main window, exports a 24-bit WAV mix, and deletes the library entry. It verifies the exported frame count and confirms that the source file's bytes are unchanged after deletion. Other UI tests cover empty-library guards, cancelling the picker, dark/light settings, export-format selection, shortcut help, and Escape dismissal. Captured screenshots were inspected. +A standalone probe separated one built-in ten-second signal with each compute path. Correct output reconstructs the input at about 36–43 dB. -Audio tests also cover streaming decode/resampling, short-file reflection, incomplete caches, cancellation/retry, on-disk library reload after the original is removed, EQ rendering, positive fader gain, mute/pan/speed, WAV/FLAC bit depth, four-file ZIP exports, Unicode filenames, final audio transients with time/pitch processing, failed-export preservation, and playback completion/seeking. Tests use generated audio and isolated libraries/preferences. +| macOS | CPU only | All compute units | Shipped model vs compiled on that Mac | +| --- | --- | --- | --- | +| 14.8 (hosted) | 3.3 dB ✗ | 43.3 dB ✓ | identical | +| 15.7 (hosted) | 3.3 dB ✗ | 3.3 dB ✗ | identical | +| 26.6 (hosted) | 42.3 dB ✓ | 42.3 dB ✓ | identical | +| 27.0 (local) | 42.2 dB ✓ | 36.8 dB ✓ | identical | -## Corrections in this continuation +Core ML in macOS 14 and 15 computes this network incorrectly on its CPU path, and on the hosted macOS 15 machine on every path it offered. The fault is in the OS runtime, not the model file. Isolate therefore runs this check whenever it loads the model, tries each compute path, and refuses to separate, with a message to update to macOS 26, if none passes. It never writes stems from a model that failed. See [MODEL.md](MODEL.md). -- Removed recursive self-assignment from observable pitch/rate setters. The initial playback test crashed with a stack overflow during track loading; repeated resets, playback and invalid-value tests now pass. -- Replaced the rename overlay with a native sheet. The desktop test reproduced a text field that could not take keyboard focus. Typing, saving, and reopening the renamed track now pass. -- Made decorative corner overlays noninteractive and removed disabled mixer controls from keyboard focus. The stray focus ring over the settings modal no longer appears in screenshots. -- Fixed spectrum and waveform meters to include right-channel audio. The new stereo regression failed before the change and passes afterward. -- Bounded exported title components by UTF-8 size while preserving character boundaries, leaving room for stem suffixes on common 255-byte filesystems. -- Fixed a duplicate actor annotation that prevented the test target from compiling, observed the installation-prompt state in its parent view, and forwarded the model-required flag through Xcode's `TEST_RUNNER_` mechanism. -- Updated generated app/version metadata to 1.2.8 and corrected installation guidance against the inspected public artifact. +## Stem synchronization -## Local evidence and artifacts +A polarity null test plays four stems that cancel exactly only while every player renders the same source frame. -- Unit results: `/private/tmp/IsolateProductionReady/Logs/Test/Test-Isolate-2026.09.21_21-57-39--0500.xcresult` -- UI results: `/private/tmp/IsolateProductionReady/Logs/Test/Test-Isolate-2026.09.21_21-55-10--0500.xcresult` -- Screenshots: `/private/tmp/isolate-production-final-screenshots/` -- Logs: `/private/tmp/isolate-production-tests.log`, `/private/tmp/isolate-production-ui-tests.log`, `/private/tmp/isolate-production-package.log` -- App packages: `/private/tmp/IsolateRelease-v1.2.8/` -- Prepared model archive: `/private/tmp/IsolateModelForRelease/HTDemucs-reference.zip` -- Model ZIP SHA-256: `c497133349d2396a2e865827255d9ceeecc8ce0bee6e25febcac7b04187adc37` +- The original fixed 30 ms host-time start left stems 9–42 ms apart. +- A longer host-time lead still misaligned 2 of 9 seeks on this Mac, and most seeks on hosted runners. +- Starting every player at one sample time in the shared render timeline kept every seek, loop wrap and resume aligned on macOS 15, 26 and 27. It also removed a ~50 ms main-thread stall per start. +- At 512 frames and 48 kHz, a loop wrap now leaves a 32–43 ms gap, down from about 100 ms. Loops are not gapless; see [ROADMAP.md](ROADMAP.md). -Use [RELEASE.md](RELEASE.md) to reproduce the checks. Xcode logged a mismatched iOS simulator-service version and skipped unused App Intents metadata extraction; these did not prevent macOS compilation or tests. Core ML may log compute-device fallback diagnostics; the actual inference and finite-output assertions passed. These results do not establish exclusive Neural Engine execution. +## Audit process recorded by the previous handoff -## Public-release gates still open +1. **Audit.** Eleven specialist audits (engine, separation, library, export, UI, concurrency, design and accessibility, release, performance, tests, robustness) produced 145 findings. +2. **Verification.** After de-duplication, every finding not already corroborated by several auditors was independently verified by adversarial reviewers, two for high-severity claims. 116 held up and 6 were refuted. +3. **Fixes.** The fixes landed in three parallel waves with disjoint file ownership. Each wave ran the full unit suite with real inference before merging. +4. **Final review.** A nine-lens review of the whole branch found 40 more confirmed issues, mostly regressions introduced by the fixes, and all were fixed. The one exception is a decision left to the owner: the tracked `CLAUDE.md` contains personal agent instructions. -1. **Model provisioning and provenance.** The public [v1.2.7 DMG](https://github.com/neokumar1/Isolate/releases/tag/v1.2.7) was downloaded and inspected read-only. Its resources contain only the icon and font; it has no model and no `CFBundleShortVersionString` entry. The prepared package includes the reference model. GitHub Actions currently has no repository variables configured: publish a verified immutable model archive and set `ISOLATE_MODEL_ARCHIVE_URL` and `ISOLATE_MODEL_ARCHIVE_SHA256`. Review the remaining provenance limits in [MODEL.md](MODEL.md). -2. **Distribution signing.** Local packages are ad-hoc signed. Developer ID signing/notarization and a fresh-download Gatekeeper check are still required for a notarized public release. -3. **Platform and listening checks.** Only this Mac/toolchain was available. Test macOS 14 and a current stable release, physical output-device changes, media keys/menu-bar behavior, and the installation/relaunch path. Listen to representative music to verify source identity and perceptual quality; synthetic audio cannot establish those qualities. -4. **Publication.** Review and publish the completed release, then update the Homebrew cask's version and checksum together. The cask remains pinned to its previously published artifact. +## Open release gates -No zero-defect or universal compatibility claim is made. MP3 encoding, automatic BPM/key analysis, seamless sample-accurate loops, and loop-region export remain explicitly outside the current contract in [ROADMAP.md](ROADMAP.md). +1. **Separation on real macOS 14 and 15 Macs is unverified.** Hosted machines show Core ML's CPU path is wrong there. Which path a real Mac uses depends on its hardware, so separation may work or may be refused with a clear message. Isolate refuses paths that fail its model check. Test on a physical macOS 14 or 15 Mac, or raise the minimum to macOS 26. +2. **Signing.** Builds are ad-hoc signed and not notarized, and the README walks through first-launch approval. Check the downloaded DMG's first launch on macOS 15 or later and on macOS 14. +3. **Listening.** Reconstruction measures alignment and scale, not how good the stems sound. Listen to representative music before announcing. +4. **Not automated:** physical output-device switching (Bluetooth, USB), media keys and the menu bar controller, double-click reset on macOS 26 (hosted runners there drop synthetic clicks; verified on 15 and 27), and the in-place upgrade from a real pre-1.3 library. +5. **Publication.** The hosted macOS 15 and 26 jobs passed on [run 36275537518](https://github.com/neokumar1/Isolate/actions/runs/36275537518). The public Latest app is still v1.2.7, without the model, and the cask is v1.2.5. After review, merge to `main`, verify the downloaded package and first launch, publish v1.3.0 as Latest, and set the cask's version and SHA-256 from the published DMG. See [RELEASE.md](RELEASE.md) and [LAUNCH.md](LAUNCH.md). diff --git a/README.md b/README.md index 912efa6..5a5da52 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,149 @@ # Isolate -A native music stem player for Apple Silicon Macs, with a Nothing-inspired mixer interface. +**Split a song into vocals, drums, bass and other on your Mac, then mix, loop, slow down and export the stems.** -Isolate separates audio into **vocals, drums, bass, and other** using a local Core ML model. Balance the four channels, practice with an A–B loop, compare against the original, and export your work. Audio processing runs on your Mac; no account or cloud service is required. +Isolate is a native macOS stem player for Apple silicon. It runs HTDemucs, the open-source separation model from Meta's Demucs project, through Core ML, so songs are separated on your own Mac with no account and no upload. Once a song is split, four channel strips let you solo the bass line, mute the vocals to sing along, slow a solo to 0.75× without changing its pitch, loop the hard part, and export the stems or your own mix. The interface is a hardware-style mixer inspired by Nothing's dot-matrix design. -## Features + + + + The Isolate window: the library sidebar on the left, four channel strips for vocals, drums, bass and other, the studio display with a 32-band spectrum above them, and the playback controls below + -- Four synchronized stem channels with calibrated −60 to +6 dB faders, silence at the bottom, mute, solo, and stereo pan. -- Three-band EQ on each stem and the master bus: 100 Hz low shelf, 1 kHz bell, and 10 kHz high shelf. EQ presets and bypass controls. -- Playback speed from 0.5× to 1.5× in preset steps; pitch from −12 to +12 semitones. -- A–B practice loops, original/mix comparison, live spectrum and level meters. -- Batch import, drag and drop, searchable SwiftData library grouped by source folder, and metadata/artwork when present. -- Content-based caching: identical source bytes reuse the same completed separation; unfinished imports never become valid cache entries. -- Four individual **24-bit WAV or FLAC stems in a ZIP**, or a **24-bit WAV mix** with the current channel levels, pan, EQ, speed, and pitch. -- Dark, light, and system appearances; menu bar controls, media keys, and trackpad haptics. +**[Download Isolate for macOS](https://github.com/neokumar1/Isolate/releases)** · Apple silicon · macOS 14 or later · Free and open source (MIT) -The original is decoded to stereo 44.1 kHz for comparison. Stem exports exclude fader, pan, tempo, and pitch changes; optional stem EQ baking is available in the EQ panel. Mix exports render the full track, including the original when comparison bypass is active. Loop boundaries do not trim exports. +Choose **v1.3.0 or newer** for an app that includes the separation model. Earlier public releases do not include it. If v1.3.0 is not on the releases page yet, the complete installer has not been published. -## Requirements and installation +## Download -- Apple Silicon Mac; deployment target macOS 14 or later. -- Disk space for the app/model and decoded stems. Cached float audio uses about **106 MB per minute** across four stems and the original. -- A compatible HTDemucs Core ML model, bundled by the release packaging process. A source checkout does not contain model weights. +1. On the [releases page](https://github.com/neokumar1/Isolate/releases), select **v1.3.0 or newer** and download `Isolate.dmg`. +2. Open the disk image and drag **Isolate.app** onto the **Applications** shortcut in its Finder window. Quit an older copy of Isolate before replacing it. +3. Eject the disk image, then open Isolate from Applications and follow [First launch](#first-launch) if macOS blocks it. +4. Drag a supported audio file or folder onto the Isolate window, or press **⌘O**. Separation happens on your Mac. -The public v1.2.7 DMG inspected on September 21, 2026 lacks the separation model. This checkout prepares v1.2.8 with model-aware packaging; it has not been published. See [INSTALL.md](INSTALL.md) and [MODEL.md](MODEL.md) for working source-build instructions, and [QUALITY_REPORT.md](QUALITY_REPORT.md) for verification. Once a complete package is available on [GitHub Releases](https://github.com/neokumar1/Isolate/releases), open its DMG and drag Isolate into Applications. +| | | +| --- | --- | +| Mac | Apple silicon (M1 or later); Intel Macs are not supported | +| macOS | 14 Sonoma or later; **26 or later recommended for separation** (see below) | +| Extra software | None for the v1.3.0+ DMG: the model is included. No Xcode, Python, Homebrew, account or model download is needed | +| Download | About 160 MB | +| Installed | About 313 MB, including the separation model | +| Separated songs | About 106 MB of disk per minute of audio, kept until you delete the song | + +Check your chip and macOS version in **Apple menu › About This Mac** before downloading. Keep enough free disk space for the app and the songs you plan to separate. DRM-protected downloads, including Apple Music subscription files, cannot be imported. + +**Separation on macOS 14 and 15.** Testing on hosted Macs found that Core ML in macOS 14 and 15 computes Isolate's separation model incorrectly on some of its compute paths (the CPU path on both, and every path available on the macOS 15 test machine), while macOS 26 and 27 are correct on every path. Before it separates anything, Isolate checks the model on a built-in test signal and uses a compute path that passes. If none passes on your Mac, it says so and asks you to update to macOS 26 or later; it never writes stems from a model that failed the check. Separation has been verified end to end on macOS 26 and 27. + +Each release lists SHA-256 checksums in `SHA256SUMS.txt`. To check your download, run `shasum -a 256 ~/Downloads/Isolate.dmg` and compare the result. Homebrew, a terminal installer and source builds are covered in [INSTALL.md](INSTALL.md). + +## First launch + +Isolate's releases are built by GitHub Actions from this repository and are ad-hoc signed. They are not signed with an Apple Developer ID or notarized by Apple, so macOS can't confirm who made the app and blocks the first launch until you approve it. You do this once for each new version. + +### macOS 15 Sequoia and later + +1. Open Isolate from Applications. macOS says **"Isolate" Not Opened** because Apple could not verify it. Click **Done**, not Move to Trash. +2. Open **System Settings › Privacy & Security** and scroll down to **Security**. +3. Next to the message that Isolate was blocked, click **Open Anyway**. The button only appears for a while after a blocked launch; if it isn't there, repeat step 1. +4. Enter your login password or use Touch ID. +5. When macOS asks one last time, click **Open**. + +From then on, Isolate opens like any other app. See [Apple's first-launch instructions](https://support.apple.com/en-us/102445) for the system approval flow. + +### macOS 14 Sonoma + +In Applications, Control-click (or right-click) **Isolate**, choose **Open**, then click **Open** in the dialog. + +### Please don't turn off Gatekeeper + +Don't disable Gatekeeper (for example with `spctl --master-disable`) or strip quarantine attributes with `xattr` to skip these steps. Disabling Gatekeeper changes checks for all apps; stripping an app's quarantine skips its normal first-launch checks. The steps above approve only Isolate. You can check the download against `SHA256SUMS.txt` or [build Isolate from source](INSTALL.md#build-from-source). + +If you open Isolate straight from the disk image, it offers to move itself into Applications. + +## What it does -## Quick start +**Separate** -1. Press **⌘O** or drop local audio files into the window. MP3, WAV, FLAC, M4A/AAC/ALAC, AIFF, and CAF are accepted when supported by the macOS decoder. DRM-protected audio is unsupported. -2. Wait for separation, or press Escape to cancel. Progress and speed are measured from the current job; the first model load can take longer. -3. Adjust the four channels. Double-click a fader, pan dial, or EQ knob to reset it. -4. Set loop markers with **[** and **]**, and toggle looping with **L**. -5. Use **File → Export Stems…** or **File → Export Mix…**. +- Four stems, always in the same order: vocals, drums, bass and other. HTDemucs runs through Core ML on your Mac. Isolate has no account, analytics or network features of its own. +- Import files or whole folders with ⌘O or by dragging them onto the window. MP3, WAV, FLAC, M4A (AAC or ALAC), AIFF and CAF are supported. Surround files are downmixed to stereo, and iCloud Drive files that aren't on your Mac yet are downloaded first. +- While a song separates, Isolate shows the file name, chunk count, time remaining and measured speed. You can cancel one song or a whole batch, and if you allow notifications, Isolate tells you when an import finishes while it is in the background. +- Separated songs are cached by the file's exact contents. Importing the identical file again, even from a different folder, reuses its stems instead of separating it again; a copy with edited tags or artwork, or in another format, is separated again. + +Separation in progress: a large percentage, the file name, the current stage and a Cancel Import button + +**Mix** + +- Four channel strips, each with a fader from −60 to +6 dB (the bottom is silence), mute, solo, stereo pan and a live meter. +- Three-band EQ on every stem and on the master bus: a 100 Hz low shelf, a 1 kHz bell and a 10 kHz high shelf, each ±12 dB. It includes factory presets, per-channel bypass and one key (⌘E) to bypass all EQ. +- One-click macros: Acapella, Instrumental, Drumless, Karaoke (vocals at −12 dB), Drums & Bass, and Reset. +- Compare Original (the **BYPASS** button) switches to the source recording under the same speed, pitch and master EQ, so you can check the separation against the real thing. + +**Practice** + +- Speed from 0.5× to 1.5× (0.5, 0.75, 0.85, 1, 1.15, 1.25 and 1.5) without changing pitch, and pitch from −12 to +12 semitones without changing speed. +- A–B loop: set the start and end at the playhead with [ and ], turn looping on or off with L, and clear the markers with ⌥L. +- A studio display above the mixer with five views: a 32-band spectrum, the stem macros, stem balance, telemetry (tempo, key, format and timecode) and an equalizer for any stem or the master bus (⌘1 to ⌘5). + +**Library** + +- Songs are grouped by the folder they came from and can be searched by title, file name, or the artist and album folders they sit in. Next and Previous follow the sidebar order. +- Artist, album, artwork, BPM and key come from the file's own tags. Isolate doesn't estimate BPM or key, and shows them as unknown when the tags don't have them. +- Rename songs in the library. Deleting a song removes only the stems Isolate made; your original file is never changed or deleted. + +**Export** + +- **Stems:** a ZIP of four 24-bit WAV or FLAC files at 44.1 kHz. Channel EQ is included unless it is bypassed; levels, pan, speed and pitch are not. If any stem would clip, all four are lowered by the same amount so they still add up to the same mix. +- **Mix:** a 24-bit WAV of the whole track with your current levels, mutes, solos, pan, EQ, speed and pitch. With Compare Original on, it exports the original recording instead and names the file `_Original.wav`. +- Exports show their progress and can be cancelled (EXPORT button or File › Cancel Export). An existing file is replaced only after the new one is complete. + +**On your Mac** + +- Dark, light and match-system themes, with support for Increase Contrast and VoiceOver labels on the controls. +- A menu bar mini controller, media keys and Now Playing, and trackpad haptics. ## Keyboard shortcuts -| Shortcut | Action | +| Keys | Action | | --- | --- | -| Space | Play / pause | -| ⌘O | Import audio / batch import | -| ⌘⇧E | Export four stems as ZIP | -| ⌘⇧M | Export the full current mix as WAV | -| ⌘⌥B | Compare original / stem mix | -| 1 / 2 / 3 / 4 | Solo vocals / drums / bass / other | -| V / D / B / O | Mute vocals / drums / bass / other | -| A / I / R | Acapella / instrumental / reset mix | -| [ / ] / L | Set loop start / end / toggle loop | -| ⌘E | Bypass all EQ | -| ⌘1 … ⌘5 | Select header display mode | -| ⌘B | Show / hide library | -| ⌘0 | Show main window | -| ⌘, | Settings and shortcuts | -| ? or / | Shortcut reference card | -| Escape | Dismiss a dialog or cancel separation | - -Focused faders and dials also support keyboard adjustment and accessibility actions. - -## Development - -Requires Xcode 26.2 or later (Swift 6.2 compiler or later) and [XcodeGen](https://github.com/yonaskolb/XcodeGen). The project currently uses Swift 5 language mode. +| Space | Play or pause | +| 1, 2, 3, 4 | Solo vocals, drums, bass or other | +| V, D, B, O | Mute vocals, drums, bass or other | +| A / I | Acapella (vocals only) / Instrumental (no vocals) | +| R | Reset levels, mutes, solos and pan | +| [ / ] | Set the loop start / end at the playhead | +| L | Turn the A–B loop on or off | +| ⌥L | Clear the loop markers | +| ⌘1 to ⌘5 | Studio display: 32-band FFT, stem macros, stem balance, telemetry, equalizer | +| ⌘E | Bypass all EQ (stems and master) | +| ⌥⌘B | Compare Original (BYPASS) | +| ⌘O | Import files or folders | +| ⇧⌘E | Export stems | +| ⇧⌘M | Export mix | +| ⌘B | Show or hide the library | +| ⌘, | Settings | +| ⌘0 | Show the Isolate window | +| ? or / | Show or hide the shortcut card | +| Esc | Close the open panel, or cancel the import in progress | +| Arrow keys | Adjust the focused fader, knob, pan control or seek bar | +| Double-click | Reset a fader, pan control, EQ knob, pitch or speed | + +The full list is also in **Settings › Shortcuts**. + +## Speed, quality and limits + +- **Speed depends on your Mac.** The September 25–26 v1.3.0 checks on an M-series MacBook Pro running macOS 27 measured 3.0–3.3× realtime: a 4:37 ALAC track in 1:33 and an 8:24 MP3 in 2:34. See [the measured results](QUALITY_REPORT.md#real-music-separation). Your speed will vary with the Mac, the macOS version and what else is running; the progress screen shows the measured speed for each song. +- **The first separation after installing or updating is slower** while macOS prepares the model for your Mac. On the test Mac that took about 17 seconds once; later model loads took 3 to 4 seconds. +- **Memory:** on the test Mac, Isolate's memory footprint was about 1.6 to 2.7 GB after a separation, while the model was loaded. Isolate releases the model after 60 seconds without a separation. +- **Disk:** separated songs use about 106 MB per minute of audio. Isolate checks for enough free space before it starts. +- **Quality depends on the recording.** Expect some bleed between stems and some artifacts, especially on dense mixes. Isolate makes no guarantee about separation quality. +- **Looping is for practice.** Playback that starts from a stop waits a fraction of a second so all four stems start together, and the A–B loop is not a sample-accurate DAW loop. +- **Not supported:** Intel Macs, DRM-protected files (such as Apple Music downloads), OGG and Opus, files with more than 8 channels, MP3 export, and exporting only the loop region. + +Having trouble? See [TROUBLESHOOTING.md](TROUBLESHOOTING.md). Changes in each version are listed in [CHANGELOG.md](CHANGELOG.md). + +## Build from source + +You need Xcode 26.2 or later and [XcodeGen](https://github.com/yonaskolb/XcodeGen). The model weights are not in this repository; [MODEL.md](MODEL.md) explains how to install the validated model. ```sh brew install xcodegen @@ -68,12 +153,12 @@ xcodebuild build -project Isolate.xcodeproj -scheme Isolate \ open build/DerivedData/Build/Products/Debug/Isolate.app ``` -Set up the model using [MODEL.md](MODEL.md) before importing audio. Run checks using [RELEASE.md](RELEASE.md). Architecture and signal flow are documented in [ARCHITECTURE.md](ARCHITECTURE.md) and [AUDIO_ENGINE.md](AUDIO_ENGINE.md). - -## Practical limits - -Separation quality depends on the source and model; some bleed and artifacts are expected. Processing speed and Core ML compute-device selection depend on hardware, OS, and workload. Isolate does not claim a fixed speed, memory ceiling, or exclusive Neural Engine execution. Looping uses scheduled playback and is intended for practice; it is not a sample-accurate DAW loop engine. BPM and key are read from metadata, with unknown values shown explicitly. +[INSTALL.md](INSTALL.md) has the full steps, [RELEASE.md](RELEASE.md) covers tests and packaging, and [ARCHITECTURE.md](ARCHITECTURE.md), [AUDIO_ENGINE.md](AUDIO_ENGINE.md) and [DESIGN.md](DESIGN.md) describe how the app works. ## Credits -App code: [MIT](LICENSE). Model architecture: [Demucs by Meta](https://github.com/facebookresearch/demucs). Typography: DotGothic16 by Fontworks. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Isolate is an independent project and is not affiliated with Nothing or Apple. +- App code: [MIT License](LICENSE), © 2026 Neo Kumar. +- Separation model: HTDemucs from Meta's [Demucs](https://github.com/facebookresearch/demucs) project, MIT License, converted to Core ML. +- Typeface: [DotGothic16](https://github.com/fontworks-fonts/DotGothic16) by Fontworks, SIL Open Font License 1.1. + +License texts ship inside the app; see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Isolate is an independent project. It is not affiliated with or endorsed by Nothing Technology Limited or Apple; "Nothing-inspired" describes the visual style only. diff --git a/RELEASE.md b/RELEASE.md index c200a34..54db24a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -6,7 +6,7 @@ Use Xcode 26.2 or newer and regenerate from `project.yml`: ```sh xcodegen generate -xcodebuild test -project Isolate.xcodeproj -scheme Isolate \ +TEST_RUNNER_ISOLATE_REQUIRE_MODEL=1 xcodebuild test -project Isolate.xcodeproj -scheme Isolate \ -destination 'platform=macOS,arch=arm64' -derivedDataPath build/DerivedData \ -only-testing:IsolateTests CODE_SIGNING_ALLOWED=NO xcodebuild test -project Isolate.xcodeproj -scheme Isolate \ @@ -14,36 +14,64 @@ xcodebuild test -project Isolate.xcodeproj -scheme Isolate \ -only-testing:IsolateUITests ``` -UI tests need a logged-in desktop and Xcode UI automation permission. They launch an isolated empty library and preserve screenshots in the `.xcresult` bundle. The complete import/playback/export/delete UI workflow requires the local model. Tests create synthetic audio rather than relying on personal music files. Unit inference skips when the model is absent in ordinary source CI; run `TEST_RUNNER_ISOLATE_REQUIRE_MODEL=1 xcodebuild test ...` for a release gate. Xcode strips `TEST_RUNNER_` when forwarding the variable to test processes; setting only `ISOLATE_REQUIRE_MODEL` in the invoking shell does not enforce this gate. +Unit tests create synthetic audio rather than relying on personal music files. With the model installed (see [MODEL.md](MODEL.md)), `TEST_RUNNER_ISOLATE_REQUIRE_MODEL=1` makes a missing model fail instead of skipping. An installed model that cannot run always fails unless `TEST_RUNNER_ISOLATE_ALLOW_INCOMPATIBLE_MODEL=1` explicitly enables the older-OS compatibility check. Keep that override off for release validation: safe refusal is not successful inference. Xcode strips `TEST_RUNNER_` when forwarding these variables; unprefixed variables in the invoking shell do not enforce the gate. A few real-time engine tests skip when no audio output device can start, as on hosted runners. The stem alignment test also skips if the output provides no render timeline and player start calls take over 0.5 seconds; that host cannot establish real-time alignment. -## Package locally +Before a release, also separate real music. Point `TEST_RUNNER_ISOLATE_REAL_AUDIO_DIR` at a folder of a few songs in different formats (for example an ALAC `.m4a`, a long MP3 and a file with punctuation in its name); the files are only read: + +```sh +TEST_RUNNER_ISOLATE_REAL_AUDIO_DIR="$HOME/Music/Isolate check" TEST_RUNNER_ISOLATE_REQUIRE_MODEL=1 \ + xcodebuild test -project Isolate.xcodeproj -scheme Isolate -destination 'platform=macOS,arch=arm64' \ + -derivedDataPath build/DerivedData -only-testing:IsolateTests/RealMusicSmokeTests CODE_SIGNING_ALLOWED=NO +``` + +It checks that every song separates into four finite stereo stems with the original's length, that each stem carries audio, and that the stems add back up to the decoded original above a 10 dB reconstruction ratio. It prints speed and reconstruction per song; the latest three-source check measured about 23–30 dB. Ordinary runs skip it. + +UI tests take over the mouse and keyboard, so run them on a logged-in desktop you are not using, with Xcode UI automation permission. They launch an isolated empty library and keep screenshots in the `.xcresult` bundle. The complete import, playback, export and delete workflow needs the local model. + +## Version -Update both version values in `project.yml`, regenerate, and use the matching tag. Do not overwrite a published version's artifacts. +`project.yml` is the source of truth. Set `CFBundleShortVersionString` and `CFBundleVersion` there (1.3.0 uses `1.3.0` for both), run `xcodegen generate`, and commit the regenerated `Info.plist`. Add a `## [x.y.z]` section to [CHANGELOG.md](CHANGELOG.md); the release notes are built from it. Then check that everything agrees with the tag you intend to push: + +```sh +bash scripts/check_version.sh v1.3.0 +bash scripts/release_notes.sh v1.3.0 +``` + +Never reuse a tag or overwrite a published version's artifacts. + +## Package locally ```sh ISOLATE_DIST_DIR=/private/tmp/IsolateReleaseCheck \ ISOLATE_MODEL_PATH="$HOME/Library/Application Support/Isolate/HTDemucs.mlmodelc" \ - bash scripts/package_release.sh v1.2.8 + bash scripts/package_release.sh v1.3.0 ``` -The script validates the reference model, builds Release for arm64, bundles the model and licenses, signs the app, verifies the signature, creates/verifies the DMG, and writes a ZIP and checksums. It operates in a temporary build directory. It does not install, commit, tag, push, or publish anything. +The script stops before building if the tag, `project.yml` and `Info.plist` disagree. It then validates the reference model, builds Release for arm64 in a temporary directory, checks the built app's version, bundles the model (the licenses are app resources), signs the app with the hardened runtime and verifies the signature, and builds the DMG. The DMG is created writable so its volume can carry the custom-icon flag, then compressed and verified. The script remounts the finished image to check its Applications shortcut, Finder artwork, bundled model and app signature. It writes `Isolate.dmg`, `Isolate--macOS.zip` and `SHA256SUMS.txt` to the dist directory. It does not install, commit, tag, push, or publish anything. For v1.3.0 on the development Mac it produced a 160 MB DMG, a 148 MB ZIP and a 313 MB app (decimal megabytes, as Finder reports them). + +Default signing is ad hoc, now with the hardened runtime. For Developer ID distribution, supply `ISOLATE_SIGNING_IDENTITY` and an `ISOLATE_NOTARY_PROFILE` already stored in your keychain. The script submits the app to Apple's notary service and staples it before packaging. Verify Gatekeeper on a freshly downloaded artifact before public release. Never place signing credentials in Git. + +The app icon's source is `Sources/Resources/AppIcon.icon`, with four SVG layers and system-rendered materials. Xcode compiles its layered representations and generates the compatibility ICNS for older macOS versions. `CFBundleIconName` and the app-icon build setting both name `AppIcon`; the separately tracked legacy ICNS is excluded from the app resource phase to avoid duplicate outputs. -Default signing is ad hoc for local evaluation. For Developer ID distribution, supply `ISOLATE_SIGNING_IDENTITY` and an `ISOLATE_NOTARY_PROFILE` already stored in your keychain. The script submits the app to Apple's notary service and staples it before packaging. Verify Gatekeeper on a freshly downloaded artifact before public release. Never place signing credentials in Git. +With **Xcode 27 selected**, run `swift scripts/generate_assets.swift` from the repository root to regenerate the macOS 27 preview, the DMG/legacy ICNS files at 16–1024 px, and the 144-dpi DMG background. Use `swift scripts/generate_assets.swift --dmg-only` while iterating on the install artwork. Commit `Assets/AppIcon-macOS27.png`, `Assets/AppIcon.icns`, `Sources/Resources/AppIcon.icns`, and `Assets/dmg_background.png` together with icon source changes. Ordinary builds use the committed sources and do not run this generator. See [Apple's Icon Composer guidance](https://developer.apple.com/documentation/xcode/creating-your-app-icon-using-icon-composer). ## GitHub Actions -- Pull requests/main builds: Release compilation, unit/audio tests, saved `.xcresult` artifacts. -- Tagged release: requires the model URL/checksum from [MODEL.md](MODEL.md), runs model-backed unit tests, builds packages, creates a **draft** GitHub release. +- **Build & Test** runs on pushes and pull requests to `main`, on both `macos-15` and `macos-26`. It checks every shell script's syntax, the cask's Ruby syntax, that `Info.plist` matches `project.yml`, and whitespace; builds Release; and runs the unit and audio tests. When the model variables are available it fetches the pinned model and requires real inference on macOS 26. macOS 15 explicitly allows the verified refusal of its incompatible Core ML runtime, with inference tests reported as skipped; a missing model still fails. Fork pull requests skip missing-model inference with a notice. Test results are kept as `.xcresult` artifacts. +- **Prepare Release Draft** runs on a `v*` tag. Its first step compares the tag with `project.yml`, `Info.plist` and `CHANGELOG.md`, so a mismatch fails in seconds. It then fetches the pinned model, runs the model-backed unit tests, packages, and creates a **draft** release named `Isolate ` with the DMG, the ZIP and the checksums. The draft's notes come from `scripts/release_notes.sh`: what to download, first-launch steps for macOS 15+ and 14, and the CHANGELOG section. Reminders for the maintainer go to the job summary, not the notes. - `workflow_dispatch` must be run against a version tag, not a branch. - Interactive UI tests are a local release gate because hosted runners do not provide a dependable logged-in desktop. ## Publication checks -1. Confirm model provenance, immutable download/checksum, source order, and bundled notices. -2. Pass unit tests with model required, UI tests, Release build, and package validation. -3. Smoke-test import, cancellation, playback, audio-device switching, export, relaunch, and deletion on the minimum supported macOS and a current stable macOS. +1. Confirm model provenance, the immutable download and checksum, source order, and bundled notices. +2. Pass unit tests with the model required, UI tests, the Release build, and local package validation. +3. Smoke-test import, cancellation, playback, audio-device switching, export, relaunch, deletion and the upgrade from a pre-1.3 library on the minimum supported macOS and a current stable macOS. 4. Listen to separated musical material to assess stem identity and artifacts; synthetic tests do not establish perceptual quality. -5. Verify Developer ID/notarization if distributing as a notarized app, or clearly label an ad-hoc build. -6. Review draft release contents, version and checksums; update the cask only against the final artifact. +5. Download the draft's DMG in a browser. Open its Finder window and confirm that the app icon, Applications shortcut, drag arrow, and both requirements lines are visible with the Finder status bar shown. Drag to Applications, eject the disk image, and walk through the README's [First launch](README.md#first-launch) steps on macOS 15 or later and on macOS 14. If the build is ever Developer ID signed and notarized, update the README, the release notes script and the cask caveats to match. +6. Merge the release branch into `main` (use a merge commit so the tagged commit stays in `main`'s history). The release notes' and cask's `#first-launch` links, the issue templates, the `install.sh` URL in INSTALL.md and the Homebrew tap all read `main`. +7. Review the draft's notes, version and checksums, then publish it and mark it as the latest release. The README's download link and `install.sh` follow the latest release, so publish and merge before announcing, then confirm that https://github.com/neokumar1/Isolate#first-launch opens the First launch section. +8. Commit the cask's `version` and `sha256` for the published `Isolate.dmg` to `main` together, and check it with `brew install --cask neokumar1/isolate/isolate` on a clean Mac. +9. Keep the model archive's prerelease unpublished as Latest. Consider editing older release notes that recommend `xattr -cr` or Control-click on macOS 15 and later. See [QUALITY_REPORT.md](QUALITY_REPORT.md) for this checkout's measured verification and remaining external release gates. diff --git a/ROADMAP.md b/ROADMAP.md index 90f4948..ec659bf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,23 +1,25 @@ # Project status -## Implemented in this checkout +## Implemented in 1.3.0 -- [x] Native SwiftUI library and Nothing-inspired four-channel mixer. -- [x] Core ML separation with bounded audio buffers, progress, cancellation and validated cache publication. -- [x] Synchronized stems, original comparison, faders, mute/solo, pan, EQ, speed and pitch. -- [x] A–B practice looping. -- [x] SwiftData library, folder grouping, search, rename/delete, metadata/artwork. -- [x] Individual WAV/FLAC stem archives and current-mix WAV export. -- [x] Real audio regression coverage and UI smoke tests. -- [x] Model-aware local packaging and draft-only release automation. -- [x] Updated build/audio/model documentation and bundled third-party notices. +- [x] Native SwiftUI library and Nothing-inspired four-channel mixer, with dark, light and match-system themes and Increase Contrast support. +- [x] Core ML separation with bounded audio buffers, progress, cancellation, disk-space preflight, iCloud Drive downloads, surround downmix and validated cache publication. +- [x] File and folder import with batch progress and one failure summary per batch. +- [x] Sample-aligned stems, original comparison, faders, mute/solo, pan, three-band EQ on each stem and the master bus, speed and pitch. +- [x] A–B practice looping with a 0.5 s minimum region and a clear-markers shortcut. +- [x] SwiftData library in its own store, with a one-time import from pre-1.3 libraries, backups instead of deletion, folder grouping, search, rename/delete, and metadata/artwork from tags. +- [x] Individual WAV/FLAC stem archives without clipping and current-mix WAV export, with progress and cancellation. +- [x] Real audio regression coverage and UI smoke tests; CI runs real model inference when the model is available. +- [x] Model-bundled, hardened-runtime packaging and draft-only release automation with user-facing notes. -Verification evidence and public-release gates are tracked in [QUALITY_REPORT.md](QUALITY_REPORT.md) and [RELEASE.md](RELEASE.md). +Verification evidence and public-release gates are tracked in [QUALITY_REPORT.md](QUALITY_REPORT.md) and [RELEASE.md](RELEASE.md); user-visible changes are in [CHANGELOG.md](CHANGELOG.md). ## Future capabilities These are not part of the current product contract: +- Developer ID signing and notarization, so first launch needs no approval. +- Keeping access to imported files across launches, so macOS asks for folder access less often. - Sample-accurate, seamless DAW-style looping. - Original-only playback before separation. - Automatic BPM/key analysis beyond source tags. diff --git a/Sources/App/AppMoveHelper.swift b/Sources/App/AppMoveHelper.swift index 15beed6..063471c 100644 --- a/Sources/App/AppMoveHelper.swift +++ b/Sources/App/AppMoveHelper.swift @@ -8,26 +8,63 @@ public final class AppMoveHelper: ObservableObject { @Published public var shouldShowMoveModal = false @Published public var isMoving = false @Published public var moveErrorMessage: String? = nil + /// Set when Applications already holds Isolate; replacing it needs a second click. + @Published public var replacementPrompt: String? = nil + /// When the prompt appeared: the second click of a double-click on MOVE + /// must not confirm the replacement the first click just asked about. + private var promptShownAt: TimeInterval = 0 + private var didCheckLocation = false + + static let installURL = URL(filePath: "/Applications/Isolate.app") + + enum MoveError: LocalizedError { + case destinationExists + var errorDescription: String? { "Isolate is already in Applications." } + } private init() {} + /// Where the user opened the app. Gatekeeper runs a quarantined app opened + /// from a downloaded disk image from a randomized read-only + /// AppTranslocation path, which never starts with /Volumes/. + nonisolated static func originalURL(of bundleURL: URL) -> URL { + guard bundleURL.path.contains("/AppTranslocation/"), + let security = dlopen("/System/Library/Frameworks/Security.framework/Security", RTLD_LAZY), + let symbol = dlsym(security, "SecTranslocateCreateOriginalPathForURL") else { return bundleURL } + typealias CreateOriginalPath = @convention(c) (CFURL, UnsafeMutablePointer?>?) -> Unmanaged? + let createOriginalPath = unsafeBitCast(symbol, to: CreateOriginalPath.self) + guard let original = createOriginalPath(bundleURL as CFURL, nil)?.takeRetainedValue() else { return bundleURL } + return original as URL + } + + /// A mounted read-only volume, as a disk image is; an app a user keeps on + /// a writable external drive is left alone. + nonisolated static func isDiskImageLocation(_ url: URL) -> Bool { + guard url.path.hasPrefix("/Volumes/") else { return false } + return (try? url.resourceValues(forKeys: [.volumeIsReadOnlyKey]))?.volumeIsReadOnly ?? true + } + public var isRunningFromApplications: Bool { - let bundlePath = Bundle.main.bundlePath + let bundlePath = Self.originalURL(of: Bundle.main.bundleURL).path return bundlePath.hasPrefix("/Applications/") || bundlePath.hasPrefix(NSHomeDirectory() + "/Applications/") } public var isRunningFromDiskImage: Bool { - let bundlePath = Bundle.main.bundlePath - return bundlePath.hasPrefix("/Volumes/") + Self.isDiskImageLocation(Self.originalURL(of: Bundle.main.bundleURL)) } public func checkLocationOnStartup() { + // The window's onAppear repeats each time it is reopened; NOT NOW must + // still last for the whole launch. + guard !didCheckLocation else { return } + didCheckLocation = true #if !DEBUG + // Earlier builds stored a permanent decline; "not now" is per launch. if AppPreferences.defaults.bool(forKey: "hasDeclinedMoveToApplications") { return } - // ONLY prompt if the user is running the app directly off a mounted disk image volume (/Volumes/...) + // ONLY prompt when running from a disk image, including a translocated one. if isRunningFromDiskImage && !isRunningFromApplications { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.shouldShowMoveModal = true @@ -36,36 +73,110 @@ public final class AppMoveHelper: ObservableObject { #endif } - public func moveToApplications() { + public func dismissMoveModal() { + // An install in progress keeps the card up so its failure is seen. guard !isMoving else { return } - isMoving = true + shouldShowMoveModal = false + replacementPrompt = nil + moveErrorMessage = nil + } + + /// Bundle identifier and version of an installed app, read without Bundle's cache. + nonisolated static func installedInfo(at url: URL) -> (identifier: String?, version: String?)? { + guard let plist = NSDictionary(contentsOf: url.appending(path: "Contents/Info.plist")) else { return nil } + return (plist["CFBundleIdentifier"] as? String, plist["CFBundleShortVersionString"] as? String) + } + + /// Says when the installed copy is newer, so replacing it reads as the downgrade it is. + nonisolated static func replacementPrompt(installed: String?, running: String?) -> String { + if let installed, let running, installed.compare(running, options: .numeric) == .orderedDescending { + return "A newer Isolate (\(installed)) is already in Applications; this copy is \(running). Replace it with this older version? The installed copy will be moved to the Trash." + } + let version = installed.map { " \($0)" } ?? "" + return "Isolate\(version) is already in Applications. Replace it? The installed copy will be moved to the Trash." + } + + public func moveToApplications(replacingExisting: Bool = false) { + guard !isMoving else { return } + if replacingExisting, ProcessInfo.processInfo.systemUptime - promptShownAt < NSEvent.doubleClickInterval { return } moveErrorMessage = nil let source = Bundle.main.bundleURL - let destination = URL(filePath: "/Applications/Isolate.app") + let destination = Self.installURL + if !replacingExisting, FileManager.default.fileExists(atPath: destination.path) { + let installed = Self.installedInfo(at: destination) + guard installed?.identifier == Bundle.main.bundleIdentifier else { + moveErrorMessage = "A different app named Isolate is already in Applications. Drag Isolate into Applications in Finder to choose which to keep." + return + } + let running = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + replacementPrompt = Self.replacementPrompt(installed: installed?.version, running: running) + promptShownAt = ProcessInfo.processInfo.systemUptime + return + } + isMoving = true Task { do { try await Task.detached(priority: .userInitiated) { - let fm = FileManager.default - let staging = destination.deletingLastPathComponent() - .appending(path: ".Isolate-install-\(UUID().uuidString).app") - defer { try? fm.removeItem(at: staging) } - // Finish copying before replacing an existing installation. - try fm.copyItem(at: source, to: staging) - if fm.fileExists(atPath: destination.path) { - _ = try fm.replaceItemAt(destination, withItemAt: staging) - } else { - try fm.moveItem(at: staging, to: destination) - } + try Self.install(from: source, to: destination, replacingExisting: replacingExisting) }.value let configuration = NSWorkspace.OpenConfiguration() configuration.createsNewApplicationInstance = true _ = try await NSWorkspace.shared.openApplication(at: destination, configuration: configuration) // Keep this instance alive if macOS cannot launch the installed copy. - NSApp.terminate(nil) + quitAfterInstall() } catch { isMoving = false + replacementPrompt = nil moveErrorMessage = "Could not install or open Isolate. \(error.localizedDescription) You can also drag Isolate into Applications in Finder." } } } + + /// Quits from a run-loop callout rather than from the install Task: inside + /// a main-queue job, AppKit's wait for a quit confirmation cannot drain the + /// main queue, and the app would hang. + private func quitAfterInstall() { + RunLoop.main.perform(inModes: [.default]) { + MainActor.assumeIsolated { + NSApp.terminate(nil) + // Returns only when the user kept a separation or export running; + // the installed copy is already open. + self.isMoving = false + self.dismissMoveModal() + } + } + } + + /// Copies the app next to `destination` first, so a failed copy never + /// touches an existing installation. An existing app is only retired (moved + /// to the Trash by default) when the user confirmed replacing it. + nonisolated static func install(from source: URL, to destination: URL, replacingExisting: Bool, + retire: (URL) throws -> Void = { try FileManager.default.trashItem(at: $0, resultingItemURL: nil) }) throws { + let fm = FileManager.default + let staging = destination.deletingLastPathComponent() + .appending(path: ".Isolate-install-\(UUID().uuidString).app") + defer { try? fm.removeItem(at: staging) } + try fm.copyItem(at: source, to: staging) + removeQuarantine(from: staging) + if fm.fileExists(atPath: destination.path) { + guard replacingExisting else { throw MoveError.destinationExists } + try retire(destination) + } + try fm.moveItem(at: staging, to: destination) + } + + /// The user already opened this copy. A copy made by the app, unlike one + /// dragged in Finder, keeps the download's quarantine flag and would be + /// translocated again on every launch from Applications. + nonisolated static func removeQuarantine(from bundle: URL) { + var paths = [bundle.path] + if let enumerator = FileManager.default.enumerator(atPath: bundle.path) { + while let relative = enumerator.nextObject() as? String { + paths.append(bundle.appending(path: relative).path) + } + } + for path in paths { + removexattr(path, "com.apple.quarantine", XATTR_NOFOLLOW) + } + } } diff --git a/Sources/App/AudioEngineManager.swift b/Sources/App/AudioEngineManager.swift index 830f7f6..f54e191 100644 --- a/Sources/App/AudioEngineManager.swift +++ b/Sources/App/AudioEngineManager.swift @@ -5,6 +5,7 @@ import SwiftData import Accelerate import AppKit import UniformTypeIdentifiers +import os public struct TrackData: Sendable { public let id: String @@ -22,6 +23,78 @@ public enum ExportState: Equatable, Sendable { case completed } +// MARK: - Live Meters +// Each tap writes to its own observable meter, and only small leaf views read them, so a +// reading re-renders the meter that shows it instead of the whole mixer. + +/// Latest reading from one stem's channel tap. +@MainActor +@Observable +public final class StemMeter { + /// Sample peak of the latest reading. + public internal(set) var peak: Float = 0 + /// True while the latest peak is at or above full scale. The fader's clip LED reads + /// this rather than `peak`, so readings below full scale do not re-render it. + public internal(set) var isClipping = false + /// Smoothed band levels (0-1), low to high. + public internal(set) var spectrum: [Float] + + init(bandCount: Int) { + spectrum = Array(repeating: 0, count: bandCount) + } + + func update(peak newPeak: Float, spectrum newSpectrum: [Float]) { + if peak != newPeak { peak = newPeak } + if isClipping != (newPeak >= 1) { isClipping = newPeak >= 1 } + if spectrum != newSpectrum { spectrum = newSpectrum } + } + + func clearSpectrum() { + if spectrum.contains(where: { $0 != 0 }) { spectrum = Array(repeating: 0, count: spectrum.count) } + } + + func clear() { + if peak != 0 { peak = 0 } + if isClipping { isClipping = false } + clearSpectrum() + } +} + +/// Latest reading from the master output tap. +@MainActor +@Observable +public final class MasterMeter { + public nonisolated static let waveformFloor: Float = 0.05 + /// Steps per unit for `artworkEnergy`. At the largest artwork (100 pt at 2x) rounding + /// moves a dot edge by at most 0.0023 px, which moves no pixel by more than one 8-bit level. + nonisolated static let artworkEnergySteps: Float = 20 + + /// Smoothed 32-band levels (0-1), low to high. + public internal(set) var spectrum: [Float] = Array(repeating: 0, count: 32) + /// RMS of 30 consecutive blocks of the latest buffer, floored at `waveformFloor`. + public internal(set) var waveform: [Float] = Array(repeating: MasterMeter.waveformFloor, count: 30) + /// Mean waveform level for the album-art pulse, rounded to 1/20 so changes that move the + /// dots by a small fraction of a pixel do not redraw all 2,500 of them. + public internal(set) var artworkEnergy: Float = MasterMeter.waveformFloor + + func update(spectrum newSpectrum: [Float], waveform newWaveform: [Float]) { + if spectrum != newSpectrum { spectrum = newSpectrum } + if waveform != newWaveform { waveform = newWaveform } + let energy = Self.artworkEnergy(for: newWaveform) + if artworkEnergy != energy { artworkEnergy = energy } + } + + func clear() { + update(spectrum: Array(repeating: 0, count: spectrum.count), + waveform: Array(repeating: Self.waveformFloor, count: waveform.count)) + } + + nonisolated static func artworkEnergy(for waveform: [Float]) -> Float { + let mean = waveform.reduce(0, +) / Float(max(1, waveform.count)) + return (mean * artworkEnergySteps).rounded() / artworkEnergySteps + } +} + public struct EQPreset: Identifiable, Hashable, Sendable { public let id: String public let name: String @@ -64,7 +137,7 @@ public final class AudioEngineManager { private let otherMixer = AVAudioMixerNode() private let stemsSumMixer = AVAudioMixerNode() private let comparisonMixer = AVAudioMixerNode() - private var configurationObserver: NSObjectProtocol? + @ObservationIgnored private var configurationObserver: NSObjectProtocol? public var importRequested = false public var hasLoadedTrack: Bool { fileVocals != nil } public var canBypass: Bool { audioFile != nil } @@ -107,9 +180,15 @@ public final class AudioEngineManager { let chromaticScaleSharp = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] let chromaticScaleFlat = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"] - let parts = trackMusicalKey.components(separatedBy: " ") - guard let root = parts.first else { return trackMusicalKey } - let mode = parts.dropFirst().joined(separator: " ") + let key = trackMusicalKey.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "♯", with: "#") + .replacingOccurrences(of: "♭", with: "b") + guard let first = key.first, "ABCDEFG".contains(first.uppercased()) else { return trackMusicalKey } + let rootLength = key.count > 1 && ["#", "b", "B"].contains(String(key.dropFirst().first!)) ? 2 : 1 + let root = String(key.prefix(rootLength)) + let suffix = String(key.dropFirst(rootLength)) + let mode = suffix.trimmingCharacters(in: .whitespaces) + guard ["", "m", "min", "minor", "maj", "major"].contains(mode.lowercased()) else { return trackMusicalKey } var currentIndex = chromaticScaleSharp.firstIndex(of: root.uppercased()) if currentIndex == nil { @@ -121,7 +200,7 @@ public final class AudioEngineManager { if newIdx < 0 { newIdx += 12 } let newRoot = chromaticScaleSharp[newIdx] - return mode.isEmpty ? newRoot : "\(newRoot) \(mode)" + return newRoot + suffix } // Dynamic real-time scaled BPM based on playbackRate @@ -135,7 +214,18 @@ public final class AudioEngineManager { public var detailedTimecode: String = "00:00.000 / -00:00.000" public var albumArt: NSImage? - public var playbackProgress: Double = 0.0 + @ObservationIgnored private var storedPlaybackProgress = 0.0 + /// Playback position (0-1). While the player is hidden the timer advances the stored + /// value without notifying views; `setUIVisible(true)` publishes it again. + public var playbackProgress: Double { + get { + access(keyPath: \.playbackProgress) + return storedPlaybackProgress + } + set { + withMutation(keyPath: \.playbackProgress) { storedPlaybackProgress = newValue } + } + } public var seekFrameOffset: AVAudioFramePosition = 0 public var currentTimeString: String = "00:00 / -00:00" public var isBypassed: Bool = false { didSet { applyVolumes() } } @@ -172,20 +262,34 @@ public final class AudioEngineManager { public var loopEndProgress: Double = 1.0 public func toggleLoop() { + guard hasLoadedTrack else { return } Haptics.playClick() isLooping.toggle() } + /// Shortest A–B region in seconds, so short phrases can be looped on long tracks. + nonisolated static let minimumLoopSeconds = 0.5 + + nonisolated static func minimumLoopProgress(duration: Double?) -> Double { + guard let duration, duration > 0 else { return 0.02 } + return min(0.5, minimumLoopSeconds / duration) + } + public func setLoopStart(_ progress: Double) { - guard progress.isFinite else { return } - loopStartProgress = max(0.0, min(progress, loopEndProgress - 0.02)) + guard hasLoadedTrack, progress.isFinite else { return } + let gap = Self.minimumLoopProgress(duration: totalTrackDuration) + // A start at or past the current end begins a new region instead of clamping backwards. + if progress >= loopEndProgress { loopEndProgress = 1.0 } + loopStartProgress = max(0.0, min(progress, loopEndProgress - gap)) isLooping = true Haptics.playClick() } public func setLoopEnd(_ progress: Double) { - guard progress.isFinite else { return } - loopEndProgress = min(1.0, max(progress, loopStartProgress + 0.02)) + guard hasLoadedTrack, progress.isFinite else { return } + let gap = Self.minimumLoopProgress(duration: totalTrackDuration) + if progress <= loopStartProgress { loopStartProgress = 0.0 } + loopEndProgress = min(1.0, max(progress, loopStartProgress + gap)) isLooping = true Haptics.playClick() } @@ -228,6 +332,8 @@ public final class AudioEngineManager { public var errorMessage: String? = nil private var playbackSessionID = UUID() + /// Changes whenever the loaded track is replaced or unloaded. + @ObservationIgnored private var loadGeneration = 0 // MARK: - Stem Volumes, Mute, Solo (Default 1.0 = Unity Gain / 0 dB) public var vocalVolume: Double = 1.0 { didSet { applyVolumes() } } @@ -411,15 +517,33 @@ public final class AudioEngineManager { } // MARK: - Live Visualizers (Waveform & Per-Stem EQ) - public var masterWaveformAmplitudes: [Float] = Array(repeating: 0.05, count: 30) + /// One meter per stem in model order: vocals, drums, bass, other. + public let stemMeters: [StemMeter] = (0..<4).map { _ in StemMeter(bandCount: 7) } + public let masterMeter = MasterMeter() public var originalWaveformAmplitudes: [Float] = Array(repeating: 0.05, count: 30) - - public var masterEQMagnitudes: [Float] = Array(repeating: 0, count: 32) - public var stemPeaks: [Float] = Array(repeating: 0, count: 4) - public var vocalEQMagnitudes: [Float] = Array(repeating: 0, count: 7) - public var drumEQMagnitudes: [Float] = Array(repeating: 0, count: 7) - public var bassEQMagnitudes: [Float] = Array(repeating: 0, count: 7) - public var otherEQMagnitudes: [Float] = Array(repeating: 0, count: 7) + + // MARK: - Player Visibility + /// False while the player window cannot be seen: the app is hidden, or the window is + /// minimized or fully covered. Meter readings and the 60 Hz position and timecode + /// updates then stay away from SwiftUI, which otherwise keeps re-rendering hidden + /// windows. Audio, loop wraps and Now Playing carry on, and `playbackProgress` stays current. + @ObservationIgnored public private(set) var isUIVisible = true + /// Mirrors `isUIVisible` for the meter taps, which run off the main actor. + @ObservationIgnored private let meterTapsEnabled = OSAllocatedUnfairLock(initialState: true) + + public func setUIVisible(_ visible: Bool) { + guard visible != isUIVisible else { return } + isUIVisible = visible + meterTapsEnabled.withLock { $0 = visible } + if visible { + // Show the current position now rather than on the next timer tick. + withMutation(keyPath: \.playbackProgress) {} + updateTimeString(for: storedPlaybackProgress) + } else { + // Start from empty meters when shown again, not from a stale clip or peak. + clearMeters() + } + } // MARK: - Splitting & Progress State public var isSplitting = false @@ -456,7 +580,7 @@ public final class AudioEngineManager { private var fileDrums: AVAudioFile? private var fileBass: AVAudioFile? private var fileOther: AVAudioFile? - private var timer: Timer? + private let playbackClock = PlaybackClock() // MARK: - Initialization public init() { @@ -534,7 +658,7 @@ public final class AudioEngineManager { guard let self, self.hasLoadedTrack else { return } let resume = self.isPlaying self.isPlaying = false - self.timer?.invalidate() + self.playbackClock.timer?.invalidate() self.seek(toPercentage: self.playbackProgress) if resume { self.playSynced() } } @@ -543,36 +667,46 @@ public final class AudioEngineManager { private func installMeter(on node: AVAudioNode, stem: Int?) { let processor = AudioMeterProcessor(bandCount: stem == nil ? 32 : 7) + let isEnabled = meterTapsEnabled node.installTap(onBus: 0, bufferSize: 1024, format: nil) { [weak self] buffer, _ in - guard let reading = processor.process(buffer) else { return } + // Skip the analysis while the player cannot be seen. + guard isEnabled.withLock({ $0 }), let reading = processor.process(buffer) else { return } Task { @MainActor [weak self] in - guard let self, self.isPlaying else { return } - if let stem { self.stemPeaks[stem] = reading.peak } - switch stem { - case 0: self.vocalEQMagnitudes = reading.spectrum - case 1: self.drumEQMagnitudes = reading.spectrum - case 2: self.bassEQMagnitudes = reading.spectrum - case 3: self.otherEQMagnitudes = reading.spectrum - default: - self.masterEQMagnitudes = reading.spectrum - self.masterWaveformAmplitudes = reading.waveform - } + self?.deliverMeterReading(reading, stem: stem) } } } - isolated deinit { + /// Publishes one tap reading to its meter; `stem` is nil for the master tap. + func deliverMeterReading(_ reading: AudioMeterProcessor.Reading, stem: Int?) { + guard isPlaying, isUIVisible else { return } + guard let stem else { + masterMeter.update(spectrum: reading.spectrum, waveform: reading.waveform) + return + } + // Compare Original silences the stem sum after these taps. + guard !(isBypassed && canBypass), stemMeters.indices.contains(stem) else { return } + stemMeters[stem].update(peak: reading.peak, spectrum: reading.spectrum) + } + + deinit { if let configurationObserver { NotificationCenter.default.removeObserver(configurationObserver) } - timer?.invalidate() activeSplitTask?.cancel() - engine.stop() - // AVAudioEngine does not remove node taps when it stops. Release the - // tap closures (and their FFT state) before the graph nodes are torn - // down, which is essential for short-lived managers in test hosts. - for node in [engine.mainMixerNode, vocalMixer, drumMixer, bassMixer, otherMixer] { - node.removeTap(onBus: 0) + metadataTask?.cancel() + let nodes = [engine.mainMixerNode, vocalMixer, drumMixer, bassMixer, otherMixer] + let teardown: @MainActor @Sendable () -> Void = { [engine, playbackClock] in + playbackClock.timer?.invalidate() + engine.stop() + for node in nodes { node.removeTap(onBus: 0) } + engine.reset() + } + // Keep graph/timer cleanup on their owning thread without the isolated + // deinit back-deployment runtime, which crashes on macOS 15 test hosts. + if Thread.isMainThread { + MainActor.assumeIsolated { teardown() } + } else { + DispatchQueue.main.async(execute: teardown) } - engine.reset() } private func configureEQNode(_ eq: AVAudioUnitEQ) { @@ -604,6 +738,7 @@ public final class AudioEngineManager { if isBypassed && canBypass { stemsSumMixer.outputVolume = 0.0 originalPlayer.volume = 1.0 + for meter in stemMeters { meter.clear() } return } @@ -625,10 +760,10 @@ public final class AudioEngineManager { applyChannel(bassVolume, bassMuted, bassSolo, bassMixer) applyChannel(otherVolume, otherMuted, otherSolo, otherMixer) - if vocalVolume <= 0.001 || vocalMuted || (anySolo && !vocalSolo) { vocalEQMagnitudes = Array(repeating: 0, count: 7) } - if drumVolume <= 0.001 || drumMuted || (anySolo && !drumSolo) { drumEQMagnitudes = Array(repeating: 0, count: 7) } - if bassVolume <= 0.001 || bassMuted || (anySolo && !bassSolo) { bassEQMagnitudes = Array(repeating: 0, count: 7) } - if otherVolume <= 0.001 || otherMuted || (anySolo && !otherSolo) { otherEQMagnitudes = Array(repeating: 0, count: 7) } + if vocalVolume <= 0.001 || vocalMuted || (anySolo && !vocalSolo) { stemMeters[0].clearSpectrum() } + if drumVolume <= 0.001 || drumMuted || (anySolo && !drumSolo) { stemMeters[1].clearSpectrum() } + if bassVolume <= 0.001 || bassMuted || (anySolo && !bassSolo) { stemMeters[2].clearSpectrum() } + if otherVolume <= 0.001 || otherMuted || (anySolo && !otherSolo) { stemMeters[3].clearSpectrum() } } // MARK: - Exclusive Radio-Style Stem Soloing & Muting @@ -804,14 +939,13 @@ public final class AudioEngineManager { } private func clearVisualizers() { - stemPeaks = Array(repeating: 0, count: 4) - masterWaveformAmplitudes = Array(repeating: 0.05, count: 30) originalWaveformAmplitudes = Array(repeating: 0.05, count: 30) - masterEQMagnitudes = Array(repeating: 0, count: 32) - vocalEQMagnitudes = Array(repeating: 0, count: 7) - drumEQMagnitudes = Array(repeating: 0, count: 7) - bassEQMagnitudes = Array(repeating: 0, count: 7) - otherEQMagnitudes = Array(repeating: 0, count: 7) + clearMeters() + } + + private func clearMeters() { + masterMeter.clear() + for meter in stemMeters { meter.clear() } } // MARK: - Loading & Splitting Audio @@ -819,35 +953,55 @@ public final class AudioEngineManager { @MainActor public func updateTrackTitle(id: String, newTitle: String) { if currentTrackID == id { + titleOverride = newTitle currentTrackName = newTitle.uppercased() + trackTitle = newTitle + publishNowPlayingMetadata() } } public func loadTrack(_ track: TrackModel) async { guard !isSplitting else { return } + // Reselecting the loaded track keeps its mix, loop, speed and position; once it has + // played to the end, reselecting it plays it again like selecting any other track. + if track.id == currentTrackID, let loaded = fileVocals?.url, + loaded.standardizedFileURL == track.vocalStemURL.standardizedFileURL { + if !isPlaying, playbackProgress >= 1, + !AppPreferences.defaults.bool(forKey: "isAutoPlayDisabled") { togglePlayback() } + return + } lastImportCancelled = false let urls = [track.vocalStemURL, track.drumStemURL, track.bassStemURL, track.otherStemURL] do { try installFiles(urls) currentTrackID = track.id - currentTrackName = track.title.uppercased() + updateTrackTitle(id: track.id, newTitle: track.title) extractMetadata(url: track.originalURL) if !AppPreferences.defaults.bool(forKey: "isAutoPlayDisabled") { playSynced() } } catch { guard FileManager.default.fileExists(atPath: track.originalURL.path) else { - unloadTrack() + // A broken entry must not stop a different track that is playing. + if currentTrackID == track.id { unloadTrack() } showError("AUDIO SOURCE NOT FOUND: '\(track.title)'. Reimport the original file to rebuild its stems.") return } + let previousStems = track.vocalStemURL.deletingLastPathComponent() if let data = await loadAndSplitAudio(url: track.originalURL) { + // The entry may have been deleted while its stems were rebuilt. + guard let context = track.modelContext else { + unloadTrack() + return + } track.vocalStemURL = data.vocalStemURL track.drumStemURL = data.drumStemURL track.bassStemURL = data.bassStemURL track.otherStemURL = data.otherStemURL currentTrackID = track.id - currentTrackName = track.title.uppercased() - do { try track.modelContext?.save() } - catch { showError("Could not save the recovered track: \(error.localizedDescription)") } + updateTrackTitle(id: track.id, newTitle: track.title) + do { + try context.save() + if !isExporting { ImportCoordinator.removeReplacedCache(previousStems, context: context) } + } catch { showError("Could not save the recovered track: \(error.localizedDescription)") } } } } @@ -875,6 +1029,7 @@ public final class AudioEngineManager { @MainActor public func unloadTrack() { playbackSessionID = UUID() + loadGeneration += 1 metadataTask?.cancel() metadataRequestID = UUID() // 1. Hard stop all audio players & invalidate playback timers @@ -883,9 +1038,12 @@ public final class AudioEngineManager { bassPlayer.stop() otherPlayer.stop() originalPlayer.stop() + // Release the output device and drop the previous track's buffered tail. + engine.pause() + timePitchNode.reset() isPlaying = false - timer?.invalidate() - timer = nil + playbackClock.timer?.invalidate() + playbackClock.timer = nil // 2. Clear all audio file references fileVocals = nil @@ -897,6 +1055,7 @@ public final class AudioEngineManager { // 3. Reset all playback state and metadata to default standby currentTrackID = nil currentTrackName = "NO TRACK LOADED" + titleOverride = nil trackTitle = "" trackArtist = "Isolate" trackAlbum = "4-Stem Neural Audio" @@ -928,7 +1087,7 @@ public final class AudioEngineManager { } // MARK: - Import lifecycle - private var activeSplitTask: Task<[URL], Error>? + @ObservationIgnored private var activeSplitTask: Task<[URL], Error>? private var splitRequestID = UUID() public private(set) var lastImportCancelled = false @@ -951,7 +1110,8 @@ public final class AudioEngineManager { etaRemainingString = "ESTIMATING..." splitStatusMessage = "CHECKING AUDIO..." liveSpeedSubtitle = "ON-DEVICE CORE ML PROCESSING" - if isPlaying { togglePlayback() } + // togglePlayback() ignores requests while splitting, so pause directly. + if isPlaying { pausePlayback() } let requestID = UUID() splitRequestID = requestID let task = Task { [weak self] in @@ -978,17 +1138,22 @@ public final class AudioEngineManager { splitRequestID = UUID() } do { - let stems = try await withTaskCancellationHandler { - try await task.value - } onCancel: { - task.cancel() + let stems = try await whileKeepingAwake("Separating stems") { + try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } } try Task.checkCancellation() guard !lastImportCancelled else { return nil } try installFiles(stems) currentTrackID = url.path - let title = url.deletingPathExtension().lastPathComponent + let title = Self.displayTitle(for: url) currentTrackName = title.uppercased() + // Keep the library title in Now Playing, matching later loads of this track. + titleOverride = title + trackTitle = title extractMetadata(url: url) splitProgress = 1 if !AppPreferences.defaults.bool(forKey: "isAutoPlayDisabled") { playSynced() } @@ -1012,15 +1177,53 @@ public final class AudioEngineManager { errorMessage = nil } - private var metadataTask: Task? + @ObservationIgnored private var metadataTask: Task? + @ObservationIgnored private var titleOverride: String? private var metadataRequestID = UUID() + /// The Finder name without its extension; POSIX names store "/" as ":". + nonisolated static func displayTitle(for url: URL) -> String { + let name = FileManager.default.displayName(atPath: url.path) + let suffix = "." + url.pathExtension + // Finder may already hide the extension, so only strip one that is present. + guard suffix.count > 1, name.count > suffix.count, + name.lowercased().hasSuffix(suffix.lowercased()) else { return name } + return String(name.dropLast(suffix.count)) + } + + /// Embedded covers can be far larger than any view. Decode a bounded thumbnail + /// (off the main actor at the call site) rather than the full-resolution image. + nonisolated static func artworkImage(from data: Data, maxPixelSize: Int = 1024) -> CGImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + if let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int, + width * height > 100_000_000 { + return nil + } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize + ] + return CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) + } + + /// Opening the source can block on sleeping network or external volumes. + nonisolated static func probeFormat(_ url: URL) -> (sampleRate: String, bitDepth: String)? { + guard let file = try? AVAudioFile(forReading: url) else { return nil } + let bitDepth = (file.fileFormat.settings[AVLinearPCMBitDepthKey] as? Int) ?? 0 + return (String(format: "%.1f kHz", file.fileFormat.sampleRate / 1000), + bitDepth > 0 ? "\(bitDepth)-BIT" : "COMPRESSED") + } + private func extractMetadata(url: URL) { metadataTask?.cancel() let requestID = UUID() metadataRequestID = requestID let asset = AVURLAsset(url: url) - metadataTask = Task { + metadataTask = Task { [weak self] in let scoped = url.startAccessingSecurityScopedResource() defer { if scoped { url.stopAccessingSecurityScopedResource() } } var foundBPM: String? @@ -1028,14 +1231,14 @@ public final class AudioEngineManager { var foundTitle: String? = nil var foundArtist: String? = nil var foundAlbum: String? = nil - var foundArt: NSImage? = nil + var foundArt: CGImage? = nil do { let metadata = try await asset.load(.commonMetadata) for item in metadata { if item.commonKey == .commonKeyArtwork { if let data = (try? await item.load(.value)) as? Data { - foundArt = NSImage(data: data) + foundArt = await Task.detached(priority: .utility) { Self.artworkImage(from: data) }.value } } else if item.commonKey == .commonKeyTitle { if let titleStr = (try? await item.load(.value)) as? String { @@ -1062,12 +1265,12 @@ public final class AudioEngineManager { if let number, number.isFinite, number > 0 { foundBPM = String(format: "%.1f BPM", number) } } if identifier.contains("tkey"), let value = try? await item.load(.stringValue), !value.isEmpty { - foundKey = value.uppercased() + foundKey = value.trimmingCharacters(in: .whitespacesAndNewlines) } if foundArt == nil && (item.commonKey == .commonKeyArtwork || item.identifier?.rawValue.contains("APIC") == true || item.identifier?.rawValue.contains("artwork") == true) { if let data = (try? await item.load(.value)) as? Data { - foundArt = NSImage(data: data) + foundArt = await Task.detached(priority: .utility) { Self.artworkImage(from: data) }.value } } if foundTitle == nil && (item.commonKey == .commonKeyTitle || item.identifier?.rawValue.contains("TIT2") == true || item.identifier?.rawValue.contains("title") == true) { @@ -1091,7 +1294,7 @@ public final class AudioEngineManager { } let finalArt = foundArt - let finalTitle = foundTitle ?? url.deletingPathExtension().lastPathComponent + let finalTitle = foundTitle ?? Self.displayTitle(for: url) let finalArtist = foundArtist ?? "Isolate" let finalAlbum = foundAlbum ?? "4-Stem Neural Audio" let ext = url.pathExtension.uppercased() @@ -1100,22 +1303,15 @@ public final class AudioEngineManager { let finalBPM = foundBPM ?? "BPM UNKNOWN" let finalKey = foundKey ?? "KEY UNKNOWN" - let (computedSampleRate, computedBitDepth): (String, String) = { - guard let f = try? AVAudioFile(forReading: url) else { - return ("44.1 kHz", "24-BIT PCM") - } - let sr = f.fileFormat.sampleRate - let srStr = String(format: "%.1f kHz", sr / 1000) - let bd = (f.fileFormat.settings[AVLinearPCMBitDepthKey] as? Int) ?? 0 - return (srStr, bd > 0 ? "\(bd)-BIT" : "COMPRESSED") - }() - let finalSampleRate = computedSampleRate - let finalBitDepth = computedBitDepth + // This task inherits the main actor; open the source file on a worker instead. + let probed = await Task.detached(priority: .utility) { Self.probeFormat(url) }.value + let finalSampleRate = probed?.sampleRate ?? "44.1 kHz" + let finalBitDepth = probed?.bitDepth ?? "24-BIT PCM" await MainActor.run { - guard !Task.isCancelled, self.metadataRequestID == requestID else { return } - self.albumArt = finalArt - self.trackTitle = finalTitle + guard !Task.isCancelled, let self, self.metadataRequestID == requestID else { return } + self.albumArt = finalArt.map { NSImage(cgImage: $0, size: NSSize(width: $0.width, height: $0.height)) } + self.trackTitle = self.titleOverride ?? finalTitle self.trackArtist = finalArtist self.trackAlbum = finalAlbum self.trackAudioFormat = finalFormat @@ -1124,20 +1320,18 @@ public final class AudioEngineManager { self.trackSampleRate = finalSampleRate self.trackBitDepth = finalBitDepth - let duration = self.totalTrackDuration ?? 0.0 - let elapsed = self.currentPlaybackTimeSeconds ?? 0.0 - NowPlayingManager.shared.updateNowPlayingInfo( - title: self.trackTitle, - artist: self.trackArtist, - album: self.trackAlbum, - artwork: self.albumArt, - duration: duration, - elapsed: elapsed, - isPlaying: self.isPlaying - ) + self.publishNowPlayingMetadata() } } } + + private func publishNowPlayingMetadata() { + NowPlayingManager.shared.updateNowPlayingInfo( + title: trackTitle, artist: trackArtist, album: trackAlbum, artwork: albumArt, + duration: totalTrackDuration ?? 0, elapsed: currentPlaybackTimeSeconds ?? 0, + isPlaying: isPlaying + ) + } // MARK: - Export public nonisolated static func renderStemToFile(sourceURL: URL, destURL: URL, low: Float, mid: Float, high: Float) throws { @@ -1162,16 +1356,57 @@ public final class AudioEngineManager { } } + /// Export names keep the library title's casing; the player shows it uppercased. + var exportTitle: String { + let title = titleOverride ?? trackTitle + return title.isEmpty ? currentTrackName : title + } + + /// Whether any stem file will have its channel EQ rendered in. + var stemExportIncludesEQ: Bool { + guard shouldBakeEQOnExport, !isGlobalEQBypassed else { return false } + return (0..<4).contains { index in + let eq = getStemEQ(index) + return !eq.isBypassed && (abs(eq.low) >= 0.01 || abs(eq.mid) >= 0.01 || abs(eq.high) >= 0.01) + } + } + + func stemExportMessage(format: String) -> String { + let eq = stemExportIncludesEQ ? "with channel EQ applied" : "without EQ" + return "Four individual stems in \(format) \(eq). Levels, pan, speed and pitch are excluded. If any stem would clip, all four are lowered together to stay below full scale." + } + + /// Compare Original exports the source instead of the stem mix, so name and describe it that way. + var mixExportPanelText: (name: String, message: String) { + let base = AudioExporter.safeFilename(exportTitle) + guard isBypassed, audioFile != nil else { + return ("\(base)_Mix.wav", "Export the full track with current levels, pan, EQ, speed and pitch as 24-bit WAV.") + } + return ("\(base)_Original.wav", + "Compare Original is on: exports the original track with master EQ, speed and pitch as 24-bit WAV.") + } + + /// Remote media commands can load another track while a modal save panel is open. + private func exportSnapshotIsCurrent(_ generation: Int) -> Bool { + guard generation == loadGeneration, hasLoadedTrack, !isExporting, !isSplitting else { + showError("The track changed while the save panel was open. Nothing was exported.") + return false + } + return true + } + public func exportStems() { guard hasLoadedTrack, !isExporting, !isSplitting else { return } - let panel = NSSavePanel() - panel.nameFieldStringValue = "\(AudioExporter.safeFilename(currentTrackName))_Stems.zip" - panel.allowedContentTypes = [.zip] - panel.message = "Four individual stems in \(AppSettings.shared.defaultExportFormat). Channel levels, pan, speed and pitch are excluded." - guard panel.runModal() == .OK, let destination = panel.url else { return } + let generation = loadGeneration let sources = exportSources(includeMix: false) - let title = currentTrackName + let title = exportTitle let format = AudioExporter.Format(rawValue: AppSettings.shared.defaultExportFormat) ?? .wav + let panel = NSSavePanel() + panel.nameFieldStringValue = "\(AudioExporter.safeFilename(title))_Stems.zip" + panel.allowedContentTypes = [.zip] + panel.message = stemExportMessage(format: AppSettings.shared.defaultExportFormat) + guard panel.runModal() == .OK, let destination = panel.url, + exportSnapshotIsCurrent(generation) else { return } beginExport { [self] in try AudioExporter.archive(sources: sources, title: title, format: format, to: destination) { progress in Task { @MainActor [self] in @@ -1186,118 +1421,328 @@ public final class AudioEngineManager { public func exportMix() { guard hasLoadedTrack, !isExporting, !isSplitting else { return } - let panel = NSSavePanel() - panel.nameFieldStringValue = "\(AudioExporter.safeFilename(currentTrackName))_Mix.wav" - panel.allowedContentTypes = [.wav] - panel.message = "Export the full track with current levels, pan, EQ, speed and pitch as 24-bit WAV." - guard panel.runModal() == .OK, let destination = panel.url else { return } + let generation = loadGeneration + let text = mixExportPanelText let sources = isBypassed && audioFile != nil ? [AudioExporter.Source(url: audioFile!.url)] : exportSources(includeMix: true) let gains = getStemEQ(4) let masterEQ = isGlobalEQBypassed || gains.isBypassed ? AudioExporter.EQ() : .init(low: gains.low, mid: gains.mid, high: gains.high) let rate = Float(playbackRate) let pitch = Float(pitchShiftSemitones) - beginExport { + let panel = NSSavePanel() + panel.nameFieldStringValue = text.name + panel.allowedContentTypes = [.wav] + panel.message = text.message + guard panel.runModal() == .OK, let destination = panel.url, + exportSnapshotIsCurrent(generation) else { return } + beginExport { [self] in let temporary = FileManager.default.temporaryDirectory.appending(path: "\(UUID().uuidString).wav") defer { try? FileManager.default.removeItem(at: temporary) } - try AudioExporter.render(sources: sources, to: temporary, masterEQ: masterEQ, rate: rate, pitch: pitch, limitPeak: true) + try AudioExporter.render(sources: sources, to: temporary, masterEQ: masterEQ, rate: rate, pitch: pitch, limitPeak: true) { progress in + Task { @MainActor [self] in + guard case .exporting = self.exportState else { return } + // Rendering reports completion before the file is published. + let shown = min(progress, 0.99) + self.exportProgress = shown + self.exportState = .exporting(stage: "RENDERING", percent: shown) + } + } + // A cancel after the last rendered block must still keep the destination. + try Task.checkCancellation() try AudioExporter.publish(temporary, to: destination) return destination } } - private func beginExport(_ operation: @escaping @Sendable () throws -> URL) { + @ObservationIgnored private var exportTask: Task? + + /// Stops an export in progress; the destination is left untouched. + public func cancelExport() { + exportTask?.cancel() + } + + /// Runs `operation` off the main actor. Internal for tests. + func beginExport(_ operation: @escaping @Sendable () throws -> URL) { exportState = .exporting(stage: "RENDERING", percent: 0) exportProgress = 0 - Task { + exportTask = Task { do { - let destination = try await Task.detached(priority: .userInitiated, operation: operation).value + let destination = try await whileKeepingAwake("Exporting audio") { + let worker = Task.detached(priority: .userInitiated, operation: operation) + return try await withTaskCancellationHandler { + try await worker.value + } onCancel: { + worker.cancel() + } + } + // Exports check for cancellation up to the final swap, so returning means the + // destination was replaced, even if Cancel arrived during that swap. exportState = .completed exportProgress = 1 NSWorkspace.shared.activateFileViewerSelecting([destination]) try? await Task.sleep(for: .seconds(2)) + } catch is CancellationError { + // Cancelled by the user or at quit; nothing to report. } catch { showError("Export failed: \(error.localizedDescription)") } exportState = .idle exportProgress = 0 + exportTask = nil } } + /// Keeps the Mac from idle-sleeping and the app out of App Nap while `work` runs, since + /// separations and exports can run unattended for minutes. The display may still sleep. + private func whileKeepingAwake(_ reason: String, _ work: () async throws -> T) async rethrows -> T { + let activity = ProcessInfo.processInfo.beginActivity(options: [.userInitiated, .idleSystemSleepDisabled], reason: reason) + defer { ProcessInfo.processInfo.endActivity(activity) } + return try await work() + } + // MARK: - Synchronized Playback Graph Scheduling private func onPlaybackEnded() { - guard isPlaying else { return } - if isLooping { - seek(toPercentage: loopStartProgress) + if isPlaying && isLooping { + seek(toPercentage: loopStartProgress, flushTail: false) } else { + // Also reached when a pause lands while this completion is queued: the players + // have nothing left, so the next Play must restart instead of resuming them. stopPlayers() playbackProgress = 1 updateTimeString(for: 1) NowPlayingManager.shared.updateNowPlayingPlaybackState() + releaseOutputAfterTail() + } + } + + /// Lets the limiter and time/pitch tails play out, then idles the output device + /// so the Mac can sleep. playSynced() restarts the engine. + private func releaseOutputAfterTail() { + Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(500)) + guard let self, !self.isPlaying else { return } + self.engine.pause() } } private func stopPlayers() { // Invalidate callbacks before stop() invokes outstanding completions. playbackSessionID = UUID() + pausedFrame = nil vocalPlayer.stop() drumPlayer.stop() bassPlayer.stop() otherPlayer.stop() originalPlayer.stop() isPlaying = false - timer?.invalidate() - timer = nil + playbackClock.timer?.invalidate() + playbackClock.timer = nil clearVisualizers() } @MainActor private func playSynced() { guard fileVocals != nil else { return } + // Players left running by pausePlayback() resume together with the engine. + let resumesPausedPlayers = vocalPlayer.isPlaying + if !resumesPausedPlayers, let frame = pausedFrame, let vocals = fileVocals, frame < vocals.length { + // The pause did not keep the players armed: restart all five from where it stopped. + stopPlayers() + timePitchNode.reset() + seekFrameOffset = frame + schedulePlayers(from: frame) + } + pausedFrame = nil + var startedEngine = false if !engine.isRunning { do { try engine.start() + startedEngine = true } catch { showError("Could not start audio output: \(error.localizedDescription)") return } } - let startHostTime = mach_absolute_time() + AVAudioTime.hostTime(forSeconds: 0.03) - let startTime = AVAudioTime(hostTime: startHostTime) - - vocalPlayer.play(at: startTime) - drumPlayer.play(at: startTime) - bassPlayer.play(at: startTime) - otherPlayer.play(at: startTime) - if audioFile != nil { originalPlayer.play(at: startTime) } + if !resumesPausedPlayers { startPlayersTogether(afterEngineStart: startedEngine) } self.isPlaying = true self.startPlaybackTimer() NowPlayingManager.shared.updateNowPlayingPlaybackState() } + + /// How the most recent synchronized start went; read by tests and useful in bug reports. + struct StartReport: Sendable { + var attempts: Int + var lead: TimeInterval + var callDuration: TimeInterval + var startedTogether: Bool + var usedRenderTimeline: Bool + var watchdogRestarts: Int + } + private(set) var lastStartReport: StartReport? + /// Set when a render-timeline start never took effect on this output; later starts use host time. + @ObservationIgnored private var renderTimelineStartUnreliable = false + @ObservationIgnored private var watchdogRestarts = 0 + + /// Starts all players on one frame of the engine's render timeline. A host-time start + /// makes each play(at:) wait about one IO cycle and, measured on a real Mac, still + /// misaligned about one seek in five; a sample-time start returns immediately and lands + /// every player on the same render frame, a few IO cycles ahead so seeks and loop wraps + /// stay short. Without a usable timeline, or when this output ignored one, the start + /// falls back to a host time with a lead that covers the blocking calls, rescheduling + /// and retrying with a measured lead if they overran it. + private func startPlayersTogether(afterEngineStart: Bool = false) { + var players = [vocalPlayer, drumPlayer, bassPlayer, otherPlayer] + if audioFile != nil { players.append(originalPlayer) } + var cycle = outputCycleDuration() + var attempt = 0 + if !renderTimelineStartUnreliable { + while attempt < 3, let start = renderTimelineStart(cycle: cycle, afterEngineStart: afterEngineStart && attempt == 0) { + attempt += 1 + let issued = mach_absolute_time() + for player in players { player.play(at: start.time) } + let took = AVAudioTime.seconds(forHostTime: mach_absolute_time() - issued) + // Every call must land before the render that reaches the start frame, so the + // newest render may have advanced by at most one cycle while they ran. + let newest = vocalPlayer.lastRenderTime.map { $0.isSampleTimeValid ? $0.sampleTime : start.anchor } ?? start.anchor + let startedTogether = newest + 2 * start.framesPerCycle <= start.time.sampleTime + lastStartReport = StartReport(attempts: attempt, lead: start.lead, callDuration: took, startedTogether: startedTogether, + usedRenderTimeline: true, watchdogRestarts: watchdogRestarts) + if startedTogether { + verifyStartTookEffect(session: playbackSessionID, after: start.lead) + return + } + stopPlayers() + timePitchNode.reset() + schedulePlayers(from: seekFrameOffset) + } + } + var margin = 4 * cycle + engine.outputNode.presentationLatency + 0.005 + var lead = Double(players.count + 1) * cycle + margin + let attempts = attempt + 4 + while attempt < attempts { + attempt += 1 + let issued = mach_absolute_time() + let startHostTime = issued + AVAudioTime.hostTime(forSeconds: lead) + for player in players { player.play(at: AVAudioTime(hostTime: startHostTime)) } + let finished = mach_absolute_time() + let took = AVAudioTime.seconds(forHostTime: finished - issued) + cycle = max(cycle, took / Double(players.count)) + margin = 4 * cycle + engine.outputNode.presentationLatency + 0.005 + let startedTogether = finished + AVAudioTime.hostTime(forSeconds: margin) <= startHostTime + lastStartReport = StartReport(attempts: attempt, lead: lead, callDuration: took, startedTogether: startedTogether, + usedRenderTimeline: false, watchdogRestarts: watchdogRestarts) + guard !startedTogether, attempt < attempts else { return } + stopPlayers() + timePitchNode.reset() + schedulePlayers(from: seekFrameOffset) + // The cap leaves room past the margin on outputs whose latency alone nears 2 s. + lead = min(max(2, margin + 0.5), max(lead * 2, took * 1.5 + margin)) + } + } + + /// A start three output cycles past the newest render on the players' own timeline + /// (44.1 kHz). The output node runs at the device rate, so its timeline must never be + /// used for the players. The frame comes from sample time alone: sample time stands still + /// while the engine is paused, but the host time reported with it then trails real time + /// by the pause, so a start mapped through host time would come that much late. Right + /// after the engine starts, waits briefly for its first render so the anchor is current. + private func renderTimelineStart(cycle: TimeInterval, afterEngineStart: Bool) + -> (time: AVAudioTime, anchor: AVAudioFramePosition, framesPerCycle: AVAudioFramePosition, lead: TimeInterval)? { + func usable(_ time: AVAudioTime?) -> Bool { + guard let time else { return false } + return time.isSampleTimeValid && time.sampleRate > 0 + } + var anchor = vocalPlayer.lastRenderTime + // Until the restarted engine renders, the reported time is the one from before it stopped. + let stale = afterEngineStart && usable(anchor) ? anchor?.sampleTime : nil + let deadline = Date().addingTimeInterval(0.1) + while !usable(anchor) || anchor?.sampleTime == stale, engine.isRunning, Date() < deadline { + usleep(1_000) + anchor = vocalPlayer.lastRenderTime + } + guard usable(anchor), let anchor, anchor.sampleTime != stale else { return nil } + // Time/pitch pulls the players `speed` times faster than the output plays them. + let speed = timePitchNode.bypass ? 1 : Double(timePitchNode.rate) + let perCycle: Double = cycle * anchor.sampleRate * max(1, speed) + let framesPerCycle = AVAudioFramePosition(perCycle.rounded(.up)) + let frame = anchor.sampleTime + 3 * framesPerCycle + let lead: TimeInterval = Double(3 * framesPerCycle) / (anchor.sampleRate * speed) + return (AVAudioTime(sampleTime: frame, atRate: anchor.sampleRate), anchor.sampleTime, framesPerCycle, lead) + } + + /// A start that never takes effect leaves every player silent. Once the start time has + /// passed, confirm the players moved; if not, restart from the same frame through the + /// host-time path and keep using it on this output. + private func verifyStartTookEffect(session: UUID, after lead: TimeInterval) { + Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(lead + 0.25)) + guard let self, self.isPlaying, self.playbackSessionID == session, + self.lastStartReport?.usedRenderTimeline == true else { return } + if let nodeTime = self.vocalPlayer.lastRenderTime, + let playerTime = self.vocalPlayer.playerTime(forNodeTime: nodeTime), playerTime.sampleTime > 0 { return } + self.renderTimelineStartUnreliable = true + self.watchdogRestarts += 1 + let frame = self.seekFrameOffset + self.stopPlayers() + self.timePitchNode.reset() + self.seekFrameOffset = frame + self.schedulePlayers(from: frame) + self.playSynced() + } + } + + /// One output IO cycle, never taken as shorter than 512 frames. Internal for tests. + func outputCycleDuration() -> TimeInterval { + var frames: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + if let unit = engine.outputNode.audioUnit { + AudioUnitGetProperty(unit, kAudioDevicePropertyBufferFrameSize, kAudioUnitScope_Global, 0, &frames, &size) + } + let rate = engine.outputNode.outputFormat(forBus: 0).sampleRate + return Double(max(frames, 512)) / (rate > 0 ? rate : 44_100) + } + + /// Pausing the engine rather than each player freezes all five on the same render cycle, + /// keeps buffered tails for a seamless resume, and releases the output device so the Mac + /// can idle-sleep. playSynced() restarts the engine to resume. + private func pausePlayback() { + pausedFrame = currentPlaybackFrame() + engine.pause() + playbackClock.timer?.invalidate() + isPlaying = false + clearVisualizers() + NowPlayingManager.shared.updateNowPlayingPlaybackState() + } + + /// Engine state for tests; false while paused or stopped. + var isOutputRunning: Bool { engine.isRunning } + + /// Where a pause stopped, so a resume that has to restart the players continues there + /// rather than at the last seek. Cleared whenever the players are stopped or rescheduled. + @ObservationIgnored private var pausedFrame: AVAudioFramePosition? + + private func currentPlaybackFrame() -> AVAudioFramePosition? { + guard let vocals = fileVocals, let nodeTime = vocalPlayer.lastRenderTime, + let playerTime = vocalPlayer.playerTime(forNodeTime: nodeTime) else { return nil } + let frame = seekFrameOffset + playerTime.sampleTime + return min(max(0, frame), vocals.length) + } @MainActor public func togglePlayback() { guard fileVocals != nil, !isSplitting else { return } if isPlaying { - vocalPlayer.pause() - drumPlayer.pause() - bassPlayer.pause() - otherPlayer.pause() - originalPlayer.pause() - timer?.invalidate() - isPlaying = false - clearVisualizers() - NowPlayingManager.shared.updateNowPlayingPlaybackState() + pausePlayback() } else { - if playbackProgress >= 1 { seek(toPercentage: 0) } + if playbackProgress >= 1 { seek(toPercentage: isLooping ? loopStartProgress : 0) } playSynced() } } // High-precision 60Hz Playback Timer (16.6ms) for Instantaneous Time & Progress Sync (Active in Common RunLoop Modes) private func startPlaybackTimer() { - timer?.invalidate() + playbackClock.timer?.invalidate() let t = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in Task { @MainActor in guard let self = self, self.isPlaying, @@ -1313,27 +1758,13 @@ public final class AudioEngineManager { let progress = max(0, min(1, elapsed / duration)) if self.isLooping && progress >= self.loopEndProgress { - self.seek(toPercentage: self.loopStartProgress) + self.seek(toPercentage: self.loopStartProgress, flushTail: false) return } - self.playbackProgress = progress - - let totalDurationSecs = Int(round(duration)) - let elapsedSecs = min(totalDurationSecs, Int(floor(elapsed))) - let remainingSecs = max(0, totalDurationSecs - elapsedSecs) - - let mins = elapsedSecs / 60 - let secs = elapsedSecs % 60 - let rMins = remainingSecs / 60 - let rSecs = remainingSecs % 60 - self.currentTimeString = String(format: "%02d:%02d / -%02d:%02d", mins, secs, rMins, rSecs) - - let elapsedMs = Int((elapsed.truncatingRemainder(dividingBy: 1.0)) * 1000) - let remExact = max(0.0, duration - elapsed) - let remMs = Int((remExact.truncatingRemainder(dividingBy: 1.0)) * 1000) - self.detailedTimecode = String(format: "%02d:%02d.%03d / -%02d:%02d.%03d", mins, secs, elapsedMs, rMins, rSecs, remMs) + self.publishPlaybackPosition(progress: progress, elapsed: elapsed, duration: duration) + let elapsedSecs = Int(min(duration, elapsed)) if elapsedSecs != self.lastSyncedNowPlayingSec { self.lastSyncedNowPlayingSec = elapsedSecs NowPlayingManager.shared.updateNowPlayingProgress(elapsed: elapsed, duration: duration) @@ -1341,37 +1772,66 @@ public final class AudioEngineManager { } } RunLoop.main.add(t, forMode: .common) - self.timer = t + playbackClock.timer = t } + /// The timer's position update. While the player cannot be seen only the stored position + /// advances, so code reading `playbackProgress` stays current without re-rendering + /// hidden views 60 times a second. + func publishPlaybackPosition(progress: Double, elapsed: Double, duration: Double) { + guard isUIVisible else { + storedPlaybackProgress = progress + return + } + playbackProgress = progress + updateTimeDisplay(elapsed: elapsed, duration: duration) + } + @MainActor public func updateTimeString(for progress: Double) { guard let fVocals = fileVocals else { return } let duration = Double(fVocals.length) / fVocals.processingFormat.sampleRate - guard duration > 0 else { return } - let totalDurationSecs = Int(round(duration)) - let elapsedSecs = min(totalDurationSecs, Int(floor(duration * progress))) - let remainingSecs = max(0, totalDurationSecs - elapsedSecs) - - let mins = elapsedSecs / 60 - let secs = elapsedSecs % 60 - let rMins = remainingSecs / 60 - let rSecs = remainingSecs % 60 - currentTimeString = String(format: "%02d:%02d / -%02d:%02d", mins, secs, rMins, rSecs) - - let exactElapsed = duration * progress - let elapsedMs = Int((exactElapsed.truncatingRemainder(dividingBy: 1.0)) * 1000) - let remExact = max(0.0, duration - exactElapsed) - let remMs = Int((remExact.truncatingRemainder(dividingBy: 1.0)) * 1000) - detailedTimecode = String(format: "%02d:%02d.%03d / -%02d:%02d.%03d", mins, secs, elapsedMs, rMins, rSecs, remMs) + guard duration > 0, progress.isFinite else { return } + updateTimeDisplay(elapsed: duration * max(0, min(1, progress)), duration: duration) + } + + private func updateTimeDisplay(elapsed: Double, duration: Double) { + let times = Self.playbackTimecodes(elapsed: elapsed, duration: duration) + currentTimeString = times.compact + detailedTimecode = times.detailed + } + + nonisolated static func playbackTimecodes(elapsed: Double, duration: Double) -> (compact: String, detailed: String) { + let current = min(duration, max(0, elapsed)) + let remaining = max(0, duration - current) + let elapsedMS = Int((current * 1000).rounded()) + let remainingMS = Int((remaining * 1000).rounded()) + let compact = String(format: "%02d:%02d / -%02d:%02d", + Int(current) / 60, Int(current) % 60, Int(ceil(remaining)) / 60, Int(ceil(remaining)) % 60) + let detailed = String(format: "%02d:%02d.%03d / -%02d:%02d.%03d", + elapsedMS / 60_000, elapsedMS / 1000 % 60, elapsedMS % 1000, + remainingMS / 60_000, remainingMS / 1000 % 60, remainingMS % 1000) + return (compact, detailed) } public func seek(toPercentage percentage: Double) { + seek(toPercentage: percentage, flushTail: true) + } + + /// Loop wraps keep the time/pitch tail so the end of the region stays audible; other + /// seeks flush it so audio from the old position is not heard after the jump. + private func seek(toPercentage percentage: Double, flushTail: Bool) { guard percentage.isFinite, let vocals = fileVocals, - let drums = fileDrums, let bass = fileBass, let other = fileOther else { return } + fileDrums != nil, fileBass != nil, fileOther != nil else { return } let progress = max(0, min(1, percentage)) + // Reaching the end while looping wraps to the loop start, like a natural end. + if progress >= 1 && isPlaying && isLooping { + seek(toPercentage: loopStartProgress, flushTail: flushTail) + return + } let wasPlaying = isPlaying stopPlayers() + if flushTail { timePitchNode.reset() } let totalFrames = vocals.length let target = min(totalFrames, max(0, AVAudioFramePosition(Double(totalFrames) * progress))) seekFrameOffset = target @@ -1380,10 +1840,18 @@ public final class AudioEngineManager { let duration = totalTrackDuration ?? 0 NowPlayingManager.shared.updateNowPlayingProgress(elapsed: duration * progress, duration: duration) guard target < totalFrames else { + engine.pause() NowPlayingManager.shared.updateNowPlayingPlaybackState() return } - let count = AVAudioFrameCount(min(Int64(UInt32.max), totalFrames - target)) + schedulePlayers(from: target) + if wasPlaying { playSynced() } + } + + private func schedulePlayers(from target: AVAudioFramePosition) { + guard let vocals = fileVocals, let drums = fileDrums, let bass = fileBass, + let other = fileOther, target < vocals.length else { return } + let count = AVAudioFrameCount(min(Int64(UInt32.max), vocals.length - target)) let session = playbackSessionID vocalPlayer.scheduleSegment(vocals, startingFrame: target, frameCount: count, at: nil, completionCallbackType: .dataPlayedBack) { [weak self] _ in @@ -1398,6 +1866,5 @@ public final class AudioEngineManager { if let audioFile { originalPlayer.scheduleSegment(audioFile, startingFrame: target, frameCount: count, at: nil) } - if wasPlaying { playSynced() } } } diff --git a/Sources/App/AudioExporter.swift b/Sources/App/AudioExporter.swift index 9bfc255..e873f53 100644 --- a/Sources/App/AudioExporter.swift +++ b/Sources/App/AudioExporter.swift @@ -1,3 +1,4 @@ +import Accelerate import AVFoundation enum AudioExporter { @@ -27,10 +28,13 @@ enum AudioExporter { } static func safeFilename(_ title: String) -> String { - let invalid = CharacterSet(charactersIn: "/:\\").union(.controlCharacters) + // Names are shared in ZIPs, so also exclude characters Windows cannot extract. + let invalid = CharacterSet(charactersIn: "/:\\?*\"<>|").union(.controlCharacters) + // A leading dot hides the file on macOS; Windows drops trailing dots and spaces. + let edges = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ".")) let name = title.components(separatedBy: invalid).joined(separator: "_") - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty, name != ".", name != ".." else { return "Isolate" } + .trimmingCharacters(in: edges) + guard !name.isEmpty else { return "Isolate" } // Leave room for stem names and extensions on 255-byte file systems. // Truncate at Character boundaries so Unicode titles remain valid. var result = "" @@ -41,13 +45,71 @@ enum AudioExporter { result.append(character) byteCount += size } + result = result.trimmingCharacters(in: edges) return result.isEmpty ? "Isolate" : result } + /// Stems in fixed-point formats peak at no more than -0.1 dBFS. + static let stemPeakCeiling = Float(pow(10, -0.1 / 20)) + /// Render on a worker task using a separate graph; live playback is untouched. + /// `progress` receives the rendered fraction on the rendering thread, at most once per whole percent. static func render(sources: [Source], to destination: URL, format: Format = .wav, masterEQ: EQ = EQ(), rate: Float = 1, pitch: Float = 0, - limitPeak: Bool = false) throws { + limitPeak: Bool = false, progress: (@Sendable (Double) -> Void)? = nil) throws { + _ = try renderMeasured(sources: sources, to: destination, settings: format.settings, masterEQ: masterEQ, + rate: rate, pitch: pitch, limitPeak: limitPeak) { progress?($0) } + } + + private static func makePeakLimiter() -> AVAudioUnitEffect { + AVAudioUnitEffect(audioComponentDescription: AudioComponentDescription( + componentType: kAudioUnitType_Effect, componentSubType: kAudioUnitSubType_PeakLimiter, + componentManufacturer: kAudioUnitManufacturer_Apple, componentFlags: 0, componentFlagsMask: 0)) + } + + /// The peak limiter's look-ahead delay in frames. `AVAudioNode.latency` reads 0 on + /// macOS 15 before rendering starts, so measure it once with an impulse instead. + static let peakLimiterDelay: AVAudioFramePosition = measurePeakLimiterDelay() ?? 0 + + static func measurePeakLimiterDelay() -> AVAudioFramePosition? { + final class Impulse: @unchecked Sendable { var pending = true } + let format = StreamingAudio.format + let engine = AVAudioEngine() + let impulse = Impulse() + let source = AVAudioSourceNode(format: format) { _, _, frameCount, bufferList in + for buffer in UnsafeMutableAudioBufferListPointer(bufferList) { + guard let data = buffer.mData?.assumingMemoryBound(to: Float.self) else { continue } + data.update(repeating: 0, count: Int(frameCount)) + if impulse.pending, frameCount > 0 { data[0] = 0.5 } + } + impulse.pending = false + return noErr + } + let limiter = makePeakLimiter() + engine.attach(source) + engine.attach(limiter) + engine.connect(source, to: limiter, format: format) + engine.connect(limiter, to: engine.mainMixerNode, format: format) + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 4096), + (try? engine.enableManualRenderingMode(.offline, format: format, maximumFrameCount: 4096)) != nil, + (try? engine.start()) != nil else { return nil } + defer { engine.stop() } + var position: AVAudioFramePosition = 0 + while position < 8192 { + guard (try? engine.renderOffline(4096, to: buffer)) == .success, + let left = buffer.floatChannelData?[0] else { return nil } + for index in 0.. 0.05 { + return position + AVAudioFramePosition(index) + } + position += AVAudioFramePosition(buffer.frameLength) + } + return nil + } + + /// Returns the largest sample magnitude written, measured before any fixed-point conversion. + private static func renderMeasured(sources: [Source], to destination: URL, settings: [String: Any], + masterEQ: EQ, rate: Float, pitch: Float, limitPeak: Bool, + progress: (Double) -> Void) throws -> Float { guard !sources.isEmpty, rate.isFinite, rate > 0 else { throw DemucsError.invalidAudioFormat } let engine = AVAudioEngine() let audioFormat = StreamingAudio.format @@ -84,13 +146,14 @@ enum AudioExporter { engine.attach(eq) engine.connect(sum, to: timePitch, format: audioFormat) engine.connect(timePitch, to: eq, format: audioFormat) + var latency: AVAudioFramePosition = 0 if limitPeak { - let limiter = AVAudioUnitEffect(audioComponentDescription: AudioComponentDescription( - componentType: kAudioUnitType_Effect, componentSubType: kAudioUnitSubType_PeakLimiter, - componentManufacturer: kAudioUnitManufacturer_Apple, componentFlags: 0, componentFlagsMask: 0)) + let limiter = makePeakLimiter() engine.attach(limiter) engine.connect(eq, to: limiter, format: audioFormat) engine.connect(limiter, to: engine.mainMixerNode, format: audioFormat) + // Drop the limiter's look-ahead so the mix stays aligned with the source and keeps its ending. + latency = peakLimiterDelay } else { engine.connect(eq, to: engine.mainMixerNode, format: audioFormat) } @@ -103,18 +166,27 @@ enum AudioExporter { for (player, file) in zip(players, files) { player.scheduleFile(file, at: nil) } try engine.start() for player in players { player.play(at: AVAudioTime(sampleTime: 0, atRate: 44_100)) } - let output = try AVAudioFile(forWriting: destination, settings: format.settings) + let output = try AVAudioFile(forWriting: destination, settings: settings) guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFormat, frameCapacity: 4096) else { throw DemucsError.invalidAudioFormat } - let frameCount = AVAudioFramePosition(ceil(Double(files[0].length) / Double(rate))) + // Time/pitch spreads the final frames past the stretched length and reports no latency, + // so render a fixed tail to keep the ending. + let tail: AVAudioFramePosition = timePitch.bypass ? 0 : 4096 + let frameCount = AVAudioFramePosition(ceil(Double(files[0].length) / Double(rate))) + tail + latency var stalled = 0 + var peak: Float = 0 + var reported: AVAudioFramePosition = 0 while engine.manualRenderingSampleTime < frameCount { try Task.checkCancellation() - let count = AVAudioFrameCount(min(4096, frameCount - engine.manualRenderingSampleTime)) let before = engine.manualRenderingSampleTime + // Blocks never straddle the discarded look-ahead. + let end = before < latency ? latency : frameCount + let count = AVAudioFrameCount(min(4096, end - before)) switch try engine.renderOffline(count, to: buffer) { case .success: + guard before >= latency else { break } + peak = max(peak, Self.peak(of: buffer)) try output.write(from: buffer) case .cannotDoInCurrentContext, .insufficientDataFromInputNode: break @@ -125,42 +197,194 @@ enum AudioExporter { } stalled = before == engine.manualRenderingSampleTime ? stalled + 1 : 0 guard stalled < 100 else { throw DemucsError.conversionFailed("Offline rendering stopped making progress.") } + let percent = engine.manualRenderingSampleTime * 100 / frameCount + if percent > reported { + reported = percent + progress(Double(engine.manualRenderingSampleTime) / Double(frameCount)) + } } + return peak } + /// Returns the gain applied to all four stems to stay below `stemPeakCeiling`, or 1 when none was needed. + @discardableResult static func archive(sources: [Source], title: String, format: Format, to destination: URL, - progress: @Sendable (Double) -> Void) throws { + progress: @Sendable (Double) -> Void) throws -> Float { let fm = FileManager.default let directory = fm.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) try fm.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? fm.removeItem(at: directory) } guard sources.count == 4 else { throw DemucsError.invalidAudioFormat } - var names: [String] = [] + // Separated stems and EQ boosts can exceed full scale, which 24-bit WAV and FLAC would clip. + // Render in Float32 first, then lower all four by one gain so their balance and sum are kept. + var rendered: [URL] = [] + var peak: Float = 0 + // Rendering fills 0-40% and encoding 40-80%; the archive step reports 80%. for (index, source) in sources.enumerated() { + let url = directory.appending(path: "render-\(index).wav") + peak = max(peak, try renderMeasured(sources: [source], to: url, settings: StreamingAudio.settings, + masterEQ: EQ(), rate: 1, pitch: 0, limitPeak: false) { + progress((Double(index) + $0) / 10) + }) + rendered.append(url) + } + let gain = peak > stemPeakCeiling ? stemPeakCeiling / peak : 1 + var names: [String] = [] + for (index, url) in rendered.enumerated() { let name = "\(safeFilename(title))_\(DemucsEngine.stemNames[index]).\(format.fileExtension)" names.append(name) - try render(sources: [source], to: directory.appending(path: name), format: format) - progress(Double(index + 1) / 5) + try encode(url, to: directory.appending(path: name), format: format, gain: gain) { + progress(0.4 + (Double(index) + $0) / 10) + } + try? fm.removeItem(at: url) } let archive = directory.appending(path: "stems.zip") let process = Process() process.executableURL = URL(filePath: "/usr/bin/zip") process.currentDirectoryURL = directory - process.arguments = ["-q", archive.path, "--"] + names + // Store entries: PCM and FLAC barely deflate, and compressing them was the slowest export step. + process.arguments = ["-q", "-0", archive.path, "--"] + names try process.run() + // Poll so a cancelled export also stops zip. + while process.isRunning { + if Task.isCancelled { process.terminate() } + Thread.sleep(forTimeInterval: 0.05) + } process.waitUntilExit() + try Task.checkCancellation() guard process.terminationStatus == 0 else { throw DemucsError.conversionFailed("Could not create the stem archive.") } + // Best effort: an unflagged name still extracts correctly on macOS. + try? markUTF8Names(in: archive) try Task.checkCancellation() try publish(archive, to: destination) progress(1) + return gain + } + + /// Converts rendered Float32 audio to the export format, scaling every sample by `gain`. + private static func encode(_ source: URL, to destination: URL, format: Format, gain: Float, + progress: (Double) -> Void) throws { + let input = try AVAudioFile(forReading: source) + let output = try AVAudioFile(forWriting: destination, settings: format.settings) + guard let buffer = AVAudioPCMBuffer(pcmFormat: input.processingFormat, frameCapacity: 65_536) else { + throw DemucsError.invalidAudioFormat + } + var scale = gain + var reported: AVAudioFramePosition = 0 + while input.framePosition < input.length { + try Task.checkCancellation() + try input.read(into: buffer) + guard buffer.frameLength > 0, let channels = buffer.floatChannelData else { + throw DemucsError.invalidAudioFormat + } + if gain != 1 { + for channel in 0.. reported { + reported = percent + progress(Double(input.framePosition) / Double(input.length)) + } + } + } + + /// /usr/bin/zip stores UTF-8 names without general purpose bit 11, so Windows and other readers + /// decode non-ASCII names as CP437. Set the bit in the central and local header of each such entry. + static func markUTF8Names(in archive: URL) throws { + let invalid = DemucsError.conversionFailed("Could not read the stem archive.") + let handle = try FileHandle(forUpdating: archive) + defer { try? handle.close() } + func read(_ offset: UInt64, _ count: Int) throws -> [UInt8] { + try handle.seek(toOffset: offset) + guard count > 0, let data = try handle.read(upToCount: count), data.count == count else { throw invalid } + return [UInt8](data) + } + func setFlag(at offset: UInt64, _ flags: UInt16) throws { + let value = flags | 0x0800 + try handle.seek(toOffset: offset) + try handle.write(contentsOf: Data([UInt8(value & 0xFF), UInt8(value >> 8)])) + } + // The end record is 22 bytes plus a comment of up to 65,535 bytes. + let size = try handle.seekToEnd() + let tailStart = size - min(size, 65_557) + let tail = try read(tailStart, Int(size - tailStart)) + guard let end = stride(from: tail.count - 22, through: 0, by: -1).first(where: { + tail.uint32(at: $0) == 0x0605_4B50 && $0 + 22 + Int(tail.uint16(at: $0 + 20)) == tail.count + }) else { throw invalid } + var directorySize = UInt64(tail.uint32(at: end + 12)) + var directoryOffset = UInt64(tail.uint32(at: end + 16)) + if end >= 20, tail.uint32(at: end - 20) == 0x0706_4B50 { + let record = try read(tail.uint64(at: end - 12), 56) + guard record.uint32(at: 0) == 0x0606_4B50 else { throw invalid } + directorySize = record.uint64(at: 40) + directoryOffset = record.uint64(at: 48) + } + guard directorySize < 1 << 24 else { throw invalid } + let directory = try read(directoryOffset, Int(directorySize)) + var position = 0 + while position + 46 <= directory.count { + guard directory.uint32(at: position) == 0x0201_4B50 else { throw invalid } + let nameEnd = position + 46 + Int(directory.uint16(at: position + 28)) + let extraEnd = nameEnd + Int(directory.uint16(at: position + 30)) + let next = extraEnd + Int(directory.uint16(at: position + 32)) + guard next <= directory.count else { throw invalid } + let name = directory[(position + 46)..= 0x80 }), String(bytes: name, encoding: .utf8) != nil { + var local = UInt64(directory.uint32(at: position + 42)) + if local == 0xFFFF_FFFF { + // The Zip64 extra field lists only the saturated sizes before the offset. + var field = nameEnd + var offset: UInt64? + while field + 4 <= extraEnd { + let length = Int(directory.uint16(at: field + 2)) + if directory.uint16(at: field) == 0x0001 { + var value = field + 4 + if directory.uint32(at: position + 24) == 0xFFFF_FFFF { value += 8 } + if directory.uint32(at: position + 20) == 0xFFFF_FFFF { value += 8 } + if value + 8 <= min(field + 4 + length, extraEnd) { offset = directory.uint64(at: value) } + } + field += 4 + length + } + guard let offset else { throw invalid } + local = offset + } + let header = try read(local, 8) + guard header.uint32(at: 0) == 0x0403_4B50 else { throw invalid } + try setFlag(at: local + 6, header.uint16(at: 6)) + try setFlag(at: directoryOffset + UInt64(position) + 8, directory.uint16(at: position + 8)) + } + position = next + } + } + + private static func peak(of buffer: AVAudioPCMBuffer) -> Float { + guard let channels = buffer.floatChannelData else { return 0 } + var peak: Float = 0 + for channel in 0..