broken changes

This commit is contained in:
Mark Gregory
2026-11-05 21:10:04 +00:00
parent 83a97ff3fd
commit 78c4411fa6
6 changed files with 218 additions and 21 deletions
+103
View File
@@ -0,0 +1,103 @@
import SwiftUI
import AppKit
struct DJLoopDrag {
enum Mode { case seek, start, end, move, create }
let mode: Mode
let viewport: Double
let secondsPerPixel: Double
let anchor: Double
let original: ClosedRange<Double>?
init(x: Double,viewport: Double,span: Double,width: Double,loop: ClosedRange<Double>?,create: Bool) {
self.viewport = viewport;secondsPerPixel = span/max(1,width);anchor = viewport+x*secondsPerPixel;original = loop
if create { mode = .create }
else if let loop {
let left = abs(anchor-loop.lowerBound),right = abs(anchor-loop.upperBound),tolerance = 8*secondsPerPixel
if min(left,right) <= tolerance { mode = left <= right ? .start : .end }
else if loop.contains(anchor) { mode = .move }
else { mode = .seek }
} else { mode = .seek }
}
func range(x: Double,duration: Double) -> ClosedRange<Double>? {
guard duration >= 0.1,secondsPerPixel > 0 else { return nil }
let point = min(duration,max(0,viewport+x*secondsPerPixel))
switch mode {
case .seek: return nil
case .create:
let first = min(duration,max(0,anchor)),start = min(duration-0.1,min(first,point))
return start...min(duration,max(start+0.1,max(first,point)))
case .start:
guard let original else { return nil };return min(point,original.upperBound-0.1)...original.upperBound
case .end:
guard let original else { return nil };return original.lowerBound...max(original.lowerBound+0.1,point)
case .move:
guard let original else { return nil }
let length = original.upperBound-original.lowerBound
let start = min(duration-length,max(0,original.lowerBound+(viewport+x*secondsPerPixel-anchor)))
return start...(start+length)
}
}
}
struct DJLoopWaveInteraction: ViewModifier {
@ObservedObject var player: Player
let start: Double
let span: Double
let width: Double
@Binding var frozenStart: Double?
@State private var drag: DJLoopDrag?
func body(content: Content) -> some View {
content.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance:0).onChanged { value in
guard player.canJog,span > 0 else { return }
if drag == nil {
let loop = player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil
drag = DJLoopDrag(x:value.startLocation.x,viewport:start,span:span,width:width,loop:loop,create:NSEvent.modifierFlags.contains(.shift))
frozenStart = start
}
guard let drag else { return }
if let range = drag.range(x:value.location.x,duration:player.duration) { player.defineLoop(start:range.lowerBound,end:range.upperBound) }
else if drag.mode == .seek { player.seek(drag.viewport+value.location.x*drag.secondsPerPixel) }
}.onEnded { _ in drag = nil;frozenStart = nil })
.onChange(of:player.currentSongID) { _ in drag = nil;frozenStart = nil }
.onContinuousHover { phase in
switch phase {
case .active(let point):
let loop = player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil
let mode = DJLoopDrag(x:point.x,viewport:frozenStart ?? start,span:span,width:width,loop:loop,create:NSEvent.modifierFlags.contains(.shift)).mode
switch mode {
case .start,.end: NSCursor.resizeLeftRight.set()
case .move: NSCursor.openHand.set()
case .create: NSCursor.crosshair.set()
case .seek: 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.")
}
}
struct DJLoopEditor: View {
@ObservedObject var player: Player
let letter: String
@State private var start: Double
@State private var end: Double
@Environment(\.dismiss) private var dismiss
init(player: Player,letter: String) {
self.player = player;self.letter = letter
let first = player.loopIn ?? min(max(0,player.time),max(0,player.duration-0.1))
_start = State(initialValue:first);_end = State(initialValue:player.loopOut ?? min(player.duration,first+4))
}
private var valid: Bool { start.isFinite && end.isFinite && start >= 0 && end <= player.duration && end-start >= 0.1-1e-9 }
var body: some View {
VStack(alignment:.leading,spacing:18) {
Text("Deck \(letter) · Loop Points").font(.title2.bold())
HStack { Text("Start (seconds)").frame(width:120,alignment:.leading);TextField("Start",value:$start,format:.number.precision(.fractionLength(3))).textFieldStyle(.roundedBorder);Button("Use Playhead") { start = player.time } }
HStack { Text("End (seconds)").frame(width:120,alignment:.leading);TextField("End",value:$end,format:.number.precision(.fractionLength(3))).textFieldStyle(.roundedBorder);Button("Use Playhead") { end = player.time } }
Text(valid ? String(format:"Loop length: %.3f seconds",end-start) : "End must be at least 0.1 seconds after start, within the track.").font(.caption).foregroundStyle(valid ? Color.secondary : Color.orange)
Text("On either waveform, drag the green edges to resize or the loop region to move. Shift-drag defines a new loop. Playback continues while editing.").font(.callout).foregroundStyle(.secondary)
HStack { Button("Cancel") { dismiss() };Spacer();Button("Activate Loop") { player.defineLoop(start:start,end:end);dismiss() }.disabled(!valid || !player.canJog).keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:540)
}
}
+16 -9
View File
@@ -165,6 +165,7 @@ struct DJAudioDevice: Identifiable, Equatable {
@Published var cueEditor: String?
@Published var cueDraft: Double = 0
@Published var cueEditing = false
@Published var loopEditor: String?
@Published var cueLevel: Double = 0.6 { didSet { preview.volume = cueLevel } }
@Published private(set) var previewDeck: String?
@Published var devices = [DJAudioDevice]()
@@ -261,9 +262,7 @@ struct DJAudioDevice: Identifiable, Equatable {
if previewDeck == letter { stopPreview() }
try deck.load(song)
let route = routing.route(letter)
if !route.uid.isEmpty || route.firstChannel != 0 || routing.mode == "External" {
do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } catch { deck.unload();throw error }
}
do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } catch { deck.unload();throw error }
if settings.resetEQOnLoad { deck.player.resetTone() }
if settings.autoPlayOnLoad && !automated { deck.player.toggle() }
status = "\(song.title) ready on \(letter) · set or tap BPM for tempo sync and beat loops"
@@ -287,14 +286,18 @@ struct DJAudioDevice: Identifiable, Equatable {
let previous = routing
perform {
try next.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput())
automix.manualTakeover();stopPreview()
automix.manualTakeover()
let previewActive = previewDeck != nil
do {
for deck in [a,b] { let route = next.route(deck.letter);try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) }
if previewActive,!next.cue.uid.isEmpty { try preview.routeOutput(try device(next.cue.uid),firstChannel:next.cue.firstChannel) }
try JSONEncoder().encode(next).write(to:root.appendingPathComponent("DJAudioRouting.json"),options:.atomic)
routing = next;masterUID = next.main.uid;cueUID = next.cue.uid;applyMix()
if next.cue.uid.isEmpty { stopPreview() }
status = "Audio routing applied · \(next.mode) mixer"
} catch {
for deck in [a,b] { let route = previous.route(deck.letter);if let output = try? device(route.uid) { try? deck.player.routeOutput(output,firstChannel:route.firstChannel) } }
if previewActive,!previous.cue.uid.isEmpty,let output = try? device(previous.cue.uid) { try? preview.routeOutput(output,firstChannel:previous.cue.firstChannel) }
throw error
}
}
@@ -734,6 +737,7 @@ struct DJConsole: View {
Button("Replace & Load",role:.destructive) { system.confirmReplacement(request) }
} message: { request in Text("Deck \(request.deck) is playing. Replacing it with \(request.title) stops the current track. Continue?") }
.sheet(isPresented:$system.cueEditing) { DJCueEditor(system:system) }
.sheet(isPresented:Binding(get:{system.loopEditor != nil},set:{if !$0 { system.loopEditor = nil }})) { let deck = system.channel(system.loopEditor ?? "A");DJLoopEditor(player:deck.player,letter:deck.letter) }
.sheet(isPresented:$settingsModal) { StudioSettingsDialog(model:model,scope:"DJ") }
.sheet(isPresented:$routing) { StudioSettingsDialog(model:model,scope:"DJ",category:"Devices") }
.sheet(isPresented:$mapping) { StudioSettingsDialog(model:model,scope:"DJ",category:"MIDI") }
@@ -940,6 +944,7 @@ private struct DJPlatter: View {
}
}
private struct DJWaveform: View {
@State private var frozenStart: Double?
@ObservedObject var player: Player
let color: Color
var bpm: Double?
@@ -959,7 +964,7 @@ private struct DJWaveform: View {
context.fill(Path(CGRect(x:x,y:0,width:1.5,height:size.height)),with:.color(.white))
}.background(.black.opacity(0.3),in:RoundedRectangle(cornerRadius:3))
.overlay { if player.waveformLoading { ProgressView().controlSize(.mini) } else if player.waveformUnavailable { Text("Waveform unavailable").font(.caption2).foregroundStyle(.secondary) } }
.contentShape(Rectangle()).gesture(DragGesture(minimumDistance:0).onChanged { value in player.seek(Double(value.location.x/max(1,geometry.size.width))*player.duration) })
.modifier(DJLoopWaveInteraction(player:player,start:0,span:player.duration,width:geometry.size.width,frozenStart:$frozenStart))
}.accessibilityLabel("Track waveform and position").accessibilityValue(DJStyle.stamp(player.time)).accessibilityAdjustableAction { player.jog($0 == .increment ? 1 : -1) }
}
}
@@ -1207,7 +1212,7 @@ private struct DJConsoleTransport: View {
HStack(spacing:6) {
if deck.letter == "B" { Spacer(minLength:0) }
ForEach(0..<4) { index in Button("\(index+1)") { system.hotCue(index,deck:deck) }.foregroundStyle(player.hotCues[index] == nil ? Color.secondary : DJStyle.color(deck.letter)).contextMenu { Button("Clear hot cue") { player.hotCue(index,clear:true) } } }
Button("Cue \(DJStyle.stamp(player.cuePosition)) ✎") { system.editCue(deck) };Button("IN") { player.markLoopIn() };Button("OUT") { player.markLoopOut() }
Button("Cue \(DJStyle.stamp(player.cuePosition)) ✎") { system.editCue(deck) };Button("IN") { player.markLoopIn() };Button("OUT") { player.markLoopOut() };Button("Loop ✎") { system.loopEditor = deck.letter }
Menu { Button("Stop") { player.stopDeck() };Button("Exit loop") { player.clearLoop() };Button("Nudge back") { player.jog(-0.05) };Button("Nudge forward") { player.jog(0.05) } } label: { Image(systemName:"ellipsis") }
if deck.letter == "A" { Spacer(minLength:0) }
}.controlSize(.mini)
@@ -1221,12 +1226,12 @@ private struct DJCentreWaveform: View {
let color: Color
private var bpm: Double? { deck.bpmConfirmed ? deck.bpm : nil }
var settings = DJPreferences()
@State private var scrubStart: Double?
@State private var frozenStart: Double?
var body: some View {
GeometryReader { geometry in
Canvas { context,size in
guard !player.waveform.isEmpty,player.duration > 0 else { return }
let span = settings.waveformSeconds,start = player.time-span/2,count = max(1,Int(size.width/2))
let span = settings.waveformSeconds,start = frozenStart ?? (player.time-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)))
@@ -1246,7 +1251,7 @@ private struct DJCentreWaveform: View {
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)
context.fill(Path(CGRect(x:size.width/2,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) }
.contentShape(Rectangle()).gesture(DragGesture(minimumDistance:0).onChanged { value in if scrubStart == nil { scrubStart = player.time-settings.waveformSeconds/2 };player.seek((scrubStart ?? 0)+Double(value.location.x/max(1,geometry.size.width))*settings.waveformSeconds) }.onEnded { _ in scrubStart = nil })
.modifier(DJLoopWaveInteraction(player:player,start:player.time-settings.waveformSeconds/2,span:settings.waveformSeconds,width:geometry.size.width,frozenStart:$frozenStart))
}.accessibilityLabel("Deck \(letter) scrolling waveform").accessibilityValue(DJStyle.stamp(player.time))
}
}
@@ -1620,6 +1625,8 @@ enum DJWaveMarkers {
let x = (time-start)/span*size.width
if x >= 0,x <= size.width {
context.fill(Path(CGRect(x:x,y:0,width:2,height:size.height)),with:.color(.green))
context.fill(Path(CGRect(x:x-3,y:size.height/2-7,width:7,height:14)),with:.color(.green))
context.fill(Path(CGRect(x:x,y:size.height/2-5,width:1,height:10)),with:.color(.black))
context.draw(Text(label).font(.system(size:8,weight:.bold)).foregroundColor(.green),at:CGPoint(x:min(size.width-12,max(12,x)),y:7))
}
}
+31 -9
View File
@@ -50,7 +50,7 @@ import CoreAudio
guard let loopIn,time-loopIn >= 0.1 else { return };loopOut = time;loopEnabled = true
}
func defineLoop(start: Double,end: Double) {
guard canJog,start.isFinite,end.isFinite,start >= 0,end <= duration,end-start >= 0.1 else { return }
guard canJog,start.isFinite,end.isFinite,start >= 0,end <= duration,end-start >= 0.1-1e-9 else { return }
loopIn = start;loopOut = end;loopEnabled = true
}
func clearLoop() { loopEnabled = false;loopIn = nil;loopOut = nil }
@@ -94,13 +94,35 @@ import CoreAudio
private var persistsMix = true
func routeOutput(_ device: AudioDeviceID,firstChannel: Int = 0) throws {
guard let unit = engine.outputNode.audioUnit else { throw LibraryError.message("Audio output unavailable") }
let running = engine.isRunning;engine.stop();var value = device
let result = AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&value,UInt32(MemoryLayout.size(ofValue:value)))
guard result == noErr else { if running { try? engine.start() };throw LibraryError.message("Audio routing failed (\(result))") }
var map = try DJAudioRouting.channelMap(channels:DJAudioDevice.channelCount(device),first:firstChannel)
let mapResult = map.withUnsafeMutableBytes { AudioUnitSetProperty(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,$0.baseAddress!,UInt32($0.count)) }
guard mapResult == noErr else { if running { try? engine.start() };throw LibraryError.message("Audio channel routing failed (\(mapResult))") }
if running { try engine.start() }
var previousDevice = AudioDeviceID(0),bytes = UInt32(MemoryLayout<AudioDeviceID>.size)
_ = AudioUnitGetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&previousDevice,&bytes)
func setMap(_ values: inout [Int32]) -> OSStatus {
values.withUnsafeMutableBytes { AudioUnitSetProperty(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,$0.baseAddress!,UInt32($0.count)) }
}
// Channel-only changes can be applied by HAL without interrupting the player.
if previousDevice == device,setMap(&map) == noErr { return }
tick()
let running = engine.isRunning,resume = playing,position = time,wasWaiting = waitingForBackground
var previousMap = outputChannelMap()
cancelPendingPlayback();generation = UUID();node.stop();engine.stop()
do {
var value = device
let result = AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&value,UInt32(MemoryLayout.size(ofValue:value)))
guard result == noErr else { throw LibraryError.message("Audio routing failed (\(result))") }
let mapResult = setMap(&map)
guard mapResult == noErr else { throw LibraryError.message("Audio channel routing failed (\(mapResult))") }
if running || resume { try engine.start() }
if file != nil { schedule(at:position,play:resume,notifyPlayback:wasWaiting) }
} catch {
if previousDevice != 0 {
_ = AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&previousDevice,UInt32(MemoryLayout<AudioDeviceID>.size))
if !previousMap.isEmpty { _ = setMap(&previousMap) }
}
if running || resume { try? engine.start() }
if file != nil { schedule(at:position,play:resume && engine.isRunning,notifyPlayback:wasWaiting) }
throw error
}
}
func outputChannelMap() -> [Int32] {
guard let unit = engine.outputNode.audioUnit else { return [] }
@@ -248,7 +270,7 @@ import CoreAudio
self.onPlaybackReady?();self.node.play()
}
}
private func schedule(at seconds: Double, play: Bool) {
private func schedule(at seconds: Double, play: Bool,notifyPlayback: Bool = true) {
guard let file else { return }
cancelPendingPlayback();generation = UUID(); let token = generation
node.stop();if !fading { setFade(1) }
@@ -268,7 +290,7 @@ import CoreAudio
}
}
}
if play { startNodePlayback() }; playing = play; render()
if play { if notifyPlayback { startNodePlayback() } else { node.play() } }; playing = play; render()
}
private func startTimer() {
timer?.invalidate()