removed unwanted bar now with waveforms, major updates

This commit is contained in:
Mark Gregory
2026-11-06 13:09:24 +00:00
parent 7b45184dff
commit f6368e4ae7
7 changed files with 243 additions and 41 deletions
+3
View File
@@ -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 }
+61 -6
View File
@@ -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)")
+77 -1
View File
@@ -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
}
}
+9 -8
View File
@@ -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
View File
@@ -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)
}