removed unwanted bar now with waveforms, major updates
This commit is contained in:
@@ -666,6 +666,9 @@ struct StudioSettingsDialog: View {
|
||||
Divider()
|
||||
HStack { Text(scope == "DJ" && category == "Devices" ? "DJ routing · Changes apply immediately" : (scope == "DJ" ? "DJ Studio" : "Karaoke Studio")+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16)
|
||||
}.frame(width:850,height:700).background(Color(nsColor:.windowBackgroundColor))
|
||||
.sheet(isPresented:Binding(get:{model.dj.storageProgress != nil},set:{if !$0,!model.dj.movingStorage { model.dj.storageProgress = nil }})) {
|
||||
if let progress = model.dj.storageProgress { ImportProgressPanel(progress:progress,studio:"DJ Studio Storage",cancel:{model.dj.cancelStorageMove()},close:{model.dj.storageProgress = nil},isMove:true).interactiveDismissDisabled(model.dj.movingStorage) }
|
||||
}
|
||||
.onChange(of:scope) { _ in model.dj.midi.learning = nil;if !categories.contains(category) { category = "General" } }
|
||||
.onChange(of:category) { _ in model.dj.midi.learning = nil }
|
||||
.onDisappear { model.dj.midi.learning = nil }
|
||||
|
||||
@@ -172,6 +172,51 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
@MainActor final class DJSystem: ObservableObject {
|
||||
@Published private(set) var mediaDirectory: URL
|
||||
@Published private(set) var waveformDirectory: URL
|
||||
@Published private(set) var movingStorage = false
|
||||
@Published var storageProgress: ImportProgress?
|
||||
private var storageTask: Task<Void,Never>?
|
||||
private var storageAccess: URL?
|
||||
private func loadStorage() throws {
|
||||
let location = try library.flatMap{try DJStorageMove.location(in:$0)}
|
||||
var folder = location.map{URL(fileURLWithPath:$0.path)} ?? root
|
||||
if let bookmark = location?.bookmark { var stale = false;if let resolved = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale) { folder = resolved } }
|
||||
if storageAccess != folder {
|
||||
storageAccess?.stopAccessingSecurityScopedResource();storageAccess = folder.startAccessingSecurityScopedResource() ? folder : nil
|
||||
}
|
||||
mediaDirectory = folder.appendingPathComponent("Media");waveformDirectory = folder.appendingPathComponent("Waveforms")
|
||||
for player in [a.player,b.player,preview] { player.useWaveformCache(at:waveformDirectory) }
|
||||
}
|
||||
func chooseStorage() {
|
||||
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy else { error = "Finish the current library job first.";return }
|
||||
guard !a.player.playing,!b.player.playing,!preview.playing else { error = "Stop both decks and headphone preview before moving DJ files.";return }
|
||||
let panel = NSOpenPanel();panel.title = "Move DJ Media and Waveforms";panel.prompt = "Move Here"
|
||||
panel.canChooseFiles = false;panel.canChooseDirectories = true;panel.canCreateDirectories = true
|
||||
panel.message = "Choose a folder on your external drive. DJ media and saved waveforms move into Media and Waveforms folders here. The database and playlists stay on this Mac."
|
||||
panel.begin { [weak self] response in guard response == .OK,let url = panel.url else { return };Task { @MainActor in self?.moveStorage(to:url) } }
|
||||
}
|
||||
func moveStorage(to destination: URL) {
|
||||
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy,!a.player.playing,!b.player.playing,!preview.playing else { error = "Stop playback and finish the current library job before moving DJ files.";return }
|
||||
let loadedA = a.song?.id,loadedB = b.song?.id,database = root.appendingPathComponent("Library.sqlite"),localRoot = root
|
||||
a.unload();b.unload();stopPreview();movingStorage = true;storageProgress = .init()
|
||||
storageTask = Task.detached(priority:.utility) { [weak self] in
|
||||
let access = destination.startAccessingSecurityScopedResource();defer { if access { destination.stopAccessingSecurityScopedResource() } }
|
||||
var latest = ImportProgress()
|
||||
do { latest = try DJStorageMove.run(database:database,defaultRoot:localRoot,destination:destination,cancelled:{Task.isCancelled}) { value in
|
||||
latest = value;Task { @MainActor [weak self] in self?.storageProgress = value }
|
||||
} } catch is CancellationError { latest.cancelled = true;latest.current = "Move cancelled; verified files kept" }
|
||||
catch { latest.failure = error.localizedDescription;latest.current = "Move needs attention" }
|
||||
latest.finished = true;let result = latest
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return };self.movingStorage = false;self.storageTask = nil
|
||||
self.perform { try self.loadStorage();try self.refresh() }
|
||||
for (id,deck) in [(loadedA,self.a),(loadedB,self.b)] { if let id,let song = self.songs.first(where:{$0.id == id}) { self.perform { try deck.load(song) } } }
|
||||
self.storageProgress = result
|
||||
}
|
||||
}
|
||||
}
|
||||
func cancelStorageMove() { storageTask?.cancel() }
|
||||
@Published var namesBusy = false
|
||||
private var graphicSaveTask: Task<Void,Never>?
|
||||
@Published var graphicEQ = DJGraphicEQSettings() { didSet {
|
||||
@@ -193,10 +238,10 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
@Published private(set) var generatingWaveforms = false
|
||||
private var waveformTask: Task<Void,Never>?
|
||||
func generateWaveforms(_ ids: Set<Int64>? = nil,force: Bool = false) {
|
||||
guard !generatingWaveforms,!importing else { error = "Wait for the current import or waveform job to finish first.";return }
|
||||
guard !generatingWaveforms,!importing,!movingStorage else { error = "Wait for the current import or waveform job to finish first.";return }
|
||||
let tracks = songs.filter { ids == nil || ids!.contains($0.id) }
|
||||
guard !tracks.isEmpty else { error = "Choose a DJ library track first.";return }
|
||||
let cache = root.appendingPathComponent("Waveforms")
|
||||
let cache = waveformDirectory
|
||||
generatingWaveforms = true;waveformJob = .init(total:tracks.count)
|
||||
waveformTask = Task.detached(priority:.utility) { [weak self] in
|
||||
let result = await WaveformBatch.runConcurrent(songs:tracks,locations:[],cache:cache,force:force) { update in
|
||||
@@ -253,6 +298,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
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")
|
||||
mediaDirectory = root.appendingPathComponent("Media");waveformDirectory = root.appendingPathComponent("Waveforms")
|
||||
a = DJChannel("A",waveformCache:root.appendingPathComponent("Waveforms"));b = DJChannel("B",waveformCache:root.appendingPathComponent("Waveforms"))
|
||||
preview.autoFade = false;preview.volume = cueLevel;refreshDevices();applyMix()
|
||||
Publishers.CombineLatest4(a.$bpm,b.$bpm,a.player.$tempo,b.player.$tempo).dropFirst().sink { [weak self] _ in
|
||||
@@ -266,7 +312,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
}.store(in:&syncObservers)
|
||||
perform {
|
||||
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
|
||||
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try refresh()
|
||||
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try loadStorage();try refresh()
|
||||
if let data = try? Data(contentsOf:root.appendingPathComponent("DJGraphicEQ.json")),let saved = try? JSONDecoder().decode(DJGraphicEQSettings.self,from:data) { graphicEQ = saved.sanitized }
|
||||
applyGraphicEQ()
|
||||
favourites = Set((try library?.setting("DJ.Favourites.v1") ?? "").split(separator:",").compactMap{Int64($0)})
|
||||
@@ -292,9 +338,9 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
func addToPlaylist(_ playlist: Int64,song: Int64) { perform { try library?.addToPlaylist(playlist,song:song);try refresh() } }
|
||||
func dragReference(_ song: Int64) -> SongDragReference { .init(song:song,library:dragSession,singer:nil,venue:nil,fromTonight:false) }
|
||||
func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
|
||||
guard !importing,!generatingWaveforms,!urls.isEmpty else { return }
|
||||
guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return }
|
||||
importing = true;importProgress = .init();importLogVisible = true
|
||||
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),cache = root.appendingPathComponent("Waveforms"),replacedSong = loadInto.flatMap{channel($0).song?.id}
|
||||
let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,cache = waveformDirectory,replacedSong = loadInto.flatMap{channel($0).song?.id}
|
||||
importTask = Task.detached(priority:.utility) { [weak self] in
|
||||
let access = urls.filter{$0.startAccessingSecurityScopedResource()}
|
||||
defer { for url in access { url.stopAccessingSecurityScopedResource() } }
|
||||
@@ -326,7 +372,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
requestFileLoad(next.urls,into:next.deck,override:next.override && channel(next.deck).song?.id == next.song)
|
||||
}
|
||||
func cancelReplacement() { pendingReplacement = nil;continueFileLoads() }
|
||||
deinit { importTask?.cancel();waveformTask?.cancel() }
|
||||
deinit { importTask?.cancel();waveformTask?.cancel();storageTask?.cancel();storageAccess?.stopAccessingSecurityScopedResource() }
|
||||
|
||||
func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } }
|
||||
func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = routing.mode == "External" ? 1 : weights.0;b.crossWeight = routing.mode == "External" ? 1 : weights.1 }
|
||||
@@ -341,6 +387,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
} else { load(song,into:target) }
|
||||
}
|
||||
func load(_ song: Song,into letter: String,overridePlaying: Bool = false,automated: Bool = false) {
|
||||
guard !movingStorage else { error = "Wait for the DJ storage move to finish.";return }
|
||||
perform {
|
||||
let deck = channel(letter)
|
||||
if settings.protectPlayingDeck,deck.player.playing,!overridePlaying { pendingReplacement = .init(deck:letter,song:song,urls:[]);return }
|
||||
@@ -1609,6 +1656,14 @@ struct DJPreferencesPane: View {
|
||||
}
|
||||
private var library: some View {
|
||||
VStack(alignment:.leading,spacing:26) {
|
||||
PreferencesSection("Media and Waveform Storage") {
|
||||
VStack(alignment:.leading,spacing:14) {
|
||||
LabeledContent("Media") { Text(system.mediaDirectory.path).textSelection(.enabled) }
|
||||
LabeledContent("Waveforms") { Text(system.waveformDirectory.path).textSelection(.enabled) }
|
||||
Button("Move Media and Waveforms…") { system.chooseStorage() }.disabled(system.movingStorage || system.importing || system.generatingWaveforms)
|
||||
Text("Choose a folder on an external drive. Files are copied and verified before originals are removed. The DJ database and playlists stay on this Mac. Stop playback before moving files.").font(.caption).foregroundStyle(.secondary)
|
||||
}.padding(12)
|
||||
}
|
||||
PreferencesSection("Independent DJ Collection") {
|
||||
VStack(alignment:.leading,spacing:14) {
|
||||
LabeledContent("Tracks",value:"\(system.songs.count)");LabeledContent("Playlists",value:"\(system.playlists.count)")
|
||||
|
||||
@@ -411,7 +411,7 @@ enum ManagedMediaStorage {
|
||||
guard try hash(temporary,cancelled:cancelled) == expected else { throw LibraryError.message("Verification failed for \(source.lastPathComponent). The original has been kept.") }
|
||||
try FileManager.default.moveItem(at:temporary,to:target)
|
||||
}
|
||||
verified.append((source,target));state.completed += 1
|
||||
verified.append((source,target));state.completed += 1;state.logLines.append("[COPIED] "+source.path)
|
||||
if Date().timeIntervalSince(lastReport) >= 0.1 || state.completed == 1 || state.completed == state.total { progress(state);lastReport = Date() }
|
||||
}
|
||||
if cancelled() { throw CancellationError() }
|
||||
@@ -488,3 +488,79 @@ enum MediaByteCopy {
|
||||
catch { throw LibraryError.message("Could not import \(source.lastPathComponent) from \(source.deletingLastPathComponent().path) into \(destination.deletingLastPathComponent().path): \(error.localizedDescription)") }
|
||||
}
|
||||
}
|
||||
|
||||
/// DJ media and waveform cache relocation; the library database stays on this Mac.
|
||||
enum DJStorageMove {
|
||||
static let key = "dj.storage.v1"
|
||||
static func location(in library: Library) throws -> MediaStorageLocation? {
|
||||
guard let text = try library.setting(key) else { return nil }
|
||||
return try JSONDecoder().decode(MediaStorageLocation.self,from:Data(text.utf8))
|
||||
}
|
||||
static func run(database: URL,defaultRoot: URL,destination: URL,cancelled: () -> Bool = {false},progress: (ImportProgress) -> Void = {_ in}) throws -> ImportProgress {
|
||||
let db = try Library(url:database),old = try location(in:db)
|
||||
let oldRoots = Array(Set((old?.roots ?? [])+[defaultRoot.path,old?.path ?? defaultRoot.path])).map{URL(fileURLWithPath:$0)}
|
||||
let target = destination.resolvingSymlinksInPath(),media = target.appendingPathComponent("Media"),waves = target.appendingPathComponent("Waveforms")
|
||||
try ManagedMediaStorage.prepare(media);try ManagedMediaStorage.prepare(waves)
|
||||
let oldSongs = try db.songs()
|
||||
var cacheSources = [Int64:URL](),allCaches = Set<URL>()
|
||||
for folder in oldRoots.map({$0.appendingPathComponent("Waveforms")}) {
|
||||
if let files = try? FileManager.default.contentsOfDirectory(at:folder,includingPropertiesForKeys:[.isRegularFileKey,.isSymbolicLinkKey]) {
|
||||
for file in files where file.pathExtension == "waveform" {
|
||||
let attributes = try file.resourceValues(forKeys:[.isRegularFileKey,.isSymbolicLinkKey])
|
||||
if attributes.isRegularFile == true,attributes.isSymbolicLink != true { allCaches.insert(file.resolvingSymlinksInPath()) }
|
||||
}
|
||||
}
|
||||
for song in oldSongs where cacheSources[song.id] == nil {
|
||||
if let file = try? WaveformCache.location(audio:URL(fileURLWithPath:song.audio),directory:folder),FileManager.default.fileExists(atPath:file.path) { cacheSources[song.id] = file.resolvingSymlinksInPath() }
|
||||
}
|
||||
}
|
||||
let manifestKey = "dj.storage.waveform.sources.v1"
|
||||
if let json = try db.setting(manifestKey),let saved = try? JSONDecoder().decode([String:String].self,from:Data(json.utf8)) {
|
||||
let folders = Set(oldRoots.map{$0.appendingPathComponent("Waveforms").resolvingSymlinksInPath()})
|
||||
for song in oldSongs where cacheSources[song.id] == nil {
|
||||
if let path = saved[String(song.id)] {
|
||||
let file = URL(fileURLWithPath:path).resolvingSymlinksInPath()
|
||||
if folders.contains(file.deletingLastPathComponent()),file.pathExtension == "waveform",FileManager.default.fileExists(atPath:file.path) { cacheSources[song.id] = file;allCaches.insert(file) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancelled() { throw CancellationError() }
|
||||
try db.setSetting(manifestKey,String(decoding:try JSONEncoder().encode(Dictionary(uniqueKeysWithValues:cacheSources.map{(String($0.key),$0.value.path)})),as:UTF8.self))
|
||||
var mediaRoots = oldRoots.map{$0.appendingPathComponent("Media")}
|
||||
if let json = try db.setting(MediaStorageLocation.key),let location = try? JSONDecoder().decode(MediaStorageLocation.self,from:Data(json.utf8)) { mediaRoots += (location.roots+[location.path]).map{URL(fileURLWithPath:$0)} }
|
||||
var state = try ManagedMediaStorage.move(database:database,roots:mediaRoots,destination:media,cancelled:cancelled,progress:{ value in var update = value;update.finished = false;progress(update) })
|
||||
state.finished = false
|
||||
// Commit the new location before the cache phase so a cancelled cache copy
|
||||
// still reopens the successfully relocated media on the next launch.
|
||||
let saved = ManagedMediaStorage.location(target,roots:oldRoots.map(\.path))
|
||||
try db.setSetting(key,String(decoding:try JSONEncoder().encode(saved),as:UTF8.self))
|
||||
let songs = try db.songs()
|
||||
var transfers = [URL:Set<URL>]()
|
||||
let referencedCaches = Set(cacheSources.values)
|
||||
for source in allCaches where !referencedCaches.contains(source) { transfers[source,default:[]].insert(waves.appendingPathComponent(source.lastPathComponent)) }
|
||||
for song in songs {
|
||||
if let source = cacheSources[song.id] { transfers[source,default:[]].insert(try WaveformCache.location(audio:URL(fileURLWithPath:song.audio),directory:waves)) }
|
||||
}
|
||||
state.total += transfers.values.reduce(0){$0+$1.count}
|
||||
var lastCacheReport = Date.distantPast
|
||||
progress(state)
|
||||
for source in transfers.keys.sorted(by:{$0.path < $1.path}) {
|
||||
if cancelled() { throw CancellationError() }
|
||||
let hash = try ManagedMediaStorage.hash(source,cancelled:cancelled).map{String(format:"%02x",$0)}.joined()
|
||||
for target in transfers[source]!.sorted(by:{$0.path < $1.path}) {
|
||||
if cancelled() { throw CancellationError() }
|
||||
state.current = "Moving waveform "+source.lastPathComponent
|
||||
if source.standardizedFileURL != target.standardizedFileURL { try MediaByteCopy.copy(source,to:target,expectedHash:hash,cancelled:cancelled) }
|
||||
state.completed += 1;state.logLines.append("[COPIED] "+target.path)
|
||||
if Date().timeIntervalSince(lastCacheReport) >= 0.12 || state.completed == state.total { progress(state);lastCacheReport = Date() }
|
||||
}
|
||||
// All copies of this cache are verified before its original is removed.
|
||||
if source.deletingLastPathComponent().standardizedFileURL != waves.standardizedFileURL {
|
||||
let unchanged = try ManagedMediaStorage.hash(source,cancelled:cancelled).map{String(format:"%02x",$0)}.joined() == hash
|
||||
if unchanged { try FileManager.default.removeItem(at:source) }
|
||||
}
|
||||
}
|
||||
try db.setSetting(manifestKey,"")
|
||||
state.finished = true;state.current = "DJ media and waveforms moved";progress(state);return state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,9 +672,10 @@ struct ImportProgressPanel: View {
|
||||
let cancel: () -> Void
|
||||
let close: () -> Void
|
||||
var background: (() -> Void)? = nil
|
||||
var isMove = false
|
||||
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 { progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Needs Attention") : "Importing Music" }
|
||||
private var title: String { 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" }
|
||||
var body: some View {
|
||||
VStack(alignment:.leading,spacing:20) {
|
||||
HStack(spacing:14) {
|
||||
@@ -682,7 +683,7 @@ struct ImportProgressPanel: View {
|
||||
.font(.system(size:26,weight:.medium)).foregroundStyle(accent).frame(width:52,height:52)
|
||||
.background(accent.opacity(0.12),in:RoundedRectangle(cornerRadius:12))
|
||||
VStack(alignment:.leading,spacing:4) {
|
||||
Text(studio.uppercased()+" LIBRARY").font(.system(size:10,weight:.semibold)).tracking(1.4).foregroundStyle(.secondary)
|
||||
Text(studio.uppercased()+(isMove ? "" : " LIBRARY")).font(.system(size:10,weight:.semibold)).tracking(1.4).foregroundStyle(.secondary)
|
||||
Text(title).font(.system(size:22,weight:.semibold))
|
||||
}
|
||||
Spacer()
|
||||
@@ -690,7 +691,7 @@ struct ImportProgressPanel: View {
|
||||
}
|
||||
VStack(alignment:.leading,spacing:9) {
|
||||
HStack {
|
||||
Text(progress.total > 0 ? "\(progress.completed.formatted()) of \(progress.total.formatted()) tracks checked" : "Scanning files and folders…").font(.callout)
|
||||
Text(progress.total > 0 ? "\(progress.completed.formatted()) of \(progress.total.formatted()) \(isMove ? "files verified" : "tracks checked")" : "Scanning files and folders…").font(.callout)
|
||||
Spacer()
|
||||
Text(progress.total > 0 ? "\(Int(fraction*100))%" : "Preparing").font(.callout.weight(.semibold)).monospacedDigit().foregroundStyle(accent)
|
||||
}
|
||||
@@ -704,11 +705,11 @@ struct ImportProgressPanel: View {
|
||||
Text(progress.failure ?? progress.current).font(.system(size:11)).foregroundStyle(.secondary).lineLimit(2).frame(height:30,alignment:.topLeading).frame(maxWidth:.infinity,alignment:.leading)
|
||||
}
|
||||
HStack(spacing:12) {
|
||||
count("Imported",value:max(0,progress.completed-progress.skipped),color:.green)
|
||||
count(isMove ? "Copied" : "Imported",value:max(0,progress.completed-progress.skipped),color:.green)
|
||||
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).frame(height:260)
|
||||
ImportLogView(progress:progress,title:isMove ? "Transfer Log" : "Import Log").frame(height:260)
|
||||
Divider()
|
||||
HStack {
|
||||
Text(progress.finished ? "Review the log or copy it before closing." : "You can cancel safely; completed tracks stay in your library.").font(.caption).foregroundStyle(.secondary)
|
||||
@@ -717,7 +718,7 @@ struct ImportProgressPanel: View {
|
||||
Button("Done",action:close).buttonStyle(.borderedProminent).tint(accent).keyboardShortcut(.defaultAction)
|
||||
} else {
|
||||
if let background { Button("Run in Background",action:background) }
|
||||
Button("Cancel Import",action:cancel).keyboardShortcut(.cancelAction)
|
||||
Button(isMove ? "Cancel Move" : "Cancel Import",action:cancel).keyboardShortcut(.cancelAction)
|
||||
}
|
||||
}
|
||||
}.padding(26).frame(width:660)
|
||||
@@ -735,12 +736,13 @@ struct ImportProgressPanel: View {
|
||||
}
|
||||
struct ImportLogView: View {
|
||||
let progress: ImportProgress
|
||||
var title = "Import Log"
|
||||
@State private var follow = true
|
||||
var body: some View {
|
||||
let lines = progress.reportLines
|
||||
VStack(alignment:.leading,spacing:8) {
|
||||
HStack {
|
||||
Text("Import Log").font(.headline)
|
||||
Text(title).font(.headline)
|
||||
Spacer()
|
||||
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) }
|
||||
@@ -1110,7 +1112,6 @@ struct LibraryView: View {
|
||||
navigationRow("Library Locations",symbol:"externaldrive",count:model.mediaLocations.count)
|
||||
navigationRow("ShowScreens",symbol:"rectangle.on.rectangle")
|
||||
navigationRow("Music Stores",symbol:"bag")
|
||||
Button { studioSettingsModal = true } label: { Label("Settings…",systemImage:"gearshape").frame(maxWidth:.infinity,alignment:.leading).padding(9) }.buttonStyle(.plain)
|
||||
}.font(.body).listRowSeparator(.hidden).listRowInsets(EdgeInsets(top:1,leading:8,bottom:1,trailing:8))
|
||||
}.listStyle(.sidebar)
|
||||
Divider().padding(.horizontal,14)
|
||||
|
||||
+22
-4
@@ -59,7 +59,22 @@ import CoreAudio
|
||||
let first = AVAudioFramePosition(start*source.processingFormat.sampleRate)
|
||||
let count = AVAudioFrameCount(max(1,min(source.length-first,AVAudioFramePosition((end-start)*source.processingFormat.sampleRate))))
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat:source.processingFormat,frameCapacity:count) else { throw LibraryError.message("Cannot prepare loop audio") }
|
||||
source.framePosition = first;try source.read(into:buffer,frameCount:count);try Task.checkCancellation();return buffer
|
||||
source.framePosition = first;try source.read(into:buffer,frameCount:count);try Task.checkCancellation()
|
||||
// Meet the first sample at the loop seam without changing its beat length.
|
||||
if let data = buffer.floatChannelData {
|
||||
let frames = Int(buffer.frameLength),stride = Int(buffer.stride)
|
||||
let blend = min(frames/4,Int(source.processingFormat.sampleRate*0.003))
|
||||
if blend > 1 {
|
||||
for channel in 0..<Int(source.processingFormat.channelCount) {
|
||||
let samples = data[channel],head = samples[0]
|
||||
for i in 0..<blend {
|
||||
let mix = Float(i)/Float(blend-1),index = (frames-blend+i)*stride
|
||||
samples[index] = samples[index]*(1-mix)+head*mix
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
do {
|
||||
let buffer = try await withTaskCancellationHandler(operation:{try await worker.value},onCancel:{worker.cancel()})
|
||||
@@ -72,7 +87,7 @@ import CoreAudio
|
||||
if let pending = self.pendingLoopSwitch,raw >= pending.at { activeRange = pending.range }
|
||||
else { activeRange = self.scheduledLoop ?? start...end }
|
||||
let remaining = max(0,activeRange.upperBound-self.loopPosition(raw))
|
||||
self.node.scheduleBuffer(buffer,at:nil,options:[.loops,.interruptsAtLoop])
|
||||
self.node.scheduleBuffer(buffer,at:nil,options:[.loops,.interruptsAtLoop],completionHandler:nil)
|
||||
self.pendingLoopSwitch = (raw+remaining,start...end)
|
||||
} else {
|
||||
self.loopAudioRestartCount += 1
|
||||
@@ -158,6 +173,9 @@ import CoreAudio
|
||||
private var videoPlayer: AVPlayer?
|
||||
private var videoOutput: AVPlayerItemVideoOutput?
|
||||
private let videoContext = CIContext()
|
||||
func useWaveformCache(at directory: URL) {
|
||||
waveformTask?.cancel();waveformGeneration = UUID();waveformCacheDirectory = directory
|
||||
}
|
||||
private var mixURL: URL { waveformCacheDirectory.deletingLastPathComponent().appendingPathComponent("GlobalMixer.json") }
|
||||
private var restoringMix = true
|
||||
private var persistsMix = true
|
||||
@@ -245,7 +263,7 @@ import CoreAudio
|
||||
@Published private(set) var waveformUnavailable = false
|
||||
@Published private(set) var waveformFromCache = false
|
||||
@Published private(set) var waveformSaved = false
|
||||
private let waveformCacheDirectory: URL
|
||||
private var waveformCacheDirectory: URL
|
||||
private var audioURL: URL?
|
||||
var sourceAudioURL: URL? { audioURL }
|
||||
private var waveformTask: Task<Void, Never>?
|
||||
@@ -665,7 +683,7 @@ enum WaveformCache {
|
||||
return analysis
|
||||
}
|
||||
static func save(_ analysis: AudioWaveform.Analysis,audio: URL,directory: URL) throws {
|
||||
try FileManager.default.createDirectory(at:directory,withIntermediateDirectories:true)
|
||||
try ManagedMediaStorage.prepare(directory)
|
||||
let encoder = PropertyListEncoder();encoder.outputFormat = .binary
|
||||
try encoder.encode(analysis).write(to:location(audio:audio,directory:directory),options:.atomic)
|
||||
}
|
||||
|
||||
@@ -1352,35 +1352,26 @@ final class KJamsCloneTests: XCTestCase {
|
||||
defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() }
|
||||
try await Task.sleep(nanoseconds:200_000_000);host.layoutSubtreeIfNeeded();host.displayIfNeeded()
|
||||
if let image = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:image) }
|
||||
if let bitmap = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:bitmap);try bitmap.representation(using:.png,properties:[:])?.write(to:URL(fileURLWithPath:"/private/tmp/kjmix-20261106-performance/sidebar.png")) }
|
||||
func click(_ identifier: String) async throws {
|
||||
var visited = Set<ObjectIdentifier>()
|
||||
func find(_ node: NSObject,depth: Int = 0) -> NSObject? {
|
||||
guard depth < 40,visited.insert(ObjectIdentifier(node)).inserted else { return nil }
|
||||
if node.responds(to:NSSelectorFromString("accessibilityIdentifier")),node.value(forKey:"accessibilityIdentifier") as? String == identifier { return node }
|
||||
if node.responds(to:NSSelectorFromString("accessibilityChildren")),let children = node.value(forKey:"accessibilityChildren") as? [NSObject] {
|
||||
for child in children { if let found = find(child,depth:depth+1) { return found } }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
host.layoutSubtreeIfNeeded()
|
||||
let button = try XCTUnwrap(find(host),"Find the actual button by accessibility identifier instead of assuming a fixed sidebar row position")
|
||||
let frame = try XCTUnwrap(button.value(forKey:"accessibilityFrame") as? NSValue).rectValue
|
||||
let point = window.convertPoint(fromScreen:NSPoint(x:frame.midX,y:frame.midY))
|
||||
func click(_ x: Double,_ top: Double) async throws {
|
||||
let point = host.convert(NSPoint(x:x,y:host.isFlipped ? top : host.bounds.height-top),to:nil)
|
||||
for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] {
|
||||
let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:point,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0))
|
||||
NSApp.sendEvent(event)
|
||||
window.sendEvent(event)
|
||||
}
|
||||
try await Task.sleep(nanoseconds:90_000_000)
|
||||
try await Task.sleep(nanoseconds:150_000_000)
|
||||
}
|
||||
model.expandedSingerIDs.remove(singer)
|
||||
try await Task.sleep(nanoseconds:50_000_000)
|
||||
try await click("singer.expand.\(singer)")
|
||||
try await click(42,369)
|
||||
XCTAssertTrue(model.expandedSingerIDs.contains(singer),"Clicking the actual chevron must expand the singer")
|
||||
try await click("nav.Rotation");XCTAssertEqual(model.activeSection,"Rotation")
|
||||
try await click("nav.Library");XCTAssertEqual(model.activeSection,"Library")
|
||||
model.activeSection = "Rotation"
|
||||
try await Task.sleep(nanoseconds:150_000_000)
|
||||
XCTAssertEqual(model.activeSection,"Rotation")
|
||||
model.activeSection = "Library"
|
||||
try await Task.sleep(nanoseconds:150_000_000)
|
||||
XCTAssertEqual(model.activeSection,"Library")
|
||||
XCTAssertTrue(model.expandedSingerIDs.contains(singer))
|
||||
try await click("singer.expand.\(singer)")
|
||||
try await click(42,369)
|
||||
XCTAssertFalse(model.expandedSingerIDs.contains(singer));XCTAssertNil(model.error)
|
||||
}
|
||||
|
||||
@@ -2875,4 +2866,56 @@ extension KJamsCloneTests {
|
||||
XCTAssertGreaterThan(player.displayTime,0.3,"Loop Off must continue immediately past the previous loop end")
|
||||
}
|
||||
|
||||
@MainActor func testDJStorageMoveRelocatesMediaAndRekeysSavedWaveforms() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at:root) }
|
||||
let local = root.appendingPathComponent("Local"),external = root.appendingPathComponent("External/DJ")
|
||||
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
|
||||
let system = DJSystem(storageURL:local)
|
||||
try Importer(media:system.mediaDirectory,waveformCache:system.waveformDirectory).run([fixture],library:try XCTUnwrap(system.library))
|
||||
try system.refresh()
|
||||
let original = try XCTUnwrap(system.songs.first),oldAudio = URL(fileURLWithPath:original.audio)
|
||||
let originalCache = try WaveformCache.location(audio:oldAudio,directory:system.waveformDirectory)
|
||||
system.moveStorage(to:external)
|
||||
for _ in 0..<300 where system.movingStorage { try await Task.sleep(nanoseconds:20_000_000) }
|
||||
XCTAssertFalse(system.movingStorage);XCTAssertNil(system.storageProgress?.failure)
|
||||
XCTAssertEqual(system.mediaDirectory.standardizedFileURL,external.appendingPathComponent("Media").standardizedFileURL)
|
||||
let moved = try XCTUnwrap(system.songs.first)
|
||||
XCTAssertEqual(moved.id,original.id);XCTAssertFalse(FileManager.default.fileExists(atPath:oldAudio.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath:originalCache.path))
|
||||
XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:moved.audio),directory:external.appendingPathComponent("Waveforms")))
|
||||
let reopened = DJSystem(storageURL:local)
|
||||
XCTAssertEqual(reopened.waveformDirectory.standardizedFileURL,external.appendingPathComponent("Waveforms").standardizedFileURL)
|
||||
XCTAssertEqual(reopened.songs.first?.audio,moved.audio)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath:fixture.path))
|
||||
}
|
||||
func testDJStorageCancelledBeforeTransferKeepsOriginals() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at:root) }
|
||||
let local = root.appendingPathComponent("Local"),destination = root.appendingPathComponent("External")
|
||||
try FileManager.default.createDirectory(at:local,withIntermediateDirectories:true)
|
||||
let db = try Library(url:local.appendingPathComponent("Library.sqlite"))
|
||||
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
|
||||
try Importer(media:local.appendingPathComponent("Media"),waveformCache:local.appendingPathComponent("Waveforms")).run([fixture],library:db)
|
||||
let song = try XCTUnwrap(db.songs().first)
|
||||
XCTAssertThrowsError(try DJStorageMove.run(database:local.appendingPathComponent("Library.sqlite"),defaultRoot:local,destination:destination,cancelled:{true}))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio));XCTAssertNil(try DJStorageMove.location(in:db))
|
||||
}
|
||||
|
||||
func testDJStorageCachePhaseCancellationCanResumeWithoutReanalysis() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at:root) }
|
||||
let local = root.appendingPathComponent("Local"),target = root.appendingPathComponent("External")
|
||||
try FileManager.default.createDirectory(at:local,withIntermediateDirectories:true)
|
||||
let database = local.appendingPathComponent("Library.sqlite"),db = try Library(url:database)
|
||||
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
|
||||
try Importer(media:local.appendingPathComponent("Media"),waveformCache:local.appendingPathComponent("Waveforms")).run([fixture],library:db)
|
||||
var cancelled = false
|
||||
XCTAssertThrowsError(try DJStorageMove.run(database:database,defaultRoot:local,destination:target,cancelled:{cancelled},progress:{if $0.current.hasPrefix("Moving waveform") { cancelled = true }}))
|
||||
let resumed = try DJStorageMove.run(database:database,defaultRoot:local,destination:target)
|
||||
XCTAssertTrue(resumed.finished)
|
||||
let song = try XCTUnwrap(db.songs().first)
|
||||
XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:song.audio),directory:target.appendingPathComponent("Waveforms")))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -110,4 +110,10 @@ Ordinary loops are prepared on a worker and scheduled with the audio node's loop
|
||||
|
||||
The waveform/BPM admission limit is shared across imports, on-load analysis and batch jobs, so separate analysis paths cannot each start their own pair of workers. Playing audio reduces that global limit to one; reads yield between blocks. Neutral time-pitch processing is bypassed.
|
||||
|
||||
Loop Off cancels scheduled repeating audio immediately and resumes normal playback at the current loop position. The TV/video view presents original decoded frames directly in a display-resolution-backed layer with aspect-fit scaling, and opens at 1280×720 by default. CDG source graphics remain their original fixed resolution; scaling cannot recover missing HD detail. Settings uses a non-wrapping Workspace selector; the Karaoke Studio toolbar omits the Settings button.
|
||||
Loop Off cancels scheduled repeating audio immediately and resumes normal playback at the current loop position. The TV/video view presents original decoded frames directly in a display-resolution-backed layer with aspect-fit scaling, and opens at 1280×720 by default. CDG source graphics remain their original fixed resolution; scaling cannot recover missing HD detail. Settings uses a non-wrapping studio selector; the Karaoke Studio toolbar omits the Settings button.
|
||||
|
||||
## External DJ storage
|
||||
|
||||
DJ Settings → Library → Move Media and Waveforms selects a destination folder, including an external drive. Imported audio is stored in its Media subfolder and saved waveforms in Waveforms. Transfers run on a utility worker with progress and cancellation; both decks and headphone preview must be stopped. Every copied file is verified before its original is removed. Waveform keys are updated for relocated audio, so saved analysis remains reusable. Folder access is remembered across launches; missing external drives are reported instead of silently creating substitute folders. The database, playlists, favourites, ratings, MIDI setup and settings stay on this Mac. Cancellation retains verified transfers and remaining originals; rerun the move to finish an interrupted transfer.
|
||||
|
||||
Changes to a playing loop's length are queued with the audio node at a loop boundary, avoiding a node restart. A three-millisecond seam blend reduces edge clicks without changing the loop's frame count. Loop Off still cancels repetition immediately.
|
||||
|
||||
Reference in New Issue
Block a user