removed unwanted bar now with waveforms, major updates and enhancements
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
import SwiftUI
|
||||
import MusicKit
|
||||
|
||||
struct AppleMusicTrack: Identifiable, Equatable {
|
||||
let id: String
|
||||
let title: String
|
||||
let artist: String
|
||||
let album: String
|
||||
let duration: Double
|
||||
let artwork: URL?
|
||||
}
|
||||
struct AppleMusicPlaylist: Identifiable, Equatable { let id: String;let name: String }
|
||||
struct AppleMusicPage { let tracks: [AppleMusicTrack];let hasMore: Bool }
|
||||
enum AppleMusicAccess { case notDetermined,authorized,denied,restricted }
|
||||
struct AppleMusicQueueItem: Identifiable, Equatable { let id: String;let title: String;let artist: String;let current: Bool }
|
||||
struct AppleMusicPlayback: Equatable { var title = "";var artist = "";var playing = false;var position: Double = 0;var duration: Double = 0;var queue = [AppleMusicQueueItem]() }
|
||||
|
||||
@MainActor protocol AppleMusicClient: AnyObject {
|
||||
var access: AppleMusicAccess { get }
|
||||
func authorize() async -> AppleMusicAccess
|
||||
func subscribed() async throws -> Bool
|
||||
func playlists() async throws -> [AppleMusicPlaylist]
|
||||
func browse(query: String,playlist: String?,offset: Int) async throws -> AppleMusicPage
|
||||
func play(_ id: String) async throws
|
||||
func enqueue(_ id: String) async throws
|
||||
func resume() async throws
|
||||
func pause()
|
||||
func stop()
|
||||
func seek(_ seconds: Double)
|
||||
func next() async throws
|
||||
var playback: AppleMusicPlayback { get }
|
||||
}
|
||||
|
||||
/// Public MusicKit playback stays inside Apple's player; protected streams are
|
||||
/// never passed to AVAudioFile, waveform analysis or the local deck DSP graph.
|
||||
@available(macOS 14.0, *) @MainActor final class NativeAppleMusicClient: AppleMusicClient {
|
||||
private var tracks = [String:MusicKit.Song]()
|
||||
private var playlistItems = [String:MusicKit.Playlist]()
|
||||
private var musicPlayer: ApplicationMusicPlayer { .shared }
|
||||
var access: AppleMusicAccess {
|
||||
switch MusicAuthorization.currentStatus {
|
||||
case .authorized:return .authorized
|
||||
case .denied:return .denied
|
||||
case .restricted:return .restricted
|
||||
default:return .notDetermined
|
||||
}
|
||||
}
|
||||
func authorize() async -> AppleMusicAccess { _ = await MusicAuthorization.request();return access }
|
||||
func subscribed() async throws -> Bool { try await MusicSubscription.current.canPlayCatalogContent }
|
||||
private func remember(_ songs: [MusicKit.Song]) -> [AppleMusicTrack] {
|
||||
songs.map { song in
|
||||
tracks[song.id.rawValue] = song
|
||||
return AppleMusicTrack(id:song.id.rawValue,title:song.title,artist:song.artistName,album:song.albumTitle ?? "",duration:song.duration ?? 0,artwork:song.artwork?.url(width:80,height:80))
|
||||
}
|
||||
}
|
||||
func playlists() async throws -> [AppleMusicPlaylist] {
|
||||
var request = MusicLibraryRequest<MusicKit.Playlist>();request.limit = 100
|
||||
var result = [AppleMusicPlaylist]()
|
||||
while true {
|
||||
try Task.checkCancellation()
|
||||
let response = try await request.response()
|
||||
for item in response.items { playlistItems[item.id.rawValue] = item;result.append(.init(id:item.id.rawValue,name:item.name)) }
|
||||
if !response.items.hasNextBatch { break };request.offset += response.items.count
|
||||
}
|
||||
return result
|
||||
}
|
||||
func browse(query: String,playlist: String?,offset: Int) async throws -> AppleMusicPage {
|
||||
if !query.isEmpty {
|
||||
var request = MusicCatalogSearchRequest(term:query,types:[MusicKit.Song.self]);request.limit = 50;request.offset = offset
|
||||
let response = try await request.response()
|
||||
return .init(tracks:remember(Array(response.songs)),hasMore:response.songs.hasNextBatch)
|
||||
}
|
||||
if let playlist,let item = playlistItems[playlist] {
|
||||
let detailed = try await item.with([.tracks])
|
||||
guard var collection = detailed.tracks else { return .init(tracks:[],hasMore:false) }
|
||||
var all = Array(collection)
|
||||
while all.count < offset+50,collection.hasNextBatch,let next = try await collection.nextBatch(limit:50) {
|
||||
try Task.checkCancellation();collection = next;all.append(contentsOf:next)
|
||||
}
|
||||
let songs = all.dropFirst(offset).prefix(50).compactMap { track -> MusicKit.Song? in
|
||||
if case .song(let song) = track { return song };return nil
|
||||
}
|
||||
return .init(tracks:remember(songs),hasMore:all.count > offset+50 || collection.hasNextBatch)
|
||||
}
|
||||
var request = MusicLibraryRequest<MusicKit.Song>();request.limit = 50;request.offset = offset
|
||||
let response = try await request.response()
|
||||
return .init(tracks:remember(Array(response.items)),hasMore:response.items.hasNextBatch)
|
||||
}
|
||||
private func song(_ id: String) throws -> MusicKit.Song {
|
||||
guard let song = tracks[id] else { throw LibraryError.message("Refresh Apple Music and select the track again.") };return song
|
||||
}
|
||||
func play(_ id: String) async throws { musicPlayer.queue = .init(for:[try song(id)]);try await musicPlayer.play() }
|
||||
func enqueue(_ id: String) async throws { try await musicPlayer.queue.insert(song(id),position:.tail) }
|
||||
func resume() async throws { try await musicPlayer.play() }
|
||||
func pause() { musicPlayer.pause() }
|
||||
func stop() { musicPlayer.stop() }
|
||||
func seek(_ seconds: Double) { musicPlayer.playbackTime = max(0,seconds) }
|
||||
func next() async throws { try await musicPlayer.skipToNextEntry() }
|
||||
var playback: AppleMusicPlayback {
|
||||
let entry = musicPlayer.queue.currentEntry
|
||||
var duration: Double = 0
|
||||
if case .song(let song) = entry?.item { duration = song.duration ?? 0 }
|
||||
let queue = musicPlayer.queue.entries.map { AppleMusicQueueItem(id:$0.id,title:$0.title,artist:$0.subtitle ?? "",current:$0.id == entry?.id) }
|
||||
return .init(title:entry?.title ?? "",artist:entry?.subtitle ?? "",playing:musicPlayer.state.playbackStatus == .playing,position:musicPlayer.playbackTime,duration:duration,queue:queue)
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14.0, *) @MainActor final class AppleMusicTransportState: ObservableObject {
|
||||
@Published var value = AppleMusicPlayback()
|
||||
}
|
||||
@available(macOS 14.0, *) @MainActor final class AppleMusicSession: ObservableObject {
|
||||
@Published private(set) var access: AppleMusicAccess
|
||||
@Published private(set) var canPlay = false
|
||||
@Published private(set) var tracks = [AppleMusicTrack]()
|
||||
@Published private(set) var playlists = [AppleMusicPlaylist]()
|
||||
@Published private(set) var busy = false
|
||||
@Published private(set) var loadingMore = false
|
||||
@Published private(set) var hasMore = false
|
||||
let transport = AppleMusicTransportState()
|
||||
var playback: AppleMusicPlayback {
|
||||
get { transport.value }
|
||||
set { if newValue != transport.value { transport.value = newValue } }
|
||||
}
|
||||
@Published var error: String?
|
||||
@Published var query = ""
|
||||
@Published var playlist: String?
|
||||
private let client: AppleMusicClient
|
||||
private var browseTask: Task<Void,Never>?
|
||||
private var playbackTask: Task<Void,Never>?
|
||||
private var generation = UUID()
|
||||
init(client: AppleMusicClient? = nil) { let client = client ?? NativeAppleMusicClient();self.client = client;access = client.access }
|
||||
deinit { browseTask?.cancel();playbackTask?.cancel() }
|
||||
func connect() async {
|
||||
guard !busy else { return };browseTask?.cancel();generation = UUID();canPlay = false;tracks = [];busy = true;error = nil
|
||||
access = await client.authorize()
|
||||
guard access == .authorized else {
|
||||
busy = false;error = access == .restricted ? "Apple Music access is restricted on this Mac." : "Allow KJMix Pro in System Settings → Privacy & Security → Media & Apple Music.";return
|
||||
}
|
||||
do { canPlay = try await client.subscribed();playlists = try await client.playlists() }
|
||||
catch { self.error = "Apple Music could not connect. Check your internet connection, Apple Music subscription and this app’s MusicKit App Service configuration. \(error.localizedDescription)" }
|
||||
busy = false
|
||||
if error == nil { reload() }
|
||||
}
|
||||
func reload() {
|
||||
browseTask?.cancel();generation = UUID();let token = generation
|
||||
tracks = [];hasMore = false;loadingMore = false
|
||||
guard access == .authorized else { return }
|
||||
busy = true;error = nil
|
||||
let query = query.trimmingCharacters(in:.whitespacesAndNewlines),playlist = playlist
|
||||
browseTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let page = try await client.browse(query:query,playlist:playlist,offset:0)
|
||||
guard !Task.isCancelled,generation == token else { return }
|
||||
tracks = page.tracks;hasMore = page.hasMore;busy = false
|
||||
} catch { guard generation == token,!Task.isCancelled else { return };self.error = error.localizedDescription;busy = false }
|
||||
}
|
||||
}
|
||||
func more() {
|
||||
guard hasMore,!busy,!loadingMore else { return };loadingMore = true
|
||||
let token = generation,offset = tracks.count,query = query.trimmingCharacters(in:.whitespacesAndNewlines),playlist = playlist
|
||||
browseTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let page = try await client.browse(query:query,playlist:playlist,offset:offset)
|
||||
guard !Task.isCancelled,generation == token else { return }
|
||||
let existing = Set(tracks.map(\.id));tracks += page.tracks.filter{!existing.contains($0.id)};hasMore = page.hasMore;loadingMore = false
|
||||
} catch { guard generation == token,!Task.isCancelled else { return };self.error = error.localizedDescription;loadingMore = false }
|
||||
}
|
||||
}
|
||||
func play(_ track: AppleMusicTrack) async {
|
||||
guard canPlay else { error = "An active Apple Music subscription is required for full playback.";return }
|
||||
do { try await client.play(track.id);observePlayback() } catch { self.error = error.localizedDescription }
|
||||
}
|
||||
func enqueue(_ track: AppleMusicTrack) async {
|
||||
guard canPlay else { error = "An active Apple Music subscription is required for full playback.";return }
|
||||
do { try await client.enqueue(track.id);observePlayback() } catch { self.error = error.localizedDescription }
|
||||
}
|
||||
func toggle() async {
|
||||
do { if playback.playing { client.pause() } else { try await client.resume() };observePlayback() } catch { self.error = error.localizedDescription }
|
||||
}
|
||||
func pause() { client.pause();playback = client.playback }
|
||||
func stop() { client.stop();playback = client.playback;playbackTask?.cancel();playbackTask = nil }
|
||||
func seek(_ value: Double) { client.seek(min(playback.duration,max(0,value)));playback = client.playback }
|
||||
func next() async { do { try await client.next();observePlayback() } catch { self.error = error.localizedDescription } }
|
||||
private func observePlayback() {
|
||||
playback = client.playback
|
||||
guard playbackTask == nil else { return }
|
||||
playbackTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
do { try await Task.sleep(nanoseconds:250_000_000) } catch { return }
|
||||
guard let self else { return };self.playback = self.client.playback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14.0, *) struct AppleMusicBrowser: View {
|
||||
@ObservedObject var session: AppleMusicSession
|
||||
@State private var pendingPlay: AppleMusicTrack?
|
||||
@State private var pendingResume = false
|
||||
let hasPlayingDeck: () -> Bool
|
||||
let stopDecks: () -> Void
|
||||
private func requestPlay(_ track: AppleMusicTrack) {
|
||||
if hasPlayingDeck() { pendingPlay = track } else { Task { await session.play(track) } }
|
||||
}
|
||||
var body: some View {
|
||||
VStack(spacing:0) {
|
||||
HStack(spacing:14) {
|
||||
Label("Apple Music",systemImage:"music.note").font(.title3.bold()).foregroundStyle(.pink)
|
||||
Picker("Apple Music library",selection:$session.playlist) {
|
||||
Text("My Library").tag(nil as String?)
|
||||
ForEach(session.playlists) { Text($0.name).tag(Optional($0.id)) }
|
||||
}.labelsHidden().frame(maxWidth:200).disabled(session.access != .authorized)
|
||||
Spacer()
|
||||
StudioSearchField(placeholder:"Search Apple Music catalog",text:$session.query).frame(maxWidth:320)
|
||||
.onSubmit { session.reload() }
|
||||
Button("Search") { session.reload() }.buttonStyle(LibraryActionButtonStyle(tint:.pink)).disabled(session.access != .authorized)
|
||||
Button(session.access == .authorized ? "Refresh Connection" : "Connect Apple Music") { Task { await session.connect() } }
|
||||
.buttonStyle(LibraryActionButtonStyle(tint:.pink,prominent:true)).disabled(session.busy)
|
||||
}.padding(14)
|
||||
if let error = session.error { HStack { Image(systemName:"exclamationmark.circle.fill").foregroundStyle(.orange);Text(error).font(.callout);Spacer();Button("Dismiss") { session.error = nil } }.padding(12).background(Color.orange.opacity(0.1)) }
|
||||
if session.busy { ProgressView("Loading Apple Music…").padding() }
|
||||
if session.access != .authorized {
|
||||
VStack(spacing:12) {
|
||||
Image(systemName:"music.note.list").font(.system(size:34)).foregroundStyle(.pink)
|
||||
Text("Your Apple Music library and playlists").font(.headline)
|
||||
Text("Connect to browse your library, search the catalog and play through Apple Music.").foregroundStyle(.secondary)
|
||||
}.frame(maxWidth:.infinity,maxHeight:.infinity)
|
||||
} else {
|
||||
Table(session.tracks) {
|
||||
TableColumn("Track") { track in
|
||||
HStack(spacing:10) {
|
||||
AsyncImage(url:track.artwork) { image in image.resizable().scaledToFill() } placeholder: { Image(systemName:"music.note").frame(maxWidth:.infinity,maxHeight:.infinity).background(Color.pink.opacity(0.12)) }.frame(width:32,height:32).clipShape(RoundedRectangle(cornerRadius:5))
|
||||
Text(track.title).lineLimit(1)
|
||||
}.padding(.vertical,3).onTapGesture(count:2) { requestPlay(track) }
|
||||
.contextMenu { Button("Play in Apple Music") { requestPlay(track) };Button("Add to Apple Music Queue") { Task { await session.enqueue(track) } } }
|
||||
}.width(min:180,ideal:300)
|
||||
TableColumn("Artist",value:\.artist).width(min:100,ideal:180)
|
||||
TableColumn("Album",value:\.album).width(min:100,ideal:180)
|
||||
TableColumn("Time") { Text(Self.time(track:$0.duration)).monospacedDigit() }.width(60)
|
||||
TableColumn("Playback") { track in
|
||||
HStack { Button { requestPlay(track) } label: { Image(systemName:"play.fill") };Button { Task { await session.enqueue(track) } } label: { Image(systemName:"text.badge.plus") } }.disabled(!session.canPlay)
|
||||
}.width(90)
|
||||
}
|
||||
if session.hasMore { Button(session.loadingMore ? "Loading…" : "Load More") { session.more() }.disabled(session.loadingMore).padding(8) }
|
||||
}
|
||||
Divider()
|
||||
AppleMusicTransportBar(session:session,toggle:{
|
||||
if hasPlayingDeck(),!session.playback.playing { pendingResume = true }
|
||||
else { Task { await session.toggle() } }
|
||||
})
|
||||
Text("MusicKit playback uses the system audio output. Deck EQ, sync, loops, waveform analysis and recording apply to local tracks.").font(.caption).foregroundStyle(.secondary).padding(.horizontal,14).padding(.bottom,10)
|
||||
}.background(Color(red:0.095,green:0.11,blue:0.135)).clipShape(RoundedRectangle(cornerRadius:10))
|
||||
.onChange(of:session.playlist) { _ in session.query = "";session.reload() }
|
||||
.onChange(of:session.query) { value in if value.isEmpty { session.reload() } }
|
||||
.alert("Stop deck playback?",isPresented:Binding(get:{pendingPlay != nil || pendingResume},set:{if !$0 { pendingPlay = nil;pendingResume = false }})) {
|
||||
Button("Cancel",role:.cancel) { pendingPlay = nil;pendingResume = false }
|
||||
Button("Stop Decks & Play") {
|
||||
stopDecks()
|
||||
let track = pendingPlay;pendingPlay = nil;pendingResume = false
|
||||
Task { if let track { await session.play(track) } else { await session.toggle() } }
|
||||
}
|
||||
} message: { Text("Apple Music uses its own player. Stop the playing decks before starting Apple Music?") }
|
||||
}
|
||||
static func time(track: Double) -> String { guard track.isFinite else { return "0:00" };let seconds = max(0,Int(track));return String(format:"%d:%02d",seconds/60,seconds%60) }
|
||||
}
|
||||
|
||||
@available(macOS 14.0, *) private struct AppleMusicTransportBar: View {
|
||||
let session: AppleMusicSession
|
||||
let toggle: () -> Void
|
||||
@State private var queueVisible = false
|
||||
@ObservedObject var transport: AppleMusicTransportState
|
||||
init(session: AppleMusicSession,toggle: @escaping () -> Void) { self.session = session;self.toggle = toggle;transport = session.transport }
|
||||
var body: some View {
|
||||
HStack(spacing:14) {
|
||||
Button(action:toggle) { Image(systemName:session.playback.playing ? "pause.fill" : "play.fill") }.disabled(session.playback.title.isEmpty)
|
||||
Button { session.stop() } label: { Image(systemName:"stop.fill") }.disabled(session.playback.title.isEmpty)
|
||||
Button { Task { await session.next() } } label: { Image(systemName:"forward.end.fill") }.disabled(session.playback.title.isEmpty)
|
||||
VStack(alignment:.leading,spacing:3) { Text(session.playback.title.isEmpty ? "Choose an Apple Music track" : session.playback.title).fontWeight(.semibold).lineLimit(1);Text(session.playback.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) }.frame(minWidth:160,maxWidth:300,alignment:.leading)
|
||||
Text(AppleMusicBrowser.time(track:session.playback.position)).monospacedDigit()
|
||||
Slider(value:Binding(get:{min(session.playback.duration,max(0,session.playback.position))},set:{session.seek($0)}),in:0...max(1,session.playback.duration)).tint(.pink).disabled(session.playback.duration <= 0)
|
||||
Text(AppleMusicBrowser.time(track:session.playback.duration)).monospacedDigit()
|
||||
Button { queueVisible = true } label: { Label("Queue",systemImage:"text.line.first.and.arrowtriangle.forward") }
|
||||
}.buttonStyle(.bordered).padding(14)
|
||||
.sheet(isPresented:$queueVisible) {
|
||||
VStack(alignment:.leading,spacing:14) {
|
||||
HStack { Text("Apple Music Queue").font(.title2.bold());Spacer();Button("Done") { queueVisible = false } }
|
||||
List(transport.value.queue) { item in
|
||||
HStack { Image(systemName:item.current ? "speaker.wave.2.fill" : "music.note").foregroundStyle(item.current ? .pink : .secondary);VStack(alignment:.leading) { Text(item.title);Text(item.artist).font(.caption).foregroundStyle(.secondary) } }
|
||||
}
|
||||
}.padding(22).frame(width:620,height:420).preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -365,6 +365,11 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
@Published var error: String?
|
||||
let root: URL
|
||||
private(set) var library: Library?
|
||||
private var appleMusicStorage: AnyObject?
|
||||
@available(macOS 14.0, *) var appleMusic: AppleMusicSession {
|
||||
if let session = appleMusicStorage as? AppleMusicSession { return session }
|
||||
let session = AppleMusicSession();appleMusicStorage = session;return session
|
||||
}
|
||||
let dragSession = UUID().uuidString
|
||||
@Published var songs = [Song]()
|
||||
@Published var playlists = [PersonPlace]()
|
||||
@@ -379,6 +384,12 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
mediaDirectory = root.appendingPathComponent("Media");waveformDirectory = root.appendingPathComponent("Waveforms")
|
||||
a = DJChannel("A",waveformCache:root.appendingPathComponent("Waveforms"));b = DJChannel("B",waveformCache:root.appendingPathComponent("Waveforms"))
|
||||
preview.autoFade = false;preview.volume = cueLevel;refreshDevices();applyMix()
|
||||
for deck in [a,b] {
|
||||
deck.player.onWillStartPlayback = { [weak self] in
|
||||
if #available(macOS 14.0, *),let session = self?.appleMusicStorage as? AppleMusicSession { session.pause() }
|
||||
return 0
|
||||
}
|
||||
}
|
||||
Publishers.CombineLatest4(a.$bpm,b.$bpm,a.player.$tempo,b.player.$tempo).dropFirst().sink { [weak self] _ in
|
||||
Task { @MainActor [weak self] in self?.maintainSync() }
|
||||
}.store(in:&syncObservers)
|
||||
@@ -1007,6 +1018,7 @@ struct DJConsole: View {
|
||||
@State private var reinterpreting = false
|
||||
@State private var reinterpretIDs = Set<Int64>()
|
||||
@State private var editingTrack: Song?
|
||||
@State private var appleMusicVisible = false
|
||||
@State private var graphicEQVisible = false
|
||||
@State private var waveformDetails = false
|
||||
@State private var favouritesOnly = false
|
||||
@@ -1052,7 +1064,13 @@ struct DJConsole: View {
|
||||
VStack(spacing:10) {
|
||||
header
|
||||
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true)
|
||||
if appleMusicVisible {
|
||||
if #available(macOS 14.0, *) {
|
||||
AppleMusicBrowser(session:system.appleMusic,hasPlayingDeck:{system.a.player.playing || system.b.player.playing},stopDecks:{system.automix.stop();if system.a.player.playing { system.a.player.toggle() };if system.b.player.playing { system.b.player.toggle() }})
|
||||
} else { Text("Apple Music playback requires macOS 14 or later.").frame(maxWidth:.infinity,maxHeight:.infinity) }
|
||||
} else {
|
||||
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.automaticWaveformProgress {
|
||||
BackgroundWaveformStatus(progress:progress,cancel:{system.automaticWaveforms.cancel()},dismiss:{system.automaticWaveformProgress = nil})
|
||||
}
|
||||
@@ -1130,6 +1148,7 @@ struct DJConsole: View {
|
||||
Image(systemName:"waveform.path").foregroundStyle(DJStyle.a)
|
||||
Text("DJ WORKSTATION").font(.system(size:12,weight:.bold,design:.rounded))
|
||||
Text("TWO DECKS").font(.system(size:9,weight:.medium)).foregroundStyle(.secondary)
|
||||
Picker("Music source",selection:$appleMusicVisible) { Text("DJ Library").tag(false);Text("Apple Music").tag(true) }.pickerStyle(.segmented).frame(width:230)
|
||||
Spacer()
|
||||
Button { graphicEQVisible = true } label: { Label("Equaliser",systemImage:"slider.vertical.3") }.tint(system.graphicEQ.enabled ? .cyan : .accentColor)
|
||||
DJQueueButton(automix:system.automix,open:{queueVisible = true})
|
||||
|
||||
+185
-52
@@ -51,51 +51,63 @@ import CoreAudio
|
||||
guard let range = scheduledLoop,position >= range.upperBound else { return position }
|
||||
return range.lowerBound+(position-range.upperBound).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound)
|
||||
}
|
||||
private func exitNativeLoopWithoutRestart() -> Bool {
|
||||
guard playing,let file,let render = node.lastRenderTime,let clock = node.playerTime(forNodeTime:render) else { return false }
|
||||
let sourceTime = rawAudioPosition,pending = pendingLoopSwitch
|
||||
let range = pending.map{sourceTime >= $0.at ? $0.range : scheduledLoop} ?? scheduledLoop
|
||||
let raw = pending.map{sourceTime >= $0.at ? $0.raw : scheduledRawLoop} ?? scheduledRawLoop
|
||||
guard let range,let raw,let source = raw.floatChannelData,!raw.format.isInterleaved else { return false }
|
||||
let rate = file.processingFormat.sampleRate,now = mach_absolute_time()
|
||||
let elapsed = render.isHostTimeValid && now >= render.hostTime ? min(0.1,AVAudioTime.seconds(forHostTime:now-render.hostTime))*tempo : 0
|
||||
let sample = clock.sampleTime+AVAudioFramePosition(elapsed*rate)+512
|
||||
let position = loopPosition(anchor+Double(sample)/rate)
|
||||
let when = AVAudioTime(sampleTime:sample,atRate:rate)
|
||||
let offset = max(0,min(Int(raw.frameLength)-1,Int((position-range.lowerBound)*rate)))
|
||||
let count = min(2048,Int(raw.frameLength)-offset)
|
||||
guard count > 0,let bridge = AVAudioPCMBuffer(pcmFormat:raw.format,frameCapacity:AVAudioFrameCount(count)),let output = bridge.floatChannelData else { return false }
|
||||
bridge.frameLength = AVAudioFrameCount(count)
|
||||
for channel in 0..<Int(raw.format.channelCount) { memcpy(output[channel],source[channel].advanced(by:offset),count*MemoryLayout<Float>.size) }
|
||||
anchor = position-Double(sample)/rate;time = position
|
||||
generation = UUID();let token = generation
|
||||
nativeLoopActive = false;pendingLoopSwitch = nil;scheduledLoop = nil;scheduledRawLoop = nil
|
||||
// Interrupt only the scheduled loop, leaving the running node and its clock intact.
|
||||
let next = min(file.length,AVAudioFramePosition(position*rate)+AVAudioFramePosition(count))
|
||||
let finished: AVAudioPlayerNodeCompletionHandler = { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self,self.generation == token else { return }
|
||||
if self.repeating { self.schedule(at:0,play:true) }
|
||||
else { self.playing = false;self.time = self.duration;self.timer?.invalidate();self.timer = nil;self.render();self.onFinished?() }
|
||||
private var loopHandoff: Task<Void,Never>?
|
||||
private var handoffPresentation: (until: UInt64,began: UInt64,position: Double,range: ClosedRange<Double>?)?
|
||||
private func cancelLoopHandoff() {
|
||||
loopHandoff?.cancel();loopHandoff = nil;handoffPresentation = nil
|
||||
let other = node === firstNode ? secondNode : firstNode
|
||||
other.stop();gainUnit(for:other)?.setGain(0,from:0,seconds:0);gainUnit(for:node)?.setGain(1,from:1,seconds:0);node.volume = Float(outputOpacity)
|
||||
}
|
||||
/// Prepare the replacement on an idle node, then overlap matching source audio.
|
||||
/// Never interrupt or flush the node that is currently feeding the output.
|
||||
private func handoffLoopAudio(at position: Double) {
|
||||
cancelLoopHandoff()
|
||||
let outgoing = node,outgoingGain = gainUnit(for:node)
|
||||
let began = mach_absolute_time()
|
||||
let lead = loopEnabled ? 0.05 : min(0.05,max(0,(duration-position)/max(0.01,tempo)*0.25))
|
||||
let host = began+AVAudioTime.hostTime(forSeconds:lead)
|
||||
let previousRange = nativeLoopActive ? scheduledLoop : nil
|
||||
var target = position+lead*tempo
|
||||
if loopEnabled,nativeLoopActive,let range = scheduledLoop,target >= range.upperBound {
|
||||
target = range.lowerBound+(target-range.upperBound).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound)
|
||||
} else if loopEnabled,let range = preparedLoop,target >= range.upperBound {
|
||||
target = range.lowerBound+(target-range.upperBound).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound)
|
||||
}
|
||||
node = outgoing === firstNode ? secondNode : firstNode
|
||||
schedule(at:target,play:false,notifyPlayback:false,cancelHandoff:false)
|
||||
time = position
|
||||
let incoming = node,incomingGain = gainUnit(for:node)
|
||||
incoming.volume = Float(outputOpacity)
|
||||
incomingGain?.setGain(1,from:0,seconds:0.012,at:host)
|
||||
outgoingGain?.setGain(0,seconds:0.012,at:host)
|
||||
incoming.play(at:AVAudioTime(hostTime:host));playing = true
|
||||
handoffPresentation = (host,began,position,previousRange)
|
||||
loopHandoff = Task { @MainActor [weak self] in
|
||||
// Wait for the envelopes to finish on the audio render thread, rather
|
||||
// than assuming a UI timer ran in time for a hardware render quantum.
|
||||
while outgoingGain?.settled == false || incomingGain?.settled == false {
|
||||
do { try await Task.sleep(nanoseconds:5_000_000) } catch { return }
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
outgoing.stop()
|
||||
guard let self else { return }
|
||||
incoming.volume = Float(outputOpacity)
|
||||
loopHandoff = nil;handoffPresentation = nil
|
||||
}
|
||||
if next < file.length {
|
||||
node.scheduleBuffer(bridge,at:when,options:.interrupts,completionHandler:nil)
|
||||
node.scheduleSegment(file,startingFrame:next,frameCount:AVAudioFrameCount(min(file.length-next,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack,completionHandler:finished)
|
||||
} else {
|
||||
node.scheduleBuffer(bridge,at:when,options:.interrupts,completionCallbackType:.dataPlayedBack,completionHandler:finished)
|
||||
}
|
||||
startTimer();self.render();return true
|
||||
startTimer()
|
||||
}
|
||||
private func exitNativeLoopWithoutRestart() -> Bool {
|
||||
guard playing,file != nil else { return false }
|
||||
let position = displayTime
|
||||
handoffLoopAudio(at:position)
|
||||
return true
|
||||
}
|
||||
private func activatePreparedLoop() -> Bool {
|
||||
guard playing,let range = preparedLoop,preparedRawLoop != nil,loopBuffer != nil else { return false }
|
||||
let position = displayTime
|
||||
guard position >= range.lowerBound else { return false }
|
||||
let handoff = position < range.upperBound ? position : range.lowerBound+(position-range.upperBound).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound)
|
||||
// File commands cannot be interrupted by scheduling a PCM buffer. Replace
|
||||
// the node's commands once, with all loop audio already prepared. Keep the
|
||||
// engine, effects and output route running throughout the handoff.
|
||||
schedule(at:handoff,play:true,notifyPlayback:false)
|
||||
handoffLoopAudio(at:handoff)
|
||||
return nativeLoopActive
|
||||
}
|
||||
private func prepareNativeLoop() {
|
||||
@@ -157,6 +169,11 @@ import CoreAudio
|
||||
var canJog: Bool { currentSongID != nil && duration > 0 }
|
||||
/// Read the audio clock for display frames without publishing the entire player at 60 Hz.
|
||||
var displayTime: Double {
|
||||
if playing,let pending = handoffPresentation,mach_absolute_time() < pending.until {
|
||||
var value = pending.position+AVAudioTime.seconds(forHostTime:mach_absolute_time()-pending.began)*tempo
|
||||
if let range = pending.range,value >= range.upperBound { value = range.lowerBound+(value-range.upperBound).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound) }
|
||||
return min(duration,max(0,value))
|
||||
}
|
||||
guard playing,node.isPlaying,let file,let render = node.lastRenderTime,
|
||||
let current = node.playerTime(forNodeTime:render) else { return time }
|
||||
let now = mach_absolute_time()
|
||||
@@ -178,7 +195,7 @@ import CoreAudio
|
||||
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,loopPosition(anchor+Double(current.sampleTime)/file.processingFormat.sampleRate))) }
|
||||
node.pause();playing = false;timer?.invalidate();timer = nil
|
||||
cancelLoopHandoff();node.pause();playing = false;timer?.invalidate();timer = nil
|
||||
}
|
||||
func hotCue(_ index: Int,clear: Bool = false) {
|
||||
guard hotCues.indices.contains(index),canJog else { return }
|
||||
@@ -226,7 +243,7 @@ import CoreAudio
|
||||
}
|
||||
}
|
||||
}
|
||||
private func setFade(_ value: Double) { let next = min(1,max(0,value));if abs(outputOpacity-next) > 0.00001 { outputOpacity = next;node.volume = Float(next) } }
|
||||
private func setFade(_ value: Double) { let next = min(1,max(0,value));if abs(outputOpacity-next) > 0.00001 { outputOpacity = next;if loopHandoff == nil { node.volume = Float(next) } } }
|
||||
private var videoPlayer: AVPlayer?
|
||||
private var videoOutput: AVPlayerItemVideoOutput?
|
||||
private let videoContext = CIContext()
|
||||
@@ -249,7 +266,7 @@ import CoreAudio
|
||||
tick()
|
||||
let running = engine.isRunning,resume = playing,position = time,wasWaiting = waitingForBackground
|
||||
var previousMap = outputChannelMap()
|
||||
cancelPendingPlayback();generation = UUID();node.stop();engine.stop()
|
||||
cancelLoopHandoff();cancelPendingPlayback();generation = UUID();node.stop();engine.stop()
|
||||
do {
|
||||
var value = device
|
||||
let result = AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&value,UInt32(MemoryLayout.size(ofValue:value)))
|
||||
@@ -339,7 +356,15 @@ import CoreAudio
|
||||
private(set) var currentSongID: Int64?
|
||||
var onFinished: (() -> Void)?
|
||||
private let engine = AVAudioEngine()
|
||||
private let node = AVAudioPlayerNode()
|
||||
private let firstNode = AVAudioPlayerNode()
|
||||
private let secondNode = AVAudioPlayerNode()
|
||||
private lazy var node = firstNode
|
||||
private let transportMixer = AVAudioMixerNode()
|
||||
private let firstEnvelope = AVAudioUnitEffect(audioComponentDescription:DJTransportEnvelopeUnit.component)
|
||||
private let secondEnvelope = AVAudioUnitEffect(audioComponentDescription:DJTransportEnvelopeUnit.component)
|
||||
private func gainUnit(for node: AVAudioPlayerNode) -> DJTransportEnvelopeUnit? {
|
||||
(node === firstNode ? firstEnvelope : secondEnvelope).auAudioUnit as? DJTransportEnvelopeUnit
|
||||
}
|
||||
private let effect = AVAudioUnitTimePitch()
|
||||
private var file: AVAudioFile?
|
||||
private var anchor: Double = 0
|
||||
@@ -350,7 +375,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(graphicEQ);engine.attach(vocalReduction);graphicEQ.bypass = true
|
||||
engine.attach(firstNode);engine.attach(secondNode);engine.attach(transportMixer);engine.attach(firstEnvelope);engine.attach(secondEnvelope);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
|
||||
}
|
||||
@@ -370,7 +395,11 @@ import CoreAudio
|
||||
let loaded = try AVAudioFile(forReading:URL(fileURLWithPath:song.audio))
|
||||
let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data()
|
||||
guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") }
|
||||
engine.connect(node,to:effect,format:loaded.processingFormat)
|
||||
engine.connect(firstNode,to:firstEnvelope,format:loaded.processingFormat)
|
||||
engine.connect(secondNode,to:secondEnvelope,format:loaded.processingFormat)
|
||||
engine.connect(firstEnvelope,to:transportMixer,fromBus:0,toBus:0,format:loaded.processingFormat)
|
||||
engine.connect(secondEnvelope,to:transportMixer,fromBus:0,toBus:1,format:loaded.processingFormat)
|
||||
engine.connect(transportMixer,to:effect,format:loaded.processingFormat)
|
||||
engine.connect(effect,to:tone,format:loaded.processingFormat)
|
||||
engine.connect(tone,to:graphicEQ,format:loaded.processingFormat)
|
||||
engine.connect(graphicEQ,to:vocalReduction,format:loaded.processingFormat)
|
||||
@@ -436,7 +465,8 @@ import CoreAudio
|
||||
self.onPlaybackReady?();self.node.play()
|
||||
}
|
||||
}
|
||||
private func schedule(at seconds: Double, play: Bool,notifyPlayback: Bool = true) {
|
||||
private func schedule(at seconds: Double, play: Bool,notifyPlayback: Bool = true,cancelHandoff: Bool = true) {
|
||||
if cancelHandoff { cancelLoopHandoff() }
|
||||
guard let file else { return }
|
||||
cancelPendingPlayback();generation = UUID(); let token = generation
|
||||
node.stop();audioScheduleRestartCount += 1;if !fading { setFade(1) }
|
||||
@@ -446,9 +476,23 @@ import CoreAudio
|
||||
guard start < file.length else { playing = false; render(); return }
|
||||
nativeLoopActive = false;pendingLoopSwitch = nil;scheduledLoop = nil;scheduledRawLoop = nil
|
||||
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)
|
||||
let first = AVAudioFramePosition(range.lowerBound*rate)
|
||||
if start < first {
|
||||
node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(first-start),at:nil)
|
||||
} else {
|
||||
// The first partial pass must use the same seam treatment as later
|
||||
// passes; a raw file prefix would click at the first wrap only.
|
||||
let offset = min(Int(loopBuffer.frameLength)-1,max(0,Int(start-first)))
|
||||
let count = Int(loopBuffer.frameLength)-offset
|
||||
if let tail = AVAudioPCMBuffer(pcmFormat:loopBuffer.format,frameCapacity:AVAudioFrameCount(count)),
|
||||
let input = loopBuffer.floatChannelData,let output = tail.floatChannelData {
|
||||
tail.frameLength = AVAudioFrameCount(count)
|
||||
for channel in 0..<Int(loopBuffer.format.channelCount) {
|
||||
memcpy(output[channel],input[channel].advanced(by:offset),count*MemoryLayout<Float>.size)
|
||||
}
|
||||
node.scheduleBuffer(tail,at:nil)
|
||||
}
|
||||
}
|
||||
node.scheduleBuffer(loopBuffer,at:nil,options:.loops)
|
||||
nativeLoopActive = true;scheduledLoop = range;scheduledRawLoop = preparedRawLoop
|
||||
if play { if notifyPlayback { startNodePlayback() } else { node.play() } }
|
||||
@@ -479,11 +523,12 @@ import CoreAudio
|
||||
if playing { tick();slideAnchor = time;playing = false } else { if time >= duration { time = 0;slideAnchor = 0 };slideStart = ProcessInfo.processInfo.systemUptime;playing = true;startTimer() };return
|
||||
}
|
||||
guard file != nil else { return }
|
||||
if playing { cancelPendingPlayback();tick(); node.pause(); playing = false }
|
||||
if playing { cancelPendingPlayback();tick();cancelLoopHandoff(); node.pause(); playing = false }
|
||||
else if time >= duration { schedule(at:0,play:true); startTimer() }
|
||||
else { startNodePlayback(); playing = true; startTimer() }
|
||||
}
|
||||
func stop() {
|
||||
cancelLoopHandoff()
|
||||
endScrubbing()
|
||||
cancelPendingPlayback()
|
||||
graphicsGeneration = UUID();graphicsTask?.cancel();graphicsTask = nil;checkpointTask?.cancel();checkpointTask = nil;checkpoints = [:];lastGraphicsPacket = -1
|
||||
@@ -495,7 +540,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(graphicEQ);engine.disconnectNodeOutput(vocalReduction)
|
||||
engine.disconnectNodeOutput(firstNode);engine.disconnectNodeOutput(secondNode);engine.disconnectNodeOutput(transportMixer);engine.disconnectNodeOutput(firstEnvelope);engine.disconnectNodeOutput(secondEnvelope); 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
|
||||
@@ -504,6 +549,8 @@ import CoreAudio
|
||||
private var lastScrubAudio = -Double.infinity
|
||||
private var scrubTarget: Double?
|
||||
var scrubOutputLevel: Float { let levels = meter.snapshot();return max(levels.0,levels.1) }
|
||||
func beginAudioContinuityCheck() { meter.beginContinuityCheck() }
|
||||
var audioContinuity: (silentFrames: Int,largestStep: Float) { meter.continuity }
|
||||
/// Preview short grains through the deck's normal EQ, fader and output route.
|
||||
/// Keep transport paused so scrubbing cannot start Automix or change sync state.
|
||||
func scrub(to value: Double) {
|
||||
@@ -590,8 +637,8 @@ import CoreAudio
|
||||
let levels = meter.snapshot()
|
||||
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,loopPosition(anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate)))
|
||||
if playing,node.lastRenderTime != nil {
|
||||
let next = displayTime
|
||||
if abs(time-next) > 0.0001 { time = next }
|
||||
if !nativeLoopActive,loopEnabled,loopBuffer != nil { _ = activatePreparedLoop() }
|
||||
if !nativeLoopActive,loopEnabled,let loopIn,let loopOut,time >= loopOut { schedule(at:loopIn,play:true);return }
|
||||
@@ -706,6 +753,17 @@ enum AudioTone {
|
||||
final class AudioLevelMeter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var levels: (Float,Float) = (0,0)
|
||||
private var checkingContinuity = false
|
||||
private var lastSample: Float?
|
||||
private var silentFrames = 0, longestSilence = 0
|
||||
private var largestStep: Float = 0
|
||||
func beginContinuityCheck() {
|
||||
lock.lock();defer { lock.unlock() }
|
||||
checkingContinuity = true;lastSample = nil;silentFrames = 0;longestSilence = 0;largestStep = 0
|
||||
}
|
||||
var continuity: (silentFrames: Int,largestStep: Float) {
|
||||
lock.lock();defer { lock.unlock() };return (longestSilence,largestStep)
|
||||
}
|
||||
func consume(_ buffer: AVAudioPCMBuffer) {
|
||||
guard let data = buffer.floatChannelData else { return }
|
||||
let channels = Int(buffer.format.channelCount), frames = Int(buffer.frameLength)
|
||||
@@ -713,7 +771,17 @@ final class AudioLevelMeter: @unchecked Sendable {
|
||||
for i in 0..<frames {
|
||||
left = max(left,abs(data[0][i]));right = max(right,abs(data[min(1,channels-1)][i]))
|
||||
}
|
||||
lock.lock();levels = (left,right);lock.unlock()
|
||||
lock.lock();levels = (left,right)
|
||||
if checkingContinuity {
|
||||
for i in 0..<frames {
|
||||
let sample = data[0][i]
|
||||
if let lastSample { largestStep = max(largestStep,abs(sample-lastSample)) }
|
||||
lastSample = sample
|
||||
silentFrames = abs(sample) < 0.000001 ? silentFrames+1 : 0
|
||||
longestSilence = max(longestSilence,silentFrames)
|
||||
}
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
func snapshot() -> (Float,Float) { lock.lock();defer { lock.unlock() };return levels }
|
||||
func reset() { lock.lock();levels = (0,0);lock.unlock() }
|
||||
@@ -799,3 +867,68 @@ final class DJVocalReductionUnit: AUAudioUnit {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gain envelopes advance per audio frame; the main thread never performs the fade.
|
||||
private final class DJEnvelopeCommand: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var revision: UInt64 = 0,completed: UInt64 = 0
|
||||
private var target: Float = 0,initial: Float?
|
||||
private var seconds: Double = 0
|
||||
private var host: UInt64 = 0
|
||||
func set(_ target: Float,from initial: Float?,seconds: Double,host: UInt64) {
|
||||
lock.lock();defer { lock.unlock() }
|
||||
revision &+= 1;self.target = target;self.initial = initial;self.seconds = seconds;self.host = host
|
||||
}
|
||||
func read(after version: UInt64) -> (UInt64,Float,Float?,Double,UInt64)? {
|
||||
guard lock.try() else { return nil };defer { lock.unlock() }
|
||||
guard revision != version else { return nil };return (revision,target,initial,seconds,host)
|
||||
}
|
||||
func finish(_ version: UInt64) { guard lock.try() else { return };completed = version;lock.unlock() }
|
||||
var settled: Bool { lock.lock();defer { lock.unlock() };return completed == revision }
|
||||
}
|
||||
final class DJTransportEnvelopeUnit: AUAudioUnit {
|
||||
private var inputs: AUAudioUnitBusArray!,outputs: AUAudioUnitBusArray!
|
||||
private let command = DJEnvelopeCommand()
|
||||
static let component: AudioComponentDescription = {
|
||||
let value = AudioComponentDescription(componentType:kAudioUnitType_Effect,componentSubType:0x6B6A656E,componentManufacturer:0x4B4A4D58,componentFlags:0,componentFlagsMask:0)
|
||||
AUAudioUnit.registerSubclass(DJTransportEnvelopeUnit.self,as:value,name:"KJMix: Transport Envelope",version:1);return value
|
||||
}()
|
||||
override init(componentDescription: AudioComponentDescription,options: AudioComponentInstantiationOptions = []) throws {
|
||||
try super.init(componentDescription:componentDescription,options:options)
|
||||
let format = AVAudioFormat(standardFormatWithSampleRate:44100,channels:2)!
|
||||
inputs = AUAudioUnitBusArray(audioUnit:self,busType:.input,busses:[try AUAudioUnitBus(format:format)])
|
||||
outputs = AUAudioUnitBusArray(audioUnit:self,busType:.output,busses:[try AUAudioUnitBus(format:format)])
|
||||
maximumFramesToRender = 4096
|
||||
}
|
||||
override var inputBusses: AUAudioUnitBusArray { inputs }
|
||||
override var outputBusses: AUAudioUnitBusArray { outputs }
|
||||
func setGain(_ value: Float,from initial: Float? = nil,seconds: Double,at host: UInt64 = 0) { command.set(value,from:initial,seconds:seconds,host:host) }
|
||||
var settled: Bool { command.settled }
|
||||
override var internalRenderBlock: AUInternalRenderBlock {
|
||||
let command = self.command,rate = outputBusses[0].format.sampleRate
|
||||
var version: UInt64 = 0,startHost: UInt64 = 0,gain: Float = 0,target: Float = 0,step: Float = 0,remaining = 0
|
||||
let ticksPerFrame = Double(AVAudioTime.hostTime(forSeconds:1))/rate
|
||||
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 }
|
||||
if let next = command.read(after:version) {
|
||||
version = next.0;target = next.1;startHost = next.4
|
||||
if let initial = next.2 { gain = initial }
|
||||
remaining = Int(next.3*rate);step = remaining > 0 ? (target-gain)/Float(remaining) : 0
|
||||
if remaining == 0 { gain = target }
|
||||
}
|
||||
let buffers = UnsafeMutableAudioBufferListPointer(data)
|
||||
for frame in 0..<Int(frames) {
|
||||
if remaining > 0,(startHost == 0 || time.pointee.mHostTime+UInt64(Double(frame)*ticksPerFrame) >= startHost) { gain += step;remaining -= 1;if remaining == 0 { gain = target } }
|
||||
for buffer in buffers {
|
||||
guard let values = buffer.mData?.assumingMemoryBound(to:Float.self) else { continue }
|
||||
let channels = Int(buffer.mNumberChannels)
|
||||
for channel in 0..<channels { values[frame*channels+channel] *= gain }
|
||||
}
|
||||
}
|
||||
if remaining == 0 { command.finish(version) }
|
||||
return noErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user