diff --git a/KJamsClone/DJWorkspace.swift b/KJamsClone/DJWorkspace.swift index e916db4..92c62dd 100644 --- a/KJamsClone/DJWorkspace.swift +++ b/KJamsClone/DJWorkspace.swift @@ -649,10 +649,7 @@ struct StudioSettingsDialog: View { HStack { VStack(alignment:.leading,spacing:4) { Text("Settings").font(.title2.bold());Text(category).font(.callout).foregroundStyle(.secondary) } Spacer(minLength:24) - VStack(alignment:.leading,spacing:6) { - Text("Workspace").font(.caption).foregroundStyle(.secondary).fixedSize() - Picker("Workspace",selection:$scope) { Text("Karaoke Studio").tag("Karaoke");Text("DJ Studio").tag("DJ") }.labelsHidden().pickerStyle(.segmented).frame(width:340) - }.fixedSize(horizontal:true,vertical:true) + Picker("Studio",selection:$scope) { Text("Karaoke Studio").tag("Karaoke");Text("DJ Studio").tag("DJ") }.labelsHidden().pickerStyle(.segmented).frame(width:340).fixedSize(horizontal:true,vertical:true) }.padding(.horizontal,24).padding(.top,18).padding(.bottom,10) HStack(spacing:8) { ForEach(categories,id:\.self) { name in @@ -667,7 +664,7 @@ struct StudioSettingsDialog: View { else if category == "Devices" { KaraokeDevicesPreferences(model:model) } else { StudioSettingsPane(showHeading:false,section:category,model:model).id(category) } Divider() - HStack { Text(scope == "DJ" && category == "Devices" ? "DJ routing · Changes apply immediately" : scope+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16) + HStack { Text(scope == "DJ" && category == "Devices" ? "DJ routing · Changes apply immediately" : (scope == "DJ" ? "DJ Studio" : "Karaoke Studio")+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16) }.frame(width:850,height:700).background(Color(nsColor:.windowBackgroundColor)) .onChange(of:scope) { _ in model.dj.midi.learning = nil;if !categories.contains(category) { category = "General" } } .onChange(of:category) { _ in model.dj.midi.learning = nil } diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift index be46576..1e8a7ee 100644 --- a/KJamsClone/Player.swift +++ b/KJamsClone/Player.swift @@ -30,9 +30,21 @@ import CoreAudio private var loopBufferTask: Task? private var loopBuffer: AVAudioPCMBuffer? private var preparedLoop: ClosedRange? + private var scheduledLoop: ClosedRange? + private var pendingLoopSwitch: (at: Double,range: ClosedRange)? + private(set) var loopAudioRestartCount = 0 + private var rawAudioPosition: Double { + guard let file,let render = node.lastRenderTime,let current = node.playerTime(forNodeTime:render) else { return time } + return anchor+Double(current.sampleTime)/file.processingFormat.sampleRate + } private(set) var nativeLoopActive = false private func loopPosition(_ position: Double) -> Double { - guard nativeLoopActive,let range = preparedLoop,position >= range.upperBound else { return position } + guard nativeLoopActive else { return position } + if let pendingLoopSwitch,position >= pendingLoopSwitch.at { + let range = pendingLoopSwitch.range + return range.lowerBound+(position-pendingLoopSwitch.at).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound) + } + guard let range = scheduledLoop,position >= range.upperBound else { return position } return range.lowerBound+(position-range.upperBound).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound) } private func prepareNativeLoop() { @@ -54,7 +66,18 @@ import CoreAudio guard !Task.isCancelled,let self,self.currentSongID == songID,self.loopEnabled,self.loopIn == start,self.loopOut == end else { return } let position = self.displayTime,resume = self.playing self.loopBuffer = buffer;self.preparedLoop = start...end - self.schedule(at:position < end ? position : start,play:resume,notifyPlayback:false) + if self.nativeLoopActive,resume { + let raw = self.rawAudioPosition + let activeRange: ClosedRange + if let pending = self.pendingLoopSwitch,raw >= pending.at { activeRange = pending.range } + else { activeRange = self.scheduledLoop ?? start...end } + let remaining = max(0,activeRange.upperBound-self.loopPosition(raw)) + self.node.scheduleBuffer(buffer,at:nil,options:[.loops,.interruptsAtLoop]) + self.pendingLoopSwitch = (raw+remaining,start...end) + } else { + self.loopAudioRestartCount += 1 + self.schedule(at:position < end ? position : start,play:resume,notifyPlayback:false) + } } catch { } } } @@ -346,13 +369,13 @@ 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 + nativeLoopActive = false;pendingLoopSwitch = nil;scheduledLoop = 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) node.scheduleBuffer(loopBuffer,at:nil,options:.loops) - nativeLoopActive = true + nativeLoopActive = true;scheduledLoop = range if play { if notifyPlayback { startNodePlayback() } else { node.play() } } playing = play;render();return } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index e2d2543..8a29eb5 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -1345,7 +1345,8 @@ final class KJamsCloneTests: XCTestCase { let model = AppModel(storageURL:root);_ = try model.createEntity(.venue,name:"Hall");let singer = try model.createEntity(.singer,name:"Alex") let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1320,height:900).preferredColorScheme(.dark)) host.frame = NSRect(x:0,y:0,width:1320,height:900) - let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.appearance = NSAppearance(named:.darkAqua);window.contentView = host + final class SidebarTestWindow: NSWindow { override var canBecomeKey: Bool { true } } + let window = SidebarTestWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.appearance = NSAppearance(named:.darkAqua);window.contentView = host let previous = NSApp.keyWindow window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey() defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() } @@ -2861,8 +2862,10 @@ extension KJamsCloneTests { XCTAssertTrue(player.audioPlaybackStarted);XCTAssertTrue(player.nativeLoopActive) XCTAssertGreaterThanOrEqual(player.displayTime,0.2);XCTAssertLessThan(player.displayTime,0.401) } + let restarts = player.loopAudioRestartCount player.defineLoop(start:0.2,end:0.3) - try await Task.sleep(nanoseconds:100_000_000) + try await Task.sleep(nanoseconds:300_000_000) + XCTAssertEqual(player.loopAudioRestartCount,restarts,"Resizing an active loop must not restart the audio node") XCTAssertTrue(player.nativeLoopActive);XCTAssertLessThan(player.displayTime,0.301) let exit = player.displayTime player.loopEnabled = false