removed unwanted bar now with waveforms, major updates

This commit is contained in:
Mark Gregory
2026-11-06 10:42:21 +00:00
parent 15c9f5914d
commit f947308421
10 changed files with 695 additions and 105 deletions
+1 -1
View File
@@ -115,7 +115,7 @@ class DJWaveMouseView: NSView {
if relativeSeek {
let target = min(player.duration,max(0,initialTime-(x-initialX)*drag.secondsPerPixel))
if abs(player.time-target) > 0.0001 { player.scrub(to:target) }
freeze?(drag.viewport+target-initialTime)
freeze?(drag.viewport+player.displayTime-initialTime)
NSCursor.closedHand.set()
} else { player.scrub(to:drag.viewport+x*drag.secondsPerPixel) }
}
+4 -1
View File
@@ -22,8 +22,11 @@ struct DJLibraryDragTitle: NSViewRepresentable {
var selectAll: (() -> Void)? = nil
func makeNSView(context: Context) -> DJLibraryDragView { DJLibraryDragView() }
func updateNSView(_ view: DJLibraryDragView,context: Context) {
let redraw = view.title != title || view.fontSize != fontSize
view.title = title;view.reference = reference;view.fontSize = fontSize;view.select = select;view.doubleClick = doubleClick;view.selectAllTracks = selectAll
view.toolTip = "Double-click to load alternating decks, or drag \(title) to either deck";view.setAccessibilityLabel(title);view.needsDisplay = true
if redraw {
view.toolTip = "Double-click to load alternating decks, or drag \(title) to either deck";view.setAccessibilityLabel(title);view.needsDisplay = true
}
}
func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJLibraryDragView,context: Context) -> CGSize? { .init(width:proposal.width ?? 240,height:24) }
}
+6 -2
View File
@@ -647,8 +647,12 @@ struct StudioSettingsDialog: View {
var body: some View {
VStack(spacing:0) {
HStack {
Text(category).font(.headline).frame(maxWidth:.infinity,alignment:.leading)
Picker("Settings for",selection:$scope) { Text("Karaoke Studio").tag("Karaoke");Text("DJ Studio").tag("DJ") }.pickerStyle(.segmented).frame(width:280)
VStack(alignment:.leading,spacing:4) { Text("Settings").font(.title2.bold());Text(category).font(.callout).foregroundStyle(.secondary) }
Spacer(minLength:24)
VStack(alignment:.leading,spacing:6) {
Text("Workspace").font(.caption).foregroundStyle(.secondary).fixedSize()
Picker("Workspace",selection:$scope) { Text("Karaoke Studio").tag("Karaoke");Text("DJ Studio").tag("DJ") }.labelsHidden().pickerStyle(.segmented).frame(width:340)
}.fixedSize(horizontal:true,vertical:true)
}.padding(.horizontal,24).padding(.top,18).padding(.bottom,10)
HStack(spacing:8) {
ForEach(categories,id:\.self) { name in
+233 -53
View File
@@ -19,6 +19,26 @@ enum DJMix {
}
}
struct DJGraphicEQSettings: Codable, Equatable {
var enabled = false
var preset = "Flat"
var gains = [Double](repeating:0,count:10)
static let presets: [(String,[Double])] = [
("Flat",[0,0,0,0,0,0,0,0,0,0]),
("Rock",[4,3,2,0,-2,-1,1,3,4,3]),
("Pop",[-1,1,3,4,2,0,-1,-1,1,2]),
("Jazz",[3,2,1,1,-1,-1,0,2,3,3]),
("Vocal",[-4,-3,-2,0,2,4,4,2,0,-2]),
("Bass Boost",[6,5,4,2,0,0,0,0,0,0]),
("Treble Boost",[0,0,0,0,0,1,2,3,4,5])
]
var sanitized: Self {
var value = self
value.gains = (0..<10).map { gains.indices.contains($0) && gains[$0].isFinite ? min(12,max(-12,gains[$0])) : 0 }
return value
}
}
struct DJPreferences: Codable, Equatable {
var autoPlayOnLoad = false
var resetEQOnLoad = false
@@ -67,16 +87,17 @@ struct DJPreferences: Codable, Equatable {
player.defineLoop(start:start,end:min(player.duration,start+length))
}
}
var master: Double = 0.8 { didSet { updateLevel() } }
var crossWeight: Double = 1 { didSet { updateLevel() } }
var master: Double = 0.8 { didSet { if master != oldValue { updateLevel() } } }
var crossWeight: Double = 1 { didSet { if crossWeight != oldValue { updateLevel() } } }
private var taps = [Double]()
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 updateLevel() { let value = min(1,max(0,level * master * crossWeight));if player.volume != value { player.volume = value } }
func load(_ song: Song) throws {
var audioOnly = song;audioOnly.cdg = nil
do { try player.load(audioOnly,startPlaying:false) } catch { self.song = nil;throw error }
self.song = song;bpmConfirmed = false;bpmEstimated = false;bpm = 120;taps = [];loadTrackInfo(song)
}
func updateTrackMetadata(_ song: Song) { self.song = song;player.title = song.title }
func unload() { infoTask?.cancel();analysisTask?.cancel();player.stop();song = nil;bpmConfirmed = false;artwork = nil;analysingBPM = false }
private func loadTrackInfo(_ song: Song) {
infoTask?.cancel();analysisTask?.cancel();artwork = nil;analysingBPM = true
@@ -151,6 +172,23 @@ struct DJAudioDevice: Identifiable, Equatable {
}
@MainActor final class DJSystem: ObservableObject {
@Published var namesBusy = false
private var graphicSaveTask: Task<Void,Never>?
@Published var graphicEQ = DJGraphicEQSettings() { didSet {
applyGraphicEQ()
graphicSaveTask?.cancel()
let url = root.appendingPathComponent("DJGraphicEQ.json"),value = graphicEQ.sanitized
graphicSaveTask = Task { @MainActor in
do { try await Task.sleep(nanoseconds:200_000_000);try JSONEncoder().encode(value).write(to:url,options:.atomic) } catch { }
}
} }
private func applyGraphicEQ() {
for player in [a.player,b.player,preview] { player.applyGraphicEQ(graphicEQ.sanitized.gains,enabled:graphicEQ.enabled) }
}
func chooseGraphicPreset(_ name: String) {
guard let gains = DJGraphicEQSettings.presets.first(where:{$0.0 == name})?.1 else { return }
graphicEQ = .init(enabled:true,preset:name,gains:gains)
}
@Published private(set) var waveformJob: WaveformJobProgress?
@Published private(set) var generatingWaveforms = false
private var waveformTask: Task<Void,Never>?
@@ -161,7 +199,7 @@ struct DJAudioDevice: Identifiable, Equatable {
let cache = root.appendingPathComponent("Waveforms")
generatingWaveforms = true;waveformJob = .init(total:tracks.count)
waveformTask = Task.detached(priority:.utility) { [weak self] in
let result = WaveformBatch.run(songs:tracks,locations:[],cache:cache,force:force,cancelled:{Task.isCancelled}) { update in
let result = await WaveformBatch.runConcurrent(songs:tracks,locations:[],cache:cache,force:force) { update in
Task { @MainActor [weak self] in self?.waveformJob = update }
}
await MainActor.run { [weak self] in
@@ -229,6 +267,8 @@ struct DJAudioDevice: Identifiable, Equatable {
perform {
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
library = try Library(url:root.appendingPathComponent("Library.sqlite"));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)})
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) }
@@ -746,14 +786,23 @@ private enum DJStyle {
/// Observe user scrolling, rather than row appearance (List preloads offscreen rows).
private struct DJLibraryPageScroll: NSViewRepresentable {
var page: Int
let next: () -> Void
func makeNSView(context: Context) -> Observer { Observer() }
func updateNSView(_ view: Observer,context: Context) { view.next = next;view.attach() }
func updateNSView(_ view: Observer,context: Context) { view.next = next;view.attach();view.setPage(page) }
final class Observer: NSView {
var next: (() -> Void)?
private weak var scroll: NSScrollView?
private var observation: NSObjectProtocol?
private var endObservation: NSObjectProtocol?
private var currentPage = 0
func setPage(_ page: Int) {
guard page != currentPage else { return };currentPage = page
DispatchQueue.main.async { [weak self] in
guard let scroll = self?.scroll else { return }
scroll.contentView.scroll(to:.zero);scroll.reflectScrolledClipView(scroll.contentView);self?.lastY = 0
}
}
private var advanced = false
private var lastY: CGFloat = 0
override func viewDidMoveToWindow() { super.viewDidMoveToWindow();DispatchQueue.main.async { [weak self] in self?.attach() } }
@@ -801,8 +850,27 @@ private struct DJQueueButton: View {
}
}
private struct DJLibrarySnapshot<Content: View>: View, Equatable {
let revision: UUID
let selection: Set<Int64>
let selected: Int64?
let page: Int
let busy: Bool
let font: Double
let height: Double
@ViewBuilder let content: () -> Content
static func == (lhs: Self,rhs: Self) -> Bool {
lhs.revision == rhs.revision && lhs.selection == rhs.selection && lhs.selected == rhs.selected && lhs.page == rhs.page && lhs.busy == rhs.busy && lhs.font == rhs.font && lhs.height == rhs.height
}
var body: some View { content() }
}
struct DJConsole: View {
@ObservedObject var system: DJSystem
@State private var reinterpreting = false
@State private var reinterpretIDs = Set<Int64>()
@State private var editingTrack: Song?
@State private var graphicEQVisible = false
@State private var waveformDetails = false
@State private var favouritesOnly = false
@State private var queueVisible = false
@@ -815,10 +883,14 @@ struct DJConsole: View {
@State private var playlistName = ""
@State private var renaming = false
@State private var page = 0
private var songs: [Song] {
@State private var libraryRevision = UUID()
@State private var filteredTracks = [Song]()
private var songs: [Song] { filteredTracks }
private func updateFilteredTracks() {
libraryRevision = UUID()
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
if playlist != nil { let lookup = Dictionary(uniqueKeysWithValues:all.map{($0.id,$0)});filteredTracks = playlistIDs.compactMap{lookup[$0]} }
else { filteredTracks = all }
}
private var selectedTracks: [Song] { songs.filter { selection.contains($0.id) } }
private func selectTrack(_ id: Int64) {
@@ -840,7 +912,7 @@ struct DJConsole: View {
VStack(spacing:10) {
header
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true)
browser.frame(maxWidth:.infinity,maxHeight:.infinity)
DJLibrarySnapshot(revision:libraryRevision,selection:selection,selected:selected,page:page,busy:system.generatingWaveforms || system.importing,font:system.settings.libraryFont,height:system.settings.libraryRowHeight) { browser }.equatable().frame(maxWidth:.infinity,maxHeight:.infinity)
if let progress = system.importProgress {
HStack {
Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit()
@@ -862,6 +934,9 @@ struct DJConsole: View {
.interactiveDismissDisabled(system.importing)
}
}
.sheet(isPresented:$reinterpreting) { ReinterpretNamesSheet(model:system,ids:reinterpretIDs) }
.sheet(item:$editingTrack) { DJTrackMetadataDialog(system:system,song:$0) }
.sheet(isPresented:$graphicEQVisible) { DJGraphicEQDialog(system:system) }
.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:Binding(get:{system.waveformJob != nil},set:{if !$0 { system.dismissWaveforms() }})) {
@@ -893,12 +968,13 @@ struct DJConsole: View {
.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() }
.onChange(of:selected) { _ in connectMIDI() }
.onChange(of:selection) { ids in if selected == nil || !ids.contains(selected!) { selected = songs.first(where:{ids.contains($0.id)})?.id };connectMIDI() }
.onChange(of:selection) { ids in if selected == nil || !ids.contains(selected!) { selected = songs.first(where:{ids.contains($0.id)})?.id } }
.onChange(of:playlist) { _ in refreshPlaylist();page = 0;selected = nil;selection = [] }
.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 }
.onChange(of:query) { _ in updateFilteredTracks();page = 0 }
.onChange(of:favouritesOnly) { _ in updateFilteredTracks();page = 0 }
.onChange(of:system.favourites) { _ in updateFilteredTracks() }
}
private var header: some View {
HStack {
@@ -906,6 +982,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()
Button { graphicEQVisible = true } label: { Label("Equaliser",systemImage:"slider.vertical.3") }.tint(system.graphicEQ.enabled ? .cyan : .accentColor)
DJQueueButton(automix:system.automix,open:{queueVisible = true})
if system.previewDeck != nil { Button("Stop headphone preview") { system.stopPreview() }.tint(.green) }
}.controlSize(.small)
@@ -923,7 +1000,7 @@ struct DJConsole: View {
} label: { Image(systemName:"folder.badge.plus") }.frame(width:25)
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)
StudioSearchField(placeholder:"Search music",text:$query).frame(maxWidth:260)
Button { system.automix.enqueue(selectedTracks) } label: { Image(systemName:"text.badge.plus") }.help("Add selected tracks to the DJ queue").disabled(selectedTracks.isEmpty)
Text("\(selection.count) selected").font(.caption).foregroundStyle(.secondary)
Button("Import Files / Folder…") { importMedia() }
@@ -933,23 +1010,34 @@ struct DJConsole: View {
Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1)
}.controlSize(.small)
ScrollViewReader { reader in
List(selection:$selection) {
ForEach(visible) { song in
HStack(spacing:8) {
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:{selectTrack(song.id)},doubleClick:{system.loadFromLibraryDoubleClick(song)},selectAll:{selectAllTracks()}).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")
Table(visible,selection:$selection) {
TableColumn("Favourite") { song in
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)")
}.width(55)
TableColumn("Title") { song in
DJLibraryDragTitle(title:song.title,reference:system.dragReference(song.id),fontSize:system.settings.libraryFont,select:{selectTrack(song.id)},doubleClick:{system.loadFromLibraryDoubleClick(song)},selectAll:{selectAllTracks()})
.frame(maxWidth:.infinity,alignment:.leading).frame(minHeight:system.settings.libraryRowHeight)
}.width(min:180,ideal:400,max:1600)
TableColumn("Artist") { song in Text(song.artist).lineLimit(1).help(song.artist) }.width(min:100,ideal:200,max:800)
TableColumn("Album") { song in Text(song.album).lineLimit(1).foregroundStyle(.secondary).help(song.album) }.width(min:90,ideal:180,max:800)
TableColumn("Time") { song in Text(DJStyle.stamp(song.duration)).monospacedDigit().foregroundStyle(.secondary) }.width(min:45,ideal:60,max:100)
TableColumn("Rating") { song in
GoldRating(rating:Binding(get:{song.rating},set:{value in system.perform { try system.library?.rate(song.id,value);try system.refresh() } }))
}.width(min:95,ideal:105,max:140)
TableColumn("Load") { song in
HStack(spacing:7) {
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).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))
}.buttonStyle(.bordered).controlSize(.small)
}.width(90)
}.font(.system(size:system.settings.libraryFont))
.contextMenu(forSelectionType:Int64.self) { ids in
if let song = songs.first(where:{ids.contains($0.id)}) { playlistMenu(song) }
else { Button("Select All Songs") { selectAllTracks() } }
}
.scrollContentBackground(.hidden).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:8))
.onCommand(#selector(NSResponder.selectAll(_:))) { selectAllTracks() }
.background(DJLibraryPageScroll { if page < pages-1 { page += 1 } })
.background(DJLibraryPageScroll(page:page) { if page < pages-1 { page += 1 } })
.onChange(of:page) { _ in
selected = visible.first?.id;selection = Set(visible.first.map{[$0.id]} ?? [])
if let first = visible.first?.id { DispatchQueue.main.async { reader.scrollTo(first,anchor:.top) } }
@@ -958,6 +1046,10 @@ struct DJConsole: View {
}
}
@ViewBuilder private func playlistMenu(_ song: Song) -> some View {
Button("Song Info / Artist…") { editingTrack = song }
Button("Reinterpret Selected Names…") { reinterpretIDs = Set(contextTracks(song).map(\.id));reinterpreting = true }.disabled(system.namesBusy)
Button("Undo Last Name Reinterpretation") { Task { do { try await system.undoNameChanges() } catch { system.error = error.localizedDescription } } }.disabled(system.namesBusy)
Divider()
Button("Load into Deck A") { system.load(song,into:"A") }
Button("Load into Deck B") { system.load(song,into:"B") }
Divider()
@@ -993,7 +1085,7 @@ struct DJConsole: View {
}.padding(24).frame(width:360)
}
private func loadSelected(_ deck: String) { if let selectedSong { system.load(selectedSong,into:deck) } else { system.status = "Select a DJ library track, then Load \(deck), or drag a DJ library track onto the deck." } }
private func refreshPlaylist() { playlistIDs = playlist.flatMap{try? system.library?.playlistSongIDs($0)} ?? [] }
private func refreshPlaylist() { playlistIDs = playlist.flatMap{try? system.library?.playlistSongIDs($0)} ?? [];updateFilteredTracks() }
private func connectMIDI() {
system.midi.currentValue = { [weak system] action in
guard let system else { return nil }
@@ -1061,6 +1153,7 @@ private struct DJDeckSurfaces {
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)
Button { player.vocalReductionEnabled.toggle() } label: { Image(systemName:player.vocalReductionEnabled ? "mic.slash.fill" : "mic.fill") }.buttonStyle(.plain).foregroundStyle(player.vocalReductionEnabled ? color : .secondary).help("Centre vocal reduction: reduces centred stereo audio, including some instruments; mono tracks pass through").accessibilityLabel("Deck \(deck.letter) centre vocal reduction").accessibilityValue(player.vocalReductionEnabled ? "On" : "Off")
}
}.frame(width:110)
}
@@ -1092,36 +1185,59 @@ struct DJKnob: View {
.accessibilityAdjustableAction { direction in let step = (range.upperBound-range.lowerBound)/100;value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? step : -step))) }
}
}
private struct DJPlatter: View {
@ObservedObject var player: Player
private struct DJPlatter: NSViewRepresentable {
let player: Player
let letter: String
let color: Color
var markerEnabled = true
@State private var lastAngle: Double?
var body: some View {
TimelineView(.animation(minimumInterval:1.0/60,paused:!player.playing)) { _ in
let displayTime = player.displayTime
GeometryReader { geometry in
ZStack {
Circle().fill(Color.black.opacity(0.55)).overlay { Circle().stroke(color.opacity(0.4),lineWidth:3) }
ForEach([0.70,0.80,0.90],id:\.self) { scale in Circle().stroke(.white.opacity(0.06),lineWidth:1).scaleEffect(scale) }
ZStack {
Circle().fill(color.opacity(0.15)).frame(width:53,height:53)
Text(letter).font(.system(size:25,weight:.bold,design:.rounded)).foregroundStyle(color)
if markerEnabled {
Rectangle().fill(color).frame(width:5,height:26).offset(y:-geometry.size.height*0.37)
Circle().fill(.white).frame(width:5,height:5).offset(y:-geometry.size.height*0.45)
}
}.rotationEffect(.degrees(displayTime*200))
}.contentShape(Circle())
.gesture(DragGesture(minimumDistance:2).onChanged { drag in
let angle = atan2(Double(drag.location.y-geometry.size.height/2),Double(drag.location.x-geometry.size.width/2))
if let previous = lastAngle { var delta = angle-previous;if delta > .pi { delta -= 2 * .pi };if delta < -.pi { delta += 2 * .pi };player.jog(delta / (2 * .pi) * 2) }
lastAngle = angle
}.onEnded{_ in lastAngle = nil})
}.accessibilityLabel("Deck \(letter) jog platter").accessibilityValue(player.playing ? "Rotating" : "Paused").help("Drag around the platter to jog (2 seconds per turn). Rotation follows source playback time.")
func makeNSView(context: Context) -> DJPlatterView { DJPlatterView() }
func updateNSView(_ view: DJPlatterView,context: Context) {
view.player = player;view.colour = NSColor(color);view.marker = markerEnabled;view.needsDisplay = true
view.setAccessibilityElement(true);view.setAccessibilityLabel("Deck \(letter) jog platter")
view.toolTip = "Drag around the platter to scrub (2 seconds per turn)"
}
static func dismantleNSView(_ view: DJPlatterView,coordinator: ()) { view.stopClock() }
}
private final class DJPlatterView: NSView {
weak var player: Player?
var colour = NSColor.systemCyan
var marker = true
private var clock: AnyObject?
private var timer: Timer?
private var angle: Double?
override var isFlipped: Bool { true }
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
func stopClock() { timer?.invalidate();timer = nil;if #available(macOS 14,* ) { (clock as? CADisplayLink)?.invalidate() };clock = nil }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow();stopClock();guard window != nil else { return }
if #available(macOS 14,*) {
let link = displayLink(target:self,selector:#selector(frameTick));link.preferredFrameRateRange = CAFrameRateRange(minimum:30,maximum:60,preferred:60);link.add(to:.main,forMode:.common);clock = link
} else {
let tick = Timer(timeInterval:1/60,repeats:true) { [weak self] _ in self?.frameTick() };RunLoop.main.add(tick,forMode:.common);timer = tick
}
}
@objc private func frameTick() { if player?.playing == true,!isHiddenOrHasHiddenAncestor { needsDisplay = true } }
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
let side = min(bounds.width,bounds.height),centre = CGPoint(x:bounds.midX,y:bounds.midY)
let disc = CGRect(x:centre.x-side/2+2,y:centre.y-side/2+2,width:side-4,height:side-4)
context.setFillColor(NSColor.black.withAlphaComponent(0.55).cgColor);context.fillEllipse(in:disc)
context.setStrokeColor(colour.withAlphaComponent(0.4).cgColor);context.setLineWidth(3);context.strokeEllipse(in:disc)
context.setStrokeColor(NSColor.white.withAlphaComponent(0.06).cgColor);context.setLineWidth(1)
for scale in [0.70,0.80,0.90] { let radius = side*scale/2;context.strokeEllipse(in:CGRect(x:centre.x-radius,y:centre.y-radius,width:radius*2,height:radius*2)) }
guard marker else { return }
context.translateBy(x:centre.x,y:centre.y);context.rotate(by:(player?.displayTime ?? 0)*200 * .pi/180)
context.setFillColor(colour.cgColor);context.fill(CGRect(x:-2.5,y:-side*0.37-13,width:5,height:26))
context.setFillColor(NSColor.white.cgColor);context.fillEllipse(in:CGRect(x:-2.5,y:-side*0.45-2.5,width:5,height:5))
}
override func mouseDown(with event: NSEvent) { let p = convert(event.locationInWindow,from:nil);angle = atan2(p.y-bounds.midY,p.x-bounds.midX) }
override func mouseDragged(with event: NSEvent) {
let p = convert(event.locationInWindow,from:nil),next = atan2(p.y-bounds.midY,p.x-bounds.midX)
if let angle,let player { var delta = next-angle;if delta > .pi { delta -= 2 * .pi };if delta < -.pi { delta += 2 * .pi };player.scrub(to:player.time+delta / .pi) }
angle = next;needsDisplay = true
}
override func mouseUp(with event: NSEvent) { angle = nil;player?.endScrubbing() }
deinit { timer?.invalidate();if #available(macOS 14,*) { (clock as? CADisplayLink)?.invalidate() } }
}
struct DJWaveform: View {
@State private var frozenStart: Double?
@@ -1619,12 +1735,15 @@ enum DJBPM {
return (60*rate/Double(bestLag)*10).rounded()/10
}
static func analyse(_ url: URL,cancelled: () -> Bool = { false }) throws -> Double? {
try WaveformWorkload.shared.acquire(cancelled:cancelled)
defer { WaveformWorkload.shared.release() }
let file = try AVAudioFile(forReading:url),format = file.processingFormat
guard let buffer = AVAudioPCMBuffer(pcmFormat:format,frameCapacity:16384),format.channelCount > 0 else { return nil }
let hop = max(1,Int(format.sampleRate/200)),limit = Int64(format.sampleRate*90)
var envelope = [Double](),sum = 0.0,count = 0
while file.framePosition < min(file.length,limit) {
if cancelled() { throw CancellationError() }
if WaveformWorkload.shared.playbackActive { Thread.sleep(forTimeInterval:0.001) }
try file.read(into:buffer)
guard buffer.frameLength > 0,let channels = buffer.floatChannelData else { break }
for i in 0..<Int(buffer.frameLength) {
@@ -1813,3 +1932,64 @@ enum DJWaveMarkers {
}
}
}
private struct DJGraphicEQDialog: View {
@ObservedObject var system: DJSystem
@Environment(\.dismiss) private var dismiss
private let labels = ["31","62","125","250","500","1k","2k","4k","8k","16k"]
var body: some View {
VStack(alignment:.leading,spacing:22) {
Label("DJ Graphic Equaliser",systemImage:"slider.vertical.3").font(.title2.bold()).foregroundStyle(DJStyle.a)
Text("Shared by both decks and all tracks. Your deck EQ controls remain independent.").font(.callout).foregroundStyle(.secondary)
HStack {
Toggle("Enabled",isOn:$system.graphicEQ.enabled).toggleStyle(.switch)
Spacer()
Picker("Preset",selection:Binding(get:{system.graphicEQ.preset},set:{system.chooseGraphicPreset($0)})) {
ForEach(DJGraphicEQSettings.presets.map(\.0),id:\.self) { Text($0).tag($0) }
if system.graphicEQ.preset == "Custom" { Text("Custom").tag("Custom") }
}.frame(width:230)
Button("Reset") { system.chooseGraphicPreset("Flat") }
}
HStack(alignment:.top,spacing:14) {
ForEach(0..<10,id:\.self) { index in
VStack(spacing:9) {
Text(String(format:"%+.0f",system.graphicEQ.gains[index])).font(.caption.monospacedDigit()).foregroundStyle(DJStyle.a)
DJHardwareFader(value:Binding(get:{(system.graphicEQ.gains[index]+12)/24},set:{ value in
var next = system.graphicEQ;next.gains[index] = value*24-12;next.preset = "Custom";system.graphicEQ = next
}),label:"Graphic EQ \(labels[index]) hertz",vertical:true).frame(width:40,height:150)
Text(labels[index]).font(.caption)
}
}
}.padding(18).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:12))
HStack { Text("±12 dB · Changes apply immediately · Saved automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }
}.padding(26).frame(width:660).background(DJStyle.background).preferredColorScheme(.dark)
}
}
private struct DJTrackMetadataDialog: View {
@ObservedObject var system: DJSystem
let song: Song
@State private var title = ""
@State private var artist = ""
@State private var album = ""
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment:.leading,spacing:18) {
Text("DJ Song Info").font(.title2.bold())
TextField("Title",text:$title).textFieldStyle(.roundedBorder)
TextField("Artist",text:$artist).textFieldStyle(.roundedBorder)
TextField("Album",text:$album).textFieldStyle(.roundedBorder)
Text("Changes update your DJ library; the original file is kept.").font(.caption).foregroundStyle(.secondary)
HStack { Spacer();Button("Cancel") { dismiss() };Button("Save") {
system.perform {
try system.library?.updateMetadata(song:song.id,title:title,artist:artist,album:album,label:song.label,rating:song.rating)
try system.refresh()
if let updated = system.songs.first(where:{$0.id == song.id}) {
for deck in [system.a,system.b] where deck.song?.id == song.id { deck.updateTrackMetadata(updated) }
}
dismiss()
}
}.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction).disabled(title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) }
}.padding(26).frame(width:480).onAppear { title = song.title;artist = song.artist;album = song.album }
}
}
+46 -6
View File
@@ -98,7 +98,7 @@ struct SongDragReference: Codable, Sendable {
let token = UUID(),locations = mediaLocations,cache = root.appendingPathComponent("Waveforms")
waveformJobToken = token;generatingWaveforms = true;waveformJob = .init(total:tracks.count)
waveformJobTask = Task.detached(priority:.utility) { [weak self] in
let result = WaveformBatch.run(songs:tracks,locations:locations,cache:cache,force:force,cancelled:{Task.isCancelled},progress:{ update in
let result = await WaveformBatch.runConcurrent(songs:tracks,locations:locations,cache:cache,force:force,progress:{ update in
Task { @MainActor [weak self] in guard let self,self.waveformJobToken == token else { return };self.waveformJob = update }
})
await MainActor.run { [weak self] in
@@ -578,7 +578,7 @@ private extension FocusedValues {
}
Window("Video", id: "lyrics") {
LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark)
}.defaultSize(width: 600, height: 400)
}.defaultSize(width: 1280, height: 720)
}
}
private struct ImportProgressDialog: View {
@@ -616,6 +616,22 @@ private struct ImportProgressDialog: View {
}
}
}
struct StudioSearchField: View {
let placeholder: String
@Binding var text: String
@FocusState private var focused: Bool
var body: some View {
HStack(spacing:7) {
Image(systemName:"magnifyingglass").foregroundStyle(.secondary)
TextField(placeholder,text:$text).textFieldStyle(.plain).focused($focused)
if !text.isEmpty {
Button { text = "";focused = true } label: { Image(systemName:"xmark.circle.fill").foregroundStyle(.secondary) }.buttonStyle(.plain).help("Clear search").accessibilityLabel("Clear search")
}
}.font(.system(size:12)).padding(.horizontal,9).padding(.vertical,7)
.background(Color(nsColor:.textBackgroundColor),in:RoundedRectangle(cornerRadius:7))
.overlay(RoundedRectangle(cornerRadius:7).strokeBorder(.primary.opacity(0.12)))
}
}
struct WaveformProgressCard: View {
let progress: WaveformJobProgress
let running: Bool
@@ -769,11 +785,35 @@ struct LyricsView: View {
ZStack {
Color.black
if let image = player.image {
Image(decorative: image, scale: 1).interpolation(.none).resizable().aspectRatio(contentMode: .fit).opacity(player.outputOpacity)
NativeVideoSurface(image:image,opacity:player.outputOpacity)
}
}
}
}
private struct NativeVideoSurface: NSViewRepresentable {
let image: CGImage
let opacity: Double
func makeNSView(context: Context) -> NativeVideoView { NativeVideoView() }
func updateNSView(_ view: NativeVideoView,context: Context) {
if view.image !== image { view.image = image;view.picture.contents = image }
view.picture.opacity = Float(opacity)
}
}
private final class NativeVideoView: NSView {
let picture = CALayer()
var image: CGImage?
override init(frame: NSRect) {
super.init(frame:frame);wantsLayer = true;layer?.backgroundColor = NSColor.black.cgColor
picture.contentsGravity = .resizeAspect;picture.magnificationFilter = .linear;picture.minificationFilter = .trilinear
picture.actions = ["contents":NSNull(),"bounds":NSNull(),"position":NSNull(),"opacity":NSNull()]
layer?.addSublayer(picture)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layout() { super.layout();picture.frame = bounds }
override func viewDidMoveToWindow() { super.viewDidMoveToWindow();updateScale() }
override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties();updateScale() }
private func updateScale() { let scale = window?.backingScaleFactor ?? 1;layer?.contentsScale = scale;picture.contentsScale = scale;picture.frame = bounds }
}
/// This is another view of the same published CGImage used by the Video window.
/// It owns no decoder, audio player or clock, so seeking/pause/tempo stay identical.
private struct SidebarVideoMonitor: View {
@@ -882,7 +922,7 @@ struct LibraryView: View {
Text(source == "Library" ? "\(model.songs.count) songs in your collection" : model.currentVenueName).font(.callout).foregroundStyle(.secondary)
}
Spacer()
TextField("Search karaoke music",text:$search).textFieldStyle(.roundedBorder).frame(maxWidth:260)
StudioSearchField(placeholder:"Search music",text:$search).frame(maxWidth:260)
if source == "Library" {
Button { importing = true } label: { Label("Import Music",systemImage:"plus") }.buttonStyle(.bordered)
}
@@ -925,7 +965,7 @@ struct LibraryView: View {
.navigationSubtitle(model.currentVenueName)
.toolbar {
ToolbarItemGroup {
Button { studioSettingsModal = true } label: { Label("Settings",systemImage:"gearshape") }
if workspaceTab == "DJ Studio" { Button { studioSettingsModal = true } label: { Label("Settings",systemImage:"gearshape") } }
if workspaceTab == "Karaoke" {
Menu {
Button("Import Files or Folder…") { importing = true }
@@ -1490,7 +1530,7 @@ struct GoldStar: View {
}.frame(width:size,height:size).accessibilityHidden(true)
}
}
private struct GoldRating: View {
struct GoldRating: View {
@Binding var rating: Int
var body: some View {
HStack(spacing:2) { ForEach(1..<6) { value in Button { rating = rating == value ? 0 : value } label: { GoldStar(filled:value <= rating) }.buttonStyle(.plain).help("\(value) stars; click the selected rating to clear").accessibilityLabel("Set \(value) stars") } }
+54
View File
@@ -134,3 +134,57 @@ enum WaveformBatch {
progress(state);return state
}
}
/// Admission control is read on workers; starting playback reduces future admissions.
final class WaveformWorkload: @unchecked Sendable {
static let shared = WaveformWorkload()
private let lock = NSCondition()
private var playing = Set<UUID>()
private var active = 0
private var allowed: Int { playing.isEmpty ? min(2,max(1,ProcessInfo.processInfo.activeProcessorCount/2)) : 1 }
func setPlaying(_ active: Bool,id: UUID) { lock.lock();defer { lock.unlock() };if active { playing.insert(id) } else { playing.remove(id) };lock.broadcast() }
var workers: Int { lock.lock();defer { lock.unlock() };return allowed }
var playbackActive: Bool { lock.lock();defer { lock.unlock() };return !playing.isEmpty }
func acquire(cancelled: () -> Bool) throws {
lock.lock();defer { lock.unlock() }
while active >= allowed {
if cancelled() { throw CancellationError() }
_ = lock.wait(until:Date().addingTimeInterval(0.02))
}
if cancelled() { throw CancellationError() };active += 1
}
func release() { lock.lock();active -= 1;lock.broadcast();lock.unlock() }
}
extension WaveformBatch {
static func runConcurrent(songs: [Song],locations: [MediaLocation],cache: URL,force: Bool = false,limit: @escaping @Sendable () -> Int = { WaveformWorkload.shared.workers },progress: (WaveformJobProgress) -> Void = { _ in }) async -> WaveformJobProgress {
var ids = Set<Int64>(),groups = [[Song]](),indices = [String:Int]()
for song in songs where ids.insert(song.id).inserted {
let key = URL(fileURLWithPath:song.audio).standardizedFileURL.path
if let index = indices[key] { groups[index].append(song) }
else { indices[key] = groups.count;groups.append([song]) }
}
var state = WaveformJobProgress(total:ids.count),next = 0,active = 0,lastReport = Date.distantPast
await withTaskGroup(of:WaveformJobProgress.self) { group in
while next < groups.count || active > 0 {
if Task.isCancelled { group.cancelAll();state.cancelled = true }
let allowed = min(2,max(1,limit()))
while !Task.isCancelled,active < allowed,next < groups.count {
let tracks = groups[next];next += 1;active += 1
state.current = tracks.first?.title ?? "Preparing waveforms…"
group.addTask(priority:.utility) {
run(songs:tracks,locations:locations,cache:cache,force:force,cancelled:{Task.isCancelled})
}
}
guard active > 0,let result = await group.next() else { break }
active -= 1
state.processed += result.processed;state.generated += result.generated;state.cached += result.cached
state.skipped += result.skipped;state.issues += result.issues;state.cancelled = state.cancelled || result.cancelled
state.messages.append(contentsOf:result.messages.prefix(max(0,100-state.messages.count)))
if Date().timeIntervalSince(lastReport) >= 0.12 { progress(state);lastReport = Date() }
}
}
state.finished = true
state.current = state.cancelled ? "Cancelled · completed waveforms kept" : state.issues > 0 ? "Waveform analysis finished with issues" : "Waveforms ready"
progress(state);return state
}
}
+32 -3
View File
@@ -360,7 +360,15 @@ struct NameTemplate {
}
}
extension AppModel {
@MainActor protocol NameReinterpretationModel: ObservableObject {
var songs: [Song] { get }
var library: Library? { get }
var namesBusy: Bool { get }
func refresh() throws
func perform(_ action: () throws -> Void)
func applyNameChanges(_ changes: [NameChange]) async throws
}
extension NameReinterpretationModel {
func savedNameTemplates() -> [String] {
let saved = ((try? library?.setting(NameTemplate.settingsKey)) ?? "").components(separatedBy:"\n").filter{!$0.trimmingCharacters(in:.whitespaces).isEmpty}
return Array(Set(NameTemplate.presets+saved)).sorted()
@@ -371,6 +379,8 @@ extension AppModel {
for (index,template) in templates.enumerated() { do { _ = try NameTemplate(template) } catch { throw LibraryError.message("Line \(index+1): \(error.localizedDescription)") } }
try library?.setSetting(NameTemplate.settingsKey,templates.joined(separator:"\n"))
}
}
extension AppModel: NameReinterpretationModel {
func applyNameChanges(_ changes:[NameChange]) async throws {
guard !namesBusy,!isImporting,!movingMedia,scanningSource == nil,!generatingWaveforms,!tagsBusy else { throw LibraryError.message("Finish the current library job first") }
namesBusy = true;defer { namesBusy = false }
@@ -389,8 +399,8 @@ extension AppModel {
}
}
struct ReinterpretNamesSheet: View {
@ObservedObject var model: AppModel
struct ReinterpretNamesSheet<Model: NameReinterpretationModel>: View {
@ObservedObject var model: Model
let ids: Set<Int64>
@Environment(\.dismiss) private var dismiss
@State private var template = "L-T - R - S"
@@ -482,3 +492,22 @@ extension FocusedValues {
set { self[NameReinterpretationKey.self] = newValue }
}
}
extension DJSystem: NameReinterpretationModel {
func applyNameChanges(_ changes: [NameChange]) async throws {
guard !namesBusy,!importing,!generatingWaveforms else { throw LibraryError.message("Finish the current library job first") }
namesBusy = true;defer { namesBusy = false }
let database = root.appendingPathComponent("Library.sqlite")
_ = try await Task.detached(priority:.utility) { try Library(url:database).reinterpretNames(changes) }.value
try refresh()
for deck in [a,b] { if let song = songs.first(where:{$0.id == deck.song?.id}) { deck.updateTrackMetadata(song) } }
}
func undoNameChanges() async throws {
guard !namesBusy,!importing,!generatingWaveforms else { throw LibraryError.message("Finish the current library job first") }
namesBusy = true;defer { namesBusy = false }
let database = root.appendingPathComponent("Library.sqlite")
_ = try await Task.detached(priority:.utility) { try Library(url:database).undoNameReinterpretation() }.value
try refresh()
for deck in [a,b] { if let song = songs.first(where:{$0.id == deck.song?.id}) { deck.updateTrackMetadata(song) } }
}
}
+161 -29
View File
@@ -18,9 +18,46 @@ import CoreAudio
var nudgeStep: Double = 0.05
@Published private(set) var cuePosition: Double = 0
@Published private(set) var hotCues: [Double?] = [nil,nil,nil,nil]
@Published private(set) var loopIn: Double?
@Published private(set) var loopOut: Double?
@Published var loopEnabled = false
@Published private(set) var loopIn: Double? { didSet { if loopEnabled { prepareNativeLoop() } } }
@Published private(set) var loopOut: Double? { didSet { if loopEnabled { prepareNativeLoop() } } }
@Published var loopEnabled = false { didSet {
if loopEnabled { prepareNativeLoop() }
else {
loopBufferTask?.cancel();loopBufferTask = nil;loopBuffer = nil
if nativeLoopActive { let position = displayTime;nativeLoopActive = false;schedule(at:position,play:playing) }
}
} }
private var loopBufferTask: Task<Void,Never>?
private var loopBuffer: AVAudioPCMBuffer?
private var preparedLoop: ClosedRange<Double>?
private(set) var nativeLoopActive = false
private func loopPosition(_ position: Double) -> Double {
guard nativeLoopActive,let range = preparedLoop,position >= range.upperBound else { return position }
return range.lowerBound+(position-range.upperBound).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound)
}
private func prepareNativeLoop() {
loopBufferTask?.cancel()
guard loopEnabled,let start = loopIn,let end = loopOut,end > start,let url = audioURL else { return }
// Bound memory for long manual loops; common beat loops stay sample-timed.
guard let file,(end-start)*file.processingFormat.sampleRate*Double(file.processingFormat.channelCount)*4 <= 64*1024*1024 else { return }
let songID = currentSongID
loopBufferTask = Task { @MainActor [weak self] in
let worker = Task.detached(priority:.userInitiated) { () throws -> AVAudioPCMBuffer in
let source = try AVAudioFile(forReading:url)
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
}
do {
let buffer = try await withTaskCancellationHandler(operation:{try await worker.value},onCancel:{worker.cancel()})
guard !Task.isCancelled,let self,self.currentSongID == songID,self.loopEnabled,self.loopIn == start,self.loopOut == end else { return }
let position = self.displayTime,resume = self.playing
self.loopBuffer = buffer;self.preparedLoop = start...end
self.schedule(at:position < end ? position : start,play:resume,notifyPlayback:false)
} catch { }
}
}
var isShowScreen: Bool { slideImage != nil }
var canJog: Bool { currentSongID != nil && duration > 0 }
/// Read the audio clock for display frames without publishing the entire player at 60 Hz.
@@ -30,7 +67,7 @@ import CoreAudio
let now = mach_absolute_time()
let elapsed = render.isHostTimeValid && now >= render.hostTime ? min(0.1,AVAudioTime.seconds(forHostTime:now-render.hostTime)) : 0
let position = anchor+Double(current.sampleTime)/file.processingFormat.sampleRate+elapsed*tempo
return min(duration,max(0,position))
return min(duration,max(0,loopPosition(position)))
}
func stopDeck() {
guard canJog else { return }
@@ -45,7 +82,7 @@ import CoreAudio
private func pauseDeck() {
cancelPendingPlayback()
if slideImage != nil,playing { time = min(duration,slideAnchor+ProcessInfo.processInfo.systemUptime-slideStart);slideAnchor = time }
else if let file,playing,let render = node.lastRenderTime,let current = node.playerTime(forNodeTime:render) { time = min(duration,max(0,anchor+Double(current.sampleTime)/file.processingFormat.sampleRate)) }
else if let file,playing,let render = node.lastRenderTime,let current = node.playerTime(forNodeTime:render) { time = min(duration,max(0,loopPosition(anchor+Double(current.sampleTime)/file.processingFormat.sampleRate))) }
node.pause();playing = false;timer?.invalidate();timer = nil
}
func hotCue(_ index: Int,clear: Bool = false) {
@@ -163,6 +200,22 @@ import CoreAudio
@Published private(set) var leftLevel: Float = 0
@Published private(set) var rightLevel: Float = 0
private let tone = AVAudioUnitEQ(numberOfBands:4)
private let graphicEQ = AVAudioUnitEQ(numberOfBands:10)
private let vocalReduction = AVAudioUnitEffect(audioComponentDescription:DJVocalReductionUnit.component)
@Published var vocalReductionEnabled = false { didSet { (vocalReduction.auAudioUnit as? DJVocalReductionUnit)?.setAmount(vocalReductionEnabled ? 1 : 0) } }
private(set) var graphicGains = [Double](repeating:0,count:10)
private(set) var graphicEnabled = false
func applyGraphicEQ(_ gains: [Double],enabled: Bool) {
graphicGains = (0..<10).map { gains.indices.contains($0) && gains[$0].isFinite ? min(12,max(-12,gains[$0])) : 0 }
graphicEnabled = enabled;graphicEQ.bypass = !enabled
let frequencies: [Double] = [31,62,125,250,500,1000,2000,4000,8000,16000]
let rate = file?.processingFormat.sampleRate ?? 44100
for i in 0..<10 {
let band = graphicEQ.bands[i];band.filterType = .parametric
band.frequency = Float(min(rate*0.45,frequencies[i]));band.bandwidth = 1
band.gain = Float(graphicGains[i]);band.bypass = false
}
}
private let meter = AudioLevelMeter()
private var metering = false
@Published private(set) var waveformLoading = false
@@ -174,14 +227,16 @@ import CoreAudio
var sourceAudioURL: URL? { audioURL }
private var waveformTask: Task<Void, Never>?
private var waveformGeneration = UUID()
@Published var playing = false
private let workloadID = UUID()
@Published var playing = false { didSet { if playing != oldValue { WaveformWorkload.shared.setPlaying(playing,id:workloadID) } } }
deinit { WaveformWorkload.shared.setPlaying(false,id:workloadID) }
@Published var time: Double = 0
@Published var duration: Double = 0
@Published var title = "Select a song"
@Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume); saveMix() } }
@Published var repeating = false
@Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } }
@Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } }
@Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100);effect.bypass = pitch == 0 && tempo == 1 } }
@Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo);effect.bypass = pitch == 0 && tempo == 1 } }
@Published var graphicsOffset: Double = 0 { didSet { if graphicsOffset != oldValue { invalidateGraphics();render() } } }
private(set) var currentSongID: Int64?
var onFinished: (() -> Void)?
@@ -197,7 +252,7 @@ import CoreAudio
init(waveformCacheDirectory: URL? = nil, persistsMix: Bool = true) {
self.persistsMix = persistsMix
self.waveformCacheDirectory = waveformCacheDirectory ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/Waveforms")
engine.attach(node);engine.attach(effect);engine.attach(tone)
engine.attach(node);engine.attach(effect);engine.attach(tone);engine.attach(graphicEQ);engine.attach(vocalReduction);graphicEQ.bypass = true
if persistsMix,let data = try? Data(contentsOf:mixURL),let mix = try? JSONDecoder().decode(GlobalMix.self,from:data) {
volume = min(1,max(0,mix.volume));lowGain = min(12,max(-12,mix.low));midGain = min(12,max(-12,mix.mid));highGain = min(12,max(-12,mix.high));filter = min(1,max(-1,mix.filter));toneEnabled = mix.enabled
}
@@ -219,12 +274,14 @@ import CoreAudio
guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") }
engine.connect(node,to:effect,format:loaded.processingFormat)
engine.connect(effect,to:tone,format:loaded.processingFormat)
engine.connect(tone,to:engine.mainMixerNode,format:loaded.processingFormat)
engine.connect(tone,to:graphicEQ,format:loaded.processingFormat)
engine.connect(graphicEQ,to:vocalReduction,format:loaded.processingFormat)
engine.connect(vocalReduction,to:engine.mainMixerNode,format:loaded.processingFormat)
file = loaded; cdg = graphics; decoder = CDGDecoder();prepareGraphicsCheckpoints()
duration = Double(loaded.length)/loaded.processingFormat.sampleRate
title = song.title; currentSongID = song.id
pitch = preference.pitch; tempo = preference.tempo
updateTone()
updateTone();applyGraphicEQ(graphicGains,enabled:graphicEnabled)
let meter = self.meter
engine.mainMixerNode.installTap(onBus:0,bufferSize:1024,format:nil) { buffer, _ in meter.consume(buffer) }
metering = true
@@ -289,6 +346,16 @@ import CoreAudio
let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate)))
anchor = Double(start)/rate; time = anchor
guard start < file.length else { playing = false; render(); return }
nativeLoopActive = false
if loopEnabled,let range = preparedLoop,range.lowerBound == loopIn,range.upperBound == loopOut,let loopBuffer,seconds < range.upperBound {
let endFrame = AVAudioFramePosition(range.upperBound*rate)
let remainder = AVAudioFrameCount(max(1,endFrame-start))
node.scheduleSegment(file,startingFrame:start,frameCount:remainder,at:nil)
node.scheduleBuffer(loopBuffer,at:nil,options:.loops)
nativeLoopActive = true
if play { if notifyPlayback { startNodePlayback() } else { node.play() } }
playing = play;render();return
}
node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in
Task { @MainActor in
guard let self, self.generation == token else { return }
@@ -330,7 +397,7 @@ import CoreAudio
generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop()
if metering { engine.mainMixerNode.removeTap(onBus:0); metering = false }
meter.reset(); leftLevel = 0; rightLevel = 0
engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone)
engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone);engine.disconnectNodeOutput(graphicEQ);engine.disconnectNodeOutput(vocalReduction)
file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song"
}
var audibleScrubbing = false
@@ -346,10 +413,7 @@ import CoreAudio
let target = min(duration,max(0,value)),now = ProcessInfo.processInfo.systemUptime
scrubTarget = target
// Do not starve HAL by stopping/restarting the node on every mouse event.
if now-lastScrubAudio < 0.12 {
if !playing { time = target;render() }
return
}
if now-lastScrubAudio < 0.06 { return }
lastScrubAudio = now
if playing { seek(target);return }
scrubPreviewTask?.cancel()
@@ -429,9 +493,9 @@ import CoreAudio
let left = playing ? levels.0 : 0,right = playing ? levels.1 : 0
if leftLevel != left { leftLevel = left };if rightLevel != right { rightLevel = right }
if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) {
let next = min(duration,max(0,anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate))
let next = min(duration,max(0,loopPosition(anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate)))
if abs(time-next) > 0.0001 { time = next }
if loopEnabled,let loopIn,let loopOut,time >= loopOut { schedule(at:loopIn,play:true);return }
if !nativeLoopActive,loopEnabled,let loopIn,let loopOut,time >= loopOut { schedule(at:loopIn,play:true);return }
}
if !fading { setFade(autoFade && fadeSeconds > 0 && duration > 0 ? min(1,max(0,(duration-time)/max(0.5,tempo)/fadeSeconds)) : 1) }
render()
@@ -461,6 +525,8 @@ enum AudioWaveform {
}
static func analyse(url: URL, bins: Int = 4096, cancelled: () -> Bool = { false }) throws -> Analysis {
guard bins > 0 else { return .init(levels:[],bands:[]) }
try WaveformWorkload.shared.acquire(cancelled:cancelled)
defer { WaveformWorkload.shared.release() }
let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatFloat32, interleaved: false)
guard file.length > 0, let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: 16384) else { return .init(levels:[],bands:[]) }
var energy = [Double](repeating: 0, count: bins)
@@ -473,23 +539,37 @@ enum AudioWaveform {
var lows = [Double](repeating:0,count:bins), mids = lows, highs = lows
while position < file.length {
if cancelled() { throw CancellationError() }
if WaveformWorkload.shared.playbackActive { Thread.sleep(forTimeInterval:0.001) }
try file.read(into: buffer, frameCount: AVAudioFrameCount(min(16384, file.length-position)))
let frames = Int(buffer.frameLength)
guard frames > 0, let data = buffer.floatChannelData else { break }
for frame in 0..<frames {
let bin = min(bins-1, Int((position+Int64(frame))*Int64(bins)/file.length))
var sum: Double = 0
// Split at bin boundaries once, then process contiguous channel samples.
// This removes per-sample integer division and array indexing of filter states.
var offset = 0
while offset < frames {
let absolute = position+Int64(offset)
let bin = min(bins-1,Int(absolute*Int64(bins)/file.length))
let boundary = ((Int64(bin+1)*file.length)+Int64(bins)-1)/Int64(bins)
let end = min(frames,max(offset+1,Int(boundary-position)))
var total = 0.0,lowEnergy = 0.0,midEnergy = 0.0,highEnergy = 0.0
for channel in 0..<channels {
let sample = Double(data[channel][frame])
if sample.isFinite {
sum += sample * sample
lowState[channel] = lowAlpha*lowState[channel]+(1-lowAlpha)*sample
highState[channel] = highAlpha*highState[channel]+(1-highAlpha)*sample
let low = lowState[channel], mid = highState[channel]-low, high = sample-highState[channel]
lows[bin] += low*low; mids[bin] += mid*mid; highs[bin] += high*high
let samples = data[channel]
var low = lowState[channel],high = highState[channel]
for frame in offset..<end {
let sample = Double(samples[frame])
if sample.isFinite {
total += sample*sample
low = lowAlpha*low+(1-lowAlpha)*sample
high = highAlpha*high+(1-highAlpha)*sample
let mid = high-low,treble = sample-high
lowEnergy += low*low;midEnergy += mid*mid;highEnergy += treble*treble
}
}
lowState[channel] = low;highState[channel] = high
}
energy[bin] += sum / Double(max(channels,1)); counts[bin] += 1
energy[bin] += total/Double(max(channels,1));counts[bin] += end-offset
lows[bin] += lowEnergy;mids[bin] += midEnergy;highs[bin] += highEnergy
offset = end
}
position += Int64(frames)
}
@@ -568,3 +648,55 @@ enum WaveformCache {
}
}
/// Stereo centre cancellation, with a non-blocking control read on the render thread.
private final class DJVocalControl: @unchecked Sendable {
private let lock = NSLock()
private var amount: Float = 0
func set(_ value: Float) { lock.lock();amount = value;lock.unlock() }
func read() -> Float { guard lock.try() else { return 0 };defer { lock.unlock() };return amount }
}
final class DJVocalReductionUnit: AUAudioUnit {
private var input: AUAudioUnitBus!
private var output: AUAudioUnitBus!
private var inputs: AUAudioUnitBusArray!
private var outputs: AUAudioUnitBusArray!
private let control = DJVocalControl()
static let component: AudioComponentDescription = {
let description = AudioComponentDescription(componentType:kAudioUnitType_Effect,componentSubType:0x6B6A7672,componentManufacturer:0x4B4A4D58,componentFlags:0,componentFlagsMask:0)
AUAudioUnit.registerSubclass(DJVocalReductionUnit.self,as:description,name:"KJMix: Centre Vocal Reduction",version:1)
return description
}()
override init(componentDescription: AudioComponentDescription,options: AudioComponentInstantiationOptions = []) throws {
try super.init(componentDescription:componentDescription,options:options)
let format = AVAudioFormat(standardFormatWithSampleRate:44100,channels:2)!
input = try AUAudioUnitBus(format:format);output = try AUAudioUnitBus(format:format)
inputs = AUAudioUnitBusArray(audioUnit:self,busType:.input,busses:[input])
outputs = AUAudioUnitBusArray(audioUnit:self,busType:.output,busses:[output])
maximumFramesToRender = 4096
}
override var inputBusses: AUAudioUnitBusArray { inputs }
override var outputBusses: AUAudioUnitBusArray { outputs }
func setAmount(_ value: Float) { control.set(min(1,max(0,value))) }
static func reduce(left: Float,right: Float,amount: Float) -> (Float,Float) {
let side = (left-right)*0.5
return (left*(1-amount)+side*amount,right*(1-amount)+side*amount)
}
override var internalRenderBlock: AUInternalRenderBlock {
let control = self.control
return { flags,time,frames,_,data,_,pull in
guard let pull else { return kAudioUnitErr_NoConnection }
let result = pull(flags,time,frames,0,data)
guard result == noErr else { return result }
let amount = control.read()
guard amount > 0 else { return noErr }
let buffers = UnsafeMutableAudioBufferListPointer(data)
if buffers.count == 2,let l = buffers[0].mData?.assumingMemoryBound(to:Float.self),let r = buffers[1].mData?.assumingMemoryBound(to:Float.self) {
for i in 0..<Int(frames) { let value = DJVocalReductionUnit.reduce(left:l[i],right:r[i],amount:amount);l[i] = value.0;r[i] = value.1 }
} else if buffers.count == 1,buffers[0].mNumberChannels == 2,let values = buffers[0].mData?.assumingMemoryBound(to:Float.self) {
for i in 0..<Int(frames) { let value = DJVocalReductionUnit.reduce(left:values[i*2],right:values[i*2+1],amount:amount);values[i*2] = value.0;values[i*2+1] = value.1 }
}
return noErr
}
}
}
+140 -10
View File
@@ -1351,23 +1351,35 @@ 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) }
func click(_ x: Double,_ top: Double) async throws {
func descendants(_ view: NSView) -> [NSView] { [view]+view.subviews.flatMap{descendants($0)} }
let tabs = try XCTUnwrap(descendants(host).compactMap{$0 as? NSTabView}.first)
let rect = tabs.contentRect
let point = tabs.convert(NSPoint(x:rect.minX+x,y:tabs.isFlipped ? rect.minY+top : rect.maxY-top),to:nil)
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))
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)
}
try await Task.sleep(nanoseconds:90_000_000)
}
try await click(24,336)
model.expandedSingerIDs.remove(singer)
try await Task.sleep(nanoseconds:50_000_000)
try await click("singer.expand.\(singer)")
XCTAssertTrue(model.expandedSingerIDs.contains(singer),"Clicking the actual chevron must expand the singer")
try await click(100,153);XCTAssertEqual(model.activeSection,"Rotation")
try await click(100,116);XCTAssertEqual(model.activeSection,"Library")
try await click("nav.Rotation");XCTAssertEqual(model.activeSection,"Rotation")
try await click("nav.Library");XCTAssertEqual(model.activeSection,"Library")
XCTAssertTrue(model.expandedSingerIDs.contains(singer))
try await click(24,336)
try await click("singer.expand.\(singer)")
XCTAssertFalse(model.expandedSingerIDs.contains(singer));XCTAssertNil(model.error)
}
@@ -2650,7 +2662,7 @@ extension KJamsCloneTests {
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)
XCTAssertEqual(player.time,0.5,accuracy:0.001,"Display stays with the audible grain until the next audio update")
player.endScrubbing()
XCTAssertFalse(player.audioPlaybackStarted);XCTAssertFalse(player.playing)
XCTAssertEqual(player.time,0.2,accuracy:0.001)
@@ -2742,4 +2754,122 @@ extension KJamsCloneTests {
XCTAssertEqual(player.time,0.69,accuracy:0.001)
}
func testConcurrentWaveformBatchSavesUniquePathsAndReusesDuplicates() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
var songs = [Song]()
for i in 0..<4 {
let audio = root.appendingPathComponent("Track\(i).wav")
try FileManager.default.copyItem(at:fixture,to:audio)
songs.append(Song(id:Int64(i),title:"Track",audio:audio.path,cdg:nil,rating:0,plays:0))
}
var duplicate = songs[0];duplicate.id = 10;songs.append(duplicate)
let cache = root.appendingPathComponent("Cache")
let result = await WaveformBatch.runConcurrent(songs:songs,locations:[],cache:cache,force:true,limit:{2})
XCTAssertEqual(result.generated,4);XCTAssertEqual(result.cached,1);XCTAssertEqual(result.processed,5);XCTAssertEqual(result.issues,0)
let reused = await WaveformBatch.runConcurrent(songs:songs,locations:[],cache:cache,limit:{1})
XCTAssertEqual(reused.cached,5);XCTAssertEqual(reused.generated,0)
}
@MainActor func testWaveformAdmissionDropsToOneDuringPlayback() throws {
let policy = WaveformWorkload.shared,id = UUID()
policy.setPlaying(true,id:id)
XCTAssertEqual(policy.workers,1)
policy.setPlaying(false,id:id)
XCTAssertLessThanOrEqual(policy.workers,2)
}
@MainActor func testSharedDJGraphicEQAppliesAcrossLoadsAndPersists() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let system = DJSystem(storageURL:root)
defer { system.a.unload();system.b.unload() }
system.chooseGraphicPreset("Rock")
XCTAssertTrue(system.a.player.graphicEnabled)
XCTAssertEqual(system.a.player.graphicGains,system.b.player.graphicGains)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
let song = Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0)
system.load(song,into:"A")
XCTAssertEqual(system.a.player.graphicGains,DJGraphicEQSettings.presets.first(where:{$0.0 == "Rock"})?.1)
system.graphicEQ.enabled = false
XCTAssertFalse(system.a.player.graphicEnabled);XCTAssertFalse(system.b.player.graphicEnabled)
try await Task.sleep(nanoseconds:300_000_000)
let reopened = DJSystem(storageURL:root)
XCTAssertEqual(reopened.graphicEQ,system.graphicEQ)
}
func testCentreVocalReductionRemovesCentreAndPreservesSideSignal() {
let centred = DJVocalReductionUnit.reduce(left:0.8,right:0.8,amount:1)
XCTAssertEqual(centred.0,0,accuracy:0.0001);XCTAssertEqual(centred.1,0,accuracy:0.0001)
let side = DJVocalReductionUnit.reduce(left:0.8,right:-0.8,amount:1)
XCTAssertEqual(side.0,0.8,accuracy:0.0001);XCTAssertEqual(side.1,0.8,accuracy:0.0001)
let bypass = DJVocalReductionUnit.reduce(left:0.6,right:0.2,amount:0)
XCTAssertEqual(bypass.0,0.6,accuracy:0.0001);XCTAssertEqual(bypass.1,0.2,accuracy:0.0001)
}
@MainActor func testDJNameReinterpretationUpdatesArtistAndUndo() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let system = DJSystem(storageURL:root)
let id = try XCTUnwrap(system.library?.add(title:"Example Artist - Example Song",audio:"/missing/example.mp3",cdg:nil))
try system.refresh()
let preview = try NameTemplate.preview(system.songs,template:"R - S",source:.song,emDashes:false,underscores:true)
try await system.applyNameChanges(preview.compactMap(\.change))
XCTAssertEqual(system.songs.first?.artist,"Example Artist");XCTAssertEqual(system.songs.first?.title,"Example Song")
try await system.undoNameChanges()
XCTAssertEqual(system.songs.first?.id,id);XCTAssertEqual(system.songs.first?.title,"Example Artist - Example Song")
}
@MainActor func testCentreVocalReductionProcessesLiveStereoAudio() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:2))
let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:88200));buffer.frameLength = 88200
let data = try XCTUnwrap(buffer.floatChannelData)
for i in 0..<88200 { let value = Float(sin(Double(i)*2 * .pi*440/44100))*0.3;data[0][i] = value;data[1][i] = value }
let url = root.appendingPathComponent("Centred.wav")
do { let file = try AVAudioFile(forWriting:url,settings:format.settings);try file.write(from:buffer) }
let player = Player(waveformCacheDirectory:root,persistsMix:false)
defer { player.stop() }
player.autoFade = false;player.volume = 0.8
try player.load(Song(id:1,title:"Centred",audio:url.path,cdg:nil,rating:0,plays:0))
for _ in 0..<50 where player.scrubOutputLevel <= 0.01 { try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertGreaterThan(player.scrubOutputLevel,0.01)
player.vocalReductionEnabled = true
for _ in 0..<70 where player.scrubOutputLevel >= 0.001 { try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertLessThan(player.scrubOutputLevel,0.001)
XCTAssertTrue(player.playing)
player.vocalReductionEnabled = false
for _ in 0..<70 where player.scrubOutputLevel <= 0.01 { try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertGreaterThan(player.scrubOutputLevel,0.01)
}
@MainActor func testShortLoopRunsOnAudioNodeAndClockWraps() 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.wav"))
let player = Player(waveformCacheDirectory:root,persistsMix:false)
defer { player.stop() }
player.autoFade = false
try player.load(Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0),startPlaying:false)
player.defineLoop(start:0.2,end:0.4)
for _ in 0..<100 where !player.nativeLoopActive { try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertTrue(player.nativeLoopActive)
player.seek(0.25);player.toggle()
for _ in 0..<30 {
try await Task.sleep(nanoseconds:20_000_000)
XCTAssertTrue(player.audioPlaybackStarted);XCTAssertTrue(player.nativeLoopActive)
XCTAssertGreaterThanOrEqual(player.displayTime,0.2);XCTAssertLessThan(player.displayTime,0.401)
}
player.defineLoop(start:0.2,end:0.3)
try await Task.sleep(nanoseconds:100_000_000)
XCTAssertTrue(player.nativeLoopActive);XCTAssertLessThan(player.displayTime,0.301)
let exit = player.displayTime
player.loopEnabled = false
XCTAssertFalse(player.nativeLoopActive);XCTAssertTrue(player.playing)
XCTAssertEqual(player.time,exit,accuracy:0.03)
try await Task.sleep(nanoseconds:180_000_000)
XCTAssertGreaterThan(player.displayTime,0.3,"Loop Off must continue immediately past the previous loop end")
}
}
+18
View File
@@ -93,3 +93,21 @@ The DJ library advances to the next page when a user scroll reaches the bottom.
DJ waveform generation opens a modal with a coloured progress bar, percentage ring, counts and Cancel. The results stay visible until Done; any analysis issues are listed in the modal.
DJ library selection supports Command-click for separate tracks, Shift-click for ranges, and Command-A or right-click Select All Songs for all matching tracks across pages. The queue toolbar action adds the selected tracks in library order. Right-click selected tracks to generate or regenerate their saved waveforms or add the selection to the DJ queue.
## Library and audio responsiveness
DJ tracks appear in a native table with resizable Title, Artist, Album, Time and Rating columns. Favourite stars and five-star ratings are independent and saved in the DJ library. Right-click Song Info / Artist to edit metadata, or Reinterpret Selected Names to use the same preview/template tools as Karaoke Studio; Undo restores the last DJ name change.
Both studios use the same search field with a clear X. Filtered DJ rows are cached until search/library/playlist/favourites change; fader events do not rebuild the library table. Unchanged drag titles no longer redraw on every selection update. Platters use native display callbacks instead of SwiftUI frame-by-frame animation.
Waveform analysis processes contiguous bin segments rather than doing division for every sample. Batches admit at most two background workers, dropping to one while any player is playing; existing workers finish their current files, and decode blocks yield during playback. Original cache validity and cancellation rules remain. A local bundled-WAV benchmark measured about 3× faster analysis, with matching levels; this is not a guarantee for other media or drives.
Equaliser opens a shared ten-band graphic EQ for both DJ decks and headphone preview. Flat, Rock, Pop, Jazz, Vocal, Bass Boost and Treble Boost presets are editable, with bypass and automatic persistence. Gain changes apply without restarting playback.
Each deck also has a microphone toggle beside its level fader for basic centre-vocal reduction. It runs in the audio render path and can be switched during playback. Centred instruments are reduced too; mono is passed through, and stereo vocals may remain. This is centre cancellation, not AI source separation.
Ordinary loops are prepared on a worker and scheduled with the audio node's looping buffer, rather than restarting from a UI timer. Source display time wraps on that same audio clock. Loop buffers are capped at 64 MB; longer manual loops retain the streaming/timer fallback. At normal speed with no pitch shift the time-pitch unit is bypassed to reduce processing overhead. During scrubbing, the waveform follows accepted audio positions, rather than showing a newer mouse target before that audio is scheduled.
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.