removed unwanted bar now with waveforms, major updates and enhancements

This commit is contained in:
Mark Gregory
2026-11-06 14:47:53 +00:00
parent f9655503df
commit c39c2bd757
6 changed files with 649 additions and 89 deletions
+185 -52
View File
@@ -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
}
}
}