diff --git a/KJamsClone/DJLoopEditing.swift b/KJamsClone/DJLoopEditing.swift index 1f5357a..24ab206 100644 --- a/KJamsClone/DJLoopEditing.swift +++ b/KJamsClone/DJLoopEditing.swift @@ -1,5 +1,6 @@ import SwiftUI import AppKit +import QuartzCore struct DJLoopDrag { enum Mode { case seek, start, end, move, create } @@ -155,6 +156,7 @@ struct DJNativeSpectrum: NSViewRepresentable { let showTimeGrid: Bool let spectralWaveforms: Bool func makeNSView(context: Context) -> DJNativeSpectrumView { DJNativeSpectrumView() } + static func dismantleNSView(_ view: DJNativeSpectrumView,coordinator: ()) { view.stopDisplayClock() } func updateNSView(_ view: DJNativeSpectrumView,context: Context) { view.player = player;view.bpm = bpm;view.color = color;view.span = seconds view.label = label;view.showTimeGrid = showTimeGrid;view.spectralWaveforms = spectralWaveforms @@ -170,6 +172,10 @@ final class DJNativeSpectrumView: DJWaveMouseView { var label = "" var showTimeGrid = true,spectralWaveforms = true private var clock: Timer? + private var displayClock: AnyObject? + private var tiles = [Int:CGImage]() + private var tileKey = "" + private(set) var waveformTileBuildCount = 0 private var frozenViewport: Double? private var hoverX: Double? private var tracking: NSTrackingArea? @@ -178,17 +184,32 @@ final class DJNativeSpectrumView: DJWaveMouseView { freeze = { [weak self] start in self?.frozenViewport = start;self?.needsDisplay = true } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + func stopDisplayClock() { + clock?.invalidate();clock = nil + if #available(macOS 14.0,*) { (displayClock as? CADisplayLink)?.invalidate() } + displayClock = nil + } override func viewDidMoveToWindow() { - super.viewDidMoveToWindow();clock?.invalidate();clock = nil - if window != nil { - let timer = Timer(timeInterval:1.0/60,repeats:true) { [weak self] _ in - guard let self,self.player?.playing == true,!self.isHiddenOrHasHiddenAncestor else { return } - self.needsDisplay = true - } + super.viewDidMoveToWindow();stopDisplayClock() + guard window != nil else { return } + if #available(macOS 14.0,*) { + let link = displayLink(target:self,selector:#selector(displayFrame(_:))) + link.preferredFrameRateRange = CAFrameRateRange(minimum:30,maximum:120,preferred:60) + link.add(to:.main,forMode:.common);displayClock = link + } else { + let timer = Timer(timeInterval:1.0/60,repeats:true) { [weak self] _ in self?.refreshFrame() } RunLoop.main.add(timer,forMode:.common);clock = timer } } - deinit { clock?.invalidate() } + @available(macOS 14.0,*) @objc private func displayFrame(_ link: CADisplayLink) { refreshFrame() } + private func refreshFrame() { + guard player?.playing == true,!isHiddenOrHasHiddenAncestor else { return } + needsDisplay = true + } + deinit { + clock?.invalidate() + if #available(macOS 14.0,*) { (displayClock as? CADisplayLink)?.invalidate() } + } override func updateTrackingAreas() { super.updateTrackingAreas();if let tracking { removeTrackingArea(tracking) } let area = NSTrackingArea(rect:.zero,options:[.activeInKeyWindow,.inVisibleRect,.mouseMoved,.mouseEnteredAndExited],owner:self) @@ -210,24 +231,7 @@ final class DJNativeSpectrumView: DJWaveMouseView { NSColor.white.withAlphaComponent(0.15).setFill() for tick in 0...8 { NSRect(x:Double(tick)/8*width,y:0,width:0.5,height:height).fill() } } - let paths = (0..<32).map { _ in NSBezierPath() },count = max(1,Int(width/2)) - for i in 0..= 0,seconds < player.duration else { continue } - let sample = min(Double(player.waveform.count-1),seconds/player.duration*Double(player.waveform.count)) - let index = Int(sample),next = min(player.waveform.count-1,index+1),fraction = sample-Double(index) - let amplitude = Double(player.waveform[index])*(1-fraction)+Double(player.waveform[next])*fraction - let barHeight = max(1,amplitude*height*0.85) - var bucket = 0 - if spectralWaveforms,player.waveformBands.indices.contains(index) { - let band = player.waveformBands[index],sum = max(0.0001,band.low+band.mid+band.high) - bucket = min(31,max(0,Int((Double(band.mid/sum)*0.38+Double(band.high/sum)*0.72)*31))) - } - paths[bucket].appendRect(NSRect(x:Double(i)*width/Double(count),y:(height-barHeight)/2,width:1.5,height:barHeight)) - } - for bucket in paths.indices where !paths[bucket].isEmpty { - (spectralWaveforms ? NSColor(calibratedHue:Double(bucket)/31,saturation:0.85,brightness:0.95,alpha:1) : color).setFill();paths[bucket].fill() - } + drawWaveform(player,start:start,width:width,height:height) if let bpm { for beat in DJWaveMarkers.beats(start:start,span:span,bpm:bpm,width:width) { let x = (Double(beat)*60/bpm-start)/span*width,major = beat % 4 == 0 @@ -258,6 +262,48 @@ final class DJNativeSpectrumView: DJWaveMouseView { text(String(format:"%d:%05.2f",Int(seconds)/60,seconds.truncatingRemainder(dividingBy:60)),at:NSPoint(x:max(0,width-65),y:max(0,height-14)),color:.white) } } + private func drawWaveform(_ player: Player,start: Double,width: Double,height: Double) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + let scale = window?.backingScaleFactor ?? 2 + let key = "\(player.waveformRevision):\(width):\(height):\(scale):\(span):\(spectralWaveforms):\(color)" + if tileKey != key { tiles.removeAll();tileKey = key } + let first = Int(floor(start/span)) + tiles = tiles.filter { (first...first+2).contains($0.key) } + context.interpolationQuality = .high + for tile in first...first+2 { + if tiles[tile] == nil { tiles[tile] = waveformTile(player,tile:tile,width:width,height:height,scale:scale) } + if let image = tiles[tile] { + let x = (Double(tile)*span-start)/span*width + context.draw(image,in:CGRect(x:x,y:0,width:width,height:height)) + } + } + } + private func waveformTile(_ player: Player,tile: Int,width: Double,height: Double,scale: Double) -> CGImage? { + guard let context = CGContext(data:nil,width:max(1,Int(ceil(width*scale))),height:max(1,Int(ceil(height*scale))),bitsPerComponent:8,bytesPerRow:0,space:CGColorSpaceCreateDeviceRGB(),bitmapInfo:CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil } + context.scaleBy(x:scale,y:scale) + let paths = (0..<32).map { _ in CGMutablePath() },count = max(1,Int(width/2)) + for i in 0..= 0,seconds < player.duration else { continue } + let sample = min(Double(player.waveform.count-1),seconds/player.duration*Double(player.waveform.count)) + let index = Int(sample),next = min(player.waveform.count-1,index+1),fraction = sample-Double(index) + let amplitude = Double(player.waveform[index])*(1-fraction)+Double(player.waveform[next])*fraction + let barHeight = max(1,amplitude*height*0.85) + var bucket = 0 + if spectralWaveforms,player.waveformBands.indices.contains(index) { + let band = player.waveformBands[index],sum = max(0.0001,band.low+band.mid+band.high) + bucket = min(31,max(0,Int((Double(band.mid/sum)*0.38+Double(band.high/sum)*0.72)*31))) + } + paths[bucket].addRect(CGRect(x:Double(i)*width/Double(count),y:(height-barHeight)/2,width:1.5,height:barHeight)) + } + for bucket in paths.indices where !paths[bucket].isEmpty { + let shade = spectralWaveforms ? NSColor(calibratedHue:Double(bucket)/31,saturation:0.85,brightness:0.95,alpha:1) : color + context.setFillColor(shade.cgColor);context.addPath(paths[bucket]);context.fillPath() + } + let image = context.makeImage() + if image != nil { waveformTileBuildCount += 1 } + return image + } private func text(_ value: String,at point: NSPoint,color: NSColor) { (value as NSString).draw(at:point,withAttributes:[.font:NSFont.monospacedSystemFont(ofSize:9,weight:.semibold),.foregroundColor:color]) } diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index 05d54b5..f944256 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -796,6 +796,9 @@ struct DJConsole: View { } } @ViewBuilder private func playlistMenu(_ song: Song) -> some View { + Button("Load into Deck A") { system.load(song,into:"A") } + Button("Load into Deck B") { system.load(song,into:"B") } + Divider() Button("Add to DJ Queue") { system.automix.enqueue([song]) } Menu("Add to Playlist") { ForEach(system.playlists) { list in Button(list.name) { system.addToPlaylist(list.id,song:song.id);refreshPlaylist() } } } if let playlist { diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index d386820..9a085bb 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -745,22 +745,19 @@ struct LibraryView: View { } Spacer() TextField("Search karaoke music",text:$search).textFieldStyle(.roundedBorder).frame(maxWidth:260) - if ["Library","Singer Songs","Playlists"].contains(source) { - Button("Song Settings") { djSongSettings = true } - .sheet(isPresented:$djSongSettings) { - VStack(spacing:0) { - HStack { VStack(alignment:.leading,spacing:4) { Text("Song Settings").font(.title2.bold());Text(selectedSong?.title ?? "Select a karaoke track to adjust its settings").font(.callout).foregroundStyle(.secondary) };Spacer() }.padding(24) - Divider() - detailPanel.frame(height:150).padding(16) - Divider() - HStack { Text("Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { djSongSettings = false }.keyboardShortcut(.defaultAction) }.padding(20) - }.frame(width:920).background(Color(nsColor:.windowBackgroundColor)) - } - } if source == "Library" { Button { importing = true } label: { Label("Import Music",systemImage:"plus") }.buttonStyle(.bordered) } }.padding(.horizontal,22).padding(.vertical,18) + .sheet(isPresented:$djSongSettings) { + VStack(spacing:0) { + HStack { VStack(alignment:.leading,spacing:4) { Text("Song Settings").font(.title2.bold());Text(selectedSong?.title ?? "Select a karaoke track to adjust its settings").font(.callout).foregroundStyle(.secondary) };Spacer() }.padding(24) + Divider() + detailPanel.frame(height:150).padding(16) + Divider() + HStack { Text("Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { djSongSettings = false }.keyboardShortcut(.defaultAction) }.padding(20) + }.frame(width:920).background(Color(nsColor:.windowBackgroundColor)) + } sourceContent.frame(maxWidth:.infinity,maxHeight:.infinity) if let progress = model.waveformJob { HStack(spacing:10) { @@ -1058,12 +1055,12 @@ struct LibraryView: View { }.tableStyle(.inset(alternatesRowBackgrounds:true)) .contextMenu(forSelectionType:Int64.self) { ids in if ids.count > 1 { - Button("Generate Waveforms for \(ids.count) Selected Tracks") { model.generateWaveforms(ids) }.disabled(model.generatingWaveforms || model.scanningSource != nil) - Button("Regenerate Selected Waveforms") { model.generateWaveforms(ids,force:true) }.disabled(model.generatingWaveforms || model.scanningSource != nil) Button("Reinterpret Names…") { beginReinterpretation(ids) } Button("Edit \(ids.count) Songs…") { tableSelection = ids;batchEditing = true } } else if let id = ids.first,let song = model.songs.first(where:{$0.id == id}) { songActions(song) } + Divider() + Menu("Waveforms") { waveformActions(ids) }.disabled(model.generatingWaveforms || model.scanningSource != nil) } primaryAction: { ids in if let id = ids.first { selected = id;tableSelection = [id];playSelected() } } .overlay { if visibleSongs.isEmpty { @@ -1078,9 +1075,8 @@ struct LibraryView: View { } @ViewBuilder private func songActions(_ song: Song) -> some View { Button("Play") { selected = song.id;playSelected() } + Button("Song Settings…") { selected = song.id;tableSelection = [song.id];djSongSettings = true } Button("Song Info…") { editingSong = song } - Button("Generate Waveform") { model.generateWaveforms([song.id]) }.disabled(model.generatingWaveforms || model.scanningSource != nil || song.audio.hasPrefix("showscreen:")) - Button("Regenerate Waveform") { model.generateWaveforms([song.id],force:true) }.disabled(model.generatingWaveforms || model.scanningSource != nil || song.audio.hasPrefix("showscreen:")) Button("Reinterpret Name…") { beginReinterpretation([song.id]) } Button("Find Metadata Online…") { metadataLookup = song } Button("Batch Edit Selected…") { if tableSelection.isEmpty { tableSelection = [song.id] };batchEditing = true } @@ -1233,13 +1229,17 @@ struct LibraryView: View { }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line)) } private var playlistPane: some View { PlaylistWorkspace(model:model,query:$search) } - @ViewBuilder private var waveformActions: some View { + @ViewBuilder private var waveformActions: some View { waveformActions(tableSelection) } + @ViewBuilder private func waveformActions(_ ids: Set) -> some View { Button("Generate Waveform for Loaded Track") { if let id = model.player.currentSongID { model.generateWaveforms([id]) } }.disabled(model.player.currentSongID == nil) - Button("Generate Waveform for Selected Track") { if let selected { model.generateWaveforms([selected]) } }.disabled(tableSelection.count > 1 || selected == nil) - Button("Generate Waveforms for \(tableSelection.count) Selected Tracks") { model.generateWaveforms(tableSelection) }.disabled(tableSelection.isEmpty) + if ids.count == 1 { + Button("Generate Waveform for Selected Track") { model.generateWaveforms(ids) } + } else { + Button("Generate Waveforms for \(ids.count) Selected Tracks") { model.generateWaveforms(ids) }.disabled(ids.isEmpty) + } Button("Generate Waveforms for All Library Tracks") { model.generateWaveforms() }.disabled(model.songs.isEmpty) Divider() - Button("Regenerate Selected Waveforms") { model.generateWaveforms(tableSelection,force:true) }.disabled(tableSelection.isEmpty) + Button("Regenerate Selected Waveforms") { model.generateWaveforms(ids,force:true) }.disabled(ids.isEmpty) Button("Regenerate All Library Waveforms") { model.generateWaveforms(force:true) }.disabled(model.songs.isEmpty) } private var footer: some View { @@ -1248,7 +1248,6 @@ struct LibraryView: View { Text("\(libraryRows.count) songs").font(.caption).foregroundStyle(.secondary) if !model.tagsStatus.isEmpty { Text(model.tagsStatus).font(.caption).foregroundStyle(.secondary).lineLimit(1) } Spacer() - Menu { waveformActions } label: { Label("Waveforms",systemImage:"waveform") }.controlSize(.small).disabled(model.generatingWaveforms || model.scanningSource != nil) if tableSelection.count > 1 { Button("Edit \(tableSelection.count) Songs…") { batchEditing = true }.controlSize(.small) } Text(model.currentVenueName).font(.caption).foregroundStyle(.secondary) RepeatButton(player:model.player).buttonStyle(.borderless) diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift index eb30fde..1f11765 100644 --- a/KJamsClone/Player.swift +++ b/KJamsClone/Player.swift @@ -151,7 +151,8 @@ import CoreAudio try? data.write(to:mixURL,options:.atomic) onGlobalMixChanged?(globalMix) } - @Published private(set) var waveform = [Float]() + @Published private(set) var waveform = [Float]() { didSet { waveformRevision = UUID() } } + private(set) var waveformRevision = UUID() @Published private(set) var waveformBands = [WaveformBands]() @Published var inputGain: Double = 0 { didSet { tone.globalGain = Float(min(12,max(-12,inputGain))) } } @Published var lowGain: Double = 0 { didSet { updateTone() } } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 96720b1..33b4430 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -2476,3 +2476,28 @@ extension KJamsCloneTests { queue.stop();XCTAssertTrue(system.a.player.playing,"Stopping automation preserves current playback") } } + + +extension KJamsCloneTests { + @MainActor func testSpectrumScrollMovesCachedPixelsWithoutRebuildingEveryFrame() async throws { + let file = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let player = Player(persistsMix:false);defer { player.stop() } + try player.load(.init(id:1,title:"Scroll",audio:file.path,cdg:nil,rating:0,plays:0),startPlaying:false) + for _ in 0..<100 where player.waveformLoading { try await Task.sleep(nanoseconds:20_000_000) } + XCTAssertFalse(player.waveform.isEmpty) + let view = DJNativeSpectrumView(frame:NSRect(x:0,y:0,width:600,height:100)) + view.player = player;view.span = 2;view.spectralWaveforms = true + func snapshot() throws -> Data { + let bitmap = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in:view.bounds)) + view.cacheDisplay(in:view.bounds,to:bitmap) + return Data(bytes:try XCTUnwrap(bitmap.bitmapData),count:bitmap.bytesPerRow*bitmap.pixelsHigh) + } + player.seek(1);let first = try snapshot(),builds = view.waveformTileBuildCount + XCTAssertGreaterThan(builds,0) + player.seek(1.01);let second = try snapshot() + XCTAssertEqual(view.waveformTileBuildCount,builds,"Playback frames translate cached waveform pixels rather than rebinning the spectrum") + XCTAssertNotEqual(first,second,"The spectrum must move as source position advances") + view.frame.size.width = 640;_ = try snapshot() + XCTAssertGreaterThan(view.waveformTileBuildCount,builds,"Changing window size must rebuild sharp waveform tiles") + } +} diff --git a/README.md b/README.md index daddce9..1569466 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Import MP3/WAV/M4A, same-basename CDG pairs, MP3+G ZIPs or MP4/M4V/MOV with macO Library Locations recursively scans mounted drives/folders, indexes supported media and prepares cached 4096-bin RMS/frequency waveforms in the background. Cancel keeps completed work. Rescan reuses unchanged entries/caches; Rebuild Waveforms forces analysis. Offline sources retain their catalogue; removing a location keeps songs/media. Reconnect the drive to play referenced files. Results refresh at completion; there is no filesystem watcher/scheduled rescan. -Karaoke and DJ Studio share the same spectrum colours from source bass/mid/treble energy, red playhead, smooth audio-clock scrolling and green loop markers. The track overview selects an absolute position; hold the left mouse button in the scrolling spectrum and drag left to advance or right to rewind without a click jump. Hover for exact time and zoom 1×–32× for precision. Karaoke estimates BPM in the background for beat lines and numbered four-beat guides; these start at source time zero and are not analysed downbeats. Re-analyse is in the waveform menu. Cache keys use source path, size, modification time and analyser version; bad/changed caches rebuild. Background analysis has its own reader and cannot replace a newer track's result. +Karaoke and DJ Studio share the same spectrum colours from source bass/mid/treble energy, red playhead, cached audio-clock scrolling synchronized to the screen on macOS 14+, with a timed fallback on macOS 13 and green loop markers. The track overview selects an absolute position; hold the left mouse button in the scrolling spectrum and drag left to advance or right to rewind without a click jump. Hover for exact time and zoom 1×–32× for precision. Karaoke estimates BPM in the background for beat lines and numbered four-beat guides; these start at source time zero and are not analysed downbeats. Re-analyse is in the waveform menu. Cache keys use source path, size, modification time and analyser version; bad/changed caches rebuild. Background analysis has its own reader and cannot replace a newer track's result. The sidebar monitor stays fixed while navigation scrolls. It and the separate Video window share one published CDG/video/slide frame. Local MP4 video is muted and follows the processed audio clock with drift correction; its sound uses the global mixer and independent pitch/tempo engine. The app has one main UI window and optional Video output, with no New Window command. There is no YouTube support. @@ -168,3 +168,5 @@ Key and Tempo controls beside the main transport adjust the loaded performance i ## DJ Studio The main window provides Karaoke and DJ Studio tabs; both retain playback while switching. DJ Studio has its own library, saved queue, Automix, MIDI profiles and saved multichannel audio routing. See [DJ workstation notes](docs/DJ_WORKSTATION.md) for supported controls, XML profile compatibility and hardware verification. + +Library row menus include Song Settings and Waveforms generation/regeneration in karaoke, and Load into Deck A/B in DJ Studio. The bottom Waveforms button and karaoke header Song Settings button have been removed. diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index d2ee8e5..7a51bac 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -5,7 +5,7 @@ The main window has two tabs: **Karaoke** and **DJ Studio**. Playback, MIDI hand ## Using the decks - Use the DJ browser’s Import Files / Folder button for DJ music. It has its own `DJ/Library.sqlite`, `DJ/Media` and playlists under the app support folder. It starts empty; existing karaoke songs are not migrated. The tested recursive importer is reused with audio-only import, without CDG graphics. MP3, WAV, M4A, MP4/M4V/MOV audio, AIFF/AIF, CAF, FLAC and AAC extensions are accepted; decoding depends on macOS format support. Imports support progress, cancellation and duplicate detection. -- Select a DJ library row and use Load A/B, use the row’s A/B buttons, or drag a DJ library song onto a deck. Loading is paused, with no brief autoplay. Finder audio files can also be dropped onto either deck’s header, platter area or centre waveform. Back-to-back drops are queued while a copy/import is running; re-dropping existing media still loads the selected deck. Dropping onto a playing deck asks for confirmation before importing or replacing anything; Cancel preserves playback and Replace & Load explicitly overrides protection. Library loads also respect the active-deck protection preference. +- Select a DJ library row and use Load A/B, use the row’s A/B buttons or right-click Load into Deck A/B, or drag a DJ library song onto a deck. Loading is paused, with no brief autoplay. Finder audio files can also be dropped onto either deck’s header, platter area or centre waveform. Back-to-back drops are queued while a copy/import is running; re-dropping existing media still loads the selected deck. Dropping onto a playing deck asks for confirmation before importing or replacing anything; Cancel preserves playback and Replace & Load explicitly overrides protection. Library loads also respect the active-deck protection preference. - Cyan identifies A; amber identifies B. Each platter’s angle follows actual source playback time, including tempo changes, pauses and seeks. Drag around a platter to jog two source seconds per revolution. This is seek-based jogging, not vinyl scratching. - Waveforms come from decoded audio and reuse existing waveform caches. Drag outside an active loop to seek. Green IN/OUT grips resize the loop; dragging inside its region moves it without changing its length. Shift-drag creates a loop in either waveform. Native mouse tracking supports scrubbing in the overview and scrolling spectrum while paused or playing. In the large spectrum, hold the left mouse button and drag left to advance or right to rewind, relative to the initial position without a click jump; the waveform follows the hand. The overview remains an absolute position selector. The scrolling viewport freezes during a drag and resumes following playback afterwards. Waveform scrolling, playheads and platter rotation use the audio clock at up to 60 display frames per second, with interpolated waveform amplitudes. - High/mid/low EQ and the bipolar filter are beside each platter. Gain and level knobs have been removed from the on-screen decks. Drag a knob vertically; double-click to reset. Knobs expose adjustable accessibility actions and current values. EQ uses the existing three-band filters; filter is low-pass left, bypass centre, high-pass right. @@ -73,3 +73,5 @@ The full macOS suite covers karaoke, recursive CDG/audio import, independent DJ Attached-interface checks verify available output devices and AUHAL stereo channel maps when the hardware is connected; that check skips when the interface is unavailable. Physical button response, visible LEDs and audible socket isolation still require hands-on verification. Builds compile for arm64 and x86_64; tests execute on Apple silicon. Temporary builds, logs, captures and scripts are kept in task directories under `/private/tmp`, including `kjmix-xml-mappings` and `kjmix-loop-edit`. Latest validation: **147 tests executed, zero failures, one attached-interface check skipped because the user confirmed the Numark was unplugged**. Earlier attached-interface runs verified the four-channel AUHAL maps; native menu selection and live binding changes are also tested independently of hardware. + +Spectrum scrolling caches source-aligned image sections and translates them by fractional pixels as playback advances. On macOS 14+ a view display link schedules refreshes with the current display; macOS 13 uses a timer. Beat and loop markers stay aligned to the same audio-clock position. Cached images are rebuilt after waveform analysis, resize, zoom or colour changes.