diff --git a/KJamsClone/DJLoopEditing.swift b/KJamsClone/DJLoopEditing.swift index 205c0ff..f266b28 100644 --- a/KJamsClone/DJLoopEditing.swift +++ b/KJamsClone/DJLoopEditing.swift @@ -12,7 +12,7 @@ struct DJLoopDrag { 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 + let left = abs(anchor-loop.lowerBound),right = abs(anchor-loop.upperBound),tolerance = min(8*secondsPerPixel,(loop.upperBound-loop.lowerBound)/4) if min(left,right) <= tolerance { mode = left <= right ? .start : .end } else if loop.contains(anchor) { mode = .move } else { mode = .seek } diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index e10d484..af8f775 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -689,12 +689,7 @@ private enum DJStyle { } struct DJConsole: View { - @ObservedObject var model: AppModel @ObservedObject var system: DJSystem - var expanded = false - @State private var settingsModal = false - @State private var routing = false - @State private var mapping = false @State private var query = "" @State private var playlist: Int64? @State private var playlistIDs = [Int64]() @@ -712,14 +707,13 @@ struct DJConsole: View { private var pages: Int { max(1,(songs.count+49)/50) } private var visible: [Song] { Array(songs.dropFirst(min(page,pages-1)*50).prefix(50)) } var body: some View { - ScrollView { VStack(spacing:10) { header - DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}) + DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true) HStack(alignment:.top,spacing:12) { - browser.frame(maxWidth:.infinity).frame(height:expanded ? 330 : 220) - DJQueuePane(system:system,automix:system.automix).frame(width:310,height:expanded ? 330 : 220) - } + browser.frame(maxWidth:.infinity,maxHeight:.infinity) + DJQueuePane(system:system,automix:system.automix).frame(width:310).frame(maxHeight:.infinity) + }.frame(maxWidth:.infinity,maxHeight:.infinity) if let progress = system.importProgress { HStack { Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit() @@ -728,9 +722,8 @@ struct DJConsole: View { else { Button("Dismiss") { system.importProgress = nil } } }.font(.caption).controlSize(.mini) } - HStack { Text(system.status).lineLimit(1);Spacer();Text("DJ master separate from karaoke").foregroundStyle(.secondary) }.font(.system(size:10)) - }.padding(14) - }.background(DJStyle.background).foregroundStyle(.white) + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.top) + .background(DJStyle.background).foregroundStyle(.white) .preferredColorScheme(.dark) .alert("Replace playing track?",isPresented:Binding(get:{system.pendingReplacement != nil},set:{if !$0 { system.pendingReplacement = nil }}),presenting:system.pendingReplacement) { request in Button("Cancel",role:.cancel) { system.cancelReplacement() } @@ -738,9 +731,6 @@ struct DJConsole: View { } 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") } .sheet(isPresented:$playlistEditor) { playlistSheet } .alert("DJ workstation",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") } .onAppear { refreshPlaylist();connectMIDI() } @@ -757,9 +747,6 @@ struct DJConsole: View { Text("TWO DECKS").font(.system(size:9,weight:.medium)).foregroundStyle(.secondary) Spacer() if system.previewDeck != nil { Button("Stop headphone preview") { system.stopPreview() }.tint(.green) } - Button { settingsModal = true } label: { Label("DJ Settings",systemImage:"gearshape") } - Button { routing = true } label: { Label("Audio Routing",systemImage:"hifispeaker.fill") } - Button { mapping = true } label: { Label("MIDI",systemImage:"pianokeys") } }.controlSize(.small) } private var browser: some View { diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index 51cf741..aa17981 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -513,10 +513,15 @@ private struct LibraryCommands { var play: () -> Void var next: () -> Void var video: () -> Void - var settings: () -> Void } +private struct StudioSettingsAction { var open: () -> Void } +private struct StudioSettingsActionKey: FocusedValueKey { typealias Value = StudioSettingsAction } private struct LibraryCommandsKey: FocusedValueKey { typealias Value = LibraryCommands } private extension FocusedValues { + var studioSettings: StudioSettingsAction? { + get { self[StudioSettingsActionKey.self] } + set { self[StudioSettingsActionKey.self] = newValue } + } var libraryCommands: LibraryCommands? { get { self[LibraryCommandsKey.self] } set { self[LibraryCommandsKey.self] = newValue } @@ -526,6 +531,7 @@ private extension FocusedValues { @StateObject private var model = AppModel(storageURL:ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil ? nil : FileManager.default.temporaryDirectory.appendingPathComponent("KJMix-TestHost-"+String(ProcessInfo.processInfo.processIdentifier))) @AppStorage("studioAppearance") private var appearance = "system" @FocusedValue(\.libraryCommands) private var actions + @FocusedValue(\.studioSettings) private var settingsAction @FocusedValue(\.nameReinterpretation) private var nameActions var body: some Scene { Window("KJMix Pro", id: "main") { @@ -540,7 +546,7 @@ private extension FocusedValues { Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) } CommandGroup(replacing:.appSettings) { - Button("Settings…") { actions?.settings() }.keyboardShortcut(",").disabled(actions == nil) + Button("Settings…") { settingsAction?.open() }.keyboardShortcut(",").disabled(settingsAction == nil) } CommandMenu("Library") { Button("Song Info…") { actions?.songInfo() }.keyboardShortcut("i").disabled(actions?.canEdit != true) @@ -775,7 +781,7 @@ struct LibraryView: View { private var workspaceLayout: some View { TabView(selection:$workspaceTab) { libraryWorkspace.tabItem { Label("Karaoke",systemImage:"music.mic") }.tag("Karaoke") - DJConsole(model:model,system:model.dj,expanded:true) + DJConsole(system:model.dj) .tabItem { Label("DJ Studio",systemImage:"opticaldisc") }.tag("DJ Studio") }.accessibilityIdentifier("workspace.tabs") } @@ -784,7 +790,8 @@ struct LibraryView: View { .background(StudioTheme.frame) .tint(.accentColor) .focusedSceneValue(\.nameReinterpretation,NameReinterpretationCommands(perform:{beginReinterpretation()},enabled:workspaceTab == "Karaoke" && ["Library","Singer Songs"].contains(source) && (selectedSong != nil || !tableSelection.isEmpty))) - .focusedSceneValue(\.libraryCommands,workspaceTab == "Karaoke" ? LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{studioSettingsModal = true}) : nil) + .focusedSceneValue(\.studioSettings,StudioSettingsAction(open:{studioSettingsModal = true})) + .focusedSceneValue(\.libraryCommands,workspaceTab == "Karaoke" ? LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")}) : nil) .sheet(isPresented:$studioSettingsModal) { StudioSettingsDialog(model:model,scope:workspaceTab == "DJ Studio" ? "DJ" : "Karaoke") } .navigationTitle(workspaceTab == "DJ Studio" ? "DJ Studio" : source == "Singer Songs" ? (model.singers.first { $0.id == model.selectedSinger }?.name ?? "Singer") : source) .navigationSubtitle(model.currentVenueName) diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index dc2468f..27735c9 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -2336,6 +2336,8 @@ extension KJamsCloneTests { XCTAssertEqual(model.player.currentSongID,id);XCTAssertEqual(model.dj.a.player.currentSongID,id) } model.dj.automix.enqueue([song,song]);try await selectTab(1) + let libraryScroll = try XCTUnwrap(descendants(host).compactMap{$0 as? NSScrollView}.filter{$0.bounds.width > 600 && !$0.isHiddenOrHasHiddenAncestor}.max(by:{$0.bounds.height < $1.bounds.height})) + XCTAssertGreaterThan(libraryScroll.bounds.height,380,"DJ library grows into the remaining workspace height") if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-xml-mappings/dj-tab.png")) } try await selectTab(0) if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-xml-mappings/karaoke-tab.png")) } @@ -2355,6 +2357,8 @@ extension KJamsCloneTests { XCTAssertEqual(move.range(x:900,duration:6),4...6) let create = DJLoopDrag(x:400,viewport:0,span:6,width:600,loop:nil,create:true) XCTAssertEqual(create.range(x:200,duration:6),2...4) + let shortOverview = DJLoopDrag(x:204,viewport:0,span:300,width:600,loop:100...104,create:false) + XCTAssertEqual(shortOverview.mode,.move,"Short loops still have a draggable centre in an overview") let seek = DJLoopDrag(x:500,viewport:0,span:6,width:600,loop:loop,create:false) XCTAssertEqual(seek.mode,.seek);XCTAssertNil(seek.range(x:400,duration:6)) let narrow = try XCTUnwrap(left.range(x:500,duration:6)) diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index c80139f..507c96b 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -1,15 +1,15 @@ # DJ workstation — feature/dj_workstation -The main window has two tabs: **Karaoke** and **DJ Studio**. Playback, MIDI handling and selections persist when switching. Native tab pages hide or detach inactive drop destinations so karaoke cannot intercept DJ drops. The DJ tab has a larger independent browser. Existing singer, venue, rotation, video, background music and recursive directory import features remain available. Background music is a compact strip above karaoke transport. Each tab opens its own settings scope. +The main window has two tabs: **Karaoke** and **DJ Studio**. Playback, MIDI handling and selections persist when switching. Native tab pages hide or detach inactive drop destinations so karaoke cannot intercept DJ drops. The DJ library and queue fill the remaining window height. The two bottom status messages and the DJ Settings, Audio Routing and MIDI header buttons have been removed. Settings… (Command-comma) stays available in either workspace and opens the matching Karaoke or DJ scope; choose Devices or MIDI there. Existing singer, venue, rotation, video, background music and recursive directory import features remain available. Background music is a compact strip above karaoke transport. Each tab opens its own settings scope. ## 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. - 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. Click/drag to seek. A white position line and green loop region show current state. +- 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. - 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. 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. +- 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. Either Sync button links both decks to deck A’s effective BPM and both buttons show active. Press either again to unlink and restore both original tempos. Sync follows changes to A’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. - The crossfader and DJ output headroom affect only DJ playback. Equal-power and linear crossfader curves are selectable. The karaoke master and saved singer preferences are independent. The wing meters show individual deck peaks, not a summed master meter. There is no master limiter; use gain/levels appropriately. - Karaoke and DJ music can play simultaneously. The existing background-to-singer interlock is preserved, but it does not automatically duck the new DJ decks. @@ -70,6 +70,6 @@ Media imports stream into a staging file, verify SHA-256, and atomically rename The full macOS suite covers karaoke, recursive CDG/audio import, independent DJ imports/playlists, rotation, background handoff, media repair, real audio loops, live loop resizing, linked Sync, cue editing, waveform/BPM guides, Finder override, artwork, real knob double-clicks, native faders, XML profiles, automatic controller detection/reconnection, actual CoreMIDI output and delayed LED initialization/shutdown. Native workspace tab switching preserves simultaneous playback. Automix tests exercise timed transitions, both playing decks during a fade, manual takeover, loop waiting, unavailable files and queue persistence without launch autoplay. -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 under `/private/tmp/kjmix-xml-mappings`. +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: **145 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. +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.