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
}
}
}