removed unwanted bar now with waveforms, major updates and enhancements

This commit is contained in:
Mark Gregory
2026-11-06 14:17:31 +00:00
parent a33ebdc1d3
commit f9655503df
3 changed files with 69 additions and 30 deletions
+20 -27
View File
@@ -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..<Int(raw.format.channelCount) { memcpy(output[channel],source[channel].advanced(by:offset),count*MemoryLayout<Float>.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..<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 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) }
+47 -3
View File
@@ -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")
}
}
+2
View File
@@ -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.