Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ env:

jobs:
python-tests:
name: Python Helper Tests
name: Separator Helper Tests
runs-on: macos-26
steps:
- name: Checkout
Expand All @@ -31,6 +31,9 @@ jobs:
- name: Run Python helper tests
run: python3 -m unittest JammLabSeparatorHelper/test_runner.py

- name: Run separator helper embed tests
run: bash scripts/test_embed_separator_helper.sh

swift-tests:
name: Swift Tests
runs-on: macos-26
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`.

## Unreleased

- Moved audio and Stem playback preparation off the main thread, with cancellable progress, safer memory limits, and transactional project/mode switching that keeps the current audio available if preparation fails.
- Hardened the bundled Stem helper with a versioned v6 job protocol, startup capability checks, stale-helper detection, and one validated manifest for bundled models and compute modes.
- Fixed automatic Stem transcription notation to show flats, naturals, and sharps consistently with the key signature and common-practice measure rules.
- Improved Notation measure spacing to prevent late notes from stretching a single measure across the view, keep visible parts aligned, backfill the final page, and balance score systems without avoidable one-measure rows.
- Added Backspace and Delete support for clearing selected Notation measures in the selected part while preserving harmony symbols and leaving default whole-measure rests.
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ Useful paths and settings:
- Model cache: `build/JammLabSeparatorHelper/model-cache`.
- PyInstaller output: `build/JammLabSeparatorHelper/dist/JammLabSeparatorHelper`.
- Python executable override: `PYTHON_BIN=/path/to/python3`.
- Default prefetched models: `htdemucs.yaml` and `UVR-MDX-NET-Inst_HQ_5.onnx`.
- Prefetched model list override: `SEPARATOR_MODELS="htdemucs.yaml UVR-MDX-NET-Inst_HQ_5.onnx other.yaml"`.
- Default prefetched models: `htdemucs.yaml`, `htdemucs_6s.yaml`, and `UVR-MDX-NET-Inst_HQ_5.onnx`.
- `JammLabSeparatorHelper/helper-manifest.json` is the single source of truth for supported models, required cache files, compute modes, and helper protocol compatibility.

The `JammLab` target copies
`build/JammLabSeparatorHelper/dist/JammLabSeparatorHelper` into
Expand Down
38 changes: 38 additions & 0 deletions JammLab.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions JammLab/AudioRenderAtomics.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#include "AudioRenderAtomics.h"

#include <stdatomic.h>
#include <stdlib.h>

struct JammLabAtomicInt64 {
_Atomic int64_t value;
};

JammLabAtomicInt64 *JammLabAtomicInt64Create(int64_t initialValue) {
JammLabAtomicInt64 *storage = malloc(sizeof(JammLabAtomicInt64));
if (storage == NULL) {
abort();
}
atomic_init(&storage->value, initialValue);
return storage;
}

void JammLabAtomicInt64Destroy(JammLabAtomicInt64 *storage) {
free(storage);
}

int64_t JammLabAtomicInt64Load(const JammLabAtomicInt64 *storage) {
return atomic_load_explicit(&storage->value, memory_order_acquire);
}

void JammLabAtomicInt64Store(JammLabAtomicInt64 *storage, int64_t value) {
atomic_store_explicit(&storage->value, value, memory_order_release);
}

int64_t JammLabAtomicInt64Increment(JammLabAtomicInt64 *storage) {
return atomic_fetch_add_explicit(&storage->value, 1, memory_order_acq_rel) + 1;
}

int64_t JammLabAtomicInt64Decrement(JammLabAtomicInt64 *storage) {
return atomic_fetch_sub_explicit(&storage->value, 1, memory_order_acq_rel) - 1;
}
15 changes: 15 additions & 0 deletions JammLab/AudioRenderAtomics.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#ifndef AudioRenderAtomics_h
#define AudioRenderAtomics_h

#include <stdint.h>

typedef struct JammLabAtomicInt64 JammLabAtomicInt64;

JammLabAtomicInt64 * _Nonnull JammLabAtomicInt64Create(int64_t initialValue);
void JammLabAtomicInt64Destroy(JammLabAtomicInt64 * _Nullable storage);
int64_t JammLabAtomicInt64Load(const JammLabAtomicInt64 * _Nonnull storage);
void JammLabAtomicInt64Store(JammLabAtomicInt64 * _Nonnull storage, int64_t value);
int64_t JammLabAtomicInt64Increment(JammLabAtomicInt64 * _Nonnull storage);
int64_t JammLabAtomicInt64Decrement(JammLabAtomicInt64 * _Nonnull storage);

#endif
1 change: 1 addition & 0 deletions JammLab/JammLab-Bridging-Header.h
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#import "Transcription/Native/JMBasicPitchBridge.h"
#import "AudioRenderAtomics.h"
124 changes: 124 additions & 0 deletions JammLab/Models/NotationEditingPlanners.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import Foundation

enum NotationPartStatePlanner {
static func knownStemTypes(
stemFiles: [StemFile],
notationItems: [NotationMeasureItem],
collapsedStemTypes: Set<StemType>,
visiblePartIDs: Set<NotationPartID>
) -> [StemType] {
let knownTypes = Set(stemFiles.map(\.type))
.union(notationItems.compactMap(\.partID.stemType))
.union(collapsedStemTypes)
.union(visiblePartIDs.compactMap(\.stemType))
return StemType.allCases.filter { knownTypes.contains($0) }
}

static func availableParts(
knownStemTypes: [StemType],
transcriptionTracks: [StemTranscriptionTrack]
) -> [NotationPartDescriptor] {
let stemParts = knownStemTypes.map(NotationPartDescriptor.stem)
let additionalTranscriptions = StemType.allCases.flatMap { stemType in
transcriptionTracks
.filter {
$0.stemType == stemType
&& $0.notationPartID != .stem(stemType)
}
.sorted { $0.createdAt < $1.createdAt }
.enumerated()
.map { index, track in
NotationPartDescriptor.stemTranscription(
stemType,
id: track.notationPartID,
sequence: index + 2
)
}
}
return [.main] + stemParts + additionalTranscriptions
}

static func normalizedVisiblePartIDs(
_ rawPartIDs: Set<NotationPartID>,
availableParts: [NotationPartDescriptor]
) -> Set<NotationPartID> {
let allowedPartIDs = Set(availableParts.map(\.id))
var normalized = rawPartIDs.intersection(allowedPartIDs)
if normalized.isEmpty {
normalized = allowedPartIDs.contains(.main) ? [.main] : Set(allowedPartIDs.prefix(1))
}
return normalized
}
}

struct NotationAccidentalEditPlan {
var chainIDs: Set<String>
var rootItemID: String
var alreadyApplied: Bool
var updatedItems: [NotationMeasureItem]
}

enum NotationAccidentalPlanner {
static func plan(
accidental: NotationAccidental,
selectedItem: NotationMeasureItem,
measure: ScoreMeasure,
allItems: [NotationMeasureItem]
) -> NotationAccidentalEditPlan? {
guard selectedItem.kind == .note,
selectedItem.pitch != nil,
measure.attributes.clef != .drums
else {
return nil
}

let chainIDs = NotationNoteEditPlanner.logicalChainItemIDs(
in: allItems,
containing: selectedItem.id,
partID: selectedItem.partID
)
guard !chainIDs.isEmpty else { return nil }

let chainItems = allItems.filter { chainIDs.contains($0.id) }
let incomingTargetIDs = Set(chainItems.compactMap(\.tieTargetItemID))
let rootItemID = chainItems.first(where: { !incomingTargetIDs.contains($0.id) })?.id
?? selectedItem.id

let hasCollision = chainItems.contains { chainItem in
guard var pitch = chainItem.pitch else { return true }
pitch.alter = accidental.alter
return allItems.contains { candidate in
!chainIDs.contains(candidate.id)
&& candidate.partID == chainItem.partID
&& candidate.kind == .note
&& candidate.pitch?.midiNoteNumber == pitch.midiNoteNumber
&& candidate.measureNumber == chainItem.measureNumber
&& abs(candidate.measureStartTime - chainItem.measureStartTime)
< NotationMeasureTiming.timelineTolerance
&& abs(candidate.offsetInQuarterNotes - chainItem.offsetInQuarterNotes)
< NotationMeasureTiming.timelineTolerance
}
}
guard !hasCollision else { return nil }

let alreadyApplied = chainItems.allSatisfy { item in
item.pitch?.alter == accidental.alter
&& item.explicitAccidental == (item.id == rootItemID ? accidental : nil)
}
let updatedItems = allItems.map { item -> NotationMeasureItem in
guard chainIDs.contains(item.id), var pitch = item.pitch else { return item }
pitch.alter = accidental.alter
var updated = item
updated.pitch = pitch
updated.explicitAccidental = item.id == rootItemID ? accidental : nil
return updated
}

return NotationAccidentalEditPlan(
chainIDs: chainIDs,
rootItemID: rootItemID,
alreadyApplied: alreadyApplied,
updatedItems: updatedItems
)
}
}
6 changes: 1 addition & 5 deletions JammLab/Models/StemBackendResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,11 @@ struct StemBackendResolver {
StemBackendCandidate(
executableURL: helperExecutableURL,
argumentsPrefix: [],
displayName: "JammLabSeparatorHelper/\(StemBackendResolver.separatorVersion)"
displayName: StemBackendResolver.separatorExecutableName
)
]
}

static var separatorVersion: String {
"1"
}

static func defaultBundledSeparatorExecutableURL(
currentExecutableURL: URL = URL(fileURLWithPath: CommandLine.arguments.first ?? "")
) -> URL {
Expand Down
27 changes: 21 additions & 6 deletions JammLab/Models/StemSeparationJobModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,18 @@ enum StemJobPhase: String, Codable, Equatable {
}

struct StemJobRequest: Codable, Equatable {
var protocolVersion: Int
var jobID: String
var audioPath: String
var cacheKey: String
var cacheDirectoryPath: String
var modelDirectoryPath: String
var sourceFingerprint: StemSourceFingerprint
var separationMethodID: String? = nil
var expectedStemTypes: [StemType]? = nil
var separationMethodID: String
var expectedStemTypes: [StemType]
var modelName: String
var settingsVersion: Int
var audioSeparatorPath: String?
var audioSeparatorBookmarkData: Data?
var computeMode: String?
var computeMode: String
var createdAt: Date
}

Expand Down Expand Up @@ -73,7 +72,13 @@ struct StemJobResult: Codable, Equatable {
}

struct StemHelperHeartbeat: Codable, Equatable {
var protocolVersion: Int = StemJobFiles.protocolVersion
var helperVersion: Int
var separatorVersion: String = ""
var executableIdentity: String = ""
var manifestSHA256: String = ""
var supportedModels: [String] = []
var supportedComputeModes: [String] = []
var updatedAt: Date
var activeJobID: String?

Expand All @@ -82,8 +87,18 @@ struct StemHelperHeartbeat: Codable, Equatable {
}
}

struct StemHelperCapabilities: Codable, Equatable {
var protocolVersion: Int
var separatorVersion: String
var executableIdentity: String
var manifestSHA256: String
var supportedModels: [String]
var supportedComputeModes: [String]
}

enum StemJobFiles {
static let helperVersion = 5
static let protocolVersion = 6
static let helperVersion = 6
static let jobsDirectoryName = "StemJobs"
static let currentJobsDirectoryName = "v\(helperVersion)"
static let cacheDirectoryName = "StemCache"
Expand Down
15 changes: 15 additions & 0 deletions JammLab/Services/AudioPlaybackControlling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation

@MainActor
protocol AudioPlaybackControlling: AnyObject {
var requiresPreparedPlayback: Bool { get }
var isLoaded: Bool { get }
var isPlaying: Bool { get }
var currentTime: TimeInterval { get }
Expand All @@ -17,6 +18,7 @@ protocol AudioPlaybackControlling: AnyObject {
func setPitchShift(semitones: Float)
func setMainVolume(_ volume: Float)
func load(stems: [StemFile], mixState: StemMixState) throws
func install(prepared asset: PreparedPlaybackAsset) throws
func applyMix(_ mixState: StemMixState)
func setClickEnabled(_ isEnabled: Bool)
func setClickVolume(_ volume: Float)
Expand All @@ -28,6 +30,19 @@ protocol AudioPlaybackControlling: AnyObject {
}

extension AudioPlaybackControlling {
var requiresPreparedPlayback: Bool { false }

func install(prepared asset: PreparedPlaybackAsset) throws {
switch asset.storage {
case .originalURL(let url):
try load(url: url)
case .stems(let stems, let mixState):
try load(stems: stems, mixState: mixState)
case .decoded:
throw MultiTrackAudioPlayerError.unsupportedPreparedAsset
}
}

func load(stems: [StemFile], mixState: StemMixState) throws {
throw MultiTrackAudioPlayerError.unsupportedStemLoad
}
Expand Down
Loading