diff --git a/KJamsClone/DJLoopEditing.swift b/KJamsClone/DJLoopEditing.swift index 5e1dd93..1f5357a 100644 --- a/KJamsClone/DJLoopEditing.swift +++ b/KJamsClone/DJLoopEditing.swift @@ -44,11 +44,12 @@ struct DJLoopWaveInteraction: ViewModifier { let start: Double let span: Double let width: Double + var relativeSeek = false @Binding var frozenStart: Double? func body(content: Content) -> some View { content.contentShape(Rectangle()) .overlay { - DJWaveMouseSurface(player:player,start:start,span:span,width:width,frozenStart:$frozenStart) + DJWaveMouseSurface(player:player,start:start,span:span,width:width,relativeSeek:relativeSeek,frozenStart:$frozenStart) } .onChange(of:player.currentSongID) { _ in frozenStart = nil } .onContinuousHover { phase in @@ -60,12 +61,12 @@ struct DJLoopWaveInteraction: ViewModifier { case .start,.end: NSCursor.resizeLeftRight.set() case .move: NSCursor.openHand.set() case .create: NSCursor.crosshair.set() - case .seek: NSCursor.arrow.set() + case .seek: (relativeSeek ? NSCursor.openHand : NSCursor.arrow).set() } case .ended: NSCursor.arrow.set() } } - .help("Drag green IN/OUT edges to resize; drag inside the loop to move it. Shift-drag to define a new loop. Drag outside it to seek.") + .help("Drag green IN/OUT edges to resize; drag inside the loop to move it. Shift-drag to define a new loop. Drag outside it to seek. In the large spectrum, hold the left button and drag left to advance or right to rewind.") } } @@ -75,18 +76,21 @@ struct DJWaveMouseSurface: NSViewRepresentable { let start: Double let span: Double let width: Double + var relativeSeek = false @Binding var frozenStart: Double? func makeNSView(context: Context) -> DJWaveMouseView { DJWaveMouseView() } func updateNSView(_ view: DJWaveMouseView,context: Context) { - view.player = player;view.viewport = start;view.span = span;view.waveWidth = width + view.player = player;view.viewport = start;view.span = span;view.waveWidth = width;view.relativeSeek = relativeSeek view.freeze = { frozenStart = $0 } } } -final class DJWaveMouseView: NSView { +class DJWaveMouseView: NSView { weak var player: Player? var viewport = 0.0,span = 0.0,waveWidth = 1.0 var freeze: ((Double?) -> Void)? + var relativeSeek = false private var drag: DJLoopDrag? + private var initialTime = 0.0,initialX = 0.0 override var isFlipped: Bool { true } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } override func mouseDown(with event: NSEvent) { @@ -94,16 +98,25 @@ final class DJWaveMouseView: NSView { let point = convert(event.locationInWindow,from:nil) let loop = player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil drag = DJLoopDrag(x:point.x,viewport:viewport,span:span,width:waveWidth,loop:loop,create:event.modifierFlags.contains(.shift)) - freeze?(viewport);updateDrag(point.x) + initialTime = player.displayTime;initialX = point.x + freeze?(viewport) + if !relativeSeek || drag?.mode != .seek { updateDrag(point.x) } } override func mouseDragged(with event: NSEvent) { updateDrag(convert(event.locationInWindow,from:nil).x) } override func mouseUp(with event: NSEvent) { - updateDrag(convert(event.locationInWindow,from:nil).x);drag = nil;freeze?(nil) + updateDrag(convert(event.locationInWindow,from:nil).x);drag = nil;freeze?(nil);NSCursor.openHand.set() } private func updateDrag(_ x: Double) { guard let player,let drag else { return } if let range = drag.range(x:x,duration:player.duration) { player.defineLoop(start:range.lowerBound,end:range.upperBound) } - else if drag.mode == .seek { player.seek(drag.viewport+x*drag.secondsPerPixel) } + else if drag.mode == .seek { + if relativeSeek { + let target = min(player.duration,max(0,initialTime-(x-initialX)*drag.secondsPerPixel)) + if abs(player.time-target) > 0.0001 { player.seek(target) } + freeze?(drag.viewport+target-initialTime) + NSCursor.closedHand.set() + } else { player.seek(drag.viewport+x*drag.secondsPerPixel) } + } } } @@ -130,3 +143,122 @@ struct DJLoopEditor: View { }.padding(24).frame(width:540) } } + +// Display-link-style redraws stay inside AppKit, keeping 60 Hz spectrum motion +// independent of SwiftUI layout, the karaoke library and lyric accessibility. +struct DJNativeSpectrum: NSViewRepresentable { + let player: Player + let bpm: Double? + let color: NSColor + let seconds: Double + let label: String + let showTimeGrid: Bool + let spectralWaveforms: Bool + func makeNSView(context: Context) -> DJNativeSpectrumView { DJNativeSpectrumView() } + 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 + view.setAccessibilityElement(true);view.setAccessibilityRole(.image) + view.setAccessibilityLabel(label == "KARAOKE" ? "Karaoke scrolling waveform" : "Deck \(label) scrolling waveform") + view.setAccessibilityValue(String(format:"%d:%02d",Int(max(0,player.time))/60,Int(max(0,player.time))%60)) + view.relativeSeek = true;view.waveWidth = view.bounds.width;view.needsDisplay = true + } +} +final class DJNativeSpectrumView: DJWaveMouseView { + var bpm: Double? + var color = NSColor.systemCyan + var label = "" + var showTimeGrid = true,spectralWaveforms = true + private var clock: Timer? + private var frozenViewport: Double? + private var hoverX: Double? + private var tracking: NSTrackingArea? + override init(frame: NSRect) { + super.init(frame:frame) + freeze = { [weak self] start in self?.frozenViewport = start;self?.needsDisplay = true } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + 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 + } + RunLoop.main.add(timer,forMode:.common);clock = timer + } + } + deinit { clock?.invalidate() } + override func updateTrackingAreas() { + super.updateTrackingAreas();if let tracking { removeTrackingArea(tracking) } + let area = NSTrackingArea(rect:.zero,options:[.activeInKeyWindow,.inVisibleRect,.mouseMoved,.mouseEnteredAndExited],owner:self) + addTrackingArea(area);tracking = area + } + override func resetCursorRects() { addCursorRect(bounds,cursor:.openHand) } + override func mouseMoved(with event: NSEvent) { hoverX = convert(event.locationInWindow,from:nil).x;needsDisplay = true } + override func mouseExited(with event: NSEvent) { hoverX = nil;needsDisplay = true } + override func mouseDown(with event: NSEvent) { + viewport = (player?.displayTime ?? 0)-span/2;waveWidth = bounds.width + super.mouseDown(with:event) + } + override func draw(_ dirtyRect: NSRect) { + NSColor.black.setFill();bounds.fill() + guard let player,player.duration > 0,span > 0,!player.waveform.isEmpty else { return } + let time = player.displayTime,start = frozenViewport ?? (time-span/2),width = bounds.width,height = bounds.height + guard width > 0,height > 0 else { return } + if showTimeGrid { + 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() + } + 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 + (major ? NSColor.systemYellow.withAlphaComponent(0.85) : NSColor.white.withAlphaComponent(0.4)).setFill() + NSRect(x:x,y:0,width:major ? 1.2 : 0.6,height:height).fill() + if major,height >= 45 { text("\(beat/4+1)",at:NSPoint(x:x+5,y:2),color:.systemYellow) } + } + } + if player.loopEnabled,let first = player.loopIn,let last = player.loopOut { + let left = max(0,(first-start)/span*width),right = min(width,(last-start)/span*width) + if right > left { + let rect = NSRect(x:left,y:0,width:right-left,height:height) + NSColor.systemGreen.withAlphaComponent(0.22).setFill();rect.fill() + NSColor.systemGreen.setStroke();let border = NSBezierPath(rect:rect.insetBy(dx:0.8,dy:0.8));border.lineWidth = 1.5;border.stroke() + } + for (point,label) in [(first,"IN"),(last,"OUT")] { + let x = (point-start)/span*width + if x >= 0,x <= width { + NSColor.systemGreen.setFill();NSRect(x:x,y:0,width:2,height:height).fill();NSRect(x:x-3,y:height/2-7,width:7,height:14).fill() + text(label,at:NSPoint(x:min(width-24,max(0,x)),y:2),color:.systemGreen) + } + } + } + NSColor.systemRed.setFill();NSRect(x:(time-start)/span*width,y:0,width:1.5,height:height).fill() + text(label+String(format:" · %.1f s",span),at:NSPoint(x:5,y:4),color:color) + if let hoverX { + let seconds = max(0,start+hoverX/width*span) + 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 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 185817e..05d54b5 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -942,23 +942,26 @@ private struct DJPlatter: View { } } } -private struct DJWaveform: View { +struct DJWaveform: View { @State private var frozenStart: Double? @ObservedObject var player: Player let color: Color var bpm: Double? var body: some View { - TimelineView(.animation(minimumInterval:1.0/60,paused:!player.playing)) { _ in let displayTime = player.displayTime GeometryReader { geometry in Canvas { context,size in let values = player.waveform guard !values.isEmpty else { return } let count = max(1,Int(size.width/2)) + var played = Path(),remaining = Path() for i in 0.. 0 else { return } - let span = settings.waveformSeconds,start = frozenStart ?? (displayTime-span/2),count = max(1,Int(size.width/2)) - for second in 0...8 { - let x = Double(second)/8*size.width - context.fill(Path(CGRect(x:x,y:0,width:0.5,height:size.height)),with:.color(.white.opacity(settings.showTimeGrid ? 0.15 : 0))) - } - 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 height = max(1,amplitude*size.height*0.85) - var shade = color - if settings.spectralWaveforms,player.waveformBands.indices.contains(index) { - let band = player.waveformBands[index],sum = max(0.0001,band.low+band.mid+band.high) - shade = Color(hue:Double(band.mid/sum)*0.38+Double(band.high/sum)*0.72,saturation:0.85,brightness:0.95) - } - context.fill(Path(CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.5,height:height)),with:.color(shade)) - } - DJWaveMarkers.draw(context:&context,size:size,start:start,span:span,bpm:bpm,loop:player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil) - let playhead = (displayTime-start)/span*size.width - context.fill(Path(CGRect(x:playhead,y:0,width:1.5,height:size.height)),with:.color(.red)) - }.overlay(alignment:.topLeading) { Text(letter+" · \(Int(settings.waveformSeconds)) s").font(.system(size:9,weight:.semibold)).foregroundStyle(color).padding(5) } - .modifier(DJLoopWaveInteraction(player:player,start:displayTime-settings.waveformSeconds/2,span:settings.waveformSeconds,width:geometry.size.width,frozenStart:$frozenStart)) - }.accessibilityLabel("Deck \(letter) scrolling waveform").accessibilityValue(DJStyle.stamp(player.time)) - } + DJScrollingSpectrum(player:player,bpm:deck.bpmConfirmed ? deck.bpm : nil,color:color,seconds:settings.waveformSeconds,label:letter,accessibilityName:"Deck \(letter) scrolling waveform",showTimeGrid:settings.showTimeGrid,spectralWaveforms:settings.spectralWaveforms) + } +} + +// The same audio-clock drawing and native mouse interaction serve both studios. +struct DJScrollingSpectrum: View { + @ObservedObject var player: Player + var bpm: Double? + var color: Color = .cyan + var seconds: Double = 16 + var label = "KARAOKE" + var accessibilityName = "Karaoke scrolling waveform" + var showTimeGrid = true + var spectralWaveforms = true + var body: some View { + DJNativeSpectrum(player:player,bpm:bpm,color:NSColor(color),seconds:seconds,label:label,showTimeGrid:showTimeGrid,spectralWaveforms:spectralWaveforms) + .accessibilityLabel(accessibilityName).accessibilityValue(DJStyle.stamp(player.time)) + .help("Hold the left button and drag left to advance or right to rewind. Shift-drag creates a loop; drag green loop edges to resize or its centre to move it.") } } diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index aa17981..d386820 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -1700,110 +1700,21 @@ private struct NowPlayingWaveform: View { @ObservedObject var player: Player @State private var mixer = false @State private var zoom: Double = 1 - @State private var hoverX: CGFloat? - @State private var scrubStart: Double? - @State private var resumeAfterScrub = false - @State private var scrubSong: Int64? - private var span: Double { max(0.001,player.duration/zoom) } - private var windowStart: Double { scrubStart ?? min(max(0,player.time-span/2),max(0,player.duration-span)) } - private var progress: Double { player.duration > 0 ? min(1,max(0,(player.time-windowStart)/span)) : 0 } + @State private var bpm: Double? private func clock(_ value: Double) -> String { let n = max(0,Int(value));return String(format:"%d:%02d",n/60,n%60) } - private func preciseClock(_ value: Double) -> String { String(format:"%d:%05.2f",max(0,Int(value))/60,max(0,value).truncatingRemainder(dividingBy:60)) } - private func bandColor(_ index: Int) -> Color { - guard player.waveformBands.indices.contains(index) else { return .cyan } - let band = player.waveformBands[index] - let largest = max(0.000001,max(band.low,max(band.mid,band.high))) - let low = Double(band.low/largest),mid = Double(band.mid/largest),high = Double(band.high/largest) - return Color(red:min(1,pow(low,1.2)+mid*0.12+high*0.6),green:min(1,low*0.08+pow(mid,1.5)*0.97+high*0.1),blue:min(1,low*0.07+mid*0.22+high)) - } var body: some View { VStack(alignment:.leading,spacing:5) { Text(player.duration > 0 ? player.title : "Ready to play").font(.system(size:13,weight:.semibold)).foregroundStyle(Color(white:0.94)) .lineLimit(1).truncationMode(.middle).frame(maxWidth:.infinity,alignment:.leading).help(player.title) - GeometryReader { geometry in - Canvas { context,size in - let top: CGFloat = 10,plotHeight = size.height-top,mid = top+plotHeight/2 - var centre = Path();centre.move(to:CGPoint(x:0,y:mid));centre.addLine(to:CGPoint(x:size.width,y:mid)) - context.stroke(centre,with:.color(.white.opacity(0.13)),lineWidth:0.5) - if player.duration > 0 { - let step = [0.1,0.25,0.5,1.0,2,5,10,15,30,60,120,300,600,1800,3600].first { $0 >= span/5 } ?? 3600 - let first = floor(windowStart/(step/4))*(step/4) - for t in stride(from:first,through:min(player.duration,windowStart+span),by:step/4) { - let x = size.width*(t-windowStart)/span - if x < 0 { continue } - let major = abs(t/step-(t/step).rounded()) < 0.001 - var tick = Path();tick.move(to:CGPoint(x:x,y:top));tick.addLine(to:CGPoint(x:x,y:size.height)) - context.stroke(tick,with:.color(major ? .yellow.opacity(0.65) : .white.opacity(0.16)),lineWidth:major ? 0.8 : 0.5) - if major && x < size.width-25 { - context.draw(Text(span < 10 ? preciseClock(t) : clock(t)).font(.system(size:8,design:.monospaced)).foregroundColor(.gray),at:CGPoint(x:x+3,y:3),anchor:.leading) - } - } - } - if !player.waveform.isEmpty { - let count = max(1,Int(size.width/2)),step = size.width/CGFloat(count) - for bar in 0.. 0 { - let x = min(size.width-2,max(2,size.width*progress)) - context.fill(Path(CGRect(x:0,y:top,width:x,height:plotHeight)),with:.color(.black.opacity(0.18))) - var head = Path();head.move(to:CGPoint(x:x,y:top));head.addLine(to:CGPoint(x:x,y:size.height)) - context.stroke(head,with:.color(.black.opacity(0.9)),lineWidth:6) - context.stroke(head,with:.color(.cyan.opacity(0.3)),lineWidth:5) - context.stroke(head,with:.color(.white),lineWidth:2) - var pointer = Path();pointer.move(to:CGPoint(x:x-5,y:top));pointer.addLine(to:CGPoint(x:x+5,y:top));pointer.addLine(to:CGPoint(x:x,y:top+7));pointer.closeSubpath() - context.fill(pointer,with:.color(.cyan)) - let labelX = min(size.width-30,max(30,x)) - context.fill(Path(roundedRect:CGRect(x:labelX-29,y:0,width:58,height:13),cornerRadius:3),with:.color(.cyan)) - context.draw(Text(preciseClock(player.time)).font(.system(size:9,weight:.bold,design:.monospaced)).foregroundColor(.black),at:CGPoint(x:labelX,y:6)) - } - }.contentShape(Rectangle()).background(WaveformJogWheel(player:player)) - .onContinuousHover { phase in switch phase { case .active(let point):hoverX = point.x;case .ended:hoverX = nil } } - .gesture(DragGesture(minimumDistance:0).onChanged { value in - guard player.duration > 0,geometry.size.width > 0 else { return } - if scrubStart == nil { - scrubStart = windowStart;scrubSong = player.currentSongID;resumeAfterScrub = player.playing - if player.playing { player.toggle() } - } - guard scrubSong == player.currentSongID else { return } - hoverX = value.location.x - let target = windowStart+Double(min(1,max(0,value.location.x/geometry.size.width)))*span - if abs(target-player.time) > 0.02 { player.seek(target) } - }.onEnded { value in - if scrubStart == nil { - if player.duration > 0 { player.seek(windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span) } - return - } - let target = windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span - if scrubSong == player.currentSongID { - player.seek(target) - if resumeAfterScrub && !player.playing && target < player.duration-0.01 { player.toggle() } - } - scrubStart = nil;resumeAfterScrub = false;scrubSong = nil - }).help("Hover for exact time. Drag to scrub; playback resumes on release. Zoom for fine positioning.") - .accessibilityLabel("Audio waveform").accessibilityValue("\(clock(player.time)) of \(clock(player.duration))") - .accessibilityAdjustableAction { direction in if direction == .increment { player.seek(player.time+5) } else if direction == .decrement { player.seek(player.time-5) } } - }.frame(height:60) + DJWaveform(player:player,color:.cyan,bpm:bpm).frame(height:14) + DJScrollingSpectrum(player:player,bpm:bpm,seconds:16/zoom) + .frame(height:46).background(.black) + .background(WaveformJogWheel(player:player)) + .accessibilityAdjustableAction { direction in player.seek(player.time+(direction == .increment ? 5 : -5)) } HStack(spacing:5) { Circle().fill(player.playing ? Color.green : Color.orange).frame(width:4,height:4) Text(player.duration == 0 ? "NO TRACK LOADED" : player.playing ? "PLAYING" : "PAUSED").tracking(1) + if let bpm { Text(String(format:"· %.1f BPM~",bpm*player.tempo)).monospacedDigit() } if player.waveformUnavailable { Text("· Waveform unavailable").foregroundStyle(.secondary) } Spacer(minLength:0) Button { zoom = max(1,zoom/2) } label: { Image(systemName:"minus.magnifyingglass") }.buttonStyle(.plain).disabled(zoom <= 1).help("Zoom out") @@ -1822,6 +1733,13 @@ private struct NowPlayingWaveform: View { .background(Color(.sRGB,red:0.055,green:0.065,blue:0.08,opacity:1),in:RoundedRectangle(cornerRadius:10)) .overlay(RoundedRectangle(cornerRadius:10).strokeBorder(.white.opacity(0.1),lineWidth:1)) .environment(\.colorScheme,.dark) + .task(id:player.currentSongID) { + bpm = nil + guard let source = player.sourceAudioURL,!player.isShowScreen else { return } + let analysis = Task.detached(priority:.utility) { try? DJBPM.analyse(source,cancelled:{Task.isCancelled}) } + let result = await withTaskCancellationHandler(operation:{ await analysis.value },onCancel:{ analysis.cancel() }) + if !Task.isCancelled { bpm = result } + } } } private struct ToneKnob: View { diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift index c90d8db..eb30fde 100644 --- a/KJamsClone/Player.swift +++ b/KJamsClone/Player.swift @@ -170,6 +170,7 @@ import CoreAudio @Published private(set) var waveformSaved = false private let waveformCacheDirectory: URL private var audioURL: URL? + var sourceAudioURL: URL? { audioURL } private var waveformTask: Task? private var waveformGeneration = UUID() @Published var playing = false diff --git a/README.md b/README.md index 256e50e..daddce9 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. -Waveforms use source bass/mid/treble energy for RGB colours. Hover for exact time, click/drag to scrub, and zoom 1×–32× for precision. 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, 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. 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. diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 457cf88..d2ee8e5 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -7,7 +7,7 @@ The main window has two tabs: **Karaoke** and **DJ Studio**. Playback, MIDI hand - 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. - 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. The scrolling viewport freezes during a drag and resumes following playback afterwards. +- 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. - Play/pause, stop-to-start, set/return cue and four set/jump hot cues are per deck. Right-click a hot cue to clear it. Use the cue-time editor to set an exact cue in seconds or capture the current playhead without moving playback. Loop ✎ opens exact start/end fields with millisecond display and Use Playhead buttons; Activate Loop validates and applies the region without pausing playback. Manual IN/OUT loops do not need BPM. Beat loops require an estimated, entered or tapped BPM; choose 1–32 beats. Loop toggles off on a second press and continues correctly at the end of a track. Changing the loop size resizes an active loop immediately, preserving its start and continuing playback; the end is clamped to track duration. - Loading estimates BPM conservatively from up to the first 90 seconds of audio, searching 70–180 BPM; silence and steady tones remain unknown. Estimates show BPM~ and can be replaced by entering source BPM (40–240) or tapping at least three beats. Sync buttons start grey and unlit. Clicking A makes B follow A’s effective BPM; clicking B makes A follow B’s effective BPM. Both buttons then show active. Press either again to unlink and restore both original tempos. Sync follows changes to the clicked leader’s tempo; it does not align beat phase or detect a beat grid. Use the jog/nudge buttons to align beats. Tempo adjustment preserves pitch, using AVAudioUnitTimePitch. @@ -60,7 +60,7 @@ The deck area is now one continuous console following the supplied reference: tr Settings now use an icon-category toolbar with separate Karaoke and DJ selectors. DJ Settings opens directly in DJ scope; Audio Routing and MIDI open the corresponding DJ categories. Karaoke device routing applies only to the singer deck. DJ General, Sound, Library, Appearance and Advanced preferences are persisted independently in DJ/DJSettings.json. Working options include paused/automatic load, active-deck protection, reset EQ/filter on load, ±8/16/50% tempo range and inversion, equal-power/linear crossfade curves, output headroom, waveform colours/window/grid, rotating marker, inactive-deck dimming, library font/row height and optional hot-cue autoplay. MIDI learning is cancelled when leaving its settings category. Singer Web is available only in Karaoke settings, with its existing server controls preserved. -Waveform beat guides use BPM and source time zero, with stronger marks every four beats; they are not analysed downbeats. Active loops have green regions and IN/OUT boundary markers. DVS, Neural Mix, booth/microphone input, recording, Spotify and cloud sync are not implemented. +Waveform beat guides use BPM and source time zero, with stronger yellow marks and numbered guides every four beats; they are not analysed downbeats. Active loops have green regions and IN/OUT boundary markers. DVS, Neural Mix, booth/microphone input, recording, Spotify and cloud sync are not implemented. ## Import integrity