removed unwanted bar now with waveforms, major updates and enhancements

This commit is contained in:
Mark Gregory
2026-11-06 14:01:54 +00:00
parent 50e7fb358a
commit a33ebdc1d3
3 changed files with 85 additions and 10 deletions
+37 -10
View File
@@ -68,19 +68,43 @@ import CoreAudio
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.
node.scheduleBuffer(bridge,at:nil,options:.interrupts,completionHandler:nil)
let next = min(file.length,AVAudioFramePosition(position*rate)+AVAudioFramePosition(count))
if next < file.length {
node.scheduleSegment(file,startingFrame:next,frameCount:AVAudioFrameCount(min(file.length-next,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [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?() }
}
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?() }
}
}
if next < file.length {
node.scheduleBuffer(bridge,at:nil,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:nil,options:.interrupts,completionCallbackType:.dataPlayedBack,completionHandler:finished)
}
startTimer();self.render();return true
}
private func activateNativeLoopWithoutRestart() -> Bool {
guard playing,let file,let range = preparedLoop,let raw = preparedRawLoop,let buffer = loopBuffer,
let renderTime = node.lastRenderTime,let clock = node.playerTime(forNodeTime:renderTime),
let source = buffer.floatChannelData,!raw.format.isInterleaved else { return false }
let current = displayTime
guard current >= range.lowerBound else { return false } // Keep playing toward a future loop.
let position = current < range.upperBound ? current : range.lowerBound,rate = file.processingFormat.sampleRate
let offset = max(0,min(Int(raw.frameLength)-1,Int((position-range.lowerBound)*rate)))
let count = 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) }
let now = mach_absolute_time(),elapsed = renderTime.isHostTimeValid && now >= renderTime.hostTime ? min(0.1,AVAudioTime.seconds(forHostTime:now-renderTime.hostTime))*tempo : 0
anchor = position-Double(clock.sampleTime)/rate-elapsed;time = position
generation = UUID();pendingLoopSwitch = nil;scheduledLoop = range;scheduledRawLoop = raw;nativeLoopActive = true
// Replace the remaining file command, not the running player. The bridge
// continues the current pass; the prepared buffer then repeats in audio time.
node.scheduleBuffer(bridge,at:nil,options:.interrupts,completionHandler:nil)
node.scheduleBuffer(buffer,at:nil,options:.loops,completionHandler:nil)
render();return true
}
private func prepareNativeLoop() {
loopBufferTask?.cancel()
guard loopEnabled,let start = loopIn,let end = loopOut,end > start,let url = audioURL else { return }
@@ -127,9 +151,11 @@ import CoreAudio
let remaining = max(0,activeRange.upperBound-self.loopPosition(raw))
self.node.scheduleBuffer(buffer,at:nil,options:[.loops,.interruptsAtLoop],completionHandler:nil)
self.pendingLoopSwitch = (raw+remaining,start...end,self.preparedRawLoop!)
} else if resume {
_ = self.activateNativeLoopWithoutRestart()
} else {
self.loopAudioRestartCount += 1
self.schedule(at:position < end ? position : start,play:resume,notifyPlayback:false)
self.schedule(at:position < end ? position : start,play:false,notifyPlayback:false)
}
} catch { }
}
@@ -425,7 +451,7 @@ import CoreAudio
let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate)))
anchor = Double(start)/rate; time = anchor
guard start < file.length else { playing = false; render(); return }
nativeLoopActive = false;pendingLoopSwitch = nil;scheduledLoop = nil
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))
@@ -574,6 +600,7 @@ import CoreAudio
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 abs(time-next) > 0.0001 { time = next }
if !nativeLoopActive,loopEnabled,loopBuffer != nil { _ = activateNativeLoopWithoutRestart() }
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) }
+46
View File
@@ -3053,4 +3053,50 @@ extension KJamsCloneTests {
for song in dj.songs { XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:song.audio),directory:dj.waveformDirectory)) }
}
@MainActor func testAddingLoopToPlayingTrackDoesNotRestartAudioNode() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
let player = Player(waveformCacheDirectory:root,persistsMix:false)
defer { player.stop() }
player.autoFade = false
try player.load(Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0))
for _ in 0..<100 where player.displayTime < 0.05 { try await Task.sleep(nanoseconds:10_000_000) }
let start = player.displayTime,restarts = player.audioScheduleRestartCount
player.defineLoop(start:start,end:start+0.3)
for _ in 0..<100 where !player.nativeLoopActive { try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertTrue(player.nativeLoopActive);XCTAssertTrue(player.audioPlaybackStarted)
XCTAssertEqual(player.audioScheduleRestartCount,restarts,"Adding a live loop must not stop the audio node")
try await Task.sleep(nanoseconds:400_000_000)
XCTAssertGreaterThanOrEqual(player.displayTime,start);XCTAssertLessThan(player.displayTime,start+0.301)
player.loopEnabled = false
XCTAssertEqual(player.audioScheduleRestartCount,restarts,"Cancelling the loop must also leave the audio node running")
XCTAssertTrue(player.audioPlaybackStarted)
}
@MainActor func testLoopOffNearTrackEndStillFinishesWithoutRestarting() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
let player = Player(waveformCacheDirectory:root,persistsMix:false)
defer { player.stop() }
player.autoFade = false
try player.load(Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0),startPlaying:false)
player.defineLoop(start:player.duration-0.2,end:player.duration)
for _ in 0..<100 where !player.nativeLoopActive { try await Task.sleep(nanoseconds:10_000_000) }
player.seek(player.duration-0.15);player.toggle()
for _ in 0..<500 {
let remaining = player.duration-player.displayTime
if remaining > 0,remaining < 0.015 { break }
try await Task.sleep(nanoseconds:1_000_000)
}
XCTAssertLessThan(player.duration-player.displayTime,0.02)
var finished = false;player.onFinished = { finished = true }
let restarts = player.audioScheduleRestartCount
player.loopEnabled = false
XCTAssertEqual(player.audioScheduleRestartCount,restarts)
for _ in 0..<200 where !finished { try await Task.sleep(nanoseconds:5_000_000) }
XCTAssertTrue(finished);XCTAssertFalse(player.playing)
}
}
+2
View File
@@ -129,3 +129,5 @@ Right-click selected DJ tracks to remove their library entries. DJ Settings →
Both library toolbars share coloured, rounded action buttons and matching search fields. DJ Library groups its heading/selection/actions above filters/search/page navigation: cyan Import, green Add to Queue, purple Playlists and amber Favourites. Filter/library changes prune stale selections and clamp pagination to the current results.
Loop Off continues from a short unmodified PCM bridge into the remainder of the source file without stopping the running audio node. This avoids the stop/reschedule gap while keeping the audio clock and displayed position continuous.
Activating a loop during playback now replaces the remaining file command with a decoded continuation and the repeat buffer, without stopping the audio node. Future loops are primed until playback reaches their range. Loop Off also preserves the normal end-of-track callback when only the continuation buffer remains.