removed unwanted bar now with waveforms, major updates
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") } }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user