From f9655503dfb83f8dd498dc1717b581a69bc0fdfc Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 6 Nov 2026 14:17:31 +0000 Subject: [PATCH] removed unwanted bar now with waveforms, major updates and enhancements --- KJamsClone/Player.swift | 47 +++++++++++-------------- KJamsCloneTests/KJamsCloneTests.swift | 50 +++++++++++++++++++++++++-- docs/DJ_WORKSTATION.md | 2 ++ 3 files changed, 69 insertions(+), 30 deletions(-) diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift index fba6c30..b887bbb 100644 --- a/KJamsClone/Player.swift +++ b/KJamsClone/Player.swift @@ -57,14 +57,17 @@ import CoreAudio 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 position = displayTime,rate = file.processingFormat.sampleRate + 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...size) } - let now = mach_absolute_time(),elapsed = render.isHostTimeValid && now >= render.hostTime ? min(0.1,AVAudioTime.seconds(forHostTime:now-render.hostTime))*tempo : 0 - anchor = position-Double(clock.sampleTime)/rate-elapsed;time = position + 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. @@ -77,33 +80,23 @@ import CoreAudio } } if next < file.length { - node.scheduleBuffer(bridge,at:nil,options:.interrupts,completionHandler:nil) + 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:nil,options:.interrupts,completionCallbackType:.dataPlayedBack,completionHandler:finished) + node.scheduleBuffer(bridge,at:when,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 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) + return nativeLoopActive } private func prepareNativeLoop() { loopBufferTask?.cancel() @@ -152,7 +145,7 @@ import CoreAudio self.node.scheduleBuffer(buffer,at:nil,options:[.loops,.interruptsAtLoop],completionHandler:nil) self.pendingLoopSwitch = (raw+remaining,start...end,self.preparedRawLoop!) } else if resume { - _ = self.activateNativeLoopWithoutRestart() + _ = self.activatePreparedLoop() } else { self.loopAudioRestartCount += 1 self.schedule(at:position < end ? position : start,play:false,notifyPlayback:false) @@ -600,7 +593,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,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) } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 5f54dd1..10b804c 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -3053,7 +3053,7 @@ extension KJamsCloneTests { for song in dj.songs { XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:song.audio),directory:dj.waveformDirectory)) } } - @MainActor func testAddingLoopToPlayingTrackDoesNotRestartAudioNode() async throws { + @MainActor func testAddingLoopReplacesFileCommandsOnceAndKeepsRepeating() 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")) @@ -3066,11 +3066,11 @@ extension KJamsCloneTests { 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") + XCTAssertEqual(player.audioScheduleRestartCount,restarts+1,"A live file command must be replaced once with the prepared loop") 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") + XCTAssertEqual(player.audioScheduleRestartCount,restarts+1,"Repeating and cancelling must not replace the node commands again") XCTAssertTrue(player.audioPlaybackStarted) } @@ -3099,4 +3099,48 @@ extension KJamsCloneTests { XCTAssertTrue(finished);XCTAssertFalse(player.playing) } + @MainActor func testLiveLoopRepeatsActualAudioRatherThanOnlyWrappingDisplay() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:2)) + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:132300));buffer.frameLength = 132300 + let data = try XCTUnwrap(buffer.floatChannelData) + for i in 0..<132300 { + let amplitude: Float = i < 30870 ? 0.1 : 0.7 + let value = amplitude*Float(sin(Double(i)*2 * .pi*440/44100)) + data[0][i] = value;data[1][i] = value + } + let url = root.appendingPathComponent("LoopSignal.wav") + do { let file = try AVAudioFile(forWriting:url,settings:format.settings);try file.write(from:buffer) } + let player = Player(waveformCacheDirectory:root.appendingPathComponent("Cache"),persistsMix:false) + defer { player.stop() } + player.autoFade = false;player.volume = 0.8 + try player.load(Song(id:1,title:"Loop signal",audio:url.path,cdg:nil,rating:0,plays:0)) + for _ in 0..<100 where player.displayTime < 0.1 { try await Task.sleep(nanoseconds:10_000_000) } + player.defineLoop(start:0.2,end:0.5) + for _ in 0..<100 where !player.nativeLoopActive { try await Task.sleep(nanoseconds:10_000_000) } + try await Task.sleep(nanoseconds:1_000_000_000) + for _ in 0..<10 { + XCTAssertGreaterThan(player.scrubOutputLevel,0.02,"Loop audio must remain audible") + XCTAssertLessThan(player.scrubOutputLevel,0.2,"The quiet loop must repeat; the loud audio after it must never render") + try await Task.sleep(nanoseconds:30_000_000) + } + let handoffs = player.audioScheduleRestartCount + player.defineLoop(start:0.2,end:1.1) + var extendedPeak: Float = 0 + for _ in 0..<50 { + try await Task.sleep(nanoseconds:30_000_000) + extendedPeak = max(extendedPeak,player.scrubOutputLevel) + } + XCTAssertGreaterThan(extendedPeak,0.4,"Extending the loop must render its newly included loud section") + player.defineLoop(start:0.2,end:0.5) + try await Task.sleep(nanoseconds:1_200_000_000) + XCTAssertLessThan(player.scrubOutputLevel,0.2,"Shortening must return to the quiet loop audio") + player.loopEnabled = false + try await Task.sleep(nanoseconds:800_000_000) + XCTAssertGreaterThan(player.scrubOutputLevel,0.4,"Loop Off must resume the actual file beyond the loop") + XCTAssertEqual(player.audioScheduleRestartCount,handoffs,"Resizing and cancellation must keep the node running") + } + } diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 021fe36..ca1d557 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -131,3 +131,5 @@ Both library toolbars share coloured, rounded action buttons and matching search 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. + +Live loop activation replaces the running file command once with the prepared loop while keeping the audio engine, effects and output route running. Repetition then runs on the audio clock; it does not seek on each UI update. Audio-output regression tests check the repeated signal, length changes and return to the file after Loop Off.