945 lines
58 KiB
Swift
945 lines
58 KiB
Swift
import SwiftUI
|
|
import AVFoundation
|
|
import CryptoKit
|
|
import CoreImage
|
|
import AudioToolbox
|
|
import CoreAudio
|
|
|
|
/// CDG follows consumed source frames, so tempo adjustment preserves graphics timing.
|
|
@MainActor final class Player: ObservableObject {
|
|
@Published var image: CGImage?
|
|
var onWillStartPlayback: (() -> Double)?
|
|
var onPlaybackReady: (() -> Void)?
|
|
private var pendingPlayback: Task<Void,Never>?
|
|
@Published private(set) var waitingForBackground = false
|
|
var audioPlaybackStarted: Bool { node.isPlaying }
|
|
var onGlobalMixChanged: ((GlobalMix) -> Void)?
|
|
var jogSecondsPerTurn: Double = 2
|
|
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? { 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
|
|
if nativeLoopActive,!exitNativeLoopWithoutRestart() { let position = displayTime;nativeLoopActive = false;schedule(at:position,play:playing) }
|
|
loopBuffer = nil;preparedRawLoop = nil
|
|
}
|
|
} }
|
|
private var loopBufferTask: Task<Void,Never>?
|
|
private var loopBuffer: AVAudioPCMBuffer?
|
|
private var preparedRawLoop: AVAudioPCMBuffer?
|
|
private var scheduledRawLoop: AVAudioPCMBuffer?
|
|
private var preparedLoop: ClosedRange<Double>?
|
|
private var scheduledLoop: ClosedRange<Double>?
|
|
private var pendingLoopSwitch: (at: Double,range: ClosedRange<Double>,raw: AVAudioPCMBuffer)?
|
|
private(set) var loopAudioRestartCount = 0
|
|
private(set) var audioScheduleRestartCount = 0
|
|
private var rawAudioPosition: Double {
|
|
guard let file,let render = node.lastRenderTime,let current = node.playerTime(forNodeTime:render) else { return time }
|
|
return anchor+Double(current.sampleTime)/file.processingFormat.sampleRate
|
|
}
|
|
private(set) var nativeLoopActive = false
|
|
private func loopPosition(_ position: Double) -> Double {
|
|
guard nativeLoopActive else { return position }
|
|
if let pendingLoopSwitch,position >= pendingLoopSwitch.at {
|
|
let range = pendingLoopSwitch.range
|
|
return range.lowerBound+(position-pendingLoopSwitch.at).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound)
|
|
}
|
|
guard let range = scheduledLoop,position >= range.upperBound else { return position }
|
|
return range.lowerBound+(position-range.upperBound).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound)
|
|
}
|
|
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
|
|
}
|
|
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)
|
|
handoffLoopAudio(at:handoff)
|
|
return nativeLoopActive
|
|
}
|
|
private func replaceLoopAtBoundary(_ buffer: AVAudioPCMBuffer) {
|
|
// Scheduling must return immediately; awaiting a looping buffer never completes.
|
|
node.scheduleBuffer(buffer,at:nil,options:[.loops,.interruptsAtLoop],completionHandler:nil)
|
|
}
|
|
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)*8 <= 64*1024*1024 else { return }
|
|
let songID = currentSongID
|
|
loopBufferTask = Task { @MainActor [weak self] in
|
|
let worker = Task.detached(priority:.userInitiated) { () throws -> (AVAudioPCMBuffer,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()
|
|
guard let raw = AVAudioPCMBuffer(pcmFormat:buffer.format,frameCapacity:buffer.frameLength) else { throw LibraryError.message("Cannot prepare loop continuation") }
|
|
raw.frameLength = buffer.frameLength
|
|
let sourceBuffers = UnsafeMutableAudioBufferListPointer(buffer.mutableAudioBufferList),rawBuffers = UnsafeMutableAudioBufferListPointer(raw.mutableAudioBufferList)
|
|
for i in sourceBuffers.indices { if let source = sourceBuffers[i].mData,let target = rawBuffers[i].mData { memcpy(target,source,Int(sourceBuffers[i].mDataByteSize)) } }
|
|
// Meet the first sample at the loop seam without changing its beat length.
|
|
if let data = buffer.floatChannelData {
|
|
let frames = Int(buffer.frameLength),stride = Int(buffer.stride)
|
|
let blend = min(frames/4,Int(source.processingFormat.sampleRate*0.003))
|
|
if blend > 1 {
|
|
for channel in 0..<Int(source.processingFormat.channelCount) {
|
|
let samples = data[channel],head = samples[0]
|
|
for i in 0..<blend {
|
|
let mix = Float(i)/Float(blend-1),index = (frames-blend+i)*stride
|
|
samples[index] = samples[index]*(1-mix)+head*mix
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return (buffer,raw)
|
|
}
|
|
do {
|
|
let (buffer,raw) = 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.preparedRawLoop = raw;self.preparedLoop = start...end
|
|
if self.nativeLoopActive,resume {
|
|
let raw = self.rawAudioPosition
|
|
let activeRange: ClosedRange<Double>
|
|
if let pending = self.pendingLoopSwitch,raw >= pending.at { activeRange = pending.range }
|
|
else { activeRange = self.scheduledLoop ?? start...end }
|
|
let remaining = max(0,activeRange.upperBound-self.loopPosition(raw))
|
|
self.replaceLoopAtBoundary(buffer)
|
|
self.pendingLoopSwitch = (raw+remaining,start...end,self.preparedRawLoop!)
|
|
} else if resume {
|
|
_ = self.activatePreparedLoop()
|
|
} else {
|
|
self.loopAudioRestartCount += 1
|
|
self.schedule(at:position < end ? position : start,play:false,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.
|
|
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()
|
|
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,loopPosition(position)))
|
|
}
|
|
func stopDeck() {
|
|
guard canJog else { return }
|
|
pauseDeck();loopEnabled = false;seek(0)
|
|
}
|
|
func setCue(at seconds: Double? = nil) {
|
|
guard canJog else { return }
|
|
if let seconds { guard seconds.isFinite else { return };cuePosition = min(duration,max(0,seconds)) }
|
|
else { tick();cuePosition = time }
|
|
}
|
|
func returnToCue() { guard canJog else { return };pauseDeck();seek(cuePosition) }
|
|
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,loopPosition(anchor+Double(current.sampleTime)/file.processingFormat.sampleRate))) }
|
|
cancelLoopHandoff();node.pause();playing = false;timer?.invalidate();timer = nil
|
|
}
|
|
func hotCue(_ index: Int,clear: Bool = false) {
|
|
guard hotCues.indices.contains(index),canJog else { return }
|
|
if clear { hotCues[index] = nil }
|
|
else if let value = hotCues[index] { seek(value) }
|
|
else { hotCues[index] = time }
|
|
}
|
|
func markLoopIn() { guard canJog else { return };loopIn = time;loopOut = nil;loopEnabled = false }
|
|
func markLoopOut() {
|
|
guard let loopIn,time-loopIn >= 0.1 else { return };loopOut = time;loopEnabled = true
|
|
}
|
|
func defineLoop(start: Double,end: Double) {
|
|
guard canJog,start.isFinite,end.isFinite,start >= 0,end <= duration,end-start >= 0.1-1e-9 else { return }
|
|
loopIn = start;loopOut = end;loopEnabled = true
|
|
}
|
|
func clearLoop() { loopEnabled = false;loopIn = nil;loopOut = nil }
|
|
func clearHotCues() { guard canJog else { return };hotCues = [nil,nil,nil,nil] }
|
|
func clearCue() { guard canJog else { return };cuePosition = 0 }
|
|
func clearDeckMarkers() { clearHotCues();clearCue();clearLoop() }
|
|
|
|
func jog(_ seconds: Double) { guard seconds.isFinite,canJog else { return };seek(time+seconds) }
|
|
private var graphicsGeneration = UUID()
|
|
private var graphicsTask: Task<Void,Never>?
|
|
private var checkpointTask: Task<Void,Never>?
|
|
private var checkpoints = [Int:CDGDecoder]()
|
|
private var lastGraphicsPacket = -1
|
|
@Published private(set) var outputOpacity: Double = 1
|
|
@Published private(set) var fading = false
|
|
var fadeSeconds: Double = 3
|
|
var autoFade = true
|
|
private var fadeTask: Task<Void,Never>?
|
|
private var slideImage: CGImage?
|
|
private var slideStart = ProcessInfo.processInfo.systemUptime
|
|
private var slideAnchor: Double = 0
|
|
func showScreen(_ image: CGImage,seconds: Double,title: String,songID: Int64) {
|
|
stop();slideImage = image;self.image = image;duration = max(3,seconds);self.title = title;currentSongID = songID
|
|
slideAnchor = 0;slideStart = ProcessInfo.processInfo.systemUptime;playing = true;startTimer()
|
|
}
|
|
func fadeOutAndStop() {
|
|
guard duration > 0,!fading else { return };fading = true
|
|
let token = generation,start = ProcessInfo.processInfo.systemUptime,seconds = max(0.05,fadeSeconds),initial = outputOpacity
|
|
fadeTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
guard let self,self.generation == token else { return }
|
|
let fraction = min(1,(ProcessInfo.processInfo.systemUptime-start)/seconds)
|
|
self.setFade(initial*(1-fraction))
|
|
if fraction >= 1 { self.stop();return }
|
|
try? await Task.sleep(nanoseconds:16_666_667)
|
|
}
|
|
}
|
|
}
|
|
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()
|
|
func useWaveformCache(at directory: URL) {
|
|
waveformTask?.cancel();waveformGeneration = UUID();waveformCacheDirectory = directory
|
|
}
|
|
private var mixURL: URL { waveformCacheDirectory.deletingLastPathComponent().appendingPathComponent("GlobalMixer.json") }
|
|
private var restoringMix = true
|
|
private var persistsMix = true
|
|
func routeOutput(_ device: AudioDeviceID,firstChannel: Int = 0) throws {
|
|
guard let unit = engine.outputNode.audioUnit else { throw LibraryError.message("Audio output unavailable") }
|
|
var map = try DJAudioRouting.channelMap(channels:DJAudioDevice.channelCount(device),first:firstChannel)
|
|
var previousDevice = AudioDeviceID(0),bytes = UInt32(MemoryLayout<AudioDeviceID>.size)
|
|
_ = AudioUnitGetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&previousDevice,&bytes)
|
|
func setMap(_ values: inout [Int32]) -> OSStatus {
|
|
values.withUnsafeMutableBytes { AudioUnitSetProperty(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,$0.baseAddress!,UInt32($0.count)) }
|
|
}
|
|
// Channel-only changes can be applied by HAL without interrupting the player.
|
|
if previousDevice == device,setMap(&map) == noErr { return }
|
|
tick()
|
|
let running = engine.isRunning,resume = playing,position = time,wasWaiting = waitingForBackground
|
|
var previousMap = outputChannelMap()
|
|
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)))
|
|
guard result == noErr else { throw LibraryError.message("Audio routing failed (\(result))") }
|
|
let mapResult = setMap(&map)
|
|
guard mapResult == noErr else { throw LibraryError.message("Audio channel routing failed (\(mapResult))") }
|
|
if running || resume { try engine.start() }
|
|
if file != nil { schedule(at:position,play:resume,notifyPlayback:wasWaiting) }
|
|
} catch {
|
|
if previousDevice != 0 {
|
|
_ = AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&previousDevice,UInt32(MemoryLayout<AudioDeviceID>.size))
|
|
if !previousMap.isEmpty { _ = setMap(&previousMap) }
|
|
}
|
|
if running || resume { try? engine.start() }
|
|
if file != nil { schedule(at:position,play:resume && engine.isRunning,notifyPlayback:wasWaiting) }
|
|
throw error
|
|
}
|
|
}
|
|
func outputChannelMap() -> [Int32] {
|
|
guard let unit = engine.outputNode.audioUnit else { return [] }
|
|
var size: UInt32 = 0
|
|
guard AudioUnitGetPropertyInfo(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,&size,nil) == noErr,size > 0 else { return [] }
|
|
var values = [Int32](repeating:-1,count:Int(size)/4)
|
|
let result = values.withUnsafeMutableBytes { AudioUnitGetProperty(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,$0.baseAddress!,&size) }
|
|
return result == noErr ? values : []
|
|
}
|
|
struct GlobalMix: Codable, Equatable {
|
|
var volume: Double; var low: Double; var mid: Double; var high: Double; var filter: Double; var enabled: Bool
|
|
}
|
|
var globalMix: GlobalMix { .init(volume:volume,low:lowGain,mid:midGain,high:highGain,filter:filter,enabled:toneEnabled) }
|
|
private func saveMix() {
|
|
guard persistsMix,!restoringMix, let data = try? JSONEncoder().encode(globalMix) else { return }
|
|
try? FileManager.default.createDirectory(at:mixURL.deletingLastPathComponent(),withIntermediateDirectories:true)
|
|
try? data.write(to:mixURL,options:.atomic)
|
|
onGlobalMixChanged?(globalMix)
|
|
}
|
|
@Published private(set) var waveform = [Float]() { didSet { waveformRevision = UUID() } }
|
|
private(set) var waveformRevision = UUID()
|
|
@Published private(set) var waveformBands = [WaveformBands]()
|
|
@Published var inputGain: Double = 0 { didSet { tone.globalGain = Float(min(12,max(-12,inputGain))) } }
|
|
@Published var lowGain: Double = 0 { didSet { updateTone() } }
|
|
@Published var midGain: Double = 0 { didSet { updateTone() } }
|
|
@Published var highGain: Double = 0 { didSet { updateTone() } }
|
|
@Published var filter: Double = 0 { didSet { updateTone() } }
|
|
@Published var toneEnabled = true { didSet { updateTone() } }
|
|
@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
|
|
@Published private(set) var waveformUnavailable = false
|
|
@Published private(set) var waveformFromCache = false
|
|
@Published private(set) var waveformSaved = false
|
|
private var waveformCacheDirectory: URL
|
|
private var audioURL: URL?
|
|
var sourceAudioURL: URL? { audioURL }
|
|
private var waveformTask: Task<Void, Never>?
|
|
private var waveformGeneration = UUID()
|
|
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);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)?
|
|
private let engine = AVAudioEngine()
|
|
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
|
|
private var generation = UUID()
|
|
private var cdg = Data()
|
|
private var decoder = CDGDecoder()
|
|
private var timer: Timer?
|
|
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(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
|
|
}
|
|
restoringMix = false;updateTone()
|
|
}
|
|
func refreshCachedWaveform() { if let audioURL { analyseWaveform(audioURL) } }
|
|
func reanalyseWaveform() { if let audioURL { analyseWaveform(audioURL,force:true) } }
|
|
func resetTone() { lowGain = 0; midGain = 0; highGain = 0; filter = 0; toneEnabled = true }
|
|
private func updateTone() {
|
|
AudioTone.configure(tone,sampleRate:file?.processingFormat.sampleRate ?? 44100,low:lowGain,mid:midGain,high:highGain,filter:filter,enabled:toneEnabled)
|
|
saveMix()
|
|
}
|
|
func load(_ song: Song, preference: SongPreference = .init(), startPlaying: Bool = true) throws {
|
|
stop()
|
|
do {
|
|
guard MediaByteCopy.nonempty(URL(fileURLWithPath:song.audio)) else { throw LibraryError.message("Audio file is empty or unavailable: \(song.title). Reimport the original media to repair the library copy.") }
|
|
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(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)
|
|
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();applyGraphicEQ(graphicGains,enabled:graphicEnabled)
|
|
let meter = self.meter
|
|
engine.mainMixerNode.installTap(onBus:0,bufferSize:1024,format:nil) { buffer, _ in meter.consume(buffer) }
|
|
metering = true
|
|
engine.mainMixerNode.outputVolume = Float(volume)
|
|
engine.prepare(); try engine.start()
|
|
schedule(at:0,play:startPlaying); startTimer(); tick()
|
|
if ["mp4","m4v","mov"].contains(URL(fileURLWithPath:song.audio).pathExtension.lowercased()) {
|
|
let item = AVPlayerItem(url:URL(fileURLWithPath:song.audio))
|
|
let output = AVPlayerItemVideoOutput(pixelBufferAttributes:[kCVPixelBufferPixelFormatTypeKey as String:kCVPixelFormatType_32BGRA])
|
|
item.add(output);videoOutput = output;videoPlayer = AVPlayer(playerItem:item);videoPlayer?.isMuted = true
|
|
}
|
|
audioURL = URL(fileURLWithPath:song.audio)
|
|
analyseWaveform(audioURL!)
|
|
} catch { stop(); throw error }
|
|
}
|
|
private func analyseWaveform(_ url: URL, force: Bool = false) {
|
|
waveformTask?.cancel()
|
|
let token = UUID();waveformGeneration = token;waveformLoading = true;waveformUnavailable = false
|
|
let directory = waveformCacheDirectory
|
|
waveformTask = Task.detached(priority:.utility) { [weak self] in
|
|
let result: Result<(AudioWaveform.Analysis,Bool,Bool),Error>
|
|
do {
|
|
result = .success(try await WaveformWorker.run { cancelled in
|
|
if !force,let analysis = try? WaveformCache.load(audio:url,directory:directory) {
|
|
return (analysis,true,true)
|
|
}
|
|
let analysis = try AudioWaveform.analyse(url:url,cancelled:cancelled)
|
|
if cancelled() { throw CancellationError() }
|
|
let saved = (try? WaveformCache.save(analysis,audio:url,directory:directory)) != nil
|
|
return (analysis,false,saved)
|
|
})
|
|
} catch { result = .failure(error) }
|
|
guard !Task.isCancelled else { return }
|
|
await MainActor.run { [weak self] in
|
|
guard let self,self.waveformGeneration == token else { return }
|
|
self.waveformLoading = false
|
|
switch result {
|
|
case .success(let (analysis,cached,saved)):
|
|
self.waveformFromCache = cached;self.waveformSaved = saved
|
|
self.waveform = analysis.levels;self.waveformBands = analysis.bands
|
|
case .failure:self.waveformUnavailable = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
private func cancelPendingPlayback() { pendingPlayback?.cancel();pendingPlayback = nil;waitingForBackground = false }
|
|
private func startNodePlayback() {
|
|
cancelPendingPlayback()
|
|
let delay = onWillStartPlayback?() ?? 0
|
|
guard delay.isFinite,delay > 0 else { onPlaybackReady?();node.play();return }
|
|
waitingForBackground = true;let token = generation
|
|
pendingPlayback = Task { @MainActor [weak self] in
|
|
do { try await Task.sleep(nanoseconds:UInt64(min(15,delay)*1_000_000_000)+20_000_000) } catch { return }
|
|
guard let self,self.generation == token,self.playing else { return }
|
|
self.waitingForBackground = false;self.pendingPlayback = nil
|
|
self.onPlaybackReady?();self.node.play()
|
|
}
|
|
}
|
|
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) }
|
|
let rate = file.processingFormat.sampleRate
|
|
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;pendingLoopSwitch = nil;scheduledLoop = nil;scheduledRawLoop = nil
|
|
if loopEnabled,let range = preparedLoop,range.lowerBound == loopIn,range.upperBound == loopOut,let loopBuffer,seconds < range.upperBound {
|
|
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() } }
|
|
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 }
|
|
if self.fading { return }
|
|
if self.loopEnabled,let start = self.loopIn { self.schedule(at:start,play:true) }
|
|
else if self.repeating { self.schedule(at:0,play:true) }
|
|
else {
|
|
self.playing = false; self.time = self.duration; self.render()
|
|
self.timer?.invalidate(); self.timer = nil; self.onFinished?()
|
|
}
|
|
}
|
|
}
|
|
if play { if notifyPlayback { startNodePlayback() } else { node.play() } }; playing = play; render()
|
|
}
|
|
private func startTimer() {
|
|
timer?.invalidate()
|
|
timer = Timer(timeInterval:1.0/30,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } }
|
|
if let timer { RunLoop.main.add(timer,forMode:.common) }
|
|
}
|
|
func toggle() {
|
|
endScrubbing()
|
|
if slideImage != nil {
|
|
if playing { tick();slideAnchor = time;playing = false } else { if time >= duration { time = 0;slideAnchor = 0 };slideStart = ProcessInfo.processInfo.systemUptime;playing = true;startTimer() };return
|
|
}
|
|
guard file != nil else { return }
|
|
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
|
|
cuePosition = 0;hotCues = [nil,nil,nil,nil];clearLoop()
|
|
fadeTask?.cancel();fadeTask = nil;fading = false;setFade(1);slideImage = nil
|
|
videoPlayer?.pause();videoPlayer = nil;videoOutput = nil
|
|
waveformTask?.cancel(); waveformTask = nil; waveformGeneration = UUID()
|
|
waveform = [];waveformBands = [];waveformLoading = false;waveformUnavailable = false;waveformFromCache = false;waveformSaved = false;audioURL = nil
|
|
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(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
|
|
private var scrubPreviewTask: Task<Void,Never>?
|
|
private var scrubPreviewActive = false
|
|
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) {
|
|
guard audibleScrubbing,let file,value.isFinite else { seek(value);return }
|
|
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.06 { return }
|
|
lastScrubAudio = now
|
|
if playing { seek(target);return }
|
|
scrubPreviewTask?.cancel()
|
|
seek(target)
|
|
guard time < duration,engine.isRunning else { return }
|
|
generation = UUID();let token = generation
|
|
node.stop()
|
|
let rate = file.processingFormat.sampleRate
|
|
let start = AVAudioFramePosition(time*rate)
|
|
let count = AVAudioFrameCount(min(file.length-start,Int64(rate*0.25)))
|
|
guard count > 0 else { return }
|
|
node.scheduleSegment(file,startingFrame:start,frameCount:count,at:nil)
|
|
scrubPreviewActive = true;node.play()
|
|
scrubPreviewTask = Task { @MainActor [weak self] in
|
|
do { try await Task.sleep(nanoseconds:250_000_000) } catch { return }
|
|
guard let self,self.generation == token else { return }
|
|
self.endScrubbing()
|
|
}
|
|
}
|
|
func endScrubbing() {
|
|
scrubPreviewTask?.cancel();scrubPreviewTask = nil
|
|
let target = scrubTarget;scrubTarget = nil;lastScrubAudio = -Double.infinity
|
|
let previewed = scrubPreviewActive;scrubPreviewActive = false
|
|
if playing,let target { seek(target) }
|
|
else if previewed { schedule(at:target ?? time,play:false) }
|
|
}
|
|
func seek(_ value: Double) {
|
|
guard value.isFinite else { return }
|
|
invalidateGraphics()
|
|
if loopEnabled,let loopIn,let loopOut,(value < loopIn || value > loopOut) { loopEnabled = false }
|
|
if fading { fadeTask?.cancel();fadeTask = nil;fading = false;setFade(1) }
|
|
if slideImage != nil,value.isFinite { time = min(duration,max(0,value));slideAnchor = time;slideStart = ProcessInfo.processInfo.systemUptime;return }
|
|
guard file != nil, value.isFinite else { return }
|
|
schedule(at:min(max(value,0),duration),play:playing); startTimer()
|
|
}
|
|
private func render() {
|
|
if let slideImage { image = slideImage;return }
|
|
if let videoPlayer, let videoOutput {
|
|
let target = max(0,time+graphicsOffset)
|
|
if abs(videoPlayer.currentTime().seconds-target) > 0.08 {
|
|
videoPlayer.seek(to:CMTime(seconds:target,preferredTimescale:600),toleranceBefore:.zero,toleranceAfter:.zero)
|
|
}
|
|
if playing { videoPlayer.rate = Float(tempo) } else { videoPlayer.pause() }
|
|
if let pixels = videoOutput.copyPixelBuffer(forItemTime:videoPlayer.currentTime(),itemTimeForDisplay:nil) {
|
|
let frame = CIImage(cvPixelBuffer:pixels);image = videoContext.createCGImage(frame,from:frame.extent)
|
|
}
|
|
} else {
|
|
guard !cdg.isEmpty else { if image != nil { image = nil };return }
|
|
let seconds = max(0,time+graphicsOffset),target = min(cdg.count/24,Int(seconds*300))
|
|
guard target != lastGraphicsPacket || image == nil else { return }
|
|
guard graphicsTask == nil else { return }
|
|
if target < decoder.packetIndex || target-decoder.packetIndex > 900 {
|
|
var start = decoder
|
|
if target < decoder.packetIndex { start = checkpoints.keys.filter{$0 <= target}.max().flatMap{checkpoints[$0]} ?? CDGDecoder() }
|
|
else if let key = checkpoints.keys.filter({$0 <= target && $0 > decoder.packetIndex}).max(),let saved = checkpoints[key] { start = saved }
|
|
let initial = start,data = cdg,token = graphicsGeneration
|
|
graphicsTask = Task.detached(priority:.userInitiated) { [weak self] in
|
|
var decoded = initial;decoded.render(data,at:seconds,cancelled:{Task.isCancelled})
|
|
guard !Task.isCancelled else { return };let frame = decoded.image(),finished = decoded
|
|
await MainActor.run { [weak self] in
|
|
guard let self,self.graphicsGeneration == token else { return }
|
|
self.decoder = finished;self.lastGraphicsPacket = target;self.image = frame;self.graphicsTask = nil
|
|
}
|
|
}
|
|
} else { decoder.render(cdg,at:seconds);lastGraphicsPacket = target;image = decoder.image() }
|
|
}
|
|
}
|
|
private func tick() {
|
|
if slideImage != nil {
|
|
if playing { time = min(duration,slideAnchor+ProcessInfo.processInfo.systemUptime-slideStart) }
|
|
if playing,time >= duration,!fading {
|
|
if repeating { time = 0;slideAnchor = 0;slideStart = ProcessInfo.processInfo.systemUptime }
|
|
else { playing = false;timer?.invalidate();timer = nil;onFinished?() }
|
|
};return
|
|
}
|
|
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,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 }
|
|
}
|
|
if !fading { setFade(autoFade && fadeSeconds > 0 && duration > 0 ? min(1,max(0,(duration-time)/max(0.5,tempo)/fadeSeconds)) : 1) }
|
|
render()
|
|
}
|
|
private func invalidateGraphics() { graphicsGeneration = UUID();graphicsTask?.cancel();graphicsTask = nil;lastGraphicsPacket = -1 }
|
|
private func prepareGraphicsCheckpoints() {
|
|
guard !cdg.isEmpty else { return }
|
|
let data = cdg,interval = max(3000,(cdg.count/24)/180)
|
|
checkpointTask = Task.detached(priority:.utility) { [weak self] in
|
|
var decoder = CDGDecoder(),saved = [Int:CDGDecoder]()
|
|
for packet in stride(from:0,through:data.count/24,by:interval) {
|
|
guard !Task.isCancelled else { return };decoder.render(data,at:Double(packet)/300,cancelled:{Task.isCancelled});saved[decoder.packetIndex] = decoder
|
|
}
|
|
guard !Task.isCancelled else { return };let completed = saved
|
|
await MainActor.run { [weak self] in guard let self,self.cdg == data else { return };self.checkpoints = completed }
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/// Streams fixed-size PCM chunks into a bounded RMS envelope, independent of playback.
|
|
/// A separate AVAudioFile keeps analysis away from the player's scheduling/read position.
|
|
enum AudioWaveform {
|
|
struct Analysis: Codable, Sendable { var levels: [Float]; var bands: [WaveformBands] }
|
|
static func read(url: URL, bins: Int = 4096, cancelled: () -> Bool = { false }) throws -> [Float] {
|
|
try analyse(url:url,bins:bins,cancelled:cancelled).levels
|
|
}
|
|
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)
|
|
var counts = [Int](repeating: 0, count: bins)
|
|
var position: Int64 = 0
|
|
let channels = Int(file.processingFormat.channelCount)
|
|
let rate = file.processingFormat.sampleRate
|
|
let lowAlpha = exp(-2*Double.pi*250/rate), highAlpha = exp(-2*Double.pi*2500/rate)
|
|
var lowState = [Double](repeating:0,count:channels), highState = lowState
|
|
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 }
|
|
// 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 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] += total/Double(max(channels,1));counts[bin] += end-offset
|
|
lows[bin] += lowEnergy;mids[bin] += midEnergy;highs[bin] += highEnergy
|
|
offset = end
|
|
}
|
|
position += Int64(frames)
|
|
}
|
|
let rms = zip(energy, counts).map { $1 > 0 ? sqrt($0/Double($1)) : 0 }
|
|
let peak = rms.max() ?? 0
|
|
let levels = peak > 0 ? rms.map { Float(pow(min(1,$0/peak),0.7)) } : [Float](repeating:0,count:bins)
|
|
let bands = (0..<bins).map { i in WaveformBands(low:Float(sqrt(lows[i])),mid:Float(sqrt(mids[i])),high:Float(sqrt(highs[i]))) }
|
|
return .init(levels:levels,bands:bands)
|
|
}
|
|
}
|
|
|
|
struct WaveformBands: Codable, Sendable {
|
|
var low: Float
|
|
var mid: Float
|
|
var high: Float
|
|
}
|
|
/// Four real audio filters; the filter knob is low-pass left, bypass at centre, high-pass right.
|
|
enum AudioTone {
|
|
static func configure(_ eq: AVAudioUnitEQ, sampleRate: Double, low: Double, mid: Double, high: Double, filter: Double, enabled: Bool) {
|
|
eq.bypass = !enabled
|
|
let frequencies = [160.0,1000.0,6000.0]
|
|
let types: [AVAudioUnitEQFilterType] = [.lowShelf,.parametric,.highShelf]
|
|
for i in 0..<3 {
|
|
let band = eq.bands[i];band.filterType = types[i];band.frequency = Float(min(frequencies[i],sampleRate*0.4))
|
|
band.bandwidth = 1;band.gain = Float(min(12,max(-12,[low,mid,high][i])));band.bypass = false
|
|
}
|
|
let value = min(1,max(-1,filter))
|
|
let band = eq.bands[3];band.bypass = abs(value) < 0.01;band.bandwidth = 0.7
|
|
band.filterType = value < 0 ? .lowPass : .highPass
|
|
let frequency = value < 0 ? 20000*pow(0.004,-value) : 20*pow(400,value)
|
|
band.frequency = Float(min(sampleRate*0.45,max(20,frequency)))
|
|
}
|
|
}
|
|
/// The audio callback only updates a tiny locked snapshot; UI reads it on the existing clock.
|
|
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)
|
|
var left: Float = 0, right: Float = 0
|
|
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)
|
|
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() }
|
|
}
|
|
|
|
/// Persistent analysis cache: file path, size, modification time and analyser version form its key.
|
|
/// Invalid or obsolete entries are treated as misses, never as playback errors.
|
|
enum WaveformCache {
|
|
static func location(audio: URL,directory: URL) throws -> URL {
|
|
let values = try FileManager.default.attributesOfItem(atPath:audio.path)
|
|
let stamp = (values[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
|
|
let size = (values[.size] as? NSNumber)?.int64Value ?? 0
|
|
let signature = "rgb-rms-v2-4096|\(audio.standardizedFileURL.path)|\(size)|\(stamp)"
|
|
let key = SHA256.hash(data:Data(signature.utf8)).map { String(format:"%02x",$0) }.joined()
|
|
return directory.appendingPathComponent(key+".waveform")
|
|
}
|
|
static func load(audio: URL,directory: URL) throws -> AudioWaveform.Analysis {
|
|
let url = try location(audio:audio,directory:directory)
|
|
let data = try Data(contentsOf:url)
|
|
guard data.count < 4_000_000 else { throw LibraryError.message("Invalid waveform cache") }
|
|
let analysis = try PropertyListDecoder().decode(AudioWaveform.Analysis.self,from:data)
|
|
guard analysis.levels.count == 4096,analysis.bands.count == 4096,
|
|
analysis.levels.allSatisfy({ $0.isFinite && $0 >= 0 && $0 <= 1 }),
|
|
analysis.bands.allSatisfy({ $0.low.isFinite && $0.mid.isFinite && $0.high.isFinite && $0.low >= 0 && $0.mid >= 0 && $0.high >= 0 }) else { throw LibraryError.message("Invalid waveform cache") }
|
|
return analysis
|
|
}
|
|
static func save(_ analysis: AudioWaveform.Analysis,audio: URL,directory: URL) throws {
|
|
try ManagedMediaStorage.prepare(directory)
|
|
let encoder = PropertyListEncoder();encoder.outputFormat = .binary
|
|
try encoder.encode(analysis).write(to:location(audio:audio,directory:directory),options:.atomic)
|
|
}
|
|
}
|
|
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
}
|