From a33ebdc1d36b5ff1be733b2ba62b733b5c76a190 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 6 Nov 2026 14:01:54 +0000 Subject: [PATCH] removed unwanted bar now with waveforms, major updates and enhancements --- KJamsClone/Player.swift | 47 +++++++++++++++++++++------ KJamsCloneTests/KJamsCloneTests.swift | 46 ++++++++++++++++++++++++++ docs/DJ_WORKSTATION.md | 2 ++ 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift index 112a97c..fba6c30 100644 --- a/KJamsClone/Player.swift +++ b/KJamsClone/Player.swift @@ -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...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) } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 8a7ad98..5f54dd1 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -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) + } + } diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 967a4f8..021fe36 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -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.