diff --git a/juke-wizard/Package.swift b/juke-wizard/Package.swift --- a/juke-wizard/Package.swift +++ b/juke-wizard/Package.swift @@ -8,15 +8,31 @@ dependencies: [ .package(path: "../slab/macos-audio"), ], targets: [ + .target( + name: "JukeDSP", + path: "Sources/JukeDSP", + publicHeadersPath: "include" + ), .executableTarget( name: "JukeWizard", dependencies: [ .product(name: "ACMacAudio", package: "macos-audio"), + "JukeDSP", ], path: "Sources/JukeWizard", resources: [ .copy("Assets"), ] + ), + .testTarget( + name: "JukeDSPTests", + dependencies: ["JukeDSP"], + path: "Tests/JukeDSPTests" + ), + .testTarget( + name: "JukeWizardTests", + dependencies: ["JukeWizard"], + path: "Tests/JukeWizardTests" ), ] ) diff --git a/juke-wizard/Sources/JukeDSP/JukeDSP.c b/juke-wizard/Sources/JukeDSP/JukeDSP.c new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeDSP/JukeDSP.c @@ -0,0 +1,179 @@ +#include "JukeDSP.h" +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif +#define TAU (2.0 * M_PI) + +static float soft(float x) { return tanhf(x * 1.12f); } + +static uint32_t hash32(uint32_t x) { + x ^= x >> 16; x *= 0x7feb352du; + x ^= x >> 15; x *= 0x846ca68bu; + return x ^ (x >> 16); +} + +static float hash_bipolar(uint32_t x) { + return ((float)(hash32(x) & 0xffffu) / 32767.5f) - 1.0f; +} + +void ac_scratch_init(ACScratchState *state) { + memset(state, 0, sizeof(*state)); + state->velocity = 1.0; +} + +double ac_scratch_motion(ACScratchState *state, double hand_velocity, + double position_error, int scratching, + double sample_rate) { + if (!scratching) { + // Playback-rate changes slew over roughly 8 ms. + const double follow = 1.0 - exp(-1.0 / fmax(1.0, sample_rate * 0.008)); + state->velocity += (hand_velocity - state->velocity) * follow; + return state->velocity; + } + + // video.mjs uses drag velocity as the playback rate. Keep that direct + // musical mapping here, with only a gentle position correction so event + // timing cannot accumulate drift between the hand and the groove. + double correction = position_error / fmax(1.0, sample_rate * 0.012); + correction = fmax(-0.35, fmin(0.35, correction)); + double desired = hand_velocity + correction; + const double follow = 1.0 - exp(-1.0 / fmax(1.0, sample_rate * 0.0035)); + state->velocity += (desired - state->velocity) * follow; + + // A still hand catches the record and lands exactly without oscillating. + // While the hand is moving, velocity remains continuous through targets. + if (fabs(hand_velocity) < 0.02 && + fabs(position_error) <= fmax(0.002, fabs(state->velocity))) { + state->velocity = 0.0; + return position_error; + } + return state->velocity; +} + +float ac_scratch_cubic(float xm1, float x0, float x1, float x2, float t) { + // Catmull-Rom: local, reversible, and continuous through sample boundaries. + float a = -0.5f*xm1 + 1.5f*x0 - 1.5f*x1 + 0.5f*x2; + float b = xm1 - 2.5f*x0 + 2.0f*x1 - 0.5f*x2; + float c = -0.5f*xm1 + 0.5f*x1; + return ((a*t + b)*t + c)*t + x0; +} + +static float pixel_groove(double position, uint32_t channel) { + // The noise coordinate travels with the record. Crossing a cell in reverse + // returns the identical grain—the tactile repeatability unique to digital. + const double cell_position = position / 24.0; + const long long cell = (long long)floor(cell_position); + float t = (float)(cell_position - floor(cell_position)); + t = t * t * (3.0f - 2.0f * t); + uint32_t akey = (uint32_t)cell ^ (channel * 0x9e3779b9u); + uint32_t bkey = (uint32_t)(cell + 1) ^ (channel * 0x9e3779b9u); + return hash_bipolar(akey) + (hash_bipolar(bkey) - hash_bipolar(akey)) * t; +} + +float ac_scratch_material(ACScratchState *state, float sample, int channel, + double sample_position, double motion, int scratching) { + int c = channel & 1; + float x = sample; + if (scratching) { + float grain = pixel_groove(sample_position, (uint32_t)c); + float grain_gain = (float)fmin(0.0045, 0.0007 + fabs(motion) * 0.0005); + state->body[c] += (x - state->body[c]) * 0.12f; + float edge = x - state->body[c]; + x = soft(x + edge * 0.08f + grain * grain_gain); + state->output[c] += (x - state->output[c]) * 0.42f; + } else { + state->body[c] = x; + state->output[c] = x; + } + return state->output[c]; +} + +double ac_platter_contact_motion(const ACPlatterContact *contacts, size_t count, + double seconds_per_revolution) { + if (!contacts || count == 0 || seconds_per_revolution <= 0.0) return 0.0; + + double torque = 0.0; + size_t engaged = 0; + for (size_t i = 0; i < count; i++) { + const ACPlatterContact *contact = &contacts[i]; + const double previous_radius = hypot(contact->previous_x, contact->previous_y); + const double current_radius = hypot(contact->current_x, contact->current_y); + const double radius = (previous_radius + current_radius) * 0.5; + if (radius <= 0.08) continue; + + double delta = atan2(contact->current_y, contact->current_x) + - atan2(contact->previous_y, contact->previous_x); + if (delta > M_PI) delta -= TAU; + if (delta < -M_PI) delta += TAU; + + const double leverage = fmax(0.0, fmin(1.0, (radius - 0.08) / 0.92)); + torque += delta * leverage; + engaged++; + } + if (engaged == 0) return 0.0; + + const double angular_motion = torque / sqrt((double)engaged); + return -(angular_motion / TAU) * seconds_per_revolution * 0.62; +} + +void ac_practice_render(int variant, float *left, float *right, size_t frames, + double sample_rate, double bpm) { + const double beat = 60.0 / bpm; + float previous_noise = 0.0f; + for (size_t i = 0; i < frames; i++) { + double t = (double)i / sample_rate; + long beat_index = (long)floor(t / beat); + double beat_time = t - beat_index * beat; + long half_index = (long)floor(t / (beat * 0.5)); + double half_time = t - half_index * beat * 0.5; + + // minitek's pitch-enveloped sine kick, expressed analytically so its + // phase remains sample-exact without an event allocator. + double kick_phase = TAU * (48.0 * beat_time + + 72.0 * (1.0 - exp(-42.0 * beat_time)) / 42.0); + double kick = tanh((sin(kick_phase) + exp(-beat_time * 360.0) * 0.7) * 1.9) + * exp(-beat_time * 8.5); + + uint32_t key = (uint32_t)i ^ (variant ? 0x57415645u : 0x48415453u); + float noise = hash_bipolar(key); + float high = noise - previous_noise; + previous_noise = noise; + double hat_decay = (half_index % 4 == 3) ? 42.0 : 130.0; + double hat = high * exp(-half_time * hat_decay) * 0.20; + + int beat_in_bar = (int)(beat_index & 3); + double clap = 0.0; + if ((beat_in_bar == 1 || beat_in_bar == 3) && beat_time < 0.20) { + double spits = beat_time < 0.028 ? exp(-fmod(beat_time, 0.010) * 600.0) : 0.0; + clap = high * (spits * 0.8 + exp(-beat_time * 16.0)) * 0.22; + } + + double sidechain = 0.38 + 0.62 * fmin(1.0, beat_time / 0.16); + double wave; + if (variant == 0) { + double note = (beat_index % 4 == 3) ? 65.406 : 55.0; + wave = tanh((sin(TAU * note * t) + 0.14 * sin(TAU * note * 2.0 * t)) * 1.25) + * 0.18 * sidechain; + } else { + static const double notes[8] = {55.0, 65.406, 73.416, 82.407, 73.416, 65.406, 49.0, 55.0}; + double hz = notes[half_index & 7]; + double phase = fmod(hz * t, 1.0); + double saw = phase * 2.0 - 1.0; + wave = tanh((saw + 0.35 * sin(TAU * hz * t)) * 1.1) * 0.13 * sidechain; + } + + double voice; + switch (variant) { + case 0: voice = kick * 0.78; break; + case 1: voice = hat * 1.35; break; + case 2: voice = clap * 1.45; break; + default: voice = wave * 1.35; break; + } + double pan = (half_index & 1) ? 0.16 : -0.16; + left[i] = soft((float)(voice * (1.0 - pan * 0.35))); + right[i] = soft((float)(voice * (1.0 + pan * 0.35))); + } +} diff --git a/juke-wizard/Sources/JukeDSP/include/JukeDSP.h b/juke-wizard/Sources/JukeDSP/include/JukeDSP.h new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeDSP/include/JukeDSP.h @@ -0,0 +1,46 @@ +#ifndef JUKE_DSP_H +#define JUKE_DSP_H + +#include +#include + +typedef struct { + double velocity; + float body[2]; + float output[2]; +} ACScratchState; + +// One trackpad contact mapped into platter space, where -1...1 spans the +// visible record in each axis. +typedef struct { + double previous_x; + double previous_y; + double current_x; + double current_y; +} ACPlatterContact; + +void ac_scratch_init(ACScratchState *state); + +// Hand inertia + stylus compliance. position_error is measured in samples. +double ac_scratch_motion(ACScratchState *state, double hand_velocity, + double position_error, int scratching, + double sample_rate); + +// Four-point interpolation avoids the brittle edge of linear resampling. +float ac_scratch_cubic(float xm1, float x0, float x1, float x2, float fraction); + +// "Pixel groove": reversible coordinate-bound grain, hysteresis, and a soft +// output slew. Texture belongs to the record position rather than wall time. +float ac_scratch_material(ACScratchState *state, float sample, int channel, + double sample_position, double motion, int scratching); + +// Convert simultaneous spatial contacts into record-time movement. Contacts +// near the rim have more leverage; moving fingers add torque for crab scratches. +double ac_platter_contact_motion(const ACPlatterContact *contacts, size_t count, + double seconds_per_revolution); + +// Deterministic one-voice practice loops: kick, hat, clap, or wave bass. +void ac_practice_render(int variant, float *left, float *right, size_t frames, + double sample_rate, double bpm); + +#endif diff --git a/juke-wizard/Sources/JukeWizard/DJFocusFeedback.swift b/juke-wizard/Sources/JukeWizard/DJFocusFeedback.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/DJFocusFeedback.swift @@ -0,0 +1,74 @@ +import AppKit +import AVFoundation + +final class DJFocusFlash: NSPanel { + static let shared = DJFocusFlash() + + private init() { + super.init(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, defer: false) + isOpaque = false + backgroundColor = .clear + hasShadow = false + level = .screenSaver + ignoresMouseEvents = true + hidesOnDeactivate = false + alphaValue = 0 + collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] + let view = NSView() + view.wantsLayer = true + contentView = view + } + + func flash(rising: Bool) { + guard let screen = NSScreen.main, let layer = contentView?.layer else { return } + setFrame(screen.frame, display: true) + layer.removeAllAnimations() + layer.backgroundColor = (rising + ? NSColor(srgbRed: 0.06, green: 0.42, blue: 1, alpha: 1) + : NSColor(srgbRed: 1, green: 0.12, blue: 0.16, alpha: 1)).cgColor + alphaValue = 0.42 + orderFrontRegardless() + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.17 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + animator().alphaValue = 0 + }, completionHandler: { [weak self] in self?.orderOut(nil) }) + } +} + +final class DJFocusDing { + static let shared = DJFocusDing() + private let engine = AVAudioEngine() + private let player = AVAudioPlayerNode() + private let sampleRate = 44_100.0 + private var started = false + + private init() { + engine.attach(player) + engine.connect(player, to: engine.mainMixerNode, + format: AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)) + engine.prepare() + } + + func play(rising: Bool) { + if !started { + guard (try? engine.start()) != nil else { return } + started = true + } + let frames = AVAudioFrameCount(sampleRate * 0.12) + guard let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1), + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames), + let data = buffer.floatChannelData?[0] else { return } + buffer.frameLength = frames + let frequency = rising ? 1318.5 : 880.0 + for frame in 0.. Void) -> Timer { + let timer = Timer(timeInterval: interval, repeats: repeats, block: block) + RunLoop.main.add(timer, forMode: .common) + return timer + } +} + +enum DJPlatterGeometry { + // A slower visual platter gives each rendered groove enough radial room + // to carry real waveform detail while preserving exact scratch mapping. + static let secondsPerRevolution = 3.6 +} + +enum DJTempoAnalyzer { + static func estimate(samples: [Float], sampleRate: Double) -> Double? { + guard sampleRate > 0, samples.count > Int(sampleRate * 4) else { return nil } + let hop = 512 + let limit = min(samples.count, Int(sampleRate * 90)) + var novelty: [Double] = [] + novelty.reserveCapacity(limit / hop) + var previousEnergy = 0.0 + var frame = 0 + while frame + hop <= limit { + var sum = 0.0 + for index in frame..<(frame + hop) { + let sample = Double(samples[index]) + sum += sample * sample + } + let energy = sqrt(sum / Double(hop)) + novelty.append(max(0, energy - previousEnergy * 0.86)) + previousEnergy = energy + frame += hop + } + guard novelty.count > 64 else { return nil } + + let stepsPerSecond = sampleRate / Double(hop) + var bestBPM = 0.0 + var bestScore = 0.0 + for bpmStep in 700...1800 { + let bpm = Double(bpmStep) / 10 + let lag = Int((60 / bpm * stepsPerSecond).rounded()) + guard lag > 1, lag < novelty.count / 2 else { continue } + var score = 0.0 + for index in lag..= 90, bpm <= 150 { score *= 1.06 } + if score > bestScore { bestScore = score; bestBPM = bpm } + } + return bestScore > 0.000_001 ? bestBPM : nil + } +} + +private final class DJDeckPCMState { + private let lock = NSLock() + private var samples: [[Float]] = [] + private var positionFrames: Double = 0 + private var playbackRate: Double = 1 + private var scratchTargetFrames: Double? + private var scratching = false + private var playing = false + private var looping = false + private var lastOutput: [Float] = [] + private var material = ACScratchState() + private(set) var sampleRate: Double = 44_100 + + var duration: Double { + lock.lock(); defer { lock.unlock() } + return Double(samples.first?.count ?? 0) / sampleRate + } + var currentTime: Double { + lock.lock(); defer { lock.unlock() } + return positionFrames / sampleRate + } + var isPlaying: Bool { + lock.lock(); defer { lock.unlock() } + return playing + } + var visualState: (motion: Double, energy: Float) { + lock.lock(); defer { lock.unlock() } + let energy = lastOutput.isEmpty + ? 0 + : lastOutput.reduce(Float(0)) { $0 + abs($1) } / Float(lastOutput.count) + return (material.velocity, min(1, energy * 2.5)) + } + + func load(samples: [[Float]], sampleRate: Double, looping: Bool) { + lock.lock() + self.samples = samples + self.sampleRate = sampleRate + self.looping = looping + positionFrames = 0 + playbackRate = 1 + scratchTargetFrames = nil + scratching = false + playing = false + lastOutput = [Float](repeating: 0, count: samples.count) + ac_scratch_init(&material) + lock.unlock() + } + + func setPlaying(_ value: Bool) { + lock.lock() + if value, positionFrames >= Double(max(0, (samples.first?.count ?? 1) - 1)) { positionFrames = 0 } + playing = value + lock.unlock() + } + + func setRate(_ value: Double) { + lock.lock(); playbackRate = value; lock.unlock() + } + + func beginScratch() { + lock.lock() + scratching = true + scratchTargetFrames = positionFrames + playbackRate = 0 + playing = true + lock.unlock() + } + + func seek(seconds: Double) { + lock.lock() + let finalFrame = Double(max(1, (samples.first?.count ?? 1) - 1)) + var target = seconds * sampleRate + if looping { + target = target.truncatingRemainder(dividingBy: finalFrame) + if target < 0 { target += finalFrame } + positionFrames = target + } else { + positionFrames = max(0, min(finalFrame, target)) + } + lock.unlock() + } + + func scratch(positionSeconds: Double, velocity: Double) { + lock.lock() + let finalFrame = Double(max(1, (samples.first?.count ?? 1) - 1)) + var target = positionSeconds * sampleRate + if looping { + target = target.truncatingRemainder(dividingBy: finalFrame) + if target < 0 { target += finalFrame } + } else { + target = max(0, min(finalFrame, target)) + } + scratchTargetFrames = target + playbackRate = velocity.isFinite ? velocity : 0 + playing = true + lock.unlock() + } + + func endScratch(normalRate: Double, resume: Bool) { + lock.lock() + if let target = scratchTargetFrames { positionFrames = target } + scratching = false + scratchTargetFrames = nil + playbackRate = normalRate + playing = resume + lock.unlock() + } + + func render(frameCount: AVAudioFrameCount, audioBufferList: UnsafeMutablePointer) -> OSStatus { + let outputs = UnsafeMutableAudioBufferListPointer(audioBufferList) + for buffer in outputs { + guard let data = buffer.mData else { continue } + memset(data, 0, Int(buffer.mDataByteSize)) + } + + lock.lock() + defer { lock.unlock() } + guard !samples.isEmpty else { return noErr } + let total = samples[0].count + guard total > 1 else { return noErr } + + for frame in 0..= Double(total - 1) { + if looping { + let length = Double(total - 1) + positionFrames = positionFrames.truncatingRemainder(dividingBy: length) + if positionFrames < 0 { positionFrames += length } + } else { + playing = false + break + } + } + var error = (scratchTargetFrames ?? positionFrames) - positionFrames + if looping, scratching { + let length = Double(total - 1) + if error > length / 2 { error -= length } + if error < -length / 2 { error += length } + } + let motion = ac_scratch_motion(&material, playbackRate, error, + scratching ? 1 : 0, sampleRate) + // A stopped hand emits silence; movement resumes from the same + // groove with a short slew instead of a discontinuous seek. + if abs(motion) >= 0.002 { + let lower = Int(positionFrames) + let upper = min(total - 1, lower + 1) + let fraction = Float(positionFrames - Double(lower)) + for channel in 0.. Void)? + + var duration: Double { pcm.duration } + var currentTime: Double { pcm.currentTime } + var isPlaying: Bool { motorEnabled } + var visualState: (motion: Double, energy: Float) { pcm.visualState } + var rate: Double { sourceBPM > 0 ? targetBPM / sourceBPM : 1 } + + func load(_ track: Track) { + engine.stop() + sourceNode = nil + pitchNode = nil + self.track = track + let suppliedBPM = track.meta?.bpm + sourceBPM = Double(suppliedBPM ?? 120) + targetBPM = sourceBPM + pitchSemitones = 0 + motorEnabled = false + bpmAnalyzed = false + guard let file = try? AVAudioFile(forReading: track.url) else { return } + let format = file.processingFormat + let capacity = AVAudioFrameCount(file.length) + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: capacity), + (try? file.read(into: buffer)) != nil, + let channelData = buffer.floatChannelData else { return } + let frameCount = Int(buffer.frameLength) + let channels = (0.. OSStatus in + state.render(frameCount: frameCount, audioBufferList: audioBufferList) + } + let pitch = AVAudioUnitTimePitch() + pitch.pitch = 0 + sourceNode = node + pitchNode = pitch + engine.attach(node) + engine.attach(pitch) + engine.connect(node, to: pitch, format: format) + engine.connect(pitch, to: engine.mainMixerNode, format: format) + engine.mainMixerNode.outputVolume = gain + engine.prepare() + try? engine.start() + applyRate() + onStateChange?() + } + + func toggle() { isPlaying ? pause() : play() } + + func play() { + guard sourceNode != nil else { return } + if !engine.isRunning { try? engine.start() } + motorEnabled = true + pcm.setRate(rate) + pcm.setPlaying(true) + onStateChange?() + } + + func pause() { + motorEnabled = false + // A motor-off deck remains available to the hand; zero transport + // velocity is silence until the platter is pushed or thrown. + pcm.setRate(0) + pcm.setPlaying(true) + onStateChange?() + } + + func seek(to time: Double) { + pcm.seek(seconds: max(0, min(duration, time))) + } + + func beginScratch() { + resumeAfterScratch = motorEnabled + pcm.beginScratch() + } + + func scratch(to time: Double, movement: Double, elapsed: Double) { + let velocity = elapsed > 0 ? movement / elapsed : 0 + pcm.scratch(positionSeconds: time, velocity: velocity) + } + + func endScratch(momentum: Double? = nil) { + let releaseRate = momentum ?? (motorEnabled ? rate : 0) + pcm.endScratch(normalRate: releaseRate, + resume: resumeAfterScratch || abs(releaseRate) >= 0.002) + onStateChange?() + } + + func holdScratch() { pcm.setRate(0) } + + /// Touch-brake multiplier used by the floating record. The underlying + /// musical/BPM rate remains unchanged and can be restored without a seek. + func setTransportScale(_ scale: Double) { + pcm.setRate(rate * max(0, min(1, scale))) + } + + func restoreTransportRate() { applyRate() } + + func setTransportVelocity(_ velocity: Double) { + guard velocity.isFinite else { return } + pcm.setRate(velocity) + pcm.setPlaying(true) + } + + func setBPM(_ bpm: Double) { + targetBPM = max(sourceBPM * 0.5, min(sourceBPM * 2.0, bpm)) + applyRate() + onStateChange?() + } + + func resetBPM() { setBPM(sourceBPM) } + + func setGain(_ value: Float) { + gain = max(0, min(1, value)) + engine.mainMixerNode.outputVolume = gain + } + + func setPitchSemitones(_ value: Double) { + pitchSemitones = max(-12, min(12, value)) + pitchNode?.pitch = Float(pitchSemitones * 100) + onStateChange?() + } + + private func applyRate() { + pcm.setRate(motorEnabled ? max(0.5, min(2, rate)) : 0) + } +} + +enum DJPracticeTracks { + private static let sampleRate = 48_000.0 + private static let bpm = 120 + private static let duration = 32.0 + + static func make() -> [Track] { + let specs = [ + ("Primpats · Sine Kick", 0, "primpat-sine-kick-v4.wav", "A1"), + ("Primpats · Closed Hat", 1, "primpat-closed-hat-v4.wav", "noise"), + ("Primpats · Clap", 2, "primpat-clap-v4.wav", "noise"), + ("Primpats · Wave Bass", 3, "primpat-wave-bass-v4.wav", "A1") + ] + return specs.compactMap { name, variant, filename, key in + guard let url = render(name: filename, variant: variant) else { return nil } + let track = Track(url: url, lane: "practice", title: name) + track.meta = TrackMeta(artist: "JukeWizard", backend: "C synthesis", status: "PRACTICE", + updated: nil, revisions: nil, bytes: nil, durationSec: duration, + bpm: bpm, key: key, + releaseDate: nil, art: nil, media: nil, links: nil) + return track + } + } + + private static func render(name: String, variant: Int) -> URL? { + let fm = FileManager.default + guard let base = fm.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil } + let directory = base.appendingPathComponent("computer.aesthetic.jukewizard", isDirectory: true) + try? fm.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent(name) + if fm.fileExists(atPath: url.path) { return url } + + let frames = Int(sampleRate * duration) + var left = [Float](repeating: 0, count: frames) + var right = [Float](repeating: 0, count: frames) + left.withUnsafeMutableBufferPointer { l in + right.withUnsafeMutableBufferPointer { r in + ac_practice_render(Int32(variant), l.baseAddress, r.baseAddress, frames, sampleRate, Double(bpm)) + } + } + guard let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: sampleRate, + channels: 2, interleaved: false), + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frames)), + let channels = buffer.floatChannelData else { return nil } + buffer.frameLength = AVAudioFrameCount(frames) + left.withUnsafeBufferPointer { channels[0].update(from: $0.baseAddress!, count: frames) } + right.withUnsafeBufferPointer { channels[1].update(from: $0.baseAddress!, count: frames) } + do { + var fileSettings = format.settings + fileSettings[AVLinearPCMIsNonInterleaved] = false + let file = try AVAudioFile(forWriting: url, settings: fileSettings) + try file.write(from: buffer) + return url + } catch { + try? fm.removeItem(at: url) + return nil + } + } +} + +final class DJPlatterView: NSView { + weak var deck: DJDeckPlayer? + var accent: NSColor = Palette.teal + var deckName = "A" + private var lastAngle: CGFloat? + private var lastTimestamp: TimeInterval? + private var scratchOrigin: Double = 0 + private var scratchOffset: Double = 0 + private var scratchIdleTimer: Timer? + + override var acceptsFirstResponder: Bool { true } + override var mouseDownCanMoveWindow: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + setAccessibilityRole(.slider) + setAccessibilityHelp("Drag the record clockwise or counterclockwise to scratch the track") + } + required init?(coder: NSCoder) { fatalError() } + deinit { scratchIdleTimer?.invalidate() } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea(rect: bounds, options: [.activeInKeyWindow, .cursorUpdate], owner: self)) + } + + override func cursorUpdate(with event: NSEvent) { NSCursor.openHand.set() } + + private var center: NSPoint { NSPoint(x: bounds.midX, y: bounds.midY) } + private var radius: CGFloat { max(1, min(bounds.width, bounds.height) / 2 - 5) } + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + let c = center, r = radius + + context.saveGState() + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.55) + shadow.shadowBlurRadius = 10 + shadow.shadowOffset = NSSize(width: 0, height: -3) + shadow.set() + NSColor(white: 0.025, alpha: 1).setFill() + NSBezierPath(ovalIn: NSRect(x: c.x - r, y: c.y - r, width: r * 2, height: r * 2)).fill() + context.restoreGState() + + for groove in stride(from: r * 0.34, through: r * 0.93, by: max(3, r * 0.038)) { + NSColor(white: 0.20, alpha: 0.55).setStroke() + let path = NSBezierPath(ovalIn: NSRect(x: c.x - groove, y: c.y - groove, + width: groove * 2, height: groove * 2)) + path.lineWidth = 0.65 + path.stroke() + } + + let labelR = r * 0.29 + accent.withAlphaComponent(0.90).setFill() + NSBezierPath(ovalIn: NSRect(x: c.x - labelR, y: c.y - labelR, + width: labelR * 2, height: labelR * 2)).fill() + Palette.gold.setFill() + NSBezierPath(ovalIn: NSRect(x: c.x - 4, y: c.y - 4, width: 8, height: 8)).fill() + + let seconds = deck?.currentTime ?? 0 + let angle = CGFloat(-seconds / DJPlatterGeometry.secondsPerRevolution * Double.pi * 2) + .pi / 2 + let marker = NSBezierPath() + marker.move(to: NSPoint(x: c.x + cos(angle) * r * 0.42, + y: c.y + sin(angle) * r * 0.42)) + marker.line(to: NSPoint(x: c.x + cos(angle) * r * 0.88, + y: c.y + sin(angle) * r * 0.88)) + accent.setStroke() + marker.lineWidth = max(2, r * 0.025) + marker.lineCapStyle = .round + marker.stroke() + + if let deck, deck.duration > 0 { + let progress = max(0, min(1, deck.currentTime / deck.duration)) + let ring = NSBezierPath() + ring.appendArc(withCenter: c, radius: r - 2, startAngle: 90, + endAngle: 90 - CGFloat(progress) * 360, clockwise: true) + Palette.gold.setStroke() + ring.lineWidth = 3 + ring.stroke() + } + + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: max(15, r * 0.20), weight: .black), + .foregroundColor: NSColor.white + ] + let name = deckName as NSString + let size = name.size(withAttributes: attrs) + name.draw(at: NSPoint(x: c.x - size.width / 2, y: c.y - size.height / 2), withAttributes: attrs) + } + + private func angle(for event: NSEvent) -> CGFloat { + let point = convert(event.locationInWindow, from: nil) + return atan2(point.y - center.y, point.x - center.x) + } + + override func mouseDown(with event: NSEvent) { + guard hypot(convert(event.locationInWindow, from: nil).x - center.x, + convert(event.locationInWindow, from: nil).y - center.y) <= radius else { return } + window?.makeFirstResponder(self) + NSCursor.closedHand.set() + lastAngle = angle(for: event) + lastTimestamp = event.timestamp + scratchOrigin = deck?.currentTime ?? 0 + scratchOffset = 0 + deck?.beginScratch() + scratchIdleTimer?.invalidate() + scratchIdleTimer = DJRunLoopTimer.scheduled(every: 0.02) { [weak self] _ in + guard let self, let timestamp = self.lastTimestamp else { return } + if ProcessInfo.processInfo.systemUptime - timestamp > 0.04 { self.deck?.holdScratch() } + } + } + + override func mouseDragged(with event: NSEvent) { + guard let prior = lastAngle else { return } + let next = angle(for: event) + var delta = next - prior + if delta > .pi { delta -= .pi * 2 } + if delta < -.pi { delta += .pi * 2 } + let seconds = Double(-delta / (.pi * 2)) * DJPlatterGeometry.secondsPerRevolution + let elapsed = max(1.0 / 240.0, event.timestamp - (lastTimestamp ?? event.timestamp)) + scratchOffset += seconds + deck?.scratch(to: scratchOrigin + scratchOffset, movement: seconds, elapsed: elapsed) + lastAngle = next + lastTimestamp = event.timestamp + needsDisplay = true + } + + override func mouseUp(with event: NSEvent) { + lastAngle = nil + lastTimestamp = nil + scratchIdleTimer?.invalidate() + scratchIdleTimer = nil + deck?.endScratch() + NSCursor.openHand.set() + } +} + +// A fixed output-time window makes the two rows directly comparable. When a +// deck's rate changes, its source waveform expands or contracts so matched +// beats occupy the same horizontal distance on both rows. +final class DJWaveformOutputView: NSView { + weak var deck: DJDeckPlayer? + var accent: NSColor = Palette.teal + var deckName = "A" + var vertical = false + private var peaks: [Float] = [] + private var peakDuration: Double = 0 + private var loadToken = 0 + private var lastX: CGFloat? + private var lastTimestamp: TimeInterval? + private var scratchOrigin: Double = 0 + private var scratchOffset: Double = 0 + private var scratchIdleTimer: Timer? + private var cachedDark: Bool? + private var cachedSurface = NSColor.clear + private var cachedInk = NSColor.clear + + override var mouseDownCanMoveWindow: Bool { false } + private var visibleSourceSpan: Double { 12.0 * (deck?.rate ?? 1) } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.cornerRadius = 7 + layer?.masksToBounds = true + setAccessibilityRole(.slider) + setAccessibilityHelp("Drag left or right to scratch this output waveform") + } + required init?(coder: NSCoder) { fatalError() } + deinit { scratchIdleTimer?.invalidate() } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea(rect: bounds, options: [.activeInKeyWindow, .cursorUpdate], owner: self)) + } + override func cursorUpdate(with event: NSEvent) { NSCursor.resizeLeftRight.set() } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + cachedDark = nil + needsDisplay = true + } + + private func updateColors(dark: Bool) { + guard cachedDark != dark else { return } + cachedDark = dark + cachedSurface = Palette.deckSurface(accent, dark: dark) + cachedInk = Palette.deckInk(accent, dark: dark) + } + + func load(_ track: Track) { + loadToken += 1 + let token = loadToken + peaks = [] + peakDuration = 0 + needsDisplay = true + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let file = try? AVAudioFile(forReading: track.url) else { return } + let format = file.processingFormat + let frames = AVAudioFrameCount(file.length) + guard frames > 0, + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames), + (try? file.read(into: buffer)) != nil, + let channels = buffer.floatChannelData else { return } + let frameCount = Int(buffer.frameLength) + let channelCount = Int(format.channelCount) + let bins = 2400 + let framesPerBin = max(1, frameCount / bins) + var output = [Float](repeating: 0, count: bins) + for bin in 0.. 0 { output = output.map { $0 / maximum } } + let duration = Double(file.length) / file.processingFormat.sampleRate + DispatchQueue.main.async { + guard let self, token == self.loadToken else { return } + self.peaks = output + self.peakDuration = duration + self.needsDisplay = true + } + } + } + + override func draw(_ dirtyRect: NSRect) { + let dark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + updateColors(dark: dark) + guard let context = NSGraphicsContext.current?.cgContext else { return } + context.setFillColor(cachedSurface.cgColor) + context.fill(bounds) + guard let deck else { return } + let centerX = bounds.midX + let centerY = bounds.midY + let current = deck.currentTime + let sourceSpan = visibleSourceSpan + let startTime = current - sourceSpan / 2 + let axisLength = vertical ? bounds.height : bounds.width + let secondsPerPoint = sourceSpan / Double(max(1, axisLength)) + + if !peaks.isEmpty, peakDuration > 0 { + let mid = vertical ? bounds.midX : bounds.midY + var bars: [CGRect] = [] + bars.reserveCapacity(Int(axisLength / 2) + 1) + var axis: CGFloat = 0 + while axis < axisLength { + let time = startTime + Double(axis) * secondsPerPoint + if time >= 0, time <= peakDuration { + let index = min(peaks.count - 1, max(0, Int(time / peakDuration * Double(peaks.count)))) + let amplitude = CGFloat(peaks[index]) * (mid - 4) + let rect = vertical + ? NSRect(x: mid - amplitude, y: axis, width: amplitude * 2, height: 1.5) + : NSRect(x: axis, y: mid - amplitude, width: 1.5, height: amplitude * 2) + bars.append(rect) + } + axis += 2 + } + context.setFillColor(accent.cgColor) + context.fill(bars) + } + + // Beat marks share output-time geometry with the waveform. + if deck.sourceBPM > 0 { + let beat = 60.0 / deck.sourceBPM + var time = floor(startTime / beat) * beat + let marks = CGMutablePath() + while time <= startTime + sourceSpan { + if time >= 0 { + let axis = CGFloat((time - startTime) / sourceSpan) * axisLength + if vertical { + marks.move(to: CGPoint(x: 0, y: axis)); marks.addLine(to: CGPoint(x: bounds.width, y: axis)) + } else { + marks.move(to: CGPoint(x: axis, y: 0)); marks.addLine(to: CGPoint(x: axis, y: bounds.height)) + } + } + time += beat + } + context.addPath(marks) + context.setStrokeColor(cachedInk.withAlphaComponent(0.16).cgColor) + context.setLineWidth(1) + context.strokePath() + } + + context.setFillColor(Palette.gold.cgColor) + context.fill(vertical + ? CGRect(x: 0, y: centerY - 1, width: bounds.width, height: 2) + : CGRect(x: centerX - 1, y: 0, width: 2, height: bounds.height)) + } + + override func mouseDown(with event: NSEvent) { + guard let deck else { return } + window?.makeFirstResponder(self) + let point = convert(event.locationInWindow, from: nil) + lastX = vertical ? point.y : point.x + lastTimestamp = event.timestamp + scratchOrigin = deck.currentTime + scratchOffset = 0 + deck.beginScratch() + scratchIdleTimer?.invalidate() + scratchIdleTimer = DJRunLoopTimer.scheduled(every: 0.02) { [weak self] _ in + guard let self, let timestamp = self.lastTimestamp else { return } + if ProcessInfo.processInfo.systemUptime - timestamp > 0.04 { self.deck?.holdScratch() } + } + } + + override func mouseDragged(with event: NSEvent) { + guard let deck, let prior = lastX else { return } + let point = convert(event.locationInWindow, from: nil) + let x = vertical ? point.y : point.x + // Pulling the printed waveform right pulls the record backward. + let dimension = vertical ? bounds.height : bounds.width + let movement = -Double(x - prior) / Double(max(1, dimension)) * visibleSourceSpan + let elapsed = max(1.0 / 240.0, event.timestamp - (lastTimestamp ?? event.timestamp)) + scratchOffset += movement + deck.scratch(to: scratchOrigin + scratchOffset, movement: movement, elapsed: elapsed) + lastX = x + lastTimestamp = event.timestamp + needsDisplay = true + } + + override func mouseUp(with event: NSEvent) { + lastX = nil + lastTimestamp = nil + scratchIdleTimer?.invalidate() + scratchIdleTimer = nil + deck?.endScratch() + } +} + +final class DJAlignmentSurface: NSView { + let strips: [DJWaveformOutputView] + private let decks: [DJDeckPlayer] + private let pitches: [NSSlider] + private let volumes: [NSSlider] + private let bpmLabels: [NSTextField] + private let trackPickers: [NSPopUpButton] + private let rateLabels: [NSTextField] + private let volumeLabels: [NSTextField] + private let rateButtons: [NSSegmentedControl] + private let playButtons: [NSButton] + private var recordChoices: [Track] = [] + private let syncButton = NSButton(title: "SYNC RATES", target: nil, action: nil) + private let alignButton = NSButton(title: "ALIGN PEAKS", target: nil, action: nil) + var onSyncRates: (() -> Void)? + var onAlignPeaks: (() -> Void)? + var onChooseTrack: ((Int, Track) -> Void)? + + init(decks: [DJDeckPlayer], names: [String], accents: [NSColor]) { + self.decks = decks + strips = decks.indices.map { index in + let strip = DJWaveformOutputView(frame: .zero) + strip.deck = decks[index] + strip.accent = accents[index] + strip.deckName = names[index] + strip.vertical = true + return strip + } + pitches = decks.indices.map { _ in + NSSlider(value: 0, minValue: -12, maxValue: 12, target: nil, action: nil) + } + volumes = decks.indices.map { _ in + NSSlider(value: 0.5, minValue: 0, maxValue: 1, target: nil, action: nil) + } + bpmLabels = decks.indices.map { _ in NSTextField(labelWithString: "120.0 BPM") } + trackPickers = decks.indices.map { _ in NSPopUpButton(frame: .zero, pullsDown: false) } + rateLabels = decks.indices.map { _ in NSTextField(labelWithString: "rate") } + volumeLabels = decks.indices.map { _ in NSTextField(labelWithString: "vol") } + rateButtons = decks.indices.map { _ in + NSSegmentedControl(labels: ["½×", "1×", "2×"], trackingMode: .selectOne, + target: nil, action: nil) + } + playButtons = decks.indices.map { _ in NSButton(title: "▶", target: nil, action: nil) } + super.init(frame: .zero) + wantsLayer = true + layer?.cornerRadius = 18 + layer?.masksToBounds = true + for index in decks.indices { + pitches[index].tag = index + pitches[index].target = self + pitches[index].action = #selector(pitchChanged(_:)) + pitches[index].isContinuous = true + pitches[index].isVertical = false + pitches[index].numberOfTickMarks = 25 + pitches[index].allowsTickMarkValuesOnly = false + pitches[index].toolTip = "Deck \(names[index]) pitch, independent of rate" + volumes[index].tag = index + volumes[index].target = self + volumes[index].action = #selector(volumeChanged(_:)) + volumes[index].isContinuous = true + volumes[index].isVertical = false + volumes[index].toolTip = "Deck \(names[index]) volume" + bpmLabels[index].font = .monospacedDigitSystemFont(ofSize: 12, weight: .bold) + bpmLabels[index].textColor = accents[index] + trackPickers[index].tag = index + trackPickers[index].target = self + trackPickers[index].action = #selector(trackChanged(_:)) + trackPickers[index].controlSize = .small + trackPickers[index].font = .systemFont(ofSize: 11, weight: .semibold) + for label in [rateLabels[index], volumeLabels[index]] { + label.font = .systemFont(ofSize: 10, weight: .medium) + label.textColor = .secondaryLabelColor + } + rateButtons[index].tag = index + rateButtons[index].target = self + rateButtons[index].action = #selector(rateButtonChanged(_:)) + rateButtons[index].selectedSegment = 1 + rateButtons[index].controlSize = .small + playButtons[index].tag = index + playButtons[index].target = self + playButtons[index].action = #selector(playChanged(_:)) + playButtons[index].bezelStyle = .inline + playButtons[index].contentTintColor = accents[index] + addSubview(strips[index]) + addSubview(pitches[index]) + addSubview(volumes[index]) + addSubview(bpmLabels[index]) + addSubview(trackPickers[index]) + addSubview(rateLabels[index]) + addSubview(volumeLabels[index]) + addSubview(rateButtons[index]) + addSubview(playButtons[index]) + } + syncButton.target = self + syncButton.action = #selector(sync) + syncButton.bezelStyle = .rounded + syncButton.contentTintColor = Palette.gold + syncButton.isHidden = decks.count < 2 + alignButton.target = self + alignButton.action = #selector(align) + alignButton.bezelStyle = .rounded + alignButton.contentTintColor = Palette.teal + alignButton.isHidden = decks.count < 2 + addSubview(syncButton) + addSubview(alignButton) + setAccessibilityLabel("Deck alignment read strips") + } + + required init?(coder: NSCoder) { fatalError() } + + override func draw(_ dirtyRect: NSRect) { + let dark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + (dark ? NSColor(white: 0.02, alpha: 0.88) + : Palette.cream.withAlphaComponent(0.94)).setFill() + bounds.fill() + + let channelTop = bounds.height - 52 + let channelBottom: CGFloat = 112 + let width = bounds.width / CGFloat(max(1, strips.count)) + for index in strips.indices { + Palette.deckSurface(strips[index].accent, dark: dark).setFill() + NSRect(x: CGFloat(index) * width, y: channelBottom, + width: width, height: max(0, channelTop - channelBottom)).fill() + } + } + + override func layout() { + let pad: CGFloat = 12 + let footer: CGFloat = 40 + let channelBottom: CGFloat = 112 + let column = bounds.width / CGFloat(max(1, strips.count)) + for index in strips.indices { + let x = CGFloat(index) * column + trackPickers[index].frame = NSRect(x: x + 2, y: bounds.height - 31, + width: max(38, column - 33), height: 24) + bpmLabels[index].frame = NSRect(x: x + 6, y: bounds.height - 49, + width: max(30, column - 12), height: 16) + playButtons[index].frame = NSRect(x: x + column - 29, y: bounds.height - 31, + width: 25, height: 23) + rateButtons[index].frame = NSRect(x: x + 4, y: footer + 3, + width: max(36, column - 8), height: 24) + let stripTop = bounds.height - 52 + strips[index].frame = NSRect(x: x, y: channelBottom, + width: column, height: stripTop - channelBottom) + rateLabels[index].stringValue = "P" + rateLabels[index].alignment = .center + rateLabels[index].frame = NSRect(x: x + 2, y: 90, width: 14, height: 14) + pitches[index].frame = NSRect(x: x + 16, y: 88, + width: max(24, column - 20), height: 17) + volumeLabels[index].stringValue = "V" + volumeLabels[index].alignment = .center + volumeLabels[index].frame = NSRect(x: x + 2, y: 69, width: 14, height: 14) + volumes[index].frame = NSRect(x: x + 16, y: 67, + width: max(24, column - 20), height: 17) + } + syncButton.frame = NSRect(x: bounds.midX - 127, y: pad + 1, width: 120, height: 28) + alignButton.frame = NSRect(x: bounds.midX + 7, y: pad + 1, width: 120, height: 28) + } + + func refresh() { + for index in decks.indices { + pitches[index].doubleValue = decks[index].pitchSemitones + volumes[index].doubleValue = Double(decks[index].gain) + playButtons[index].title = decks[index].isPlaying ? "Ⅱ" : "▶" + let rate = decks[index].rate + rateButtons[index].selectedSegment = abs(rate - 0.5) < 0.01 ? 0 + : abs(rate - 1) < 0.01 ? 1 + : abs(rate - 2) < 0.01 ? 2 : -1 + rateLabels[index].toolTip = String(format: "Pitch %+.1f semitones", + decks[index].pitchSemitones) + bpmLabels[index].stringValue = String(format: "%@%.1f BPM", + decks[index].bpmAnalyzed ? "≈" : "", + decks[index].targetBPM) + } + } + + func setRecordChoices(_ tracks: [Track]) { + recordChoices = tracks + let titles = tracks.map { + $0.title + .replacingOccurrences(of: "Primpats · ", with: "") + .replacingOccurrences(of: "Practice · ", with: "") + } + for index in trackPickers.indices { + trackPickers[index].removeAllItems() + trackPickers[index].addItems(withTitles: titles) + if let url = decks[index].track?.url, + let selected = tracks.firstIndex(where: { $0.url == url }) { + trackPickers[index].selectItem(at: selected) + } + } + } + + func load(_ track: Track, at index: Int) { + guard strips.indices.contains(index) else { return } + strips[index].load(track) + if let selected = recordChoices.firstIndex(where: { $0.url == track.url }) { + trackPickers[index].selectItem(at: selected) + } + } + + @objc private func trackChanged(_ sender: NSPopUpButton) { + guard decks.indices.contains(sender.tag), + recordChoices.indices.contains(sender.indexOfSelectedItem) else { return } + onChooseTrack?(sender.tag, recordChoices[sender.indexOfSelectedItem]) + } + + @objc private func pitchChanged(_ sender: NSSlider) { + guard decks.indices.contains(sender.tag) else { return } + decks[sender.tag].setPitchSemitones(sender.doubleValue) + rateLabels[sender.tag].toolTip = String(format: "Pitch %+.1f semitones", + sender.doubleValue) + } + @objc private func volumeChanged(_ sender: NSSlider) { + guard decks.indices.contains(sender.tag) else { return } + decks[sender.tag].setGain(Float(sender.doubleValue)) + } + @objc private func rateButtonChanged(_ sender: NSSegmentedControl) { + guard decks.indices.contains(sender.tag) else { return } + let multiplier = [0.5, 1.0, 2.0][max(0, sender.selectedSegment)] + decks[sender.tag].setBPM(decks[sender.tag].sourceBPM * multiplier) + refresh() + } + @objc private func playChanged(_ sender: NSButton) { + guard decks.indices.contains(sender.tag) else { return } + decks[sender.tag].toggle() + refresh() + } + @objc private func sync() { onSyncRates?(); refresh() } + @objc private func align() { onAlignPeaks?(); refresh() } +} + +final class DJAlignmentWindowController: NSWindowController, NSWindowDelegate { + private let surface: DJAlignmentSurface + private var displayTimer: Timer? + private var positioned = false + private var displayFrame = 0 + + init(decks: [DJDeckPlayer], names: [String], accents: [NSColor]) { + surface = DJAlignmentSurface(decks: decks, names: names, accents: accents) + let solo = decks.count == 1 + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: solo ? 150 : 300, height: 400), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, defer: false) + window.title = "JukeWizard · Alignment" + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = true + window.level = .floating + window.collectionBehavior = [.fullScreenAuxiliary, .moveToActiveSpace] + window.minSize = NSSize(width: solo ? 120 : 260, height: 340) + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = true + window.contentView = surface + super.init(window: window) + window.delegate = self + } + + required init?(coder: NSCoder) { fatalError() } + deinit { displayTimer?.invalidate() } + + func show(tracks: [Track?]) { + for (index, track) in tracks.enumerated() { + if let track { surface.load(track, at: index) } + } + if !positioned, let window, let screen = NSScreen.main { + positioned = true + let visible = screen.visibleFrame + window.setFrameOrigin(NSPoint(x: visible.midX - window.frame.width / 2, + y: visible.midY - window.frame.height / 2)) + } + showWindow(nil) + window?.orderFrontRegardless() + displayTimer?.invalidate() + let timer = DJRunLoopTimer.scheduled(every: 1.0 / 60.0) { [weak self] _ in + guard let self else { return } + self.surface.strips.forEach { $0.needsDisplay = true } + self.displayFrame &+= 1 + if self.displayFrame.isMultiple(of: 4) { self.surface.refresh() } + } + displayTimer = timer + } + + func setSyncActions(rates: @escaping () -> Void, peaks: @escaping () -> Void) { + surface.onSyncRates = rates + surface.onAlignPeaks = peaks + } + + func trackChanged(_ track: Track, at index: Int) { surface.load(track, at: index) } + + func setRecordChoices(_ tracks: [Track], onChoose: @escaping (Int, Track) -> Void) { + surface.onChooseTrack = onChoose + surface.setRecordChoices(tracks) + } + + func windowWillClose(_ notification: Notification) { + displayTimer?.invalidate() + displayTimer = nil + } +} + +final class DJDeckView: NSView { + let deck = DJDeckPlayer() + let platter = DJPlatterView(frame: .zero) + private let deckLabel: NSTextField + private let trackPopup = NSPopUpButton(frame: .zero, pullsDown: false) + private let playButton = NSButton(title: "▶", target: nil, action: nil) + private let bpmSlider = NSSlider(value: 120, minValue: 60, maxValue: 180, target: nil, action: nil) + private let bpmLabel = NSTextField(labelWithString: "120.0 BPM") + private let timeLabel = NSTextField(labelWithString: "0:00 / 0:00") + private let syncButton = NSButton(title: "SYNC", target: nil, action: nil) + private let resetButton = NSButton(title: "1×", target: nil, action: nil) + private let popoutButton = NSButton(title: "↗", target: nil, action: nil) + private var tracks: [Track] = [] + var onStateChange: (() -> Void)? + var onTrackLoaded: ((Track) -> Void)? + var onSync: (() -> Void)? + var onPopout: (() -> Void)? + + init(name: String, accent: NSColor) { + deckLabel = NSTextField(labelWithString: name) + super.init(frame: .zero) + wantsLayer = true + layer?.cornerRadius = 12 + layer?.borderWidth = 1 + layer?.borderColor = accent.withAlphaComponent(0.55).cgColor + layer?.backgroundColor = NSColor.black.withAlphaComponent(0.18).cgColor + + platter.deck = deck + platter.accent = accent + platter.deckName = name + platter.setAccessibilityLabel("Deck \(name) record") + deckLabel.font = .systemFont(ofSize: 17, weight: .black) + deckLabel.textColor = accent + trackPopup.controlSize = .small + trackPopup.target = self + trackPopup.action = #selector(trackChanged) + playButton.target = self + playButton.action = #selector(togglePlay) + playButton.bezelStyle = .rounded + playButton.contentTintColor = accent + bpmSlider.target = self + bpmSlider.action = #selector(bpmChanged) + bpmSlider.isContinuous = true + bpmLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .bold) + bpmLabel.alignment = .center + timeLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .medium) + timeLabel.textColor = .secondaryLabelColor + syncButton.target = self + syncButton.action = #selector(sync) + syncButton.bezelStyle = .rounded + syncButton.contentTintColor = accent + syncButton.toolTip = "Match this deck's tempo and beat phase to the other deck" + resetButton.target = self + resetButton.action = #selector(resetBPM) + resetButton.bezelStyle = .rounded + resetButton.toolTip = "Reset this deck to the track's original tempo" + popoutButton.target = self + popoutButton.action = #selector(popout) + popoutButton.bezelStyle = .rounded + popoutButton.contentTintColor = accent + popoutButton.toolTip = "Float this record as its own scratch deck" + + [deckLabel, trackPopup, platter, playButton, bpmSlider, bpmLabel, + timeLabel, syncButton, resetButton, popoutButton].forEach(addSubview) + deck.onStateChange = { [weak self] in + self?.refresh() + self?.onStateChange?() + } + } + required init?(coder: NSCoder) { fatalError() } + + func configure(tracks: [Track], selectedIndex: Int) { + self.tracks = tracks + trackPopup.removeAllItems() + trackPopup.addItems(withTitles: tracks.map { "\($0.title) — \($0.lane)" }) + guard !tracks.isEmpty else { return } + let index = max(0, min(tracks.count - 1, selectedIndex)) + trackPopup.selectItem(at: index) + load(index) + } + + func step(by offset: Int) { + guard !tracks.isEmpty else { return } + let next = max(0, min(tracks.count - 1, trackPopup.indexOfSelectedItem + offset)) + trackPopup.selectItem(at: next) + load(next) + } + + func select(_ track: Track, autoplay: Bool) { + guard let index = tracks.firstIndex(where: { $0.url == track.url }) else { return } + trackPopup.selectItem(at: index) + load(index) + if autoplay { deck.play() } + } + + func refresh() { + playButton.title = deck.isPlaying ? "❚❚" : "▶" + bpmLabel.stringValue = String(format: "%.1f BPM", deck.targetBPM) + bpmSlider.doubleValue = deck.targetBPM + timeLabel.stringValue = "\(JukeController.mmss(deck.currentTime)) / \(JukeController.mmss(deck.duration))" + platter.needsDisplay = true + } + + override func layout() { + let pad: CGFloat = 12 + deckLabel.frame = NSRect(x: pad, y: bounds.height - 31, width: 24, height: 23) + trackPopup.frame = NSRect(x: 40, y: bounds.height - 32, width: max(90, bounds.width - 52), height: 24) + + let platterBottom: CGFloat = 91 + let platterTop = bounds.height - 39 + let diameter = max(72, min(bounds.width - pad * 2, platterTop - platterBottom)) + platter.frame = NSRect(x: (bounds.width - diameter) / 2, + y: platterBottom + (platterTop - platterBottom - diameter) / 2, + width: diameter, height: diameter) + + bpmLabel.frame = NSRect(x: 64, y: 62, width: max(80, bounds.width - 128), height: 18) + bpmSlider.frame = NSRect(x: 65, y: 38, width: max(70, bounds.width - 130), height: 20) + playButton.frame = NSRect(x: pad, y: 36, width: 45, height: 27) + timeLabel.frame = NSRect(x: pad, y: 10, width: max(70, bounds.width - 170), height: 18) + popoutButton.frame = NSRect(x: bounds.width - 150, y: 8, width: 34, height: 24) + syncButton.frame = NSRect(x: bounds.width - 112, y: 8, width: 58, height: 24) + resetButton.frame = NSRect(x: bounds.width - 50, y: 8, width: 38, height: 24) + } + + @objc private func trackChanged() { load(trackPopup.indexOfSelectedItem) } + + private func load(_ index: Int) { + guard index >= 0, index < tracks.count else { return } + deck.load(tracks[index]) + onTrackLoaded?(tracks[index]) + bpmSlider.minValue = deck.sourceBPM * 0.5 + bpmSlider.maxValue = deck.sourceBPM * 1.5 + bpmSlider.doubleValue = deck.targetBPM + bpmSlider.toolTip = "Tempo: \(Int(bpmSlider.minValue))–\(Int(bpmSlider.maxValue)) BPM" + refresh() + } + + @objc private func togglePlay() { deck.toggle() } + @objc private func bpmChanged() { deck.setBPM(bpmSlider.doubleValue); refresh() } + @objc private func sync() { onSync?() } + @objc private func resetBPM() { deck.resetBPM(); refresh() } + @objc private func popout() { onPopout?() } +} + +final class DJMixerView: NSView { + let deckA = DJDeckView(name: "A", accent: Palette.teal) + let deckB = DJDeckView(name: "B", accent: Palette.coral) + private let deckC = DJDeckPlayer() + private let deckD = DJDeckPlayer() + private let waveformA = DJWaveformOutputView(frame: .zero) + private let waveformB = DJWaveformOutputView(frame: .zero) + private let crossfader = NSSlider(value: 0, minValue: -1, maxValue: 1, target: nil, action: nil) + private let crossLabel = NSTextField(labelWithString: "A 50 · 50 B") + private let practiceButton = NSButton(title: "PRIMPATS", target: nil, action: nil) + private var displayTimer: Timer? + private var availableTracks: [Track] = [] + private var primpatCount = 0 + private var practiceStartIndex = 0 + private var practiceCount = 0 + private var soloMode = false + private var popoutA: DJPopoutDeckController? + private var popoutB: DJPopoutDeckController? + private var popoutC: DJPopoutDeckController? + private var popoutD: DJPopoutDeckController? + private var alignmentPopout: DJAlignmentWindowController? + private var rateSyncTimer: Timer? + private var peakAlignTimer: Timer? + private(set) var masterVolume: Float = 0.8 + var onStateChange: (() -> Void)? + var onDetach: (() -> Void)? + private var deckAppearance: NSAppearance? + + var isPlaying: Bool { deckA.deck.isPlaying || deckB.deck.isPlaying || deckC.isPlaying || deckD.isPlaying } + var dominantDeck: DJDeckView { crossfader.doubleValue <= 0 ? deckA : deckB } + var dominantTitle: String { dominantDeck.deck.track?.title ?? "DJ Mix" } + var dominantBPM: Double { dominantDeck.deck.targetBPM } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + crossfader.target = self + crossfader.action = #selector(crossfadeChanged) + crossfader.isContinuous = true + crossLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .bold) + crossLabel.alignment = .center + practiceButton.target = self + practiceButton.action = #selector(loadPractice) + practiceButton.bezelStyle = .rounded + practiceButton.contentTintColor = Palette.gold + practiceButton.toolTip = "Load four one-voice primitive records" + waveformA.deck = deckA.deck + waveformA.accent = Palette.teal + waveformA.deckName = "A" + waveformA.setAccessibilityLabel("Deck A output waveform") + waveformB.deck = deckB.deck + waveformB.accent = Palette.coral + waveformB.deckName = "B" + waveformB.setAccessibilityLabel("Deck B output waveform") + [deckA, deckB, waveformA, waveformB, crossfader, crossLabel, practiceButton].forEach(addSubview) + deckA.onStateChange = { [weak self] in self?.onStateChange?() } + deckB.onStateChange = { [weak self] in self?.onStateChange?() } + deckA.onTrackLoaded = { [weak self] track in + self?.waveformA.load(track) + self?.popoutA?.trackChanged(track) + self?.alignmentPopout?.trackChanged(track, at: 0) + } + deckB.onTrackLoaded = { [weak self] track in + self?.waveformB.load(track) + self?.popoutB?.trackChanged(track) + self?.alignmentPopout?.trackChanged(track, at: 1) + } + deckA.onSync = { [weak self] in self?.sync(self?.deckA, to: self?.deckB) } + deckB.onSync = { [weak self] in self?.sync(self?.deckB, to: self?.deckA) } + deckA.onPopout = { [weak self] in self?.showPopoutA() } + deckB.onPopout = { [weak self] in self?.showPopoutB() } + applyCrossfade() + } + required init?(coder: NSCoder) { fatalError() } + deinit { + displayTimer?.invalidate() + rateSyncTimer?.invalidate() + peakAlignTimer?.invalidate() + } + + func configure(tracks: [Track], primaryIndex: Int) { + soloMode = false + let primpats = DJPrimpats.makeTracks() + let practice = DJPracticeTracks.make() + primpatCount = primpats.count + practiceStartIndex = primpats.count + practiceCount = practice.count + availableTracks = primpats + practice + tracks + let requested = primaryIndex + primpatCount + practiceCount + let first = max(0, min(max(0, availableTracks.count - 1), requested)) + let second = availableTracks.count > 1 ? (first + 1) % availableTracks.count : first + deckA.configure(tracks: availableTracks, selectedIndex: first) + deckB.configure(tracks: availableTracks, selectedIndex: second) + crossfader.doubleValue = 0 + applyCrossfade() + } + + func configureSolo(tracks: [Track], primaryIndex: Int) { + soloMode = true + let primpats = DJPrimpats.makeTracks() + let practice = DJPracticeTracks.make() + primpatCount = primpats.count + practiceStartIndex = primpats.count + practiceCount = practice.count + availableTracks = primpats + practice + tracks + let requested = primaryIndex + primpatCount + practiceCount + let first = max(0, min(max(0, availableTracks.count - 1), requested)) + deckA.configure(tracks: availableTracks, selectedIndex: first) + crossfader.doubleValue = -1 + applyCrossfade() + } + + func startDisplay() { + displayTimer?.invalidate() + displayTimer = DJRunLoopTimer.scheduled(every: 1.0 / 60.0) { [weak self] _ in + guard let self else { return } + self.deckA.refresh() + self.waveformA.needsDisplay = true + if !self.soloMode { + self.deckB.refresh() + self.waveformB.needsDisplay = true + } + } + } + + func stopDisplay() { displayTimer?.invalidate(); displayTimer = nil } + func pauseAll() { deckA.deck.pause(); deckB.deck.pause(); deckC.pause(); deckD.pause() } + func toggleDominant() { dominantDeck.deck.toggle() } + func stepDominant(by offset: Int) { dominantDeck.step(by: offset) } + + func setMasterVolume(_ value: Float) { + masterVolume = max(0, min(1, value)) + applyCrossfade() + } + + func setAppearance(_ appearance: NSAppearance?) { + deckAppearance = appearance + popoutA?.window?.appearance = appearance + popoutB?.window?.appearance = appearance + popoutC?.window?.appearance = appearance + popoutD?.window?.appearance = appearance + alignmentPopout?.window?.appearance = appearance + popoutA?.window?.contentView?.needsDisplay = true + popoutB?.window?.contentView?.needsDisplay = true + popoutC?.window?.contentView?.needsDisplay = true + popoutD?.window?.contentView?.needsDisplay = true + alignmentPopout?.window?.contentView?.needsDisplay = true + } + + @objc func loadPractice() { loadPrimpats() } + + func loadPrimpats(openPopouts: Bool = false) { + guard primpatCount >= 8 else { return } + deckA.configure(tracks: availableTracks, selectedIndex: 0) + deckB.configure(tracks: availableTracks, selectedIndex: 2) + deckC.load(availableTracks[4]) + deckD.load(availableTracks[7]) + crossfader.doubleValue = 0 + applyCrossfade() + if openPopouts { + showPopoutA() + showPopoutB() + showPopoutC() + showPopoutD() + showAlignmentPopout() + onDetach?() + } + onStateChange?() + } + + func loadBeats(openPopouts: Bool = false, autoplay: Bool = false, solo: Bool = false) { + guard practiceCount >= 4 else { return } + deckA.configure(tracks: availableTracks, selectedIndex: practiceStartIndex) + if !solo { + deckB.configure(tracks: availableTracks, selectedIndex: practiceStartIndex + 1) + deckC.load(availableTracks[practiceStartIndex + 2]) + deckD.load(availableTracks[practiceStartIndex + 3]) + } + crossfader.doubleValue = solo ? -1 : 0 + applyCrossfade() + if autoplay { + deckA.deck.play() + if !solo { + deckB.deck.play() + deckC.play() + deckD.play() + } + } + if openPopouts { + showPopoutA() + if solo { + showSoloAlignmentPopout() + } else { + showPopoutB() + showPopoutC() + showPopoutD() + showAlignmentPopout() + } + onDetach?() + } + onStateChange?() + } + + private func showPopoutA() { + if popoutA == nil { + popoutA = DJPopoutDeckController(deck: deckA.deck, name: "A", accent: Palette.teal) + } + popoutA?.window?.appearance = deckAppearance + popoutA?.show(track: deckA.deck.track) + } + + private func showPopoutB() { + if popoutB == nil { + popoutB = DJPopoutDeckController(deck: deckB.deck, name: "B", accent: Palette.coral) + } + popoutB?.window?.appearance = deckAppearance + popoutB?.show(track: deckB.deck.track) + } + + private func showPopoutC() { + if popoutC == nil { + popoutC = DJPopoutDeckController(deck: deckC, name: "C", accent: Palette.gold) + } + popoutC?.window?.appearance = deckAppearance + popoutC?.show(track: deckC.track) + } + + private func showPopoutD() { + if popoutD == nil { + popoutD = DJPopoutDeckController(deck: deckD, name: "D", accent: .systemPurple) + } + popoutD?.window?.appearance = deckAppearance + popoutD?.show(track: deckD.track) + } + + private func showAlignmentPopout() { + if alignmentPopout == nil { + alignmentPopout = DJAlignmentWindowController( + decks: [deckA.deck, deckB.deck, deckC, deckD], + names: ["A", "B", "C", "D"], + accents: [Palette.teal, Palette.coral, Palette.gold, .systemPurple]) + alignmentPopout?.setSyncActions( + rates: { [weak self] in + guard let self else { return } + let decks = self.audibleDecks + guard let reference = decks.first, decks.count > 1 else { return } + self.slideRates(Array(decks.dropFirst()), to: reference) + }, + peaks: { [weak self] in + guard let self else { return } + let decks = self.audibleDecks + guard let reference = decks.first, decks.count > 1 else { return } + self.alignPeaks(Array(decks.dropFirst()), to: reference) + }) + } + alignmentPopout?.setRecordChoices(availableTracks) { [weak self] index, track in + self?.replaceTrack(at: index, with: track) + } + alignmentPopout?.window?.appearance = deckAppearance + alignmentPopout?.show(tracks: [deckA.deck.track, deckB.deck.track, deckC.track, deckD.track]) + } + + private func showSoloAlignmentPopout() { + alignmentPopout = DJAlignmentWindowController( + decks: [deckA.deck], names: ["A"], accents: [Palette.teal]) + let primitiveEnd = min(availableTracks.count, practiceStartIndex + practiceCount) + let primitiveTracks = Array(availableTracks.prefix(primitiveEnd)) + alignmentPopout?.setRecordChoices(primitiveTracks) { [weak self] index, track in + self?.replaceTrack(at: index, with: track) + } + alignmentPopout?.window?.appearance = deckAppearance + alignmentPopout?.show(tracks: [deckA.deck.track]) + } + + private func replaceTrack(at index: Int, with track: Track) { + let decks = [deckA.deck, deckB.deck, deckC, deckD] + guard decks.indices.contains(index) else { return } + let autoplay = decks[index].motorEnabled + switch index { + case 0: + deckA.select(track, autoplay: autoplay) + case 1: + deckB.select(track, autoplay: autoplay) + case 2: + deckC.load(track) + if autoplay { deckC.play() } + popoutC?.trackChanged(track) + alignmentPopout?.trackChanged(track, at: 2) + default: + deckD.load(track) + if autoplay { deckD.play() } + popoutD?.trackChanged(track) + alignmentPopout?.trackChanged(track, at: 3) + } + applyCrossfade() + onStateChange?() + } + + private func sync(_ target: DJDeckView?, to reference: DJDeckView?) { + guard let target, let reference else { return } + slideRates([target.deck], to: reference.deck) + } + + private var audibleDecks: [DJDeckPlayer] { + [deckA.deck, deckB.deck, deckC, deckD].filter { + $0.motorEnabled && $0.gain > 0.0001 + } + } + + private func matchedTempo(for target: DJDeckPlayer, referenceBPM: Double) -> Double { + let candidates = [referenceBPM / 2, referenceBPM, referenceBPM * 2] + return candidates + .filter { $0 >= target.sourceBPM * 0.5 && $0 <= target.sourceBPM * 2.0 } + .min { abs(log($0 / target.sourceBPM)) < abs(log($1 / target.sourceBPM)) } + ?? referenceBPM + } + + private func slideRates(_ targets: [DJDeckPlayer], to reference: DJDeckPlayer) { + rateSyncTimer?.invalidate() + guard reference.motorEnabled, reference.gain > 0.0001 else { return } + let targets = targets.filter { $0.motorEnabled && $0.gain > 0.0001 } + guard !targets.isEmpty else { return } + let starts = targets.map(\.targetBPM) + let destinations = targets.map { matchedTempo(for: $0, referenceBPM: reference.targetBPM) } + let began = ProcessInfo.processInfo.systemUptime + let duration = 0.65 + rateSyncTimer = DJRunLoopTimer.scheduled(every: 1.0 / 30.0) { [weak self] timer in + let raw = min(1, (ProcessInfo.processInfo.systemUptime - began) / duration) + let eased = raw * raw * (3 - 2 * raw) + for index in targets.indices { + targets[index].setBPM(starts[index] + (destinations[index] - starts[index]) * eased) + } + if raw >= 1 { + timer.invalidate() + self?.rateSyncTimer = nil + } + } + } + + private func alignPeaks(_ targets: [DJDeckPlayer], to reference: DJDeckPlayer) { + peakAlignTimer?.invalidate() + guard reference.motorEnabled, reference.gain > 0.0001 else { return } + let targets = targets.filter { $0.motorEnabled && $0.gain > 0.0001 } + guard !targets.isEmpty else { return } + let beat = 60 / max(1, reference.targetBPM) + let referenceOutput = reference.currentTime / max(0.01, reference.rate) + let referencePhase = referenceOutput.truncatingRemainder(dividingBy: beat) + let corrections = targets.map { target -> Double in + let output = target.currentTime / max(0.01, target.rate) + let phase = output.truncatingRemainder(dividingBy: beat) + var delta = referencePhase - phase + if delta > beat / 2 { delta -= beat } + if delta < -beat / 2 { delta += beat } + return delta * target.rate + } + let began = ProcessInfo.processInfo.systemUptime + let duration = 0.42 + var priorEase = 0.0 + peakAlignTimer = DJRunLoopTimer.scheduled(every: 1.0 / 60.0) { [weak self] timer in + let raw = min(1, (ProcessInfo.processInfo.systemUptime - began) / duration) + let eased = raw * raw * (3 - 2 * raw) + let step = eased - priorEase + priorEase = eased + for index in targets.indices { + targets[index].seek(to: targets[index].currentTime + corrections[index] * step) + } + if raw >= 1 { + timer.invalidate() + self?.peakAlignTimer = nil + } + } + } + + override func layout() { + let pad: CGFloat = 4 + let gap: CGFloat = 8 + let crossHeight: CGFloat = 52 + let waveHeight: CGFloat = min(104, max(76, bounds.height * 0.22)) + let waveRow = (waveHeight - 4) / 2 + let waveBottom = crossHeight + gap + waveformB.frame = NSRect(x: pad, y: waveBottom, width: bounds.width - pad * 2, height: waveRow) + waveformA.frame = NSRect(x: pad, y: waveBottom + waveRow + 4, + width: bounds.width - pad * 2, height: waveRow) + let width = max(1, (bounds.width - pad * 2 - gap) / 2) + let deckBottom = waveBottom + waveHeight + gap + deckA.frame = NSRect(x: pad, y: deckBottom, width: width, + height: max(120, bounds.height - deckBottom)) + deckB.frame = NSRect(x: pad + width + gap, y: deckBottom, width: width, + height: max(120, bounds.height - deckBottom)) + crossLabel.frame = NSRect(x: bounds.midX - 100, y: 29, width: 200, height: 18) + practiceButton.frame = NSRect(x: pad, y: 8, width: 86, height: 26) + crossfader.frame = NSRect(x: max(32, bounds.midX - min(250, bounds.width * 0.32)), y: 6, + width: min(500, bounds.width - 64), height: 20) + } + + @objc private func crossfadeChanged() { + applyCrossfade() + onStateChange?() + } + + private func applyCrossfade() { + let position = max(-1, min(1, crossfader.doubleValue)) + let blend = (position + 1) / 2 + let a = cos(blend * .pi / 2) + let b = sin(blend * .pi / 2) + deckA.deck.setGain(Float(a) * masterVolume) + deckB.deck.setGain(Float(b) * masterVolume) + deckC.setGain(masterVolume * 0.42) + deckD.setGain(masterVolume * 0.42) + crossLabel.stringValue = "A \(Int((a * a * 100).rounded())) · \(Int((b * b * 100).rounded())) B" + } +} diff --git a/juke-wizard/Sources/JukeWizard/DJPopoutDeck.swift b/juke-wizard/Sources/JukeWizard/DJPopoutDeck.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/DJPopoutDeck.swift @@ -0,0 +1,929 @@ +import AppKit +import AVFoundation +import CoreGraphics +import JukeDSP + +/// A floating, single-record surface for direct deck play. The platter is the +/// control: press to stop it under the hand, drag around the groove to scratch, +/// and release to resume the state it had before the touch. +final class DJPopoutDeckController: NSWindowController, NSWindowDelegate { + private let recordView: DJRadialRecordView + private let deckName: String + private var displayTimer: Timer? + private var hasPositioned = false + + init(deck: DJDeckPlayer, name: String, accent: NSColor) { + deckName = name + recordView = DJRadialRecordView(frame: NSRect(x: 0, y: 0, width: 350, height: 350)) + recordView.deck = deck + recordView.accent = accent + recordView.deckName = name + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 350, height: 350), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.title = "JukeWizard · Deck \(name)" + window.isMovableByWindowBackground = true + window.level = .floating + window.collectionBehavior = [.fullScreenAuxiliary, .moveToActiveSpace] + window.minSize = NSSize(width: 260, height: 260) + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = false + window.contentView = recordView + + super.init(window: window) + window.delegate = self + recordView.onClose = { [weak window] in window?.performClose(nil) } + } + + required init?(coder: NSCoder) { fatalError() } + deinit { displayTimer?.invalidate() } + + func show(track: Track?) { + if let track { recordView.load(track) } + window?.title = "JukeWizard · \(deckName) · \(track?.title ?? "record")" + positionOnce() + showWindow(nil) + window?.orderFrontRegardless() + startDisplay() + } + + func trackChanged(_ track: Track) { + recordView.load(track) + window?.title = "JukeWizard · \(deckName) · \(track.title)" + } + + private func startDisplay() { + displayTimer?.invalidate() + displayTimer = DJRunLoopTimer.scheduled(every: 1.0 / 60.0) { [weak self] _ in + self?.recordView.advanceEffects() + self?.recordView.needsDisplay = true + } + } + + private func positionOnce() { + guard !hasPositioned, let window, let screen = NSScreen.main else { return } + hasPositioned = true + let visible = screen.visibleFrame + let frame = window.frame + let inset: CGFloat = 18 + let origin: NSPoint + switch deckName { + case "A": origin = NSPoint(x: visible.minX + inset, y: visible.maxY - frame.height - inset) + case "B": origin = NSPoint(x: visible.maxX - frame.width - inset, + y: visible.maxY - frame.height - inset) + case "C": origin = NSPoint(x: visible.minX + inset, y: visible.minY + inset) + default: origin = NSPoint(x: visible.maxX - frame.width - inset, y: visible.minY + inset) + } + window.setFrameOrigin(origin) + } + + func windowWillClose(_ notification: Notification) { + recordView.cancelTrackpadLock() + displayTimer?.invalidate() + displayTimer = nil + } + + func windowDidResignKey(_ notification: Notification) { + recordView.cancelTrackpadLock() + } +} + +final class DJRadialRecordView: NSView { + private struct Spark { + var point: NSPoint + var velocity: CGVector + var life: Double + let duration: Double + } + private struct GrooveTrail { + let rotation: CGFloat + var life: Double + let energy: CGFloat + } + weak var deck: DJDeckPlayer? + var accent: NSColor = Palette.teal + var deckName = "A" + var onClose: (() -> Void)? + + private var trackTitle = "record" + private var trackDetail = "press · hold · scratch" + private var envelope: [Float] = [] + private var loadToken = 0 + private var lastAngle: CGFloat? + private var lastTimestamp: TimeInterval? + private var scratchOrigin: Double = 0 + private var scratchOffset: Double = 0 + private var wasPlayingAtPress = false + private var didDrag = false + private var pointerScratching = false + private var pressTimer: Timer? + private var releaseTimer: Timer? + private var brakeFactor = 1.0 + private var brakeActive = false + private var pressStartedAt: TimeInterval? + private var trackpadScratching = false + private var trackpadOrigin: Double = 0 + private var trackpadOffset: Double = 0 + private var trackpadLastTimestamp: TimeInterval? + private var trackpadEndTimer: Timer? + private var touchPositions: [ObjectIdentifier: NSPoint] = [:] + private var multitouchScratching = false + private var multitouchOrigin: Double = 0 + private var multitouchOffset: Double = 0 + private var multitouchLastTimestamp: TimeInterval? + private var multitouchArmed = false + private var multitouchWasPlaying = false + private var multitouchTravel: Double = 0 + private var trackpadLockActive = false + private var cursorHiddenByLock = false + private var sparks: [Spark] = [] + private var grooveTrails: [GrooveTrail] = [] + private var lastEffectTime = ProcessInfo.processInfo.systemUptime + private var lastTrailTime: TimeInterval = 0 + private var sparkBudget: Double = 0 + private var recordCache: NSImage? + private var shadowCache: NSImage? + private var energyTraceCache: NSImage? + private var cachedSize: NSSize = .zero + private var cachedDark = false + private var cachedMotor = false + private var spinMomentum = 0.0 + private var centerDragMouse: NSPoint? + private var centerDragOrigin: NSPoint? + + override var acceptsFirstResponder: Bool { true } + override var mouseDownCanMoveWindow: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + allowedTouchTypes = [.indirect] + wantsRestingTouches = true + layer?.cornerRadius = 22 + layer?.masksToBounds = false + setAccessibilityRole(.slider) + setAccessibilityLabel("Floating scratch record") + setAccessibilityHelp("Press and hold to slow the record. Double-click the vinyl for one-finger trackpad lock; press Escape to exit.") + } + + required init?(coder: NSCoder) { fatalError() } + deinit { + pressTimer?.invalidate() + releaseTimer?.invalidate() + trackpadEndTimer?.invalidate() + releaseTrackpadLock() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea(rect: bounds, + options: [.activeInKeyWindow, .cursorUpdate], + owner: self)) + } + + override func cursorUpdate(with event: NSEvent) { NSCursor.openHand.set() } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + invalidateRecordCache() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + invalidateRecordCache() + } + + private var center: NSPoint { NSPoint(x: bounds.midX, y: bounds.midY) } + private var radius: CGFloat { max(1, min(bounds.width, bounds.height) * 0.44) } + private var needleAngle: CGFloat { .pi * 0.257 } + private var trackProgress: CGFloat { + guard let deck, deck.duration > 0 else { return 0 } + return CGFloat(max(0, min(1, deck.currentTime / deck.duration))) + } + private var needleTip: NSPoint { + let grooveRadius = radius * (0.82 - trackProgress * 0.48) + return NSPoint(x: center.x + cos(needleAngle) * grooveRadius, + y: center.y + sin(needleAngle) * grooveRadius) + } + + func load(_ track: Track) { + if let primpat = DJPrimpats.metadata(for: track) { + trackTitle = "\(deckName) · \(primpat.waveform.rawValue.uppercased())" + let number = String(format: "%.2f", primpat.frequency) + .replacingOccurrences(of: #"0+$"#, with: "", options: .regularExpression) + .replacingOccurrences(of: #"\.$"#, with: "", options: .regularExpression) + trackDetail = "\(primpat.key) · \(number) Hz" + } else { + trackTitle = track.title + } + if DJPrimpats.metadata(for: track) != nil { + // The frequency is already the useful record label. + } else if let key = track.meta?.key, !key.isEmpty { + trackDetail = key + } else if let bpm = track.meta?.bpm { + trackDetail = "\(bpm) BPM" + } else { + trackDetail = "press · hold · scratch" + } + envelope = [] + invalidateRecordCache() + loadToken += 1 + let token = loadToken + needsDisplay = true + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let file = try? AVAudioFile(forReading: track.url) else { return } + let format = file.processingFormat + let frameCount = Int(file.length) + let channelCount = max(1, Int(format.channelCount)) + guard frameCount > 1 else { return } + + let bins = 2_880 + var env = [Float](repeating: 0, count: bins) + let chunkFrames = AVAudioFrameCount(min(frameCount, 8192)) + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: chunkFrames) else { return } + var absoluteFrame = 0 + while absoluteFrame < frameCount { + let count = AVAudioFrameCount(min(Int(chunkFrames), frameCount - absoluteFrame)) + do { try file.read(into: buffer, frameCount: count) } catch { return } + guard let channels = buffer.floatChannelData else { return } + let loaded = Int(buffer.frameLength) + guard loaded > 0 else { break } + for localFrame in 0.. 0 { env = env.map { $0 / envMax } } + + DispatchQueue.main.async { + guard let self, token == self.loadToken else { return } + self.envelope = env + self.invalidateRecordCache() + self.needsDisplay = true + } + } + } + + func advanceEffects() { + let now = ProcessInfo.processInfo.systemUptime + let elapsed = min(0.05, max(0, now - lastEffectTime)) + lastEffectTime = now + for index in sparks.indices { + sparks[index].point.x += sparks[index].velocity.dx * elapsed + sparks[index].point.y += sparks[index].velocity.dy * elapsed + sparks[index].velocity.dy -= 120 * elapsed + sparks[index].life -= elapsed + } + sparks.removeAll { $0.life <= 0 } + for index in grooveTrails.indices { grooveTrails[index].life -= elapsed } + grooveTrails.removeAll { $0.life <= 0 } + + guard let deck else { return } + let state = deck.visualState + guard abs(state.motion) >= 0.002 else { return } + let energy = min(1, max(Double(state.energy), abs(state.motion) * 0.10)) + let rotation = CGFloat(-deck.currentTime / DJPlatterGeometry.secondsPerRevolution * Double.pi * 2) + if energy > 0.035, now - lastTrailTime > 0.035 { + grooveTrails.append(GrooveTrail(rotation: rotation, life: 0.20, + energy: CGFloat(energy))) + if grooveTrails.count > 5 { grooveTrails.removeFirst(grooveTrails.count - 5) } + lastTrailTime = now + } + + sparkBudget += energy * elapsed * 38 + let tip = needleTip + while sparkBudget >= 1, sparks.count < 24 { + sparkBudget -= 1 + let duration = Double.random(in: 0.16...0.34) + sparks.append(Spark( + point: tip, + velocity: CGVector(dx: Double.random(in: -48...52) + state.motion * 8, + dy: Double.random(in: 48...116)), + life: duration, + duration: duration)) + } + } + + override func draw(_ dirtyRect: NSRect) { + let dark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + NSColor.clear.setFill() + bounds.fill(using: .copy) + + let c = center + let r = radius + let rotation = CGFloat(-(deck?.currentTime ?? 0) / + DJPlatterGeometry.secondsPerRevolution * Double.pi * 2) + let visual = deck?.visualState ?? (motion: 0.0, energy: Float(0)) + let energy = min(CGFloat(1), max(CGFloat(visual.energy), CGFloat(abs(visual.motion)) * 0.10)) + + ensureRecordCache(dark: dark) + // The cast shadow belongs to the screen, not the rotating material. + shadowCache?.draw(in: bounds, from: .zero, operation: .sourceOver, fraction: 1) + NSGraphicsContext.saveGraphicsState() + let transform = NSAffineTransform() + transform.translateX(by: c.x, yBy: c.y) + transform.rotate(byRadians: rotation) + transform.translateX(by: -c.x, yBy: -c.y) + transform.concat() + + recordCache?.draw(in: bounds, from: .zero, operation: .sourceOver, fraction: 1) + NSGraphicsContext.restoreGraphicsState() + + drawGrooveTrails(center: c, radius: r) + + NSGraphicsContext.saveGraphicsState() + transform.concat() + energyTraceCache?.draw(in: bounds, from: .zero, operation: .plusLighter, + fraction: min(0.78, energy * 0.72)) + NSGraphicsContext.restoreGraphicsState() + + // The needle stays still while the waveform turns underneath it. + let needle = NSBezierPath() + needle.move(to: NSPoint(x: c.x + r * 0.18, y: c.y + r * 0.96)) + needle.line(to: needleTip) + Palette.gold.setStroke() + needle.lineWidth = max(2, r * 0.018) + needle.lineCapStyle = .round + needle.stroke() + drawNeedleHeat(center: c, radius: r, energy: energy) + drawSparks() + + if trackpadLockActive { + accent.withAlphaComponent(0.88).setStroke() + let lockRing = NSBezierPath(ovalIn: NSRect(x: c.x - r - 7, y: c.y - r - 7, + width: (r + 7) * 2, height: (r + 7) * 2)) + lockRing.lineWidth = 3 + lockRing.stroke() + } + + } + + private func drawEnvelopeGroove(center c: NSPoint, radius r: CGFloat) { + let values = envelope.isEmpty ? [Float](repeating: 0.55, count: 2_880) : envelope + let revolution = DJPlatterGeometry.secondsPerRevolution + let turns = max(1, (deck?.duration ?? revolution) / revolution) + let path = NSBezierPath() + for (index, value) in values.enumerated() { + let progress = CGFloat(index) / CGFloat(max(1, values.count - 1)) + let angle = needleAngle + progress * CGFloat(turns) * .pi * 2 + let grooveRadius = r * (0.82 - progress * 0.48) + CGFloat(value) * r * 0.020 + let point = NSPoint(x: c.x + cos(angle) * grooveRadius, + y: c.y + sin(angle) * grooveRadius) + index == 0 ? path.move(to: point) : path.line(to: point) + } + accent.withAlphaComponent(0.80).setStroke() + path.lineWidth = max(1.15, r * 0.008) + path.lineJoinStyle = .round + path.stroke() + } + + private func drawGrooveTrails(center c: NSPoint, radius r: CGFloat) { + guard let energyTraceCache else { return } + for trail in grooveTrails { + NSGraphicsContext.saveGraphicsState() + let transform = NSAffineTransform() + transform.translateX(by: c.x, yBy: c.y) + transform.rotate(byRadians: trail.rotation) + transform.translateX(by: -c.x, yBy: -c.y) + transform.concat() + let alpha = CGFloat(max(0, trail.life / 0.20)) * trail.energy * 0.22 + energyTraceCache.draw(in: bounds, from: .zero, operation: .plusLighter, + fraction: alpha) + NSGraphicsContext.restoreGraphicsState() + } + } + + private func invalidateRecordCache() { + recordCache = nil + shadowCache = nil + energyTraceCache = nil + } + + private func ensureRecordCache(dark: Bool) { + let motor = deck?.motorEnabled ?? false + guard recordCache == nil || cachedSize != bounds.size || cachedDark != dark || + cachedMotor != motor else { return } + cachedSize = bounds.size + cachedDark = dark + cachedMotor = motor + let c = center + let r = radius + + let shadowImage = NSImage(size: bounds.size) + shadowImage.lockFocus() + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(motor ? 0.70 : 0.46) + shadow.shadowBlurRadius = motor ? 6 : 9 + shadow.shadowOffset = NSSize(width: 0, height: motor ? -10 : -7) + shadow.set() + NSColor.black.setFill() + NSBezierPath(ovalIn: NSRect(x: c.x - r, y: c.y - r, + width: r * 2, height: r * 2)).fill() + shadowImage.unlockFocus() + shadowImage.cacheMode = .always + shadowCache = shadowImage + + let record = NSImage(size: bounds.size) + record.lockFocus() + Palette.deckSurface(accent, dark: dark).setFill() + NSBezierPath(ovalIn: NSRect(x: c.x - r, y: c.y - r, width: r * 2, height: r * 2)).fill() + drawEnvelopeGroove(center: c, radius: r) + drawLabel(center: c, radius: r) + record.unlockFocus() + record.cacheMode = .always + recordCache = record + + let trace = NSImage(size: bounds.size) + trace.lockFocus() + drawEnergyTrace(center: c, radius: r) + trace.unlockFocus() + trace.cacheMode = .always + energyTraceCache = trace + } + + private func drawEnergyTrace(center c: NSPoint, radius r: CGFloat) { + let values = envelope.isEmpty ? [Float](repeating: 0.55, count: 2_880) : envelope + let revolution = DJPlatterGeometry.secondsPerRevolution + let turns = max(1, (deck?.duration ?? revolution) / revolution) + let path = NSBezierPath() + for (index, value) in values.enumerated() { + let progress = CGFloat(index) / CGFloat(max(1, values.count - 1)) + let angle = needleAngle + progress * CGFloat(turns) * .pi * 2 + let grooveRadius = r * (0.82 - progress * 0.48) + CGFloat(value) * r * 0.020 + let point = NSPoint(x: c.x + cos(angle) * grooveRadius, + y: c.y + sin(angle) * grooveRadius) + index == 0 ? path.move(to: point) : path.line(to: point) + } + accent.setStroke() + path.lineWidth = max(1.4, r * 0.01) + path.stroke() + } + + private func drawNeedleHeat(center c: NSPoint, radius r: CGFloat, energy: CGFloat) { + guard energy > 0.015 else { return } + let tip = needleTip + for ring in stride(from: 4, through: 1, by: -1) { + let size = CGFloat(ring) * (3 + energy * 3) + NSColor(srgbRed: 1, green: 0.18 + 0.15 * CGFloat(ring), blue: 0.02, + alpha: energy * (0.10 + CGFloat(5 - ring) * 0.08)).setFill() + NSBezierPath(ovalIn: NSRect(x: tip.x - size, y: tip.y - size, + width: size * 2, height: size * 2)).fill() + } + NSColor(calibratedRed: 1, green: 0.94, blue: 0.56, alpha: energy).setFill() + NSBezierPath(ovalIn: NSRect(x: tip.x - 2.2, y: tip.y - 2.2, width: 4.4, height: 4.4)).fill() + } + + private func drawSparks() { + for spark in sparks { + let alpha = CGFloat(max(0, spark.life / spark.duration)) + NSColor(srgbRed: 1, green: 0.32 + alpha * 0.55, blue: 0.04, + alpha: alpha).setFill() + let size = 1.5 + alpha * 2.4 + NSBezierPath(ovalIn: NSRect(x: spark.point.x - size, y: spark.point.y - size, + width: size * 2, height: size * 2)).fill() + } + } + + private func drawLabel(center c: NSPoint, radius r: CGFloat) { + let labelR = r * 0.21 + accent.withAlphaComponent(0.94).setFill() + NSBezierPath(ovalIn: NSRect(x: c.x - labelR, y: c.y - labelR, + width: labelR * 2, height: labelR * 2)).fill() + if deck?.motorEnabled == true { + let spindleShadow = NSShadow() + spindleShadow.shadowColor = NSColor.black.withAlphaComponent(0.55) + spindleShadow.shadowBlurRadius = 4 + spindleShadow.shadowOffset = NSSize(width: 1, height: -3) + spindleShadow.set() + Palette.gold.setFill() + NSBezierPath(ovalIn: NSRect(x: c.x - 5, y: c.y - 5, width: 10, height: 10)).fill() + NSColor.white.withAlphaComponent(0.72).setFill() + NSBezierPath(ovalIn: NSRect(x: c.x - 2.5, y: c.y + 0.5, width: 3, height: 3)).fill() + } else { + let dark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + Palette.deckInk(accent, dark: dark).withAlphaComponent(0.58).setFill() + NSBezierPath(ovalIn: NSRect(x: c.x - 3, y: c.y - 3, width: 6, height: 6)).fill() + } + + let title = deckName as NSString + let titleAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: max(12, r * 0.11), weight: .black), + .foregroundColor: NSColor.white + ] + let titleSize = title.size(withAttributes: titleAttrs) + title.draw(at: NSPoint(x: c.x - titleSize.width / 2, + y: c.y + r * 0.025), withAttributes: titleAttrs) + + let detail = trackDetail as NSString + let detailAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: max(9, r * 0.055), weight: .bold), + .foregroundColor: NSColor.white.withAlphaComponent(0.82) + ] + let detailSize = detail.size(withAttributes: detailAttrs) + detail.draw(at: NSPoint(x: c.x - detailSize.width / 2, + y: c.y - r * 0.105), withAttributes: detailAttrs) + } + + private func angle(for event: NSEvent) -> CGFloat { + let point = convert(event.locationInWindow, from: nil) + return atan2(point.y - center.y, point.x - center.x) + } + + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + let distance = hypot(point.x - center.x, point.y - center.y) + // The visible vinyl is the instrument; the translucent surround is + // the window handle. Keep these hit regions identical to what is drawn. + guard distance <= radius else { + window?.performDrag(with: event) + return + } + // The center sticker is a second, easy-to-find window handle. Moving + // it never changes playback position or catches the virtual platter. + if distance <= radius * 0.21 { + centerDragMouse = NSEvent.mouseLocation + centerDragOrigin = window?.frame.origin + return + } + if event.clickCount == 2 { + trackpadLockActive ? releaseTrackpadLock() : engageTrackpadLock() + return + } + window?.makeFirstResponder(self) + NSCursor.closedHand.set() + lastAngle = angle(for: event) + lastTimestamp = event.timestamp + scratchOrigin = deck?.currentTime ?? 0 + scratchOffset = 0 + spinMomentum = deck?.visualState.motion ?? 0 + wasPlayingAtPress = deck?.isPlaying ?? false + didDrag = false + pointerScratching = false + releaseTimer?.invalidate() + releaseTimer = nil + pressStartedAt = ProcessInfo.processInfo.systemUptime + pressTimer?.invalidate() + pressTimer = DJRunLoopTimer.scheduled(every: 1.0 / 60.0) { [weak self] _ in + guard let self, self.wasPlayingAtPress, !self.pointerScratching, + let started = self.pressStartedAt else { return } + // Same deliberate-hold threshold and 60 Hz decay as video.mjs. + guard ProcessInfo.processInfo.systemUptime - started >= 0.09 else { return } + self.brakeActive = true + self.didDrag = true // a brake never becomes a tap action on lift + self.brakeFactor *= 0.85 + if self.brakeFactor < 0.02 { self.brakeFactor = 0 } + self.deck?.setTransportScale(self.brakeFactor) + } + } + + override func mouseDragged(with event: NSEvent) { + if let startMouse = centerDragMouse, let startOrigin = centerDragOrigin { + let mouse = NSEvent.mouseLocation + window?.setFrameOrigin(NSPoint(x: startOrigin.x + mouse.x - startMouse.x, + y: startOrigin.y + mouse.y - startMouse.y)) + return + } + guard let prior = lastAngle else { return } + if !pointerScratching { + pressTimer?.invalidate() + pressTimer = nil + if brakeActive { + brakeActive = false + brakeFactor = 1 + deck?.restoreTransportRate() + } + pointerScratching = true + deck?.beginScratch() + } + let next = angle(for: event) + var delta = next - prior + if delta > .pi { delta -= .pi * 2 } + if delta < -.pi { delta += .pi * 2 } + let seconds = Double(-delta / (.pi * 2)) * DJPlatterGeometry.secondsPerRevolution + let elapsed = max(1.0 / 240.0, event.timestamp - (lastTimestamp ?? event.timestamp)) + scratchOffset += seconds + if abs(scratchOffset) > 0.008 { didDrag = true } + deck?.scratch(to: scratchOrigin + scratchOffset, movement: seconds, elapsed: elapsed) + lastAngle = next + lastTimestamp = event.timestamp + needsDisplay = true + } + + override func mouseUp(with event: NSEvent) { + if let startMouse = centerDragMouse { + let mouse = NSEvent.mouseLocation + let travelled = hypot(mouse.x - startMouse.x, mouse.y - startMouse.y) + centerDragMouse = nil + centerDragOrigin = nil + if travelled < 3 { + deck?.toggle() + invalidateRecordCache() + needsDisplay = true + } + return + } + lastAngle = nil + lastTimestamp = nil + pressStartedAt = nil + pressTimer?.invalidate() + pressTimer = nil + if pointerScratching { + pointerScratching = false + spinMomentum = deck?.visualState.motion ?? 0 + deck?.endScratch(momentum: spinMomentum) + beginMomentumRelease() + } else if brakeActive { + beginBrakeRelease() + } else if !wasPlayingAtPress, !didDrag { + deck?.play() + } + NSCursor.openHand.set() + } + + private func beginBrakeRelease() { + brakeActive = false + releaseTimer?.invalidate() + releaseTimer = DJRunLoopTimer.scheduled(every: 1.0 / 60.0) { [weak self] timer in + guard let self else { timer.invalidate(); return } + // video.mjs ramps toward the pre-touch rate rather than snapping. + self.brakeFactor += (1 - self.brakeFactor) * 0.12 + self.deck?.setTransportScale(self.brakeFactor) + if self.brakeFactor >= 0.995 { + self.brakeFactor = 1 + self.deck?.restoreTransportRate() + timer.invalidate() + self.releaseTimer = nil + } + } + } + + private func beginMomentumRelease() { + releaseTimer?.invalidate() + releaseTimer = DJRunLoopTimer.scheduled(every: 1.0 / 60.0) { [weak self] timer in + guard let self, let deck = self.deck else { timer.invalidate(); return } + let restingRate = deck.motorEnabled ? deck.rate : 0 + self.spinMomentum += (restingRate - self.spinMomentum) * 0.022 + deck.setTransportVelocity(self.spinMomentum) + if abs(self.spinMomentum - restingRate) < 0.004 { + self.spinMomentum = restingRate + deck.motorEnabled ? deck.restoreTransportRate() : deck.setTransportVelocity(0) + timer.invalidate() + self.releaseTimer = nil + } + } + } + + override func scrollWheel(with event: NSEvent) { + if multitouchScratching { return } + let point = convert(event.locationInWindow, from: nil) + guard hypot(point.x - center.x, point.y - center.y) <= radius + 10 else { + super.scrollWheel(with: event) + return + } + + if !trackpadScratching { + releaseTimer?.invalidate() + releaseTimer = nil + brakeFactor = 1 + deck?.restoreTransportRate() + trackpadScratching = true + trackpadOrigin = deck?.currentTime ?? 0 + trackpadOffset = 0 + spinMomentum = deck?.visualState.motion ?? 0 + trackpadLastTimestamp = event.timestamp + deck?.beginScratch() + } + + let horizontal = event.scrollingDeltaX + let vertical = -event.scrollingDeltaY + let points = abs(horizontal) >= abs(vertical) ? horizontal : vertical + let secondsPerPoint = DJPlatterGeometry.secondsPerRevolution / + Double(max(120, radius * 2)) + let movement = -Double(points) * secondsPerPoint + let elapsed = max(1.0 / 240.0, + event.timestamp - (trackpadLastTimestamp ?? event.timestamp)) + trackpadOffset += movement + deck?.scratch(to: trackpadOrigin + trackpadOffset, + movement: movement, elapsed: elapsed) + trackpadLastTimestamp = event.timestamp + needsDisplay = true + + trackpadEndTimer?.invalidate() + if event.phase == .ended || event.phase == .cancelled || + event.momentumPhase == .ended || event.momentumPhase == .cancelled { + endTrackpadScratch() + } else { + // Some trackpad and wheel drivers omit explicit phase endings. + trackpadEndTimer = DJRunLoopTimer.scheduled(every: 0.12, repeats: false) { [weak self] _ in + self?.endTrackpadScratch() + } + } + } + + private func endTrackpadScratch() { + guard trackpadScratching else { return } + trackpadEndTimer?.invalidate() + trackpadEndTimer = nil + trackpadScratching = false + trackpadLastTimestamp = nil + spinMomentum = deck?.visualState.motion ?? 0 + deck?.endScratch(momentum: spinMomentum) + beginMomentumRelease() + } + + private func touchID(_ touch: NSTouch) -> ObjectIdentifier { + ObjectIdentifier(touch.identity) + } + + private func platterPosition(_ touch: NSTouch) -> NSPoint { + let normalized = touch.normalizedPosition + return NSPoint(x: (normalized.x - 0.5) * 2, + y: (normalized.y - 0.5) * 2) + } + + override func touchesBegan(with event: NSEvent) { + for touch in event.touches(matching: .touching, in: self) { + touchPositions[touchID(touch)] = platterPosition(touch) + } + guard touchPositions.count >= requiredTouchCount else { + needsDisplay = true + return + } + + if trackpadScratching { endTrackpadScratch() } + if !multitouchScratching && !multitouchArmed { + releaseTimer?.invalidate() + releaseTimer = nil + pressTimer?.invalidate() + pressTimer = nil + brakeFactor = 1 + deck?.restoreTransportRate() + multitouchArmed = true + multitouchWasPlaying = deck?.isPlaying ?? false + multitouchTravel = 0 + multitouchLastTimestamp = event.timestamp + let started = ProcessInfo.processInfo.systemUptime + pressTimer = DJRunLoopTimer.scheduled(every: 1.0 / 60.0) { [weak self] _ in + guard let self, self.multitouchArmed, self.multitouchWasPlaying, + ProcessInfo.processInfo.systemUptime - started >= 0.09 else { return } + self.brakeActive = true + self.brakeFactor *= 0.85 + if self.brakeFactor < 0.02 { self.brakeFactor = 0 } + self.deck?.setTransportScale(self.brakeFactor) + } + } + needsDisplay = true + } + + override func touchesMoved(with event: NSEvent) { + let touches = event.touches(matching: .touching, in: self) + var contacts: [ACPlatterContact] = [] + contacts.reserveCapacity(touches.count) + + for touch in touches { + let id = touchID(touch) + let current = platterPosition(touch) + let previous = touchPositions[id] ?? current + var contact = ACPlatterContact() + contact.previous_x = Double(previous.x) + contact.previous_y = Double(previous.y) + contact.current_x = Double(current.x) + contact.current_y = Double(current.y) + contacts.append(contact) + touchPositions[id] = current + } + + guard touchPositions.count >= requiredTouchCount else { + needsDisplay = true + return + } + let elapsed = max(1.0 / 240.0, + event.timestamp - (multitouchLastTimestamp ?? event.timestamp)) + let movement = contacts.withUnsafeBufferPointer { + ac_platter_contact_motion($0.baseAddress, $0.count, + DJPlatterGeometry.secondsPerRevolution) + } + multitouchTravel += abs(movement) + if !multitouchScratching && multitouchTravel > 0.002 { + pressTimer?.invalidate() + pressTimer = nil + multitouchArmed = false + if brakeActive { + brakeActive = false + brakeFactor = 1 + deck?.restoreTransportRate() + } + multitouchScratching = true + multitouchOrigin = deck?.currentTime ?? 0 + multitouchOffset = 0 + spinMomentum = deck?.visualState.motion ?? 0 + deck?.beginScratch() + } + guard multitouchScratching else { + multitouchLastTimestamp = event.timestamp + needsDisplay = true + return + } + + multitouchOffset += movement + deck?.scratch(to: multitouchOrigin + multitouchOffset, + movement: movement, elapsed: elapsed) + multitouchLastTimestamp = event.timestamp + needsDisplay = true + } + + override func touchesEnded(with event: NSEvent) { + for touch in event.touches(matching: .ended, in: self) { + touchPositions.removeValue(forKey: touchID(touch)) + } + finishMultitouchIfNeeded() + } + + override func touchesCancelled(with event: NSEvent) { + for touch in event.touches(matching: .cancelled, in: self) { + touchPositions.removeValue(forKey: touchID(touch)) + } + if touchPositions.count < requiredTouchCount { finishMultitouch() } + needsDisplay = true + } + + private func finishMultitouchIfNeeded() { + if touchPositions.count < requiredTouchCount { finishMultitouch() } + needsDisplay = true + } + + private func finishMultitouch() { + pressTimer?.invalidate() + pressTimer = nil + multitouchArmed = false + multitouchLastTimestamp = nil + if multitouchScratching { + multitouchScratching = false + spinMomentum = deck?.visualState.motion ?? 0 + deck?.endScratch(momentum: spinMomentum) + beginMomentumRelease() + } else if brakeActive { + beginBrakeRelease() + } + } + + private var requiredTouchCount: Int { trackpadLockActive ? 1 : 2 } + + private func engageTrackpadLock() { + guard !trackpadLockActive else { return } + trackpadLockActive = true + window?.makeKey() + window?.makeFirstResponder(self) + if CGAssociateMouseAndMouseCursorPosition(0) == .success { + NSCursor.hide() + cursorHiddenByLock = true + } + DJFocusFlash.shared.flash(rising: true) + DJFocusDing.shared.play(rising: true) + needsDisplay = true + } + + private func releaseTrackpadLock() { + guard trackpadLockActive || cursorHiddenByLock else { return } + let wasActive = trackpadLockActive + if multitouchScratching || multitouchArmed { finishMultitouch() } + touchPositions.removeAll() + trackpadLockActive = false + CGAssociateMouseAndMouseCursorPosition(1) + if cursorHiddenByLock { + NSCursor.unhide() + cursorHiddenByLock = false + } + if wasActive { + DJFocusFlash.shared.flash(rising: false) + DJFocusDing.shared.play(rising: false) + } + needsDisplay = true + } + + func cancelTrackpadLock() { releaseTrackpadLock() } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 53, trackpadLockActive { + releaseTrackpadLock() + return + } + super.keyDown(with: event) + } +} diff --git a/juke-wizard/Sources/JukeWizard/DJPrimpats.swift b/juke-wizard/Sources/JukeWizard/DJPrimpats.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/DJPrimpats.swift @@ -0,0 +1,183 @@ +import AVFoundation +import Foundation + +/// Small, deterministic records for learning the physical behavior of a deck. +/// +/// Primpats are rendered locally and contain no samples, network requests, or +/// user data. Each eight-second record contains a whole number of cycles so a +/// looping deck can cross its boundary without a phase discontinuity. +enum DJPrimpats { + enum Waveform: String, CaseIterable, Codable { + case sine + } + + struct Metadata: Hashable { + let id: String + let title: String + let frequency: Double + let waveform: Waveform + let duration: Double + let bpm: Int + let key: String + } + + struct Record { + let track: Track + let metadata: Metadata + + var title: String { metadata.title } + var frequency: Double { metadata.frequency } + var waveform: Waveform { metadata.waveform } + } + + private static let sampleRate = 48_000.0 + private static let amplitude: Float = 0.55 + private static let cacheVersion = "v3" + private static let renderLock = NSLock() + + /// One octave of white piano keys. Rendering chooses an imperceptibly + /// adjusted sample-exact frequency so every eight-second record loops on a + /// zero-phase boundary while retaining the equal-tempered musical pitch. + static let catalog: [Metadata] = [ + metadata(id: "sine-c4", frequency: 261.6256, key: "C4"), + metadata(id: "sine-d4", frequency: 293.6648, key: "D4"), + metadata(id: "sine-e4", frequency: 329.6276, key: "E4"), + metadata(id: "sine-f4", frequency: 349.2282, key: "F4"), + metadata(id: "sine-g4", frequency: 391.9954, key: "G4"), + metadata(id: "sine-a4", frequency: 440.0000, key: "A4"), + metadata(id: "sine-b4", frequency: 493.8833, key: "B4"), + metadata(id: "sine-c5", frequency: 523.2511, key: "C5"), + ] + + /// Render the catalog as scratch-ready Tracks. Failed cache entries are + /// omitted so one unwritable file never prevents the remaining records. + static func make() -> [Record] { + catalog.compactMap { metadata in + guard let url = render(metadata) else { return nil } + let track = Track(url: url, lane: "primpats", title: metadata.title) + track.meta = TrackMeta( + artist: "JukeWizard", + backend: "Primpats local \(metadata.waveform.rawValue) synthesis · \(frequencyLabel(metadata.frequency)) Hz", + status: "PRIMPAT", + updated: nil, + revisions: nil, + bytes: fileSize(at: url), + durationSec: metadata.duration, + bpm: metadata.bpm, + key: metadata.key, + releaseDate: nil, + art: nil, + media: nil, + links: nil + ) + return Record(track: track, metadata: metadata) + } + } + + /// Convenience API for consumers that only need the existing Track type. + static func makeTracks() -> [Track] { + make().map(\.track) + } + + /// Recover primpat metadata after a Track has passed through a deck queue. + static func metadata(for track: Track) -> Metadata? { + let id = track.url.deletingPathExtension().lastPathComponent + return catalog.first { $0.id == id } + } + + private static func metadata(id: String, frequency: Double, key: String) -> Metadata { + Metadata( + id: id, + title: "Primpats · \(key) · Sine \(frequencyLabel(frequency)) Hz", + frequency: frequency, + waveform: .sine, + duration: 8, + bpm: 120, + key: key + ) + } + + private static func frequencyLabel(_ frequency: Double) -> String { + let hundredths = Int((frequency * 100).rounded()) + if hundredths.isMultiple(of: 100) { return String(hundredths / 100) } + if hundredths.isMultiple(of: 10) { return "\(hundredths / 100).\((hundredths % 100) / 10)" } + return "\(hundredths / 100).\(String(format: "%02d", hundredths % 100))" + } + + private static func render(_ metadata: Metadata) -> URL? { + renderLock.lock() + defer { renderLock.unlock() } + + let fm = FileManager.default + guard let caches = fm.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil } + let directory = caches + .appendingPathComponent("computer.aesthetic.jukewizard", isDirectory: true) + .appendingPathComponent("primpats", isDirectory: true) + .appendingPathComponent(cacheVersion, isDirectory: true) + do { + try fm.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + return nil + } + + let url = directory.appendingPathComponent(metadata.id).appendingPathExtension("wav") + let expectedFrames = AVAudioFramePosition(sampleRate * metadata.duration) + if audioIsUsable(at: url, expectedFrames: expectedFrames) { return url } + if fm.fileExists(atPath: url.path) { try? fm.removeItem(at: url) } + + let temporaryURL = directory + .appendingPathComponent(".\(metadata.id)-rendering") + .appendingPathExtension("wav") + if fm.fileExists(atPath: temporaryURL.path) { try? fm.removeItem(at: temporaryURL) } + + let frames = Int(expectedFrames) + let cycles = max(1, Int((metadata.frequency * metadata.duration).rounded())) + let renderedFrequency = Double(cycles) * sampleRate / Double(frames) + guard let format = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: sampleRate, + channels: 2, + interleaved: false + ), let buffer = AVAudioPCMBuffer( + pcmFormat: format, + frameCapacity: AVAudioFrameCount(frames) + ), let channels = buffer.floatChannelData else { return nil } + + buffer.frameLength = AVAudioFrameCount(frames) + let radiansPerFrame = 2 * Double.pi * renderedFrequency / sampleRate + for frame in 0.. Bool { + guard let file = try? AVAudioFile(forReading: url) else { return false } + return file.length == expectedFrames && file.processingFormat.channelCount == 2 + } + + private static func fileSize(at url: URL) -> Int? { + let values = try? url.resourceValues(forKeys: [.fileSizeKey]) + return values?.fileSize + } +} diff --git a/juke-wizard/Sources/JukeWizard/JukeController.swift b/juke-wizard/Sources/JukeWizard/JukeController.swift --- a/juke-wizard/Sources/JukeWizard/JukeController.swift +++ b/juke-wizard/Sources/JukeWizard/JukeController.swift @@ -23,6 +23,16 @@ static let inkDim = NSColor(srgbRed: 0.42, green: 0.40, blue: 0.36, alpha: 1) static func bg(_ dark: Bool) -> NSColor { dark ? NSColor(srgbRed: 0.10, green: 0.12, blue: 0.13, alpha: 1) : cream } + static func deckSurface(_ accent: NSColor, dark: Bool, alpha: CGFloat = 1) -> NSColor { + let base = dark ? NSColor(white: 0.025, alpha: 1) : cream + let wash = dark ? 0.20 : 0.14 + return base.blended(withFraction: wash, of: accent)?.withAlphaComponent(alpha) + ?? base.withAlphaComponent(alpha) + } + static func deckInk(_ accent: NSColor, dark: Bool) -> NSColor { + let target = dark ? NSColor.white : NSColor.black + return accent.blended(withFraction: dark ? 0.38 : 0.48, of: target) ?? target + } } final class JukeController: NSWindowController, NSWindowDelegate, @@ -106,6 +116,9 @@ var playButton: NSButton! var ledLabel: NSTextField! var notesToggle: NSButton! var roomButton: NSButton! + var djButton: NSButton! + var djMixer: DJMixerView! + var djMode = false var cloudButton: NSButton! var cloudWindow: JukeCloudWindowController? var roomPopover: NSPopover? @@ -140,7 +153,8 @@ } var appearanceMode: AppearanceMode = .automatic init(library: Library, watch: [String], select selectArg: String? = nil, - spotifySearch: String? = nil) { + spotifySearch: String? = nil, startPrimpats: Bool = false, + startBeats: Bool = false) { self.library = library self.watchDirs = watch self.selectPath = selectArg @@ -201,6 +215,13 @@ select(idx, autoplay: true) } else if !library.tracks.isEmpty { select(0, autoplay: false) } } else if !library.tracks.isEmpty { select(0, autoplay: false) } activateSpotifyMode() + if startBeats { + setDJMode(true, singleDeck: true) + djMixer.loadBeats(solo: true) + } else if startPrimpats { + setDJMode(true) + djMixer.loadPrimpats() + } spotify.start() DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in guard let self, case .idle = self.roomAudio.state else { return } @@ -236,10 +257,12 @@ private func handleKey(_ e: NSEvent) -> Bool { switch e.keyCode { case 49: togglePlay(); return true // space case 123: - if spotifyMode { spotify.seek(offsetMS: -5000) } else { wave.seek(to: wave.currentTime - 5) } + if djMode { djMixer.dominantDeck.deck.seek(to: djMixer.dominantDeck.deck.currentTime - 5) } + else if spotifyMode { spotify.seek(offsetMS: -5000) } else { wave.seek(to: wave.currentTime - 5) } return true // ← back 5s case 124: - if spotifyMode { spotify.seek(offsetMS: 5000) } else { wave.seek(to: wave.currentTime + 5) } + if djMode { djMixer.dominantDeck.deck.seek(to: djMixer.dominantDeck.deck.currentTime + 5) } + else if spotifyMode { spotify.seek(offsetMS: 5000) } else { wave.seek(to: wave.currentTime + 5) } return true // → fwd 5s case 126: prevTrack(); return true // ↑ prev track case 125: nextTrack(); return true // ↓ next track @@ -357,6 +380,13 @@ appearanceTabs.controlSize = .small appearanceTabs.toolTip = "Follow macOS, or pin JukeWizard to light or dark" content.addSubview(appearanceTabs) + djButton = NSButton(title: "DJ", target: self, action: #selector(toggleDJMode)) + djButton.bezelStyle = .rounded + djButton.setButtonType(.pushOnPushOff) + djButton.contentTintColor = Palette.teal + djButton.toolTip = "Open the DJ interface" + content.addSubview(djButton) + cloudButton = NSButton(title: "☁︎", target: self, action: #selector(showCloud)) cloudButton.bezelStyle = .rounded cloudButton.contentTintColor = Palette.teal @@ -403,6 +433,12 @@ content.addSubview(listScroll) buildDrawer(in: content) + djMixer = DJMixerView(frame: .zero) + djMixer.isHidden = true + djMixer.onStateChange = { [weak self] in self?.refreshMenuBar() } + djMixer.onDetach = { [weak self] in self?.window?.orderOut(nil) } + content.addSubview(djMixer) + playButton.contentTintColor = Palette.teal transportExtra.forEach { $0.contentTintColor = Palette.teal } } @@ -496,9 +532,90 @@ case .automatic: window?.appearance = nil case .light: window?.appearance = NSAppearance(named: .aqua) case .dark: window?.appearance = NSAppearance(named: .darkAqua) } + djMixer?.setAppearance(window?.appearance) applyThemeBackground() } + @objc private func toggleDJMode() { setDJMode(!djMode) } + + private func setDJMode(_ enabled: Bool, singleDeck: Bool = false) { + guard enabled != djMode else { return } + if enabled { + if spotifyMode { activateLibraryMode() } + wave.pause() + playButton.title = "▶" + nowPlaying.setPaused(true) + if drawerOpen { + drawerOpen = false + notesToggle.state = .off + } + if singleDeck { + djMixer.configureSolo(tracks: library.tracks, primaryIndex: max(0, current)) + } else { + djMixer.configure(tracks: library.tracks, primaryIndex: max(0, current)) + } + djMixer.setMasterVolume(quickVolume) + djMode = true + djButton.state = .on + djButton.contentTintColor = Palette.coral + djMixer.isHidden = false + setPlayerChromeHidden(true) + djMixer.startDisplay() + roomAudio.useSource(.aesthetic) + window?.isMovableByWindowBackground = false + window?.title = "JukeWizard · DJ" + } else { + djMixer.pauseAll() + djMixer.stopDisplay() + djMixer.isHidden = true + djMode = false + djButton.state = .off + djButton.contentTintColor = Palette.teal + window?.isMovableByWindowBackground = true + setPlayerChromeHidden(false) + window?.title = spotifyMode ? "JukeWizard · Spotify" : "JukeWizard — \(library.tracks.count) tracks" + } + relayout() + refreshMenuBar() + } + + private func setPlayerChromeHidden(_ hidden: Bool) { + [nowPlaying, titleLabel, artistLabel, laneLabel, activityLabel, playButton, + ledLabel, notesToggle, roomButton, sortPopup, spotifySearchField, + listScroll, wave, spotifyProgress, drawerPanel].forEach { $0?.isHidden = hidden } + transportExtra.forEach { $0.isHidden = hidden } + linkButtons.forEach { $0.isHidden = hidden } + guard !hidden else { return } + + nowPlaying.isHidden = false + titleLabel.isHidden = false + artistLabel.isHidden = false + laneLabel.isHidden = false + activityLabel.isHidden = false + playButton.isHidden = false + ledLabel.isHidden = false + roomButton.isHidden = false + listScroll.isHidden = false + transportExtra.forEach { $0.isHidden = false } + wave.isHidden = spotifyMode + spotifyProgress.isHidden = !spotifyMode + sortPopup.isHidden = spotifyMode + spotifySearchField.isHidden = !spotifyMode + notesToggle.isHidden = false + drawerPanel.isHidden = !drawerOpen + if !spotifyMode, let t = track { loadLinks(t) } + } + + func showDetachedPrimpats() { + if !djMode { setDJMode(true) } + djMixer.loadPrimpats(openPopouts: true) + } + + func showDetachedBeats() { + if !djMode { setDJMode(true, singleDeck: true) } + djMixer.loadBeats(openPopouts: true, autoplay: true, solo: true) + } + @objc private func appearanceChanged() { appearanceMode = AppearanceMode(rawValue: appearanceTabs.selectedSegment) ?? .automatic UserDefaults.standard.set(appearanceMode.rawValue, forKey: "appearanceMode") @@ -529,12 +646,18 @@ let topBarH: CGFloat = 34 let contentTop = H - topBarH sourceTabs.frame = NSRect(x: pad, y: H - 27, width: 170, height: 22) appearanceTabs.frame = NSRect(x: W - pad - 172, y: H - 27, width: 172, height: 22) - let outputX = pad + 178 + djButton.frame = NSRect(x: 184, y: H - 28, width: 52, height: 24) + let outputX: CGFloat = 242 cloudButton.frame = NSRect(x: appearanceTabs.frame.minX - 50, y: H - 28, width: 44, height: 24) let outputRight = cloudButton.frame.minX - 6 outputPopup.frame = NSRect(x: outputX, y: H - 27, width: max(110, min(260, outputRight - outputX)), height: 22) + if djMode { + djMixer.frame = NSRect(x: pad, y: pad, width: W - pad * 2, + height: H - topBarH - pad) + return + } // ── header (now-playing) across the top ─────────────────────────────── let headerH = max(178, min(245, (H - topBarH) * 0.39)) let headerBottom = contentTop - headerH @@ -613,9 +736,12 @@ // ── menu-bar CD ──────────────────────────────────────────────────────── // Keep the bar disc's tempo + spin in step with playback. private func refreshMenuBar() { - let bpm = spotifyMode ? nil : track?.meta?.bpm.map(Double.init) - let playing = spotifyMode ? (spotifyState?.isPlaying ?? false) : wave.isPlaying - let title = spotifyMode ? (spotifyState?.title ?? "") : (track?.title ?? "") + let bpm = djMode ? djMixer.dominantBPM + : (spotifyMode ? nil : track?.meta?.bpm.map(Double.init)) + let playing = djMode ? djMixer.isPlaying + : (spotifyMode ? (spotifyState?.isPlaying ?? false) : wave.isPlaying) + let title = djMode ? djMixer.dominantTitle + : (spotifyMode ? (spotifyState?.title ?? "") : (track?.title ?? "")) menuBar?.setBPM(bpm) menuBar?.setNowPlaying(title: title, art: currentArt) menuBar?.setPlaying(playing) @@ -741,6 +867,7 @@ pollActivityStatus() } @objc private func sourceTabChanged() { + if djMode { setDJMode(false) } sourceTabs.selectedSegment == 0 ? activateSpotifyMode() : activateLibraryMode() } @@ -828,14 +955,16 @@ } refreshMenuBar() } var quickTitle: String { - spotifyMode ? (spotifyState?.title ?? "Spotify") : (track?.title ?? "Aesthetic") + djMode ? djMixer.dominantTitle + : (spotifyMode ? (spotifyState?.title ?? "Spotify") : (track?.title ?? "Aesthetic")) } var quickSubtitle: String { + if djMode { return String(format: "DJ · %.1f BPM", djMixer.dominantBPM) } if spotifyMode { return [spotifyState?.artists ?? "", "Spotify"].filter { !$0.isEmpty }.joined(separator: " · ") } return [track?.meta?.artist ?? "Aesthetic Dot Computer", "Aesthetic"].joined(separator: " · ") } var quickIsPlaying: Bool { - spotifyMode ? (spotifyState?.isPlaying ?? false) : wave.isPlaying + djMode ? djMixer.isPlaying : (spotifyMode ? (spotifyState?.isPlaying ?? false) : wave.isPlaying) } var quickRoomSummary: String { switch roomAudio.state { @@ -876,6 +1005,7 @@ @objc func quickNext() { nextTrack(); miniPlayer?.refresh() } @objc func quickVolumeChanged(_ sender: NSSlider) { quickVolume = max(0, min(1, sender.floatValue)) wave.volume = quickVolume + djMixer.setMasterVolume(quickVolume) spotify.volume(percent: Int((quickVolume * 100).rounded())) UserDefaults.standard.set(quickVolume, forKey: "playerVolume") miniPlayer?.refresh() @@ -940,6 +1070,7 @@ private func setQuickVolume(_ value: Float) { quickVolume = max(0, min(1, value)) wave.volume = quickVolume + djMixer.setMasterVolume(quickVolume) spotify.volume(percent: Int((quickVolume * 100).rounded())) UserDefaults.standard.set(quickVolume, forKey: "playerVolume") miniPlayer?.refresh() @@ -1025,6 +1156,7 @@ private var track: Track? { (current >= 0 && current < library.tracks.count) ? library.tracks[current] : nil } func select(_ i: Int, autoplay: Bool) { guard i >= 0, i < library.tracks.count else { return } + if djMode { setDJMode(false) } if spotifyMode { spotify.pause(); activateLibraryMode() } commitNotes() let old = current @@ -1113,7 +1245,9 @@ if spotifyMode { playSpotifyResult(at: r) } else if r >= 0 { select(r, autoplay: true) } } @objc private func togglePlay() { - if spotifyMode { + if djMode { + djMixer.toggleDominant() + } else if spotifyMode { spotify.toggle() let playing = !(spotifyState?.isPlaying ?? false) playButton.title = playing ? "❚❚" : "▶" @@ -1126,11 +1260,13 @@ } refreshMenuBar() } @objc private func prevTrack() { - if spotifyMode { spotify.previous() } + if djMode { djMixer.stepDominant(by: -1) } + else if spotifyMode { spotify.previous() } else if current > 0 { select(current - 1, autoplay: true) } } @objc private func nextTrack() { - if spotifyMode { spotify.next() } + if djMode { djMixer.stepDominant(by: 1) } + else if spotifyMode { spotify.next() } else if current < library.tracks.count - 1 { select(current + 1, autoplay: true) } } diff --git a/juke-wizard/Sources/JukeWizard/main.swift b/juke-wizard/Sources/JukeWizard/main.swift --- a/juke-wizard/Sources/JukeWizard/main.swift +++ b/juke-wizard/Sources/JukeWizard/main.swift @@ -9,6 +9,8 @@ // jukewizard [ ...] [--watch ] // bin/jukewizard --queue focused ordered queue // jukewizard --spotify-search "artist or track" headless Spotify search // jukewizard --background start resident without raising the full window +// jukewizard --primpats open floating primitive-pattern records +// jukewizard --beats open one floating scratchable beat record // (no args → opens ~/Desktop/MASTER-playlist.m3u8 if present) // // --watch auto-pop: when a fresh audio file lands here, add it @@ -26,12 +28,16 @@ var paths: [String] = [] var selectPath: String? = nil var spotifySearch: String? = nil var launchInBackground = false + var startPrimpats = false + var startBeats = false var i = 0 while i < args.count { if args[i] == "--watch", i + 1 < args.count { watch.append(args[i + 1]); i += 2; continue } if args[i] == "--select", i + 1 < args.count { selectPath = args[i + 1]; i += 2; continue } if args[i] == "--spotify-search", i + 1 < args.count { spotifySearch = args[i + 1]; i += 2; continue } if args[i] == "--background" { launchInBackground = true; i += 1; continue } + if args[i] == "--primpats" { startPrimpats = true; i += 1; continue } + if args[i] == "--beats" { startBeats = true; i += 1; continue } paths.append(args[i]); i += 1 } if paths.isEmpty { @@ -53,8 +59,10 @@ library.addFile(u, lane: lane) } } controller = JukeController(library: library, watch: watch, select: selectPath, - spotifySearch: spotifySearch) - if launchInBackground { + spotifySearch: spotifySearch, + startPrimpats: startPrimpats, + startBeats: startBeats) + if launchInBackground && !startPrimpats && !startBeats { controller?.window?.orderOut(nil) } else { controller?.showWindow(nil) @@ -65,7 +73,11 @@ } // Launch Services can re-apply the previous process's minimized Dock // state just after didFinishLaunching. Restore once more on the next // run-loop turn so a relaunch can never masquerade as a crash. - if !launchInBackground { + if startBeats { + DispatchQueue.main.async { [weak self] in self?.controller?.showDetachedBeats() } + } else if startPrimpats { + DispatchQueue.main.async { [weak self] in self?.controller?.showDetachedPrimpats() } + } else if !launchInBackground { DispatchQueue.main.async { [weak self] in self?.controller?.quickOpenFull() } } } diff --git a/juke-wizard/Tests/JukeDSPTests/JukeDSPTests.swift b/juke-wizard/Tests/JukeDSPTests/JukeDSPTests.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Tests/JukeDSPTests/JukeDSPTests.swift @@ -0,0 +1,97 @@ +import XCTest +import JukeDSP + +final class JukeDSPTests: XCTestCase { + func testCubicInterpolationPreservesLinearMotion() { + XCTAssertEqual(ac_scratch_cubic(-1, 0, 1, 2, 0.5), 0.5, accuracy: 0.000_01) + } + + func testScratchMotionChangesDirectionWithoutJumping() { + var state = ACScratchState() + ac_scratch_init(&state) + var motion = 1.0 + for _ in 0..<64 { + motion = ac_scratch_motion(&state, -2, -2_000, 1, 48_000) + XCTAssertTrue(motion.isFinite) + } + XCTAssertLessThan(motion, 0) + XCTAssertGreaterThan(motion, -8) + } + + func testScratchVelocityHasNoArtificialForwardOrReverseBoundary() { + for velocity in [-24.0, 24.0] { + var state = ACScratchState() + ac_scratch_init(&state) + var motion = state.velocity + for _ in 0..<4_000 { + motion = ac_scratch_motion(&state, velocity, 0, 1, 48_000) + XCTAssertTrue(motion.isFinite) + } + XCTAssertEqual(motion, velocity, accuracy: 0.001) + } + } + + func testScratchReleasePreservesVelocityBeforeFriction() { + var state = ACScratchState() + ac_scratch_init(&state) + for _ in 0..<2_000 { + _ = ac_scratch_motion(&state, 11.5, 0, 1, 48_000) + } + let heldVelocity = state.velocity + let firstReleased = ac_scratch_motion(&state, heldVelocity, 0, 0, 48_000) + XCTAssertEqual(firstReleased, heldVelocity, accuracy: 0.000_001) + } + + func testScratchFollowerLandsWithoutOvershooting() { + var state = ACScratchState() + ac_scratch_init(&state) + var error = 2_400.0 + var previousError = error + for _ in 0..<8_000 { + let motion = ac_scratch_motion(&state, 0, error, 1, 48_000) + error -= motion + XCTAssertGreaterThanOrEqual(error, -0.000_001) + XCTAssertLessThanOrEqual(error, previousError + 0.000_001) + previousError = error + } + XCTAssertLessThan(error, 0.5) // sub-sample after 167 ms, without reversal + } + + func testSpatialPlatterContactsUseRadiusAndCombineFingers() { + var rim = ACPlatterContact(previous_x: 1, previous_y: 0, + current_x: 0, current_y: 1) + var center = ACPlatterContact(previous_x: 0.04, previous_y: 0, + current_x: 0, current_y: 0.04) + let rimMotion = ac_platter_contact_motion(&rim, 1, 1.8) + let centerMotion = ac_platter_contact_motion(¢er, 1, 1.8) + XCTAssertEqual(rimMotion, -0.279, accuracy: 0.000_01) + XCTAssertEqual(centerMotion, 0, accuracy: 0.000_01) + + let contacts = [rim, rim] + let twoFingerMotion = contacts.withUnsafeBufferPointer { + ac_platter_contact_motion($0.baseAddress, $0.count, 1.8) + } + XCTAssertGreaterThan(abs(twoFingerMotion), abs(rimMotion)) + XCTAssertLessThan(abs(twoFingerMotion), abs(rimMotion) * 2) + } + + func testPracticeLoopsAreFiniteAndAudible() { + let frames = 48_000 + for variant in 0...3 { + var left = [Float](repeating: 0, count: frames) + var right = [Float](repeating: 0, count: frames) + left.withUnsafeMutableBufferPointer { l in + right.withUnsafeMutableBufferPointer { r in + ac_practice_render(Int32(variant), l.baseAddress, r.baseAddress, + frames, 48_000, 120) + } + } + XCTAssertTrue(left.allSatisfy(\.isFinite)) + XCTAssertTrue(right.allSatisfy(\.isFinite)) + XCTAssertGreaterThan(left.map { abs($0) }.max() ?? 0, 0.1) + XCTAssertGreaterThan(right.map { abs($0) }.max() ?? 0, 0.1) + XCTAssertLessThanOrEqual(left.map { abs($0) }.max() ?? 2, 1.001) + XCTAssertLessThanOrEqual(right.map { abs($0) }.max() ?? 2, 1.001) + } + } +} diff --git a/juke-wizard/Tests/JukeWizardTests/DJPrimpatsTests.swift b/juke-wizard/Tests/JukeWizardTests/DJPrimpatsTests.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Tests/JukeWizardTests/DJPrimpatsTests.swift @@ -0,0 +1,57 @@ +import AVFoundation +import XCTest +@testable import JukeWizard + +final class DJPrimpatsTests: XCTestCase { + func testCatalogRendersDeterministicLoopableStereoTracks() throws { + let records = DJPrimpats.make() + XCTAssertEqual(records.count, DJPrimpats.catalog.count) + XCTAssertEqual(Set(records.map(\.track.url)).count, records.count) + + for record in records { + XCTAssertEqual(record.track.lane, "primpats") + XCTAssertEqual(record.track.title, record.metadata.title) + XCTAssertEqual(record.waveform, .sine) + XCTAssertTrue(record.title.contains("\(frequencyLabel(record.frequency)) Hz")) + + let file = try AVAudioFile(forReading: record.track.url) + XCTAssertEqual(file.processingFormat.channelCount, 2) + XCTAssertEqual(file.length, AVAudioFramePosition(file.processingFormat.sampleRate * record.metadata.duration)) + + let frameCount = AVAudioFrameCount(file.length) + let buffer = try XCTUnwrap(AVAudioPCMBuffer( + pcmFormat: file.processingFormat, + frameCapacity: frameCount + )) + try file.read(into: buffer) + let channels = try XCTUnwrap(buffer.floatChannelData) + let last = Int(buffer.frameLength) - 1 + + XCTAssertEqual(channels[0][0], 0, accuracy: 0.000_001) + XCTAssertEqual(channels[0][0] - channels[0][last], channels[0][1] - channels[0][0], accuracy: 0.000_001) + XCTAssertEqual(channels[0][last], channels[1][last], accuracy: 0.000_001) + } + } + + func testCachedTracksRetainTheirFilesAndMetadataLookup() throws { + let first = DJPrimpats.make() + let dates = try Dictionary(uniqueKeysWithValues: first.map { + ($0.track.url, try $0.track.url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) + }) + let second = DJPrimpats.make() + + XCTAssertEqual(first.map(\.track.url), second.map(\.track.url)) + for record in second { + let date = try record.track.url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + XCTAssertEqual(date, dates[record.track.url] ?? nil) + XCTAssertEqual(DJPrimpats.metadata(for: record.track), record.metadata) + } + } + + private func frequencyLabel(_ frequency: Double) -> String { + let hundredths = Int((frequency * 100).rounded()) + if hundredths.isMultiple(of: 100) { return String(hundredths / 100) } + if hundredths.isMultiple(of: 10) { return "\(hundredths / 100).\((hundredths % 100) / 10)" } + return "\(hundredths / 100).\(String(format: "%02d", hundredths % 100))" + } +}