removed unwanted bar now with waveforms, major updates and enhancements

This commit is contained in:
Mark Gregory
2026-11-07 11:15:40 +00:00
parent ec601f3544
commit 9d0d1cccc8
5 changed files with 145 additions and 34 deletions
+8 -16
View File
@@ -373,6 +373,7 @@ struct DJAudioDevice: Identifiable, Equatable {
@Published var importLogVisible = false
@Published private(set) var importing = false
private var importTask: Task<Void,Never>?
private var importGeneration = UUID()
private var queuedFileLoads = [(urls:[URL],deck:String,override:Bool,song:Int64?)]()
init(storageURL: URL? = nil) {
root = storageURL ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/DJ")
@@ -418,6 +419,7 @@ struct DJAudioDevice: Identifiable, Equatable {
func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return }
importing = true;importProgress = .init();importLogVisible = true
let importID = UUID();importGeneration = importID
let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,beforeIDs = Set(songs.map(\.id)),copyMedia = copyImportedMedia,replacedSong = loadInto.flatMap{channel($0).song?.id}
importTask = Task.detached(priority:.utility) { [weak self] in
let access = urls.filter{$0.startAccessingSecurityScopedResource()}
@@ -433,15 +435,15 @@ struct DJAudioDevice: Identifiable, Equatable {
_ = try db.saveMediaLocation(name:url.lastPathComponent,url:url,bookmark:bookmark,external:true)
}
}
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false).run(urls,library:db,copyMedia:copyMedia,cancelled:{Task.isCancelled}) { value in
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false,continueOnUnreadableAudio:true).run(urls,library:db,copyMedia:copyMedia,cancelled:{Task.isCancelled}) { value in
latest = value
Task { @MainActor [weak self] in if self?.importing == true { self?.importProgress = value } }
Task { @MainActor [weak self] in if self?.importing == true,self?.importGeneration == importID { self?.importProgress = value } }
}
} catch is CancellationError { latest.cancelled = true;latest.current = "DJ import cancelled" }
catch { latest.failure = error.localizedDescription;latest.current = "DJ import stopped" }
latest.finished = true;let result = latest,loadedIDs = importedIDs
await MainActor.run { [weak self] in
guard let self else { return };self.importing = false;self.importTask = nil;self.importProgress = result
guard let self,self.importGeneration == importID else { return };self.importing = false;self.importTask = nil;self.importProgress = result
self.lastImportReport = result
self.perform { try self.refresh() }
if let failure = result.failure { if loadInto != nil,!self.importLogVisible { self.error = failure } }
@@ -451,7 +453,7 @@ struct DJAudioDevice: Identifiable, Equatable {
}
}
}
func cancelImport() { queuedFileLoads = [];importTask?.cancel() }
func cancelImport() { queuedFileLoads = [];importTask?.cancel();if importing { importProgress?.current = "Cancelling…" } }
func continueFileLoads() {
guard !importing,pendingReplacement == nil,!queuedFileLoads.isEmpty else { return }
let next = queuedFileLoads.removeFirst()
@@ -1121,17 +1123,6 @@ struct DJConsole: View {
DJLibrarySnapshot(revision:libraryRevision,selection:selection,selected:selected,page:page,busy:system.generatingWaveforms || system.importing,font:system.settings.libraryFont,height:system.settings.libraryRowHeight) { browser }.equatable().frame(maxWidth:.infinity,maxHeight:.infinity)
}
}
if let progress = system.automaticWaveformProgress {
BackgroundWaveformStatus(progress:progress,cancel:{system.automaticWaveforms.cancel()},dismiss:{system.automaticWaveformProgress = nil})
}
if let progress = system.importProgress {
HStack {
Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit()
Spacer()
if system.importing { ProgressView().controlSize(.mini);Button("View Progress") { system.importLogVisible = true };Button("Cancel") { system.cancelImport() } }
else { Button("View Log") { system.importLogVisible = true };Button("Dismiss") { system.importProgress = nil } }
}.font(.caption).controlSize(.mini)
}
}.padding(14).frame(minWidth:900,maxWidth:.infinity,minHeight:706,maxHeight:.infinity,alignment:.top)
.background(DJStyle.background).foregroundStyle(.white)
.preferredColorScheme(.dark)
@@ -1141,7 +1132,7 @@ struct DJConsole: View {
} message: { request in Text("Deck \(request.deck) is playing. Replacing it with \(request.title) stops the current track. Continue?") }
.sheet(isPresented:$system.importLogVisible) {
if let progress = system.importing ? system.importProgress : system.lastImportReport {
ImportProgressPanel(progress:progress,studio:"DJ Studio",cancel:{ system.cancelImport() },close:{ system.importLogVisible = false },background:{ system.importLogVisible = false })
ImportProgressPanel(progress:progress,studio:"DJ Studio",cancel:{ system.cancelImport() },close:{ system.importLogVisible = false },background:{ system.importLogVisible = false },waveformStatus:system.automaticWaveformProgress.map { progress in BackgroundWaveformStatus(progress:progress,cancel:{system.automaticWaveforms.cancel()},dismiss:{system.automaticWaveformProgress = nil}) })
.interactiveDismissDisabled(system.importing)
}
}
@@ -1205,6 +1196,7 @@ struct DJConsole: View {
Spacer()
HStack(spacing:8) { Text("MASTER").font(.system(size:9,weight:.semibold)).foregroundStyle(.secondary);DJHardwareFader(value:$system.master,label:"DJ master level").frame(width:95,height:28).help("Adjust the overall DJ output level for both decks") }
Button { sidebarVisible.toggle() } label: { Image(systemName:"sidebar.left") }.help("Show or hide collections").buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a))
Button { system.importLogVisible = true } label: { Image(systemName:"doc.text") }.buttonStyle(LibraryActionButtonStyle(tint:.gray)).disabled(system.importProgress == nil).help("Open the import log and background waveform progress")
Button { graphicEQVisible = true } label: { Label("Equaliser",systemImage:"slider.vertical.3") }.buttonStyle(LibraryActionButtonStyle(tint:system.graphicEQ.enabled ? DJStyle.a : .gray)).help("Open the ten-band graphic equaliser and sound presets for both decks")
DJQueueButton(automix:system.automix,open:{queueVisible = true})
if system.previewDeck != nil { Button("Stop Preview") { system.stopPreview() }.tint(.green).help("Stop independent headphone preview") }
+63 -14
View File
@@ -26,6 +26,9 @@ struct Importer {
var audioExtensions: Set<String> = ["mp3","wav","m4a","mp4","m4v","mov"]
var includeGraphics = true
var waveformCache: URL? = nil
var continueOnUnreadableAudio = false
var metadataTimeout: TimeInterval = 2
var metadataLoader: (@Sendable (URL) async throws -> [AVMetadataKey:String])? = nil
@discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false },progress: (ImportProgress) -> Void = { _ in }) throws -> [Int64] {
if copyMedia { try ManagedMediaStorage.prepare(media) }
var state = ImportProgress()
@@ -73,6 +76,7 @@ struct Importer {
}
} else if values.isRegularFile == true,supported.contains(url.pathExtension.lowercased()) {
discovered.insert(url)
report("Scanning · \(discovered.count.formatted()) supported files found · \(url.lastPathComponent)")
}
} catch is CancellationError { throw CancellationError() }
catch { throw LibraryError.message("Could not scan \(url.path): \(error.localizedDescription)") }
@@ -85,7 +89,10 @@ struct Importer {
// Older libraries may contain indexed tracks without stored fingerprints.
// Compare only same-size candidates, and hash each legacy file at most once.
var legacyBySize = [UInt64:[Song]]()
var existingChecked = 0
for song in try library.songs() where song.fingerprint.isEmpty {
if cancelled() { throw CancellationError() }
existingChecked += 1;report("Checking existing library · \(existingChecked.formatted()) tracks · \(song.title)")
if let info = try? FileManager.default.attributesOfItem(atPath:song.audio),let size = info[.size] as? NSNumber,size.uint64Value > 0 {
legacyBySize[size.uint64Value,default:[]].append(song)
}
@@ -222,9 +229,24 @@ struct Importer {
state.logLines.append("[SKIPPED] "+sourcePath(audio)+" (already imported)")
report("Already imported: "+audio.lastPathComponent,force:state.completed == 1);continue
}
guard MediaByteCopy.nonempty(audio) else { throw LibraryError.message("Audio file is empty or unreadable: "+audio.path) }
let hash = try audioFingerprint(audio,cancelled:cancelled)
let graphicsHash = try cdg.map { try audioFingerprint($0,cancelled:cancelled) }
let hash: String
let graphicsHash: String?
do {
guard MediaByteCopy.nonempty(audio) else { throw LibraryError.message("Audio file is empty or unreadable: "+audio.path) }
report("Checking audio: "+audio.lastPathComponent)
if continueOnUnreadableAudio { _ = try AVAudioFile(forReading:audio) }
hash = try audioFingerprint(audio,cancelled:cancelled,progress:{ bytes in
report("Reading "+audio.lastPathComponent+" · "+ByteCountFormatter.string(fromByteCount:bytes,countStyle:.file))
})
graphicsHash = try cdg.map { try audioFingerprint($0,cancelled:cancelled) }
} catch is CancellationError { throw CancellationError() }
catch {
guard continueOnUnreadableAudio else { throw error }
state.completed += 1;state.skipped += 1
state.logLines.append("[SKIPPED] "+sourcePath(audio)+" (unreadable audio: "+error.localizedDescription+")")
report("Skipped unreadable file: "+audio.lastPathComponent,force:state.completed == 1)
continue
}
let duplicate = try duplicateWithFingerprint(hash,audio:audio)
var sameGraphics = cdg == nil
if let graphicsHash,let stored = duplicate?.cdg,FileManager.default.fileExists(atPath:stored) {
@@ -238,7 +260,10 @@ struct Importer {
report("Already in library: "+audio.lastPathComponent,force:state.completed == 1);continue
}
let dest = copy ? media.appendingPathComponent(hash+"."+audio.pathExtension.lowercased()) : audio.standardizedFileURL
if copy { try MediaByteCopy.copy(audio,to:dest,expectedHash:hash,cancelled:cancelled) }
if copy {
report("Copying: "+audio.lastPathComponent)
try MediaByteCopy.copy(audio,to:dest,expectedHash:hash,cancelled:cancelled)
}
var graphics: String?
if let cdg {
let target = copy ? media.appendingPathComponent(hash+"-"+(graphicsHash ?? "graphics")+".cdg") : cdg.standardizedFileURL
@@ -246,12 +271,25 @@ struct Importer {
graphics = target.path
}
imported += 1
report("Reading tags: "+audio.lastPathComponent,force:imported == 1)
let metadata = try audioMetadata(audio,cancelled:cancelled)
func tag(_ key: AVMetadataKey) -> String { metadata[key] ?? "" }
if metadata == nil { state.logLines.append("[WARNING] "+sourcePath(audio)+" (tag read timed out; imported using its filename)") }
func tag(_ key: AVMetadataKey) -> String { metadata?[key] ?? "" }
let taggedTitle = tag(.commonKeyTitle)
let id = try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher),duration:audioDuration(audio),fingerprint:hash,preferIncomingFile:copy)
let duration = audioDuration(audio)
report("Saving track: "+audio.lastPathComponent)
try library.execute("SAVEPOINT kjmix_import_track")
let id: Int64
do {
id = try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher),duration:duration,fingerprint:hash,preferIncomingFile:copy)
try remember(audio,signature,id)
try library.execute("RELEASE SAVEPOINT kjmix_import_track")
} catch {
try? library.execute("ROLLBACK TO SAVEPOINT kjmix_import_track")
try? library.execute("RELEASE SAVEPOINT kjmix_import_track")
throw error
}
songIDs.insert(id)
try remember(audio,signature,id)
state.completed += 1
state.logLines.append("[IMPORTED] "+sourcePath(audio))
if let waveformCache {
@@ -274,29 +312,40 @@ struct Importer {
state.finished = true;report("Import complete",force:true)
return Array(songIDs)
}
private func audioFingerprint(_ url: URL,cancelled: () -> Bool) throws -> String {
private func audioFingerprint(_ url: URL,cancelled: () -> Bool,progress: (Int64) -> Void = {_ in}) throws -> String {
let file = try FileHandle(forReadingFrom:url);defer { try? file.close() }
var hasher = SHA256()
while let data = try file.read(upToCount:1_048_576),!data.isEmpty { if cancelled() { throw CancellationError() };hasher.update(data:data) }
var bytes: Int64 = 0
while true {
if cancelled() { throw CancellationError() }
guard let data = try file.read(upToCount:1_048_576),!data.isEmpty else { break }
hasher.update(data:data);bytes += Int64(data.count);progress(bytes)
}
return hasher.finalize().map { String(format:"%02x",$0) }.joined()
}
/// The importer runs on its worker queue. Bridge asynchronous tag loading here
/// so the existing synchronous transaction can retain its cancellation semantics.
private func audioMetadata(_ url: URL,cancelled: () -> Bool) throws -> [AVMetadataKey:String] {
private func audioMetadata(_ url: URL,cancelled: () -> Bool) throws -> [AVMetadataKey:String]? {
if cancelled() { throw CancellationError() }
let result = ImportMetadataResult(),ready = DispatchSemaphore(value:0)
let asset = AVURLAsset(url:url),loader = metadataLoader
let task = Task.detached(priority:.utility) {
defer { ready.signal() }
if let loader { result.set((try? await loader(url)) ?? [:]);return }
var tags = [AVMetadataKey:String]()
let items = (try? await AVURLAsset(url:url).load(.commonMetadata)) ?? []
let items = (try? await asset.load(.commonMetadata)) ?? []
for item in items {
if Task.isCancelled { break }
guard let key = item.commonKey,[AVMetadataKey.commonKeyTitle,.commonKeyArtist,.commonKeyAlbumName,.commonKeyPublisher].contains(key),tags[key] == nil else { continue }
if let value = try? await item.load(.stringValue) { tags[key] = value }
}
result.set(tags);ready.signal()
result.set(tags)
}
defer { task.cancel() }
while ready.wait(timeout:.now()+0.05) == .timedOut {
defer { task.cancel();asset.cancelLoading() }
let deadline = ProcessInfo.processInfo.systemUptime+max(0.01,metadataTimeout)
while ready.wait(timeout:.now()+0.02) == .timedOut {
if cancelled() { throw CancellationError() }
if ProcessInfo.processInfo.systemUptime >= deadline { return nil }
}
if cancelled() { throw CancellationError() }
return result.get()
+8 -4
View File
@@ -719,6 +719,7 @@ struct ImportProgressPanel: View {
var background: (() -> Void)? = nil
var isMove = false
var activity: String? = nil
var waveformStatus: BackgroundWaveformStatus? = nil
private var fraction: Double { progress.total > 0 ? min(1,max(0,Double(progress.completed)/Double(progress.total))) : 0 }
private var accent: Color { progress.failure != nil || progress.cancelled ? .orange : progress.finished ? .green : .cyan }
private var title: String { if let activity { return progress.finished ? (progress.cancelled ? "Cancelled" : progress.failure == nil ? "Library Updated" : "Library Needs Attention") : activity };if isMove { return progress.finished ? (progress.cancelled ? "Move Cancelled" : progress.failure == nil ? "Move Complete" : "Move Needs Attention") : "Moving Media and Waveforms" };return progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Needs Attention") : "Importing Music" }
@@ -755,7 +756,8 @@ struct ImportProgressPanel: View {
count("Skipped",value:progress.skipped,color:.orange)
count("Remaining",value:progress.total > 0 ? max(0,progress.total-progress.completed) : -1,color:.cyan)
}
ImportLogView(progress:progress,title:activity != nil ? "Library Log" : isMove ? "Transfer Log" : "Import Log").frame(height:260)
if let waveformStatus { waveformStatus }
ImportLogView(progress:progress,title:activity != nil ? "Library Log" : isMove ? "Transfer Log" : "Import Log").frame(height:waveformStatus == nil ? 260 : 200)
Divider()
HStack {
Text(progress.finished ? "Review the log or copy it before closing." : isMove ? "Copies are verified before originals are removed." : "You can cancel safely; completed tracks stay in your library.").font(.caption).foregroundStyle(.secondary)
@@ -785,7 +787,8 @@ struct ImportLogView: View {
var title = "Import Log"
@State private var follow = true
var body: some View {
let lines = progress.reportLines
let allLines = progress.reportLines
let lines = Array(allLines.suffix(300))
VStack(alignment:.leading,spacing:8) {
HStack {
Text(title).font(.headline)
@@ -793,13 +796,14 @@ struct ImportLogView: View {
if !progress.finished { Toggle("Follow log",isOn:$follow).toggleStyle(.checkbox).controlSize(.small) }
Button("Copy Log") { NSPasteboard.general.clearContents();NSPasteboard.general.setString(progress.logText,forType:.string) }
}
if allLines.count > lines.count { Text("Showing the latest \(lines.count.formatted()) of \(allLines.count.formatted()) entries. Copy Log includes the full report.").font(.caption).foregroundStyle(.secondary) }
ScrollViewReader { reader in
ScrollView {
LazyVStack(alignment:.leading,spacing:7) {
ForEach(lines.indices,id:\.self) { index in
let line = lines[index]
HStack(alignment:.top,spacing:8) {
Circle().fill(line.hasPrefix("[FAILED]") ? Color.red : line.hasPrefix("[SKIPPED]") ? Color.orange : Color.green).frame(width:5,height:5).padding(.top,5)
Circle().fill(line.hasPrefix("[FAILED]") ? Color.red : (line.hasPrefix("[SKIPPED]") || line.hasPrefix("[WARNING]")) ? Color.orange : Color.green).frame(width:5,height:5).padding(.top,5)
Text(line).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading)
}.id(index)
}
@@ -807,7 +811,7 @@ struct ImportLogView: View {
}.padding(12)
}.background(Color(nsColor:.textBackgroundColor),in:RoundedRectangle(cornerRadius:8))
.overlay(RoundedRectangle(cornerRadius:8).strokeBorder(.primary.opacity(0.06)))
.onStudioChange(of:lines.count) { _ in if follow,let last = lines.indices.last { reader.scrollTo(last,anchor:.bottom) } }
.onStudioChange(of:allLines.count) { _ in if follow,let last = lines.indices.last { reader.scrollTo(last,anchor:.bottom) } }
}
}
}
+62
View File
@@ -3294,3 +3294,65 @@ extension KJamsCloneTests {
XCTAssertGreaterThan(player.displayTime,position+0.08)
}
}
extension KJamsCloneTests {
func testDJImportContinuesAfterUnreadableFilesAndStalledTags() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let input = root.appendingPathComponent("Input");try FileManager.default.createDirectory(at:input,withIntermediateDirectories:true)
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1))
for (index,name) in ["A Slow Tags","B Good","C Good","D Good"].enumerated() {
let samples = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024));samples.frameLength = 1024
for frame in 0..<1024 { samples.floatChannelData![0][frame] = Float(sin(Double(frame)*Double(index+1)*0.1))*0.2 }
let file = try AVAudioFile(forWriting:input.appendingPathComponent(name+".wav"),settings:format.settings);try file.write(from:samples)
}
try Data().write(to:input.appendingPathComponent("AA Empty.wav"))
let library = try Library(url:root.appendingPathComponent("Library.sqlite"))
let importer = Importer(media:root.appendingPathComponent("Media"),audioExtensions:["wav"],includeGraphics:false,continueOnUnreadableAudio:true,metadataTimeout:0.08,metadataLoader:{ url in
if url.lastPathComponent.hasPrefix("A Slow") { try await Task.sleep(nanoseconds:10_000_000_000) }
return [.commonKeyTitle:"Tagged "+url.deletingPathExtension().lastPathComponent]
})
var report = ImportProgress();let began = ProcessInfo.processInfo.systemUptime
let ids = try importer.run([input],library:library,copyMedia:false,progress:{report = $0})
XCTAssertLessThan(ProcessInfo.processInfo.systemUptime-began,3,"A stalled tag read must not hold up later files")
XCTAssertEqual(ids.count,4);XCTAssertEqual(try library.songs().count,4)
XCTAssertEqual(report.total,5);XCTAssertEqual(report.completed,5);XCTAssertEqual(report.skipped,1);XCTAssertTrue(report.finished)
XCTAssertTrue(report.logLines.contains{$0.contains("tag read timed out")})
XCTAssertTrue(report.logLines.contains{$0.contains("AA Empty.wav") && $0.hasPrefix("[SKIPPED]")})
XCTAssertTrue(try library.songs().contains{$0.title == "A Slow Tags"});XCTAssertTrue(try library.songs().contains{$0.title == "Tagged D Good"})
}
func testImportCancellationInterruptsStalledMetadataWait() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let library = try Library(url:root.appendingPathComponent("Library.sqlite")),began = ProcessInfo.processInfo.systemUptime
let importer = Importer(media:root.appendingPathComponent("Media"),metadataTimeout:20,metadataLoader:{ _ in try await Task.sleep(nanoseconds:10_000_000_000);return [:] })
XCTAssertThrowsError(try importer.run([fixture],library:library,copyMedia:false,cancelled:{ProcessInfo.processInfo.systemUptime-began > 0.12})) { XCTAssertTrue($0 is CancellationError) }
XCTAssertLessThan(ProcessInfo.processInfo.systemUptime-began,1)
XCTAssertTrue(try library.songs().isEmpty)
}
@MainActor func testLargeDJImportKeepsUIResponsiveAndCompletesEveryInput() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let input = root.appendingPathComponent("Input");try FileManager.default.createDirectory(at:input,withIntermediateDirectories:true)
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1))
for index in 0..<128 {
let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024));buffer.frameLength = 1024
for frame in 0..<1024 { buffer.floatChannelData![0][frame] = Float(sin(Double(frame)*0.1+Double(index)))*0.2 }
let file = try AVAudioFile(forWriting:input.appendingPathComponent(String(format:"Unique %04d.wav",index)),settings:format.settings);try file.write(from:buffer)
}
for index in 0..<512 { try FileManager.default.copyItem(at:input.appendingPathComponent("Unique 0000.wav"),to:input.appendingPathComponent(String(format:"Duplicate %04d.wav",index))) }
let system = DJSystem(storageURL:root.appendingPathComponent("DJ"))
system.importFiles([input]);XCTAssertTrue(system.importing);XCTAssertTrue(system.importLogVisible)
var heartbeat = false;Task { @MainActor in heartbeat = true }
system.crossfader = 0.75
for _ in 0..<1000 where system.importing { try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertTrue(heartbeat);XCTAssertFalse(system.importing);XCTAssertEqual(system.crossfader,0.75)
let report = try XCTUnwrap(system.lastImportReport)
XCTAssertEqual(report.completed,640);XCTAssertEqual(report.total,640);XCTAssertEqual(report.skipped,512)
XCTAssertNil(report.failure);XCTAssertEqual(system.songs.count,128,"Every distinct track is saved, while repeated audio remains deduplicated")
system.automaticWaveforms.cancel()
for _ in 0..<200 where system.automaticWaveforms.isRunning { try await Task.sleep(nanoseconds:5_000_000) }
}
}
+4
View File
@@ -140,3 +140,7 @@ The library sidebar provides All Music, Favourites, Top Rated (four or five star
EQ and filter knobs use native mouse-event click counting, so double-clicking either the knob or its label restores its defined default (0 dB for EQ and centred filter). Dragging remains vertical; arrow keys adjust the focused control and Space resets it. Resize handle positions and collection-sidebar visibility are remembered between launches.
The eraser menu beside IN/OUT clears individual hot cues, all hot cues, the main cue, loop markers, or every cue/loop marker on that deck. Hot cues also support Option-click and right-click to clear. Clearing the main cue resets it to track start without seeking; clearing markers retains the loaded track and playback. Hover descriptions cover the performance controls, mixer, library actions, queue controls and cue/loop editors.
Large DJ imports show scanning, existing-library checks, file reading, tag reading and saving stages in the import modal. Tag reads have a two-second deadline: a stalled read is cancelled, the track is imported using its filename, and the warning is retained in the log. DJ imports skip unreadable audio and continue with later tracks; destination/storage and database failures still stop the operation. Each track’s database rows and import-history records commit together, preserving completed work on cancellation.
Import and automatic-waveform feedback is shown only in the import modal, reopened using the document icon in the DJ Studio header. The workspace no longer shows import banners or background waveform feedback. Large logs display only their latest 300 entries for responsive scrolling; Copy Log retains the complete report. Waveforms remain a separate background job after track import.