removed unwanted bar

This commit is contained in:
Mark Gregory
2026-11-06 09:51:44 +00:00
parent b1868ee2d3
commit 794fa595d7
7 changed files with 285 additions and 37 deletions
+23 -4
View File
@@ -1,3 +1,4 @@
import CoreText
import SwiftUI
import AppKit
import QuartzCore
@@ -105,7 +106,7 @@ class DJWaveMouseView: NSView {
}
override func mouseDragged(with event: NSEvent) { updateDrag(convert(event.locationInWindow,from:nil).x) }
override func mouseUp(with event: NSEvent) {
updateDrag(convert(event.locationInWindow,from:nil).x);drag = nil;freeze?(nil);NSCursor.openHand.set()
updateDrag(convert(event.locationInWindow,from:nil).x);player?.endScrubbing();drag = nil;freeze?(nil);NSCursor.openHand.set()
}
private func updateDrag(_ x: Double) {
guard let player,let drag else { return }
@@ -113,10 +114,10 @@ class DJWaveMouseView: NSView {
else if drag.mode == .seek {
if relativeSeek {
let target = min(player.duration,max(0,initialTime-(x-initialX)*drag.secondsPerPixel))
if abs(player.time-target) > 0.0001 { player.seek(target) }
if abs(player.time-target) > 0.0001 { player.scrub(to:target) }
freeze?(drag.viewport+target-initialTime)
NSCursor.closedHand.set()
} else { player.seek(drag.viewport+x*drag.secondsPerPixel) }
} else { player.scrub(to:drag.viewport+x*drag.secondsPerPixel) }
}
}
}
@@ -310,7 +311,25 @@ final class DJNativeSpectrumView: DJWaveMouseView {
if image != nil { waveformTileBuildCount += 1 }
return image
}
private let markerFont = CTFontCreateWithName("Menlo-Bold" as CFString,9,nil)
private var markerLines = [String:CTLine]()
private func text(_ value: String,at point: NSPoint,color: NSColor) {
(value as NSString).draw(at:point,withAttributes:[.font:NSFont.monospacedSystemFont(ofSize:9,weight:.semibold),.foregroundColor:color])
guard let context = NSGraphicsContext.current?.cgContext else { return }
let key = value+"|"+color.description
let line: CTLine
if let saved = markerLines[key] { line = saved }
else {
let attributes: [NSAttributedString.Key:Any] = [
NSAttributedString.Key(kCTFontAttributeName as String):markerFont,
NSAttributedString.Key(kCTForegroundColorAttributeName as String):color.cgColor
]
line = CTLineCreateWithAttributedString(NSAttributedString(string:value,attributes:attributes))
if markerLines.count >= 128 { markerLines.removeAll(keepingCapacity:true) }
markerLines[key] = line
}
context.saveGState();defer { context.restoreGState() }
context.translateBy(x:point.x,y:point.y+10);context.scaleBy(x:1,y:-1)
context.textMatrix = .identity;context.textPosition = .zero
CTLineDraw(line,context)
}
}
+112 -22
View File
@@ -48,7 +48,7 @@ struct DJPreferences: Codable, Equatable {
}
@MainActor final class DJChannel: ObservableObject {
let player = Player(persistsMix:false)
let player: Player
let letter: String
@Published private(set) var song: Song?
@Published var bpm: Double = 120
@@ -70,7 +70,7 @@ struct DJPreferences: Codable, Equatable {
var master: Double = 0.8 { didSet { updateLevel() } }
var crossWeight: Double = 1 { didSet { updateLevel() } }
private var taps = [Double]()
init(_ letter: String) { self.letter = letter;player.autoFade = false;updateLevel() }
init(_ letter: String,waveformCache: URL? = nil) { self.letter = letter;player = Player(waveformCacheDirectory:waveformCache,persistsMix:false);player.audibleScrubbing = true;player.autoFade = false;updateLevel() }
func updateLevel() { player.volume = min(1,max(0,level * master * crossWeight)) }
func load(_ song: Song) throws {
var audioOnly = song;audioOnly.cdg = nil
@@ -151,7 +151,9 @@ struct DJAudioDevice: Identifiable, Equatable {
}
@MainActor final class DJSystem: ObservableObject {
let a = DJChannel("A"),b = DJChannel("B")
@Published private(set) var favourites = Set<Int64>()
let a: DJChannel
let b: DJChannel
let preview = Player(persistsMix:false)
let midi = DJMIDI()
lazy var automix = DJAutomix(system:self)
@@ -191,6 +193,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")
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
Task { @MainActor [weak self] in self?.maintainSync() }
@@ -204,6 +207,7 @@ struct DJAudioDevice: Identifiable, Equatable {
perform {
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try refresh()
favourites = Set((try library?.setting("DJ.Favourites.v1") ?? "").split(separator:",").compactMap{Int64($0)})
if let data = try? Data(contentsOf:root.appendingPathComponent("DJSettings.json")),let saved = try? JSONDecoder().decode(DJPreferences.self,from:data) { settings = saved.sanitized }
if let data = try? Data(contentsOf:root.appendingPathComponent("DJAudioRouting.json")),let saved = try? JSONDecoder().decode(DJAudioRouting.self,from:data) { applyRouting(saved) }
applyMix()
@@ -212,6 +216,14 @@ struct DJAudioDevice: Identifiable, Equatable {
}
private func saveSettings() { perform { let data = try JSONEncoder().encode(settings.sanitized);try data.write(to:root.appendingPathComponent("DJSettings.json"),options:.atomic) } }
func refresh() throws { songs = try library?.songs() ?? [];playlists = try library?.entities("playlists") ?? [] }
func toggleFavourite(_ song: Int64) {
var updated = favourites
if !updated.insert(song).inserted { updated.remove(song) }
perform {
try library?.setSetting("DJ.Favourites.v1",updated.sorted().map(String.init).joined(separator:","))
favourites = updated
}
}
func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name);try refresh() } }
func rename(_ table: String,id: Int64,name: String) { perform { try library?.rename(table,id:id,name:name);try refresh() } }
func delete(_ table: String,id: Int64) { perform { try library?.deleteEntity(table,id:id);try refresh() } }
@@ -220,7 +232,7 @@ struct DJAudioDevice: Identifiable, Equatable {
func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
guard !importing,!urls.isEmpty else { return }
importing = true;importProgress = .init();importLogVisible = true
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),replacedSong = loadInto.flatMap{channel($0).song?.id}
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),cache = root.appendingPathComponent("Waveforms"),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() } }
@@ -228,7 +240,7 @@ struct DJAudioDevice: Identifiable, Equatable {
var importedIDs = [Int64]()
do {
let db = try Library(url:database)
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false).run(urls,library:db,cancelled:{Task.isCancelled}) { value in
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false,waveformCache:cache).run(urls,library:db,cancelled:{Task.isCancelled}) { value in
latest = value
Task { @MainActor [weak self] in if self?.importing == true { self?.importProgress = value } }
}
@@ -264,9 +276,9 @@ struct DJAudioDevice: Identifiable, Equatable {
if !automated { automix.manualTakeover() }
if syncActive { disableSync() }
if previewDeck == letter { stopPreview() }
try deck.load(song)
let route = routing.route(letter)
do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } catch { deck.unload();throw error }
// Configure HAL before load starts the engine, rather than restarting it immediately afterwards.
do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel);try deck.load(song) } catch { deck.unload();throw error }
if settings.resetEQOnLoad { deck.player.resetTone() }
if settings.autoPlayOnLoad && !automated { deck.player.toggle() }
status = "\(song.title) ready on \(letter) · set or tap BPM for tempo sync and beat loops"
@@ -701,8 +713,67 @@ private enum DJStyle {
}
}
/// Observe user scrolling, rather than row appearance (List preloads offscreen rows).
private struct DJLibraryPageScroll: NSViewRepresentable {
let next: () -> Void
func makeNSView(context: Context) -> Observer { Observer() }
func updateNSView(_ view: Observer,context: Context) { view.next = next;view.attach() }
final class Observer: NSView {
var next: (() -> Void)?
private weak var scroll: NSScrollView?
private var observation: NSObjectProtocol?
private var endObservation: NSObjectProtocol?
private var advanced = false
private var lastY = 0.0
override func viewDidMoveToWindow() { super.viewDidMoveToWindow();DispatchQueue.main.async { [weak self] in self?.attach() } }
func attach() {
guard window != nil,scroll == nil else { return }
func find(_ view: NSView) -> NSScrollView? {
if let scroll = view as? NSScrollView { return scroll }
for child in view.subviews { if let found = find(child) { return found } }
return nil
}
var parent = superview
while let view = parent {
if let found = find(view) { scroll = found;break }
parent = view.superview
}
guard let scroll else { return }
lastY = scroll.contentView.bounds.origin.y
observation = NotificationCenter.default.addObserver(forName:NSScrollView.didLiveScrollNotification,object:scroll,queue:.main) { [weak self] _ in
guard let self,let scroll = self.scroll,let document = scroll.documentView else { return }
let y = scroll.contentView.bounds.origin.y
let forward = y > self.lastY+0.1
self.lastY = y
let bottom = document.bounds.height-scroll.contentView.bounds.height
guard !self.advanced,forward,bottom > 1,y >= bottom-2 else { return }
self.advanced = true
DispatchQueue.main.async { [weak self] in self?.next?() }
}
endObservation = NotificationCenter.default.addObserver(forName:NSScrollView.didEndLiveScrollNotification,object:scroll,queue:.main) { [weak self] _ in
self?.advanced = false;self?.lastY = self?.scroll?.contentView.bounds.origin.y ?? 0
}
}
deinit {
if let observation { NotificationCenter.default.removeObserver(observation) }
if let endObservation { NotificationCenter.default.removeObserver(endObservation) }
}
}
}
private struct DJQueueButton: View {
@ObservedObject var automix: DJAutomix
let open: () -> Void
var body: some View {
Button(action:open) { Label("DJ Queue (\(automix.entries.count))",systemImage:"text.line.first.and.arrowtriangle.forward") }
.tint(automix.enabled ? .green : .accentColor)
}
}
struct DJConsole: View {
@ObservedObject var system: DJSystem
@State private var favouritesOnly = false
@State private var queueVisible = false
@State private var query = ""
@State private var playlist: Int64?
@State private var playlistIDs = [Int64]()
@@ -712,7 +783,7 @@ struct DJConsole: View {
@State private var renaming = false
@State private var page = 0
private var songs: [Song] {
let all = system.songs.filter{!$0.audio.hasPrefix("showscreen:") && SongSearch.matches($0,query:query)}
let all = system.songs.filter{!$0.audio.hasPrefix("showscreen:") && (!favouritesOnly || system.favourites.contains($0.id)) && SongSearch.matches($0,query:query)}
if playlist != nil { let lookup = Dictionary(uniqueKeysWithValues:all.map{($0.id,$0)});return playlistIDs.compactMap{lookup[$0]} }
return all
}
@@ -723,10 +794,7 @@ struct DJConsole: View {
VStack(spacing:10) {
header
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true)
HStack(alignment:.top,spacing:12) {
browser.frame(maxWidth:.infinity,maxHeight:.infinity)
DJDeckDropZone(system:system,letter:"Queue") { DJQueuePane(system:system,automix:system.automix) }.frame(width:310).frame(maxHeight:.infinity)
}.frame(maxWidth:.infinity,maxHeight:.infinity)
browser.frame(maxWidth:.infinity,maxHeight:.infinity)
if let progress = system.importProgress {
HStack {
Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit()
@@ -750,6 +818,12 @@ struct DJConsole: View {
}
.sheet(isPresented:$system.cueEditing) { DJCueEditor(system:system) }
.sheet(isPresented:Binding(get:{system.loopEditor != nil},set:{if !$0 { system.loopEditor = nil }})) { let deck = system.channel(system.loopEditor ?? "A");DJLoopEditor(player:deck.player,letter:deck.letter) }
.sheet(isPresented:$queueVisible) {
VStack(spacing:16) {
DJDeckDropZone(system:system,letter:"Queue") { DJQueuePane(system:system,automix:system.automix) }.frame(maxWidth:.infinity,maxHeight:.infinity)
HStack { Spacer();Button("Done") { queueVisible = false }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:620,height:560).background(DJStyle.background).preferredColorScheme(.dark)
}
.sheet(isPresented:$playlistEditor) { playlistSheet }
.alert("DJ workstation",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") }
.onAppear { refreshPlaylist();connectMIDI() }
@@ -758,6 +832,7 @@ struct DJConsole: View {
.onChange(of:system.songs) { _ in refreshPlaylist();connectMIDI() }
.onChange(of:system.playlists) { _ in if let playlist,!system.playlists.contains(where:{$0.id == playlist}) { self.playlist = nil };refreshPlaylist() }
.onChange(of:query) { _ in page = 0 }
.onChange(of:favouritesOnly) { _ in page = 0 }
}
private var header: some View {
HStack {
@@ -765,6 +840,7 @@ struct DJConsole: View {
Text("DJ WORKSTATION").font(.system(size:12,weight:.bold,design:.rounded))
Text("TWO DECKS").font(.system(size:9,weight:.medium)).foregroundStyle(.secondary)
Spacer()
DJQueueButton(automix:system.automix,open:{queueVisible = true})
if system.previewDeck != nil { Button("Stop headphone preview") { system.stopPreview() }.tint(.green) }
}.controlSize(.small)
}
@@ -779,7 +855,9 @@ struct DJConsole: View {
Button("Delete Playlist",role:.destructive) { system.delete("playlists",id:playlist) }
}
} label: { Image(systemName:"folder.badge.plus") }.frame(width:25)
TextField("Search title, artist or album",text:$query).textFieldStyle(.roundedBorder)
Button { favouritesOnly.toggle() } label: { Label("Favourites",systemImage:favouritesOnly ? "star.fill" : "star") }.foregroundStyle(favouritesOnly ? Color.yellow : Color.secondary).help("Show favourite tracks only")
Spacer(minLength:12)
TextField("Search DJ music",text:$query).textFieldStyle(.roundedBorder).controlSize(.regular).frame(maxWidth:260)
Button { system.automix.enqueue(songs) } label: { Image(systemName:"text.badge.plus") }.help("Add this library or playlist to the DJ queue").disabled(songs.isEmpty)
Button("Import Files / Folder…") { importMedia() }
Spacer(minLength:0)
@@ -787,27 +865,35 @@ struct DJConsole: View {
Text("\(min(page,pages-1)+1)/\(pages) · \(songs.count)").font(.caption.monospacedDigit()).fixedSize()
Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1)
}.controlSize(.small)
ScrollViewReader { reader in
List(selection:$selected) {
ForEach(visible) { song in
HStack(spacing:8) {
Image(systemName:"music.note").foregroundStyle(.secondary)
Button { system.toggleFavourite(song.id) } label: { GoldStar(filled:system.favourites.contains(song.id),size:15) }.buttonStyle(.plain).accessibilityLabel(system.favourites.contains(song.id) ? "Remove \(song.title) from favourites" : "Favourite \(song.title)")
DJLibraryDragTitle(title:song.title,reference:system.dragReference(song.id),fontSize:system.settings.libraryFont,select:{selected = song.id}).frame(maxWidth:.infinity,alignment:.leading)
Text(song.artist).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).frame(width:130,alignment:.leading)
Text(DJStyle.stamp(song.duration)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary).frame(width:35)
Button { system.automix.enqueue([song]) } label: { Image(systemName:"plus") }.accessibilityLabel("Add \(song.title) to DJ queue")
Button("A") { system.load(song,into:"A") }.tint(DJStyle.a).accessibilityLabel("Load \(song.title) into A")
Button("B") { system.load(song,into:"B") }.tint(DJStyle.b).accessibilityLabel("Load \(song.title) into B")
}.padding(.vertical,max(0,(system.settings.libraryRowHeight-24)/2)).tag(song.id).buttonStyle(.bordered).controlSize(.mini)
}.padding(.vertical,max(0,(system.settings.libraryRowHeight-24)/2)).tag(song.id).id(song.id).buttonStyle(.bordered).controlSize(.mini)
.contextMenu { playlistMenu(song) }
}
if visible.isEmpty { Text("Import music or choose another playlist.").font(.caption).foregroundStyle(.secondary) }
}.listStyle(.inset).scrollContentBackground(.hidden).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:6))
.background(DJLibraryPageScroll { if page < pages-1 { page += 1 } })
.onChange(of:page) { _ in
selected = visible.first?.id
if let first = visible.first?.id { DispatchQueue.main.async { reader.scrollTo(first,anchor:.top) } }
}
}
}
}
@ViewBuilder private func playlistMenu(_ song: Song) -> some View {
Button("Load into Deck A") { system.load(song,into:"A") }
Button("Load into Deck B") { system.load(song,into:"B") }
Divider()
Button(system.favourites.contains(song.id) ? "Remove from Favourites" : "Add to Favourites") { system.toggleFavourite(song.id) }
Button("Add to DJ Queue") { system.automix.enqueue([song]) }
Menu("Add to Playlist") { ForEach(system.playlists) { list in Button(list.name) { system.addToPlaylist(list.id,song:song.id);refreshPlaylist() } } }
if let playlist {
@@ -889,11 +975,15 @@ private struct DJDeckSurfaces {
}
VStack(spacing:12) {
DJKnob(label:"FILTER",value:$player.filter,range:-1...1,reset:0,color:color,format:{String(format:"%+.0f%%",$0*100)})
VStack(spacing:5) {
ForEach(0..<8) { i in RoundedRectangle(cornerRadius:1).fill(Double(max(player.leftLevel,player.rightLevel))*8 > Double(7-i) ? color : Color.white.opacity(0.07)).frame(width:8,height:5) }
}.accessibilityLabel("Deck output meter")
HStack(spacing:3) {
VStack(spacing:5) {
ForEach(0..<8) { i in RoundedRectangle(cornerRadius:1).fill(Double(max(player.leftLevel,player.rightLevel))*8 > Double(7-i) ? color : Color.white.opacity(0.07)).frame(width:5,height:5) }
}.accessibilityLabel("Deck output meter")
DJHardwareFader(value:$deck.level,label:"Deck \(deck.letter) level",vertical:true).frame(width:44,height:94)
}
Text("LEVEL").font(.system(size:8,weight:.semibold)).foregroundStyle(.secondary)
}
}.frame(width:88)
}.frame(width:110)
}
}
@@ -1119,14 +1209,14 @@ struct DJContinuousConsole: View {
}.frame(height:92).background(Color.black.opacity(0.4))
Divider()
HStack(spacing:0) {
DJDeckDropZone(system:system,letter:"A") { DJConsoleWing(deck:system.a,player:system.a.player,system:system) }.frame(width:322)
DJDeckDropZone(system:system,letter:"A") { DJConsoleWing(deck:system.a,player:system.a.player,system:system) }.frame(width:344)
Divider()
VStack(spacing:1) {
DJDeckDropZone(system:system,letter:"A") { DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings) }
DJDeckDropZone(system:system,letter:"B") { DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings) }
}.frame(maxWidth:.infinity).background(.black)
Divider()
DJDeckDropZone(system:system,letter:"B") { DJConsoleWing(deck:system.b,player:system.b.player,system:system) }.frame(width:322)
DJDeckDropZone(system:system,letter:"B") { DJConsoleWing(deck:system.b,player:system.b.player,system:system) }.frame(width:344)
}.frame(height:198).background(Color(white:0.12))
Divider()
HStack(spacing:12) {
@@ -1199,7 +1289,7 @@ private struct DJConsoleWing: View {
}
if deck.letter == "A" { face.tonePanel }
if deck.letter == "B" { face.tempoStrip }
}.padding(.horizontal,9).frame(width:322).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1)
}.padding(.horizontal,9).frame(width:344).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1)
}
}
+11
View File
@@ -25,6 +25,7 @@ struct Importer {
let media: URL
var audioExtensions: Set<String> = ["mp3","wav","m4a","mp4","m4v","mov"]
var includeGraphics = true
var waveformCache: URL? = nil
@discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false },progress: (ImportProgress) -> Void = { _ in }) throws -> [Int64] {
try ManagedMediaStorage.prepare(media)
var state = ImportProgress()
@@ -252,6 +253,16 @@ struct Importer {
try remember(audio,signature,id)
state.completed += 1
state.logLines.append("[IMPORTED] "+sourcePath(audio))
if let waveformCache {
report("Generating waveform: "+audio.lastPathComponent,force:true)
do {
if (try? WaveformCache.load(audio:dest,directory:waveformCache)) == nil {
let waveform = try AudioWaveform.analyse(url:dest,cancelled:cancelled)
try WaveformCache.save(waveform,audio:dest,directory:waveformCache)
}
} catch is CancellationError { throw CancellationError() }
catch { state.logLines.append("[WARNING] Waveform unavailable for "+sourcePath(audio)+": "+error.localizedDescription) }
}
report(audio.lastPathComponent,force:state.completed == 1)
}
for archive in archives {
+38 -10
View File
@@ -476,7 +476,7 @@ struct SongDragReference: Codable, Sendable {
func importFiles(_ urls: [URL]) {
guard !isImporting,scanningSource == nil,!generatingWaveforms,!movingMedia,!namesBusy else { error = "Wait for the current import, scan or waveform job to finish first.";return }
guard !urls.isEmpty else { return }
let token = UUID(),database = root.appendingPathComponent("Library.sqlite"),media = managedMediaDirectory
let token = UUID(),database = root.appendingPathComponent("Library.sqlite"),media = managedMediaDirectory,cache = root.appendingPathComponent("Waveforms")
importToken = token;isImporting = true;importProgress = ImportProgress()
importTask = Task.detached(priority:.utility) { [weak self] in
let accessed = urls.filter { $0.startAccessingSecurityScopedResource() }
@@ -484,7 +484,7 @@ struct SongDragReference: Codable, Sendable {
var latest = ImportProgress()
do {
let database = try Library(url:database)
try Importer(media:media).run(urls,library:database,cancelled:{Task.isCancelled},progress:{ update in
try Importer(media:media,waveformCache:cache).run(urls,library:database,cancelled:{Task.isCancelled},progress:{ update in
latest = update
Task { @MainActor [weak self] in
guard let self,self.importToken == token else { return };self.importProgress = update
@@ -616,6 +616,39 @@ private struct ImportProgressDialog: View {
}
}
}
private struct WaveformProgressCard: View {
let progress: WaveformJobProgress
let running: Bool
let cancel: () -> Void
let details: () -> Void
let dismiss: () -> Void
private var fraction: Double { min(1,max(0,Double(progress.processed)/Double(max(1,progress.total)))) }
private var colour: Color { progress.cancelled || progress.issues > 0 ? .orange : running ? .cyan : .green }
var body: some View {
HStack(spacing:16) {
ZStack {
Circle().stroke(colour.opacity(0.15),lineWidth:5)
Circle().trim(from:0,to:fraction).stroke(colour,style:StrokeStyle(lineWidth:5,lineCap:.round)).rotationEffect(.degrees(-90))
Text("\(Int(fraction*100))%").font(.system(size:11,weight:.semibold)).monospacedDigit()
}.frame(width:46,height:46).accessibilityLabel("Waveform progress").accessibilityValue("\(Int(fraction*100)) percent")
VStack(alignment:.leading,spacing:7) {
HStack { Text(running ? "Preparing Karaoke Waveforms" : "Waveform Results").font(.callout.weight(.semibold));Spacer();Text("\(progress.processed.formatted()) / \(progress.total.formatted())").font(.caption).monospacedDigit().foregroundStyle(.secondary) }
GeometryReader { proxy in
ZStack(alignment:.leading) {
Capsule().fill(colour.opacity(0.12))
Capsule().fill(LinearGradient(colors:[colour.opacity(0.6),colour],startPoint:.leading,endPoint:.trailing)).frame(width:proxy.size.width*fraction)
}
}.frame(height:7)
Text(progress.current).font(.caption).lineLimit(1).foregroundStyle(.secondary)
Text("\(progress.generated) generated · \(progress.cached) reused · \(progress.skipped) skipped · \(progress.issues) issues").font(.caption2).foregroundStyle(.secondary)
}
if running { Button("Cancel",action:cancel).controlSize(.small) }
if progress.issues > 0 { Button("Details",action:details).controlSize(.small) }
if !running { Button(action:dismiss) { Image(systemName:"xmark") }.buttonStyle(.plain).help("Dismiss waveform results") }
}.padding(16).background(colour.opacity(0.05),in:RoundedRectangle(cornerRadius:12))
.overlay(RoundedRectangle(cornerRadius:12).strokeBorder(colour.opacity(0.18)))
}
}
struct ImportProgressPanel: View {
let progress: ImportProgress
let studio: String
@@ -864,13 +897,8 @@ struct LibraryView: View {
}
sourceContent.frame(maxWidth:.infinity,maxHeight:.infinity)
if let progress = model.waveformJob {
HStack(spacing:10) {
Image(systemName:"waveform").foregroundStyle(Color.accentColor)
VStack(alignment:.leading,spacing:2) { Text(progress.current).lineLimit(1);Text("\(progress.processed)/\(progress.total) · \(progress.generated) generated · \(progress.cached) reused · \(progress.issues) issues").foregroundStyle(.secondary) }.font(.caption)
Spacer();if model.generatingWaveforms { ProgressView(value:Double(progress.processed),total:Double(max(1,progress.total))).frame(width:100);Button("Cancel") { model.cancelWaveformGeneration() }.controlSize(.small) }
if progress.issues > 0 { Button("Details") { waveformDetails = true }.controlSize(.small) }
if !model.generatingWaveforms { Button { model.dismissWaveformProgress() } label: { Image(systemName:"xmark") }.buttonStyle(.plain).help("Dismiss waveform results") }
}.padding(.horizontal,18).padding(.vertical,8)
WaveformProgressCard(progress:progress,running:model.generatingWaveforms,cancel:{model.cancelWaveformGeneration()},details:{waveformDetails = true},dismiss:{model.dismissWaveformProgress()})
.padding(.horizontal,18).padding(.vertical,10)
}
Divider()
footer.padding(.horizontal,18).padding(.vertical,8)
@@ -1442,7 +1470,7 @@ private struct TonightSongDropTarget: ViewModifier {
}
}
/// Drawn with literal colours, so native menu/button tint cannot turn favourite stars white.
private struct GoldStar: View {
struct GoldStar: View {
var filled: Bool
var size: CGFloat = 14
var body: some View {
+31
View File
@@ -309,6 +309,7 @@ import CoreAudio
if let timer { RunLoop.main.add(timer,forMode:.common) }
}
func toggle() {
endScrubbing()
if slideImage != nil {
if playing { tick();slideAnchor = time;playing = false } else { if time >= duration { time = 0;slideAnchor = 0 };slideStart = ProcessInfo.processInfo.systemUptime;playing = true;startTimer() };return
}
@@ -318,6 +319,7 @@ import CoreAudio
else { startNodePlayback(); playing = true; startTimer() }
}
func stop() {
endScrubbing()
cancelPendingPlayback()
graphicsGeneration = UUID();graphicsTask?.cancel();graphicsTask = nil;checkpointTask?.cancel();checkpointTask = nil;checkpoints = [:];lastGraphicsPacket = -1
cuePosition = 0;hotCues = [nil,nil,nil,nil];clearLoop()
@@ -331,6 +333,35 @@ import CoreAudio
engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone)
file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song"
}
var audibleScrubbing = false
private var scrubPreviewTask: Task<Void,Never>?
private var scrubPreviewActive = false
/// Preview short grains through the deck's normal EQ, fader and output route.
/// Keep transport paused so scrubbing cannot start Automix or change sync state.
func scrub(to value: Double) {
guard audibleScrubbing,!playing,let file,value.isFinite else { seek(value);return }
scrubPreviewTask?.cancel()
seek(value)
guard time < duration,engine.isRunning else { return }
generation = UUID();let token = generation
node.stop()
let rate = file.processingFormat.sampleRate
let start = AVAudioFramePosition(time*rate)
let count = AVAudioFrameCount(min(file.length-start,Int64(rate*0.1)))
guard count > 0 else { return }
node.scheduleSegment(file,startingFrame:start,frameCount:count,at:nil)
scrubPreviewActive = true;node.play()
scrubPreviewTask = Task { @MainActor [weak self] in
do { try await Task.sleep(nanoseconds:100_000_000) } catch { return }
guard let self,self.generation == token else { return }
self.endScrubbing()
}
}
func endScrubbing() {
scrubPreviewTask?.cancel();scrubPreviewTask = nil
guard scrubPreviewActive else { return };scrubPreviewActive = false
if !playing { schedule(at:time,play:false) }
}
func seek(_ value: Double) {
guard value.isFinite else { return }
invalidateGraphics()
+61
View File
@@ -2622,5 +2622,66 @@ extension KJamsCloneTests {
XCTAssertTrue(report.finished);XCTAssertNil(report.failure)
XCTAssertEqual(report.completed,48);XCTAssertEqual(report.skipped,47)
XCTAssertEqual(report.reportLines.count,48);XCTAssertEqual(system.songs.count,1)
let song = try XCTUnwrap(system.songs.first)
XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:song.audio),directory:root.appendingPathComponent("DJ/Waveforms")))
}
@MainActor func testDJFavouritesPersistIndependentlyAndToggleOff() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let system = DJSystem(storageURL:root)
system.toggleFavourite(42)
XCTAssertEqual(system.favourites,[42])
let reopened = DJSystem(storageURL:root)
XCTAssertEqual(reopened.favourites,[42])
reopened.toggleFavourite(42)
XCTAssertTrue(reopened.favourites.isEmpty)
XCTAssertTrue(DJSystem(storageURL:root).favourites.isEmpty)
}
@MainActor func testDJAudibleScrubbingPreviewsPausedAudioAndPreservesTransport() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let player = Player(waveformCacheDirectory:root)
defer { player.stop() }
player.audibleScrubbing = true
try player.load(Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0),startPlaying:false)
player.scrub(to:0.5)
XCTAssertFalse(player.playing)
XCTAssertTrue(player.audioPlaybackStarted,"Paused scrubbing must render an audio grain")
player.scrub(to:0.2)
XCTAssertEqual(player.time,0.2,accuracy:0.001)
player.endScrubbing()
XCTAssertFalse(player.audioPlaybackStarted);XCTAssertFalse(player.playing)
XCTAssertEqual(player.time,0.2,accuracy:0.001)
player.scrub(to:0.4)
try await Task.sleep(nanoseconds:180_000_000)
XCTAssertFalse(player.audioPlaybackStarted,"Preview must stop if dragging stops")
player.toggle()
player.scrub(to:0.6)
XCTAssertTrue(player.playing);XCTAssertTrue(player.audioPlaybackStarted)
player.endScrubbing()
XCTAssertTrue(player.playing,"Releasing a playing deck must keep it playing")
}
@MainActor func testLoadingSecondDJDeckKeepsFirstDeckRendering() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let system = DJSystem(storageURL:root)
defer { system.a.unload();system.b.unload() }
let song = Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0)
system.load(song,into:"A");system.a.player.toggle()
try await Task.sleep(nanoseconds:100_000_000)
let before = system.a.player.displayTime
for _ in 0..<3 {
system.load(song,into:"B")
XCTAssertTrue(system.a.player.playing);XCTAssertTrue(system.a.player.audioPlaybackStarted)
XCTAssertFalse(system.b.player.playing)
try await Task.sleep(nanoseconds:50_000_000)
}
XCTAssertGreaterThan(system.a.player.displayTime,before)
XCTAssertNil(system.error)
}
}
+9 -1
View File
@@ -1,6 +1,6 @@
# DJ workstation — feature/dj_workstation
The main window has two tabs: **Karaoke Studio** and **DJ Studio**. Playback, MIDI handling and selections persist when switching. Native tab pages hide or detach inactive drop destinations so karaoke cannot intercept DJ drops. The DJ library and queue fill the remaining window height. The two bottom status messages and the DJ Settings, Audio Routing and MIDI header buttons have been removed. Settings… (Command-comma) stays available in either workspace and opens the matching Karaoke or DJ scope; choose Devices or MIDI there. Existing singer, venue, rotation, video, background music and recursive directory import features remain available. Background music is a compact strip above karaoke transport. Each tab opens its own settings scope.
The main window has two tabs: **Karaoke Studio** and **DJ Studio**. Playback, MIDI handling and selections persist when switching. Native tab pages hide or detach inactive drop destinations so karaoke cannot intercept DJ drops. The DJ library fills the remaining window height. The DJ Queue button opens the queue and Automix controls in a modal, with a live waiting-track count. The two bottom status messages and the DJ Settings, Audio Routing and MIDI header buttons have been removed. Settings… (Command-comma) stays available in either workspace and opens the matching Karaoke or DJ scope; choose Devices or MIDI there. Existing singer, venue, rotation, video, background music and recursive directory import features remain available. Background music is a compact strip above karaoke transport. Each tab opens its own settings scope.
## Using the decks
@@ -79,3 +79,11 @@ Spectrum scrolling caches source-aligned image sections and translates them by f
DJ imports show a live progress panel with a coloured progress bar, imported/skipped/remaining totals, the current file and a selectable, copyable log. DJ imports, including Finder drops, offer Run in Background; View Progress reopens the panel, and View Log shows the completed report. Scanning, hashing, copying and metadata reads run on a utility background worker with a separate database connection; interface progress updates are throttled to about ten per second. Cancelling retains completed tracks. Duplicate detection reuses existing track IDs, including identical files renamed or selected from another folder; a repeated drop can still load that track into either deck. Different songs are never considered duplicates solely because their titles match.
Karaoke Studio imports run in the background without the DJ import modal; import failures use the existing error alert.
New tracks imported into either studio also receive saved waveforms on the import worker. DJ decks reuse the DJ waveform cache. Waveform failures leave the imported track available and add a warning to its import log. Karaoke waveform batch jobs show a coloured progress bar and percentage ring, with generated/reused/skipped/issue counts and cancellation.
Each deck has a vertical channel-level fader beside its EQ/filter controls. The DJ library search uses the same compact rounded field as Karaoke Studio.
DJ library rows include the same gold favourite star as Karaoke Studio. Favourites are saved independently in the DJ library, with a Favourites filter and a right-click toggle.
Dragging a DJ waveform previews short audio grains through that deck’s EQ, level and output routing while paused; releasing stops the preview and retains the selected position. Scrubbing a playing deck continues playback. This is audio preview seeking, rather than reverse vinyl scratching.