broken changes

This commit is contained in:
Mark Gregory
2026-11-05 13:42:20 +00:00
parent d5b24bff29
commit d565e40215
7 changed files with 808 additions and 258 deletions
+80 -17
View File
@@ -269,6 +269,7 @@ struct DJDeckControls: View {
struct StudioSettingsPane: View {
var showHeading = true
var section: String? = nil
@AppStorage("studioAppearance") private var appearance = "system"
@ObservedObject var model: AppModel
@State private var port = ""
@@ -276,7 +277,8 @@ struct StudioSettingsPane: View {
ScrollView {
VStack(alignment:.leading,spacing:22) {
if showHeading { HStack { VStack(alignment:.leading,spacing:4) { Text("Studio Settings").font(.title2.bold());Text("Changes save automatically on this Mac.").foregroundStyle(.secondary) };Spacer() } }
GroupBox("Appearance") {
if section == nil || section == "Appearance" {
PreferencesSection("Appearance") {
HStack {
Picker("Appearance",selection:$appearance) {
Text("System").tag("system")
@@ -287,7 +289,9 @@ struct StudioSettingsPane: View {
}.padding(10)
Text("System follows your Mac. The waveform and video output remain dark for clear monitoring.").font(.caption).foregroundStyle(.secondary).padding([.horizontal,.bottom],10)
}
GroupBox("Playback & Jog") {
}
if section == nil || section == "General" {
PreferencesSection("Playback & Jog") {
VStack(alignment:.leading,spacing:12) {
Toggle("Automatically play the next queued singer",isOn:$model.studio.autoAdvance)
Toggle("Repeat on startup",isOn:$model.studio.repeatTrack)
@@ -297,14 +301,18 @@ struct StudioSettingsPane: View {
Text("Option-scroll over the waveform to jog. Cue, hot cues and loops belong to the loaded deck and reset on track change.").font(.caption).foregroundStyle(.secondary)
}.padding(10)
}
GroupBox("Hosting · \(model.currentVenueName)") {
}
if section == nil || section == "General" {
PreferencesSection("Hosting · \(model.currentVenueName)") {
VStack(alignment:.leading,spacing:12) {
Toggle("Fade song endings",isOn:Binding(get:{model.hostSettings.autoFade},set:{model.hostSettings.autoFade = $0;model.saveHostSettings()}))
Stepper("Fade duration: \(Int(model.hostSettings.fadeSeconds)) seconds",value:Binding(get:{model.hostSettings.fadeSeconds},set:{model.hostSettings.fadeSeconds = $0;model.saveHostSettings()}),in:1...15,step:1)
Picker("Between singers",selection:Binding(get:{model.hostSettings.intermission},set:{model.hostSettings.intermission = $0;model.saveHostSettings()})) { Text("No automatic slide").tag(nil as Int64?);ForEach(model.showScreens) { Text($0.title).tag(Optional($0.id)) } }
}.padding(10)
}.disabled(model.currentVenue == nil)
GroupBox("Background Music") {
}
if section == nil || section == "Sound" {
PreferencesSection("Background Music") {
VStack(alignment:.leading,spacing:12) {
HStack { Text(model.studio.backgroundPath.map{URL(fileURLWithPath:$0).lastPathComponent} ?? "No track selected").lineLimit(1);Spacer();Button("Choose Track…") { model.chooseBackground() };Button("Clear") { model.studio.backgroundPath = nil;model.studio.backgroundBookmark = nil }.disabled(model.studio.backgroundPath == nil) }
Toggle("Automatically play background music between singers",isOn:Binding(get:{model.studio.backgroundAutoEnabled},set:{model.studio.automaticBackground = $0}))
@@ -313,11 +321,15 @@ struct StudioSettingsPane: View {
HStack { Text("Fade out");Slider(value:Binding(get:{model.studio.backgroundFadeOut ?? 0},set:{model.studio.backgroundFadeOut = $0}),in:0...15,step:0.25);Text(String(format:"%.2f s",model.studio.backgroundFadeOut ?? 0)).monospacedDigit().frame(width:65) }
Text("Play uses Fade in; background Pause and Stop use Fade out. Zero disables a fade. Singer audio waits for the background fade-out to finish.").font(.caption).foregroundStyle(.secondary)
HStack { Text("Background level");Slider(value:$model.studio.backgroundTrim,in:0...1);Text("\(Int(model.studio.backgroundTrim*100))%").monospacedDigit() }
Text("Auto resumes background music after the singer stops, pauses, or finishes. Starting a singer fades it out first. Manual background Pause or Stop switches Auto off. Restoring a saved track at launch does not autoplay. Global EQ and master apply to both decks.").font(.caption).foregroundStyle(.secondary)
Text("Auto resumes background music after the singer stops, pauses, or finishes. Starting a singer fades it out first. Manual background Pause or Stop switches Auto off. Restoring a saved track at launch does not autoplay. Karaoke master and EQ apply to singer and background audio; DJ has separate controls.").font(.caption).foregroundStyle(.secondary)
}.padding(10)
}
}
if section == nil || section == "Sound" {
GlobalMixerSettings(player:model.player)
GroupBox("Imported Media Storage") {
}
if section == nil || section == "Library" {
PreferencesSection("Imported Media Storage") {
VStack(alignment:.leading,spacing:12) {
Label(model.managedMediaDirectory.path,systemImage:"externaldrive").font(.callout).textSelection(.enabled).lineLimit(3)
HStack {
@@ -329,7 +341,9 @@ struct StudioSettingsPane: View {
Text("The small library database, singer lists, settings and waveform cache stay on this Mac.").font(.caption).foregroundStyle(.secondary)
}.padding(10)
}
GroupBox("Singer Web & Library") {
}
if section == nil || section == "Advanced" {
PreferencesSection("Singer Web & Library") {
VStack(alignment:.leading,spacing:12) {
HStack { Text("Default server port");TextField("8080",text:$port).frame(width:90);Button("Apply Port") { if let value = Int(port),(1024...65535).contains(value) { model.studio.serverPort = value } else { model.error = "Use a port from 1024 to 65535" } } }
Text("Used next time you start Singer Web. The server remains off until started explicitly.").font(.caption).foregroundStyle(.secondary)
@@ -337,6 +351,7 @@ struct StudioSettingsPane: View {
Text("Audio output follows the Mac’s current sound output. Configure hardware output in macOS Sound settings.").font(.caption).foregroundStyle(.secondary)
}.padding(10)
}
}
}.padding(22)
}.background(Color(nsColor:.windowBackgroundColor)).onAppear { port = String(model.studio.serverPort) }
}
@@ -610,24 +625,72 @@ struct RotationSongBrowser: View {
}
}
struct PreferencesSection<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
init(_ title: String,@ViewBuilder content: @escaping () -> Content) { self.title = title;self.content = content }
var body: some View {
VStack(alignment:.leading,spacing:12) {
HStack(spacing:8) { Text(title).font(.headline);Rectangle().fill(Color.primary.opacity(0.12)).frame(height:1) }
content().frame(maxWidth:.infinity,alignment:.leading)
}.frame(maxWidth:.infinity,alignment:.leading)
}
}
struct StudioSettingsDialog: View {
@ObservedObject var model: AppModel
@State var scope = "Karaoke"
@State var category = "General"
@Environment(\.dismiss) private var dismiss
private var categories: [String] { scope == "DJ" ? ["General","Devices","Sound","Library","Appearance","MIDI","Advanced"] : ["General","Devices","Sound","Library","Appearance","Singer Web","Advanced"] }
private func icon(_ name: String) -> String { ["General":"gearshape","Devices":"hifispeaker","Sound":"waveform","Library":"music.note.house","Appearance":"eye","MIDI":"pianokeys","Advanced":"gearshape.2","Singer Web":"network"][name] ?? "gearshape" }
var body: some View {
VStack(spacing:0) {
HStack(spacing:14) {
Image(systemName:"slider.horizontal.3").font(.title2).foregroundStyle(Color.accentColor)
VStack(alignment:.leading,spacing:4) {
Text("Studio Settings").font(.title2.bold())
Text("Karaoke playback, background music and hosting").font(.callout).foregroundStyle(.secondary)
HStack {
Text(category).font(.headline).frame(maxWidth:.infinity,alignment:.leading)
Picker("Settings for",selection:$scope) { Text("Karaoke").tag("Karaoke");Text("DJ").tag("DJ") }.pickerStyle(.segmented).frame(width:220)
}.padding(.horizontal,24).padding(.top,18).padding(.bottom,10)
HStack(spacing:8) {
ForEach(categories,id:\.self) { name in
Button { category = name } label: {
VStack(spacing:5) { Image(systemName:icon(name)).font(.system(size:24));Text(name).font(.system(size:11,weight:.medium)) }.frame(maxWidth:.infinity).frame(height:58).foregroundStyle(category == name ? Color.accentColor : Color.secondary).background(category == name ? Color.primary.opacity(0.08) : .clear,in:RoundedRectangle(cornerRadius:9))
}.buttonStyle(.plain).accessibilityLabel(scope+" "+name+" settings")
}
Spacer()
}.padding(24)
}.padding(.horizontal,24).padding(.bottom,16)
Divider()
StudioSettingsPane(showHeading:false,model:model)
if scope == "DJ" { DJPreferencesPane(system:model.dj,category:category) }
else if category == "Singer Web" { SingerServerPane(model:model,server:model.singerServer) }
else if category == "Devices" { KaraokeDevicesPreferences(model:model) }
else { StudioSettingsPane(showHeading:false,section:category,model:model).id(category) }
Divider()
HStack { Text("Changes save automatically on this Mac").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16)
}.frame(width:760,height:680).background(Color(nsColor:.windowBackgroundColor))
HStack { Text(scope+" 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 }
.onDisappear { model.dj.midi.learning = nil }
.alert("Settings",isPresented:Binding(get:{model.error != nil},set:{if !$0 { model.error = nil }})) { Button("OK") { model.error = nil } } message: { Text(model.error ?? "") }
}
}
private struct KaraokeDevicesPreferences: View {
@ObservedObject var model: AppModel
var body: some View {
ScrollView {
PreferencesSection("Karaoke Audio Output") {
VStack(alignment:.leading,spacing:16) {
Picker("Main output",selection:Binding(get:{model.karaokeOutputUID},set:{model.routeKaraokeOutput($0)})) { Text("System default").tag("");ForEach(model.dj.devices) { Text($0.name).tag($0.uid) } }.frame(maxWidth:520)
Button("Refresh devices") { model.dj.refreshDevices() }
Text("Applies to the singer deck. Background music follows the system output. DJ audio is configured separately under DJ → Devices.").font(.callout).foregroundStyle(.secondary)
}.padding(12)
}.padding(24)
}.onAppear { model.dj.refreshDevices() }
}
}
@MainActor extension AppModel {
func routeKaraokeOutput(_ uid: String) {
perform {
guard let id = uid.isEmpty ? DJAudioDevice.defaultOutput() : dj.devices.first(where:{$0.uid == uid})?.id else { throw LibraryError.message("Output device unavailable") }
try player.routeOutput(id);karaokeOutputUID = uid
}
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -65,6 +65,7 @@ struct SongDragReference: Codable, Sendable {
@Published var activeSection = "Library"
@Published var studio = StudioSettings() { didSet { applyStudioSettings(save:true,previous:oldValue) } }
let backgroundDeck = BackgroundDeck()
@Published var karaokeOutputUID = ""
let dj: DJSystem
var backgroundAutomationReady = false
var backgroundPlaybackObserver: AnyCancellable?
@@ -933,7 +934,6 @@ struct LibraryView: View {
navigationRow("Venues",symbol:"building.2",count:model.venues.count)
navigationRow("Library Locations",symbol:"externaldrive",count:model.mediaLocations.count)
navigationRow("ShowScreens",symbol:"rectangle.on.rectangle")
navigationRow("Singer Web",symbol:"network")
navigationRow("Music Stores",symbol:"bag")
Button { studioSettingsModal = true } label: { Label("Settings…",systemImage:"gearshape").frame(maxWidth:.infinity,alignment:.leading).padding(9) }.buttonStyle(.plain)
}.font(.body).listRowSeparator(.hidden).listRowInsets(EdgeInsets(top:1,leading:8,bottom:1,trailing:8))
+11 -2
View File
@@ -27,7 +27,11 @@ import CoreAudio
guard canJog else { return }
pauseDeck();loopEnabled = false;seek(0)
}
func setCue() { guard canJog else { return };cuePosition = time }
func setCue(at seconds: Double? = nil) {
guard canJog else { return }
if let seconds { guard seconds.isFinite else { return };cuePosition = min(duration,max(0,seconds)) }
else { tick();cuePosition = time }
}
func returnToCue() { guard canJog else { return };pauseDeck();seek(cuePosition) }
private func pauseDeck() {
cancelPendingPlayback()
@@ -45,6 +49,10 @@ import CoreAudio
func markLoopOut() {
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 }
loopIn = start;loopOut = end;loopEnabled = true
}
func clearLoop() { loopEnabled = false;loopIn = nil;loopOut = nil }
func jog(_ seconds: Double) { guard seconds.isFinite,canJog else { return };seek(time+seconds) }
private var graphicsGeneration = UUID()
@@ -241,7 +249,8 @@ import CoreAudio
Task { @MainActor in
guard let self, self.generation == token else { return }
if self.fading { return }
if self.repeating { self.schedule(at:0,play:true) }
if self.loopEnabled,let start = self.loopIn { self.schedule(at:start,play:true) }
else if self.repeating { self.schedule(at:0,play:true) }
else {
self.playing = false; self.time = self.duration; self.render()
self.timer?.invalidate(); self.timer = nil; self.onFinished?()
+79 -8
View File
@@ -1733,14 +1733,28 @@ extension KJamsCloneTests {
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-dj-native.png")) }
XCTAssertEqual(model.dj.a.player.currentSongID,id);XCTAssertEqual(model.dj.b.player.currentSongID,id)
XCTAssertFalse(model.dj.a.player.playing);XCTAssertNil(model.player.currentSongID)
let settings = NSHostingView(rootView:StudioSettingsDialog(model:model).preferredColorScheme(.dark))
settings.frame = NSRect(x:0,y:0,width:760,height:680)
let settingsWindow = NSWindow(contentRect:settings.frame,styleMask:[.borderless],backing:.buffered,defer:false)
settingsWindow.appearance = NSAppearance(named:.darkAqua);settingsWindow.contentView = settings
settingsWindow.setFrameOrigin(NSPoint(x:-10000,y:-10000));settingsWindow.orderFront(nil)
defer { settingsWindow.orderOut(nil);settingsWindow.contentView = nil }
try await Task.sleep(nanoseconds:200_000_000);settings.layoutSubtreeIfNeeded()
if let rep = settings.bitmapImageRepForCachingDisplay(in:settings.bounds) { settings.cacheDisplay(in:settings.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-settings-native.png")) }
func capturePreferences(_ scope: String,_ category: String,_ path: String) async throws {
let settings = NSHostingView(rootView:StudioSettingsDialog(model:model,scope:scope,category:category).preferredColorScheme(.dark))
settings.frame = NSRect(x:0,y:0,width:850,height:700)
let settingsWindow = NSWindow(contentRect:settings.frame,styleMask:[.borderless],backing:.buffered,defer:false)
settingsWindow.appearance = NSAppearance(named:.darkAqua);settingsWindow.contentView = settings
settingsWindow.setFrameOrigin(NSPoint(x:-10000,y:-10000));settingsWindow.orderFront(nil)
defer { settingsWindow.orderOut(nil);settingsWindow.contentView = nil }
try await Task.sleep(nanoseconds:150_000_000);settings.layoutSubtreeIfNeeded()
if let rep = settings.bitmapImageRepForCachingDisplay(in:settings.bounds) { settings.cacheDisplay(in:settings.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:path)) }
}
try await capturePreferences("Karaoke","General","/private/tmp/kjmix-settings-native.png")
try await capturePreferences("DJ","General","/private/tmp/kjmix-dj-settings-general.png")
try await capturePreferences("DJ","Sound","/private/tmp/kjmix-dj-settings-sound.png")
try await capturePreferences("DJ","Appearance","/private/tmp/kjmix-dj-settings-appearance.png")
let console = NSHostingView(rootView:DJContinuousConsole(system:model.dj,loadA:{},loadB:{}).frame(width:1320,height:390).foregroundStyle(.white).preferredColorScheme(.dark))
console.frame = NSRect(x:0,y:0,width:1320,height:390)
let consoleWindow = NSWindow(contentRect:console.frame,styleMask:[.borderless],backing:.buffered,defer:false)
consoleWindow.appearance = NSAppearance(named:.darkAqua);consoleWindow.contentView = console;consoleWindow.setFrameOrigin(NSPoint(x:-10000,y:-10000));consoleWindow.orderFront(nil)
defer { consoleWindow.orderOut(nil);consoleWindow.contentView = nil }
try await Task.sleep(nanoseconds:150_000_000);console.layoutSubtreeIfNeeded()
if let rep = console.bitmapImageRepForCachingDisplay(in:console.bounds) { console.cacheDisplay(in:console.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-dj-console.png")) }
}
}
@@ -1852,3 +1866,60 @@ extension KJamsCloneTests {
XCTAssertFalse(player.playing);XCTAssertNil(player.currentSongID)
}
}
extension KJamsCloneTests {
@MainActor func testNativeDJFaderOrientationAndControlActionUpdateBinding() async throws {
var value = 0.5
let host = NSHostingView(rootView:DJHardwareFader(value:Binding(get:{value},set:{value = $0}),label:"Tempo test",vertical:true).frame(width:52,height:94))
host.frame = NSRect(x:0,y:0,width:52,height:94)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false)
window.contentView = host;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil)
defer { window.orderOut(nil);window.contentView = nil }
try await Task.sleep(nanoseconds:150_000_000);host.layoutSubtreeIfNeeded()
func find(_ view: NSView) -> NSSlider? { if let slider = view as? NSSlider { return slider };return view.subviews.compactMap{find($0)}.first }
let slider = try XCTUnwrap(find(host))
XCTAssertTrue(slider.isVertical);XCTAssertEqual(slider.doubleValue,0.5,accuracy:0.001)
slider.doubleValue = 0.25
XCTAssertTrue(slider.sendAction(slider.action,to:slider.target));XCTAssertEqual(value,0.25,accuracy:0.001)
slider.doubleValue = 1
XCTAssertTrue(slider.sendAction(slider.action,to:slider.target));XCTAssertEqual(value,1,accuracy:0.001)
}
}
extension KJamsCloneTests {
func testDJPreferencesTempoDirectionRangesAndMixCurves() {
var prefs = DJPreferences()
XCTAssertEqual(prefs.tempo(at:0.5),1,accuracy:0.0001)
XCTAssertEqual(prefs.tempo(at:1),0.84,accuracy:0.0001)
XCTAssertEqual(prefs.position(for:1.16),0,accuracy:0.0001)
prefs.invertTempo = false;prefs.tempoRange = 8
XCTAssertEqual(prefs.tempo(at:0),0.92,accuracy:0.0001)
XCTAssertEqual(prefs.tempo(at:1),1.08,accuracy:0.0001)
XCTAssertEqual(prefs.position(for:prefs.tempo(at:0.25)),0.25,accuracy:0.0001)
let linear = DJMix.weights(0.5,curve:"Linear")
XCTAssertEqual(linear.0,0.5);XCTAssertEqual(linear.1,0.5)
prefs.tempoRange = .nan;prefs.libraryFont = .infinity;prefs.crossfaderCurve = "invalid"
XCTAssertEqual(prefs.sanitized.tempoRange,16);XCTAssertEqual(prefs.sanitized.libraryFont,11);XCTAssertEqual(prefs.sanitized.crossfaderCurve,"Equal power")
}
@MainActor func testDJSettingsPersistenceLoadProtectionEQAndHotCueAutoplay() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let first = Song(id:1,title:"First",audio:fixture.path,cdg:nil,rating:0,plays:0)
let second = Song(id:2,title:"Second",audio:fixture.path,cdg:nil,rating:0,plays:0)
let system = DJSystem(storageURL:root);system.master = 0
defer { system.a.player.stop();system.b.player.stop() }
system.load(first,into:"A");system.a.player.toggle()
system.load(second,into:"A");XCTAssertNotNil(system.error);XCTAssertEqual(system.a.song?.id,1)
system.a.player.stopDeck();system.error = nil
system.settings.autoPlayOnLoad = true;system.settings.resetEQOnLoad = true
system.settings.tempoRange = 8;system.settings.crossfaderCurve = "Linear";system.settings.headroomDB = 6
system.a.player.lowGain = 6;system.load(second,into:"A")
XCTAssertNil(system.error);XCTAssertEqual(system.a.song?.id,2);XCTAssertTrue(system.a.player.playing);XCTAssertEqual(system.a.player.lowGain,0)
system.a.player.stopDeck();system.settings.autoPlayOnLoad = false;system.settings.autoPlayHotCue = true
system.hotCue(0,deck:system.a);XCTAssertFalse(system.a.player.playing)
system.hotCue(0,deck:system.a);XCTAssertTrue(system.a.player.playing)
let restored = DJSystem(storageURL:root)
XCTAssertEqual(restored.settings,system.settings)
}
}
+12 -2
View File
@@ -8,10 +8,10 @@ The main window now opens with the original karaoke workspace above a resizable
- 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. Loading a playing deck replaces that track.
- 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.
- High/mid/low EQ, bipolar filter, gain and level are beside each platter. 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.
- 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. Manual IN/OUT loops do not need BPM. Beat loops require confirmed BPM; choose 1–32 beats.
- Enter source BPM (40–240) or tap at least three beats. Loading resets BPM to an unconfirmed placeholder. Sync matches the other deck’s effective tempo; it does not analyse BPM or align a beat grid. Use the jog/nudge buttons to align beats. Tempo adjustment preserves pitch, using AVAudioUnitTimePitch.
- DJ master, channel levels and equal-power crossfader affect only DJ playback. The karaoke master and saved singer preferences are independent. The two meters show individual deck peaks, not a summed master meter. There is no master limiter; use gain/levels appropriately.
- 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.
## Library and playlists
@@ -58,3 +58,13 @@ The three zero-byte files observed in the local DJ media directory were repaired
Studio settings open as a dedicated modal from the toolbar, sidebar or Command-comma. Song Settings also opens as a titled modal with a Done action. The deprecated AVAsset metadata read in FileTagWriter now uses asynchronous loading.
Verification after these changes: Debug build succeeds; **116 tests pass with zero failures**, including partial-copy repair, source permission preservation, failed-verification/cancellation cleanup and readable empty-audio errors. Native settings modal capture was inspected.
## Continuous console and separate preferences
The deck area is now one continuous console following the supplied reference: track headers and overview waveforms above; mirrored tempo/platter/EQ/filter wings; stacked, source-timed eight-second scrolling waveforms in the centre; and a shared bottom transport strip with a centred rectangular-cap crossfader. Native vertical tempo faders have visible scale marks, keyboard/accessibility support and a tested control-to-binding action. Play, Cue, loop size, headphones and hot cues remain available. No on-screen gain or channel-level knobs are shown. Cover tiles currently show a music-note placeholder; album artwork and key analysis are not added.
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.
No nonfunctional switches were added for DVS, booth/microphone routing, Neural Mix, automix, recording, Spotify or cloud sync. Sync remains entered/tapped BPM tempo matching within the chosen range; centre waveform grids mark time, not automatically detected beats.
Validation: full suite **119 tests, zero failures**, including preference persistence, load protection, EQ reset, cue autoplay, tempo mapping and native fader orientation/actions. Native console and settings captures were visually inspected. Temporary builds, logs and repair tools were moved outside Developer to /private/tmp.
-48
View File
@@ -1,48 +0,0 @@
import hashlib
import os
from pathlib import Path
import sqlite3
import sys
import tempfile
root = Path.home() / 'Library/Application Support/KJamsClone/DJ'
media = root / 'Media'
def digest(path):
h = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
h.update(chunk)
return h.hexdigest()
with sqlite3.connect(f'file:{root / "Library.sqlite"}?mode=ro', uri=True) as db:
originals = [Path(row[0]) for row in db.execute('select original_path from songs') if row[0]]
# Include the failed, unregistered source only within the same explicitly imported folder.
folders = {p.parent for p in originals}
sources = set(originals)
for folder in folders:
sources.update(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() == '.mp3')
repairs = []
for source in sorted(sources):
if not source.is_file() or source.stat().st_size == 0:
continue
expected = digest(source)
target = media / (expected + source.suffix.lower())
if target.is_file() and target.stat().st_size == 0:
repairs.append((source, target, expected))
print(f'Verified {len(repairs)} empty DJ copies with matching original audio.')
if '--apply' in sys.argv:
for source, target, expected in repairs:
descriptor, temporary = tempfile.mkstemp(prefix='.kjmix-repair-', dir=media)
try:
with os.fdopen(descriptor, 'wb') as output, source.open('rb') as input_stream:
for chunk in iter(lambda: input_stream.read(1024 * 1024), b''):
output.write(chunk)
output.flush()
os.fsync(output.fileno())
assert digest(Path(temporary)) == expected
assert target.stat().st_size == 0, 'Destination changed; refusing to replace it'
os.chmod(temporary, 0o644)
os.replace(temporary, target)
print(f'Repaired verified audio: {target.name} ({target.stat().st_size} bytes)')
finally:
if os.path.exists(temporary):
os.unlink(temporary)