diff --git a/.mcp.json b/.mcp.json index 05ba1312c2..edfdab3950 100644 --- a/.mcp.json +++ b/.mcp.json @@ -48,6 +48,13 @@ "args": [ "slab/bin/dmgify-mcp.mjs" ] + }, + "jukewizard": { + "type": "stdio", + "command": "node", + "args": [ + "juke-wizard/bin/jukewizard-mcp.mjs" + ] } } } diff --git a/juke-wizard/Sources/JukeWizard/ControlServer.swift b/juke-wizard/Sources/JukeWizard/ControlServer.swift new file mode 100644 index 0000000000..f38b760bd1 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/ControlServer.swift @@ -0,0 +1,82 @@ +import Foundation +import Darwin + +/// Local, bounded JSON-line control for the running JukeWizard. The Unix +/// socket is user-only; every command is still validated by JukeController. +final class JukeControlServer { + private let controller: JukeController + private let socketPath: String + private var listenFD: Int32 = -1 + private var source: DispatchSourceRead? + + init(controller: JukeController) { + self.controller = controller + let config = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/jukewizard", isDirectory: true) + try? FileManager.default.createDirectory(at: config, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + socketPath = config.appendingPathComponent("control.sock").path + } + + func start() { + guard socketPath.utf8.count < MemoryLayout.size - 2 else { return } + unlink(socketPath) + listenFD = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard listenFD >= 0 else { return } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutablePointer(to: &address.sun_path) { raw in + raw.withMemoryRebound(to: CChar.self, capacity: 104) { path in + _ = socketPath.withCString { strlcpy(path, $0, 104) } + } + } + let length = socklen_t(MemoryLayout.size + socketPath.utf8.count + 1) + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(listenFD, $0, length) } + } + guard bound == 0, Darwin.listen(listenFD, 8) == 0 else { stop(); return } + chmod(socketPath, S_IRUSR | S_IWUSR) + let s = DispatchSource.makeReadSource(fileDescriptor: listenFD, queue: .main) + s.setEventHandler { [weak self] in self?.acceptOne() } + s.setCancelHandler { [fd = listenFD] in if fd >= 0 { Darwin.close(fd) } } + source = s + s.resume() + } + + private func acceptOne() { + let fd = Darwin.accept(listenFD, nil, nil) + guard fd >= 0 else { return } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + var data = Data() + var byte: UInt8 = 0 + while data.count < 65_536, Darwin.read(fd, &byte, 1) == 1, byte != 10 { data.append(byte) } + guard !data.isEmpty, data.count < 65_536, + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + Self.reply(fd, ["ok": false, "error": "invalid or oversized JSON request"]); return + } + DispatchQueue.main.async { + guard let self else { Self.reply(fd, ["ok": false, "error": "JukeWizard unavailable"]); return } + Self.reply(fd, self.controller.control(object)) + } + } + } + + private static func reply(_ fd: Int32, _ object: [String: Any]) { + var data = (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])) ?? Data("{\"ok\":false}".utf8) + data.append(10) + data.withUnsafeBytes { bytes in + if let base = bytes.baseAddress { _ = Darwin.write(fd, base, data.count) } + } + Darwin.close(fd) + } + + func stop() { + let hadSource = source != nil + source?.cancel(); source = nil + if listenFD >= 0, !hadSource { Darwin.close(listenFD) } + listenFD = -1 + unlink(socketPath) + } + + deinit { stop() } +} diff --git a/juke-wizard/Sources/JukeWizard/DJMixerView.swift b/juke-wizard/Sources/JukeWizard/DJMixerView.swift index 5a9f76209a..491ae46e8c 100644 --- a/juke-wizard/Sources/JukeWizard/DJMixerView.swift +++ b/juke-wizard/Sources/JukeWizard/DJMixerView.swift @@ -242,6 +242,12 @@ final class DJDeckPlayer: NSObject { func resetBPM() { setBPM(sourceBPM) } + func setRate(_ value: Double) { + targetBPM = sourceBPM * max(0.5, min(1.5, value)) + applyRate() + onStateChange?() + } + func setGain(_ value: Float) { gain = max(0, min(1, value)) engine.mainMixerNode.outputVolume = gain diff --git a/juke-wizard/Sources/JukeWizard/JukeController.swift b/juke-wizard/Sources/JukeWizard/JukeController.swift index 2bf5dfa560..7c3388ef98 100644 --- a/juke-wizard/Sources/JukeWizard/JukeController.swift +++ b/juke-wizard/Sources/JukeWizard/JukeController.swift @@ -104,6 +104,9 @@ final class JukeController: NSWindowController, NSWindowDelegate, var sourceTabs: NSSegmentedControl! var appearanceTabs: NSSegmentedControl! var playButton: NSButton! + var speedSlider: NSSlider! + var speedLabel: NSTextField! + var speedResetButton: NSButton! var ledLabel: NSTextField! var notesToggle: NSButton! var djButton: NSButton! @@ -183,6 +186,11 @@ final class JukeController: NSWindowController, NSWindowDelegate, quickVolume = UserDefaults.standard.float(forKey: "playerVolume") } wave.volume = quickVolume + if UserDefaults.standard.object(forKey: "listPlaybackRate") != nil { + speedSlider.doubleValue = max(0.5, min(1.5, + UserDefaults.standard.double(forKey: "listPlaybackRate"))) + } + speedLabel.stringValue = String(format: "%.2f×", speedSlider.doubleValue) relayout() menuBar = MenuBarCD() menuBar?.onClick = { [weak self] in self?.quickOpenFull() } @@ -312,6 +320,21 @@ final class JukeController: NSWindowController, NSWindowDelegate, playButton.bezelStyle = .rounded playButton.setButtonType(.momentaryPushIn) content.addSubview(playButton) + speedSlider = NSSlider(value: 1, minValue: 0.5, maxValue: 1.5, + target: self, action: #selector(speedChanged(_:))) + speedSlider.controlSize = .small + speedSlider.isContinuous = true + speedSlider.toolTip = "Playback speed · drag the waveform to scratch" + content.addSubview(speedSlider) + speedLabel = label("1.00×", size: 11, color: Palette.teal) + speedLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .semibold) + speedLabel.alignment = .right + content.addSubview(speedLabel) + speedResetButton = NSButton(title: "↺", target: self, action: #selector(resetSpeed)) + speedResetButton.bezelStyle = .inline + speedResetButton.toolTip = "Reset playback speed" + speedResetButton.contentTintColor = Palette.teal + content.addSubview(speedResetButton) let prev = NSButton(title: "⏮", target: self, action: #selector(prevTrack)) prev.bezelStyle = .rounded; prev.tag = 1 let next = NSButton(title: "⏭", target: self, action: #selector(nextTrack)) @@ -534,7 +557,8 @@ final class JukeController: NSWindowController, NSWindowDelegate, private func setPlayerChromeHidden(_ hidden: Bool) { [nowPlaying, titleLabel, artistLabel, laneLabel, activityLabel, playButton, - ledLabel, notesToggle, roomButton, sortPopup, spotifySearchField, + ledLabel, notesToggle, roomButton, speedSlider, speedLabel, speedResetButton, + sortPopup, spotifySearchField, listScroll, wave, spotifyProgress, drawerPanel].forEach { $0?.isHidden = hidden } transportExtra.forEach { $0.isHidden = hidden } linkButtons.forEach { $0.isHidden = hidden } @@ -548,6 +572,9 @@ final class JukeController: NSWindowController, NSWindowDelegate, playButton.isHidden = false ledLabel.isHidden = false roomButton.isHidden = false + speedSlider.isHidden = spotifyMode + speedLabel.isHidden = spotifyMode + speedResetButton.isHidden = spotifyMode listScroll.isHidden = false transportExtra.forEach { $0.isHidden = false } wave.isHidden = spotifyMode @@ -653,7 +680,11 @@ final class JukeController: NSWindowController, NSWindowDelegate, // waveform fills the space between the links row and the transport let waveTop = linksBottom - 6 - let waveBottom = transY + 31 + let speedY = transY + 30 + speedLabel.frame = NSRect(x: rx, y: speedY + 1, width: 45, height: 18) + speedSlider.frame = NSRect(x: rx + 48, y: speedY, width: max(70, rw - 80), height: 20) + speedResetButton.frame = NSRect(x: rx + rw - 27, y: speedY, width: 27, height: 20) + let waveBottom = speedY + 23 wave.frame = NSRect(x: rx, y: waveBottom, width: rw, height: max(32, waveTop - waveBottom)) spotifyProgress.frame = wave.frame @@ -824,6 +855,9 @@ final class JukeController: NSWindowController, NSWindowDelegate, roomAudio.useSource(.spotify) window?.title = "JukeWizard · Spotify" wave?.isHidden = true + speedSlider?.isHidden = true + speedLabel?.isHidden = true + speedResetButton?.isHidden = true spotifyProgress?.isHidden = false sortPopup?.isHidden = true spotifySearchField?.isHidden = false @@ -845,6 +879,9 @@ final class JukeController: NSWindowController, NSWindowDelegate, roomAudio.useSource(.aesthetic) window?.title = "JukeWizard — \(library.tracks.count) tracks" wave.isHidden = false + speedSlider.isHidden = false + speedLabel.isHidden = false + speedResetButton.isHidden = false spotifyProgress.isHidden = true sortPopup.isHidden = false spotifySearchField.isHidden = true @@ -1055,7 +1092,8 @@ final class JukeController: NSWindowController, NSWindowDelegate, updateNowPlaying(t) loadLinks(t) relayout() // link count changes the header row width - wave.load(url: t.url) + wave.load(track: t) + wave.playbackRate = speedSlider.doubleValue wave.comments = t.data.comments notesView.string = t.data.notes notesPlaceholder.isHidden = !t.data.notes.isEmpty @@ -1144,6 +1182,16 @@ final class JukeController: NSWindowController, NSWindowDelegate, } refreshPlaybackPresence() } + @objc private func speedChanged(_ sender: NSSlider) { + guard !spotifyMode, !djMode else { return } + wave.playbackRate = sender.doubleValue + speedLabel.stringValue = String(format: "%.2f×", sender.doubleValue) + UserDefaults.standard.set(sender.doubleValue, forKey: "listPlaybackRate") + } + @objc private func resetSpeed() { + speedSlider.doubleValue = 1 + speedChanged(speedSlider) + } @objc private func prevTrack() { if djMode { djMixer.stepDominant(by: -1) } else if spotifyMode { spotify.previous() } @@ -1155,6 +1203,82 @@ final class JukeController: NSWindowController, NSWindowDelegate, else if current < library.tracks.count - 1 { select(current + 1, autoplay: true) } } + /// Stable command surface used by the user-only Unix socket. This is + /// always called on AppKit's main thread by JukeControlServer. + func control(_ request: [String: Any]) -> [String: Any] { + guard Thread.isMainThread else { return ["ok": false, "error": "control must run on main thread"] } + guard let command = request["command"] as? String else { + return ["ok": false, "error": "missing command"] + } + func state() -> [String: Any] { + var out: [String: Any] = [ + "ok": true, "mode": djMode ? "dj" : (spotifyMode ? "spotify" : "library"), + "playing": quickIsPlaying, "index": current, + "queueCount": library.tracks.count + ] + if let t = track { out["title"] = t.title; out["path"] = t.url.path; out["lane"] = t.lane } + if !spotifyMode && !djMode { + out["position"] = wave.currentTime; out["duration"] = wave.duration + out["speed"] = wave.playbackRate + } + return out + } + switch command { + case "status": return state() + case "list": + let limit = max(1, min(1000, request["limit"] as? Int ?? 500)) + let rows: [[String: Any]] = library.tracks.prefix(limit).enumerated().map { i, t in + ["index": i, "title": t.title, "path": t.url.path, "lane": t.lane] + } + return ["ok": true, "count": library.tracks.count, "tracks": rows, "truncated": rows.count < library.tracks.count] + case "select", "play": + if let path = request["path"] as? String { + let wanted = URL(fileURLWithPath: (path as NSString).expandingTildeInPath).standardizedFileURL.path + guard let i = library.tracks.firstIndex(where: { $0.url.standardizedFileURL.path == wanted }) else { + return ["ok": false, "error": "exact path is not in the queue"] + } + select(i, autoplay: command == "play") + } else if let title = request["title"] as? String { + let matches = library.tracks.indices.filter { library.tracks[$0].title == title } + guard matches.count == 1 else { + return ["ok": false, "error": matches.isEmpty ? "exact title not found" : "title is ambiguous; use path or index"] + } + select(matches[0], autoplay: command == "play") + } else if let i = request["index"] as? Int { + guard library.tracks.indices.contains(i) else { return ["ok": false, "error": "index out of range"] } + select(i, autoplay: command == "play") + } else if command == "play" { + guard !spotifyMode, !djMode else { return ["ok": false, "error": "plain play is unavailable in Spotify or DJ mode"] } + wave.play(); playButton.title = "❚❚"; nowPlaying.setPaused(false); refreshPlaybackPresence() + } else { return ["ok": false, "error": "select requires exact path, title, or index"] } + return state() + case "pause": + guard !spotifyMode, !djMode else { return ["ok": false, "error": "pause is unavailable in Spotify or DJ mode"] } + wave.pause(); playButton.title = "▶"; nowPlaying.setPaused(true); refreshPlaybackPresence(); return state() + case "toggle": + guard !spotifyMode, !djMode else { return ["ok": false, "error": "toggle is unavailable in Spotify or DJ mode"] } + togglePlay(); return state() + case "seek": + guard !spotifyMode, !djMode, let seconds = request["seconds"] as? Double, seconds.isFinite else { + return ["ok": false, "error": "seek requires finite seconds in library mode"] + } + wave.seek(to: seconds); return state() + case "speed": + guard !spotifyMode, !djMode, let value = request["speed"] as? Double, + value.isFinite, (0.5...1.5).contains(value) else { + return ["ok": false, "error": "speed must be between 0.5 and 1.5 in library mode"] + } + speedSlider.doubleValue = value; speedChanged(speedSlider); return state() + case "next": + guard !spotifyMode, !djMode else { return ["ok": false, "error": "next is unavailable in Spotify or DJ mode"] } + nextTrack(); return state() + case "previous": + guard !spotifyMode, !djMode else { return ["ok": false, "error": "previous is unavailable in Spotify or DJ mode"] } + prevTrack(); return state() + default: return ["ok": false, "error": "unknown command"] + } + } + // ── rating ───────────────────────────────────────────────────────────── private func renderStars(_ n: Int) { for (i, b) in starButtons.enumerated() { b.title = (i < n) ? "★" : "☆" } diff --git a/juke-wizard/Sources/JukeWizard/WaveformView.swift b/juke-wizard/Sources/JukeWizard/WaveformView.swift index c2118fa7ac..8363571578 100644 --- a/juke-wizard/Sources/JukeWizard/WaveformView.swift +++ b/juke-wizard/Sources/JukeWizard/WaveformView.swift @@ -13,23 +13,31 @@ protocol WaveformViewDelegate: AnyObject { func waveformTick() // ~30fps → update time label } -final class WaveformView: NSView, AVAudioPlayerDelegate { +final class WaveformView: NSView { weak var delegate: WaveformViewDelegate? - private var player: AVAudioPlayer? + private let deck = DJDeckPlayer() private var peaks: [Float] = [] private var peaksToken = 0 private var timer: Timer? private var preferredVolume: Float = 0.8 var comments: [Comment] = [] { didSet { needsDisplay = true } } - var duration: Double { player?.duration ?? 0 } - var currentTime: Double { player?.currentTime ?? 0 } - var isPlaying: Bool { player?.isPlaying ?? false } + private var wasPlaying = false + private var scratchOrigin = 0.0 + private var lastScratchTime: TimeInterval = 0 + private var isScratching = false + var duration: Double { deck.duration } + var currentTime: Double { deck.currentTime } + var isPlaying: Bool { deck.isPlaying } + var playbackRate: Double { + get { deck.rate } + set { deck.setRate(newValue) } + } var volume: Float { - get { player?.volume ?? preferredVolume } + get { preferredVolume } set { preferredVolume = max(0, min(1, newValue)) - player?.volume = preferredVolume + deck.setGain(preferredVolume) } } @@ -41,50 +49,53 @@ final class WaveformView: NSView, AVAudioPlayerDelegate { layer?.backgroundColor = NSColor(white: 0.07, alpha: 1).cgColor layer?.cornerRadius = 8 layer?.masksToBounds = true + toolTip = "Click to seek · drag to scratch · Option-click to comment" + setAccessibilityLabel("Track waveform and scratch surface") + setAccessibilityHelp("Click to seek, drag left or right to scratch, or Option-click to add a comment") } required init?(coder: NSCoder) { fatalError() } // ── load / transport ──────────────────────────────────────────────── - func load(url: URL) { + func load(track: Track) { stop() peaks = [] - player = try? AVAudioPlayer(contentsOf: url) - player?.volume = preferredVolume - player?.delegate = self - player?.prepareToPlay() + deck.load(track) + deck.setGain(preferredVolume) needsDisplay = true - computePeaks(url: url) + computePeaks(url: track.url) } - func play() { player?.play(); startTimer() } - func pause() { player?.pause(); stopTimer(); needsDisplay = true } + func play() { deck.play(); wasPlaying = true; startTimer() } + func pause() { deck.pause(); wasPlaying = false; stopTimer(); needsDisplay = true } func togglePlay() { if isPlaying { pause() } else { play() } } func seek(to t: Double) { - player?.currentTime = max(0, min(duration, t)) + deck.seek(to: max(0, min(duration, t))) needsDisplay = true delegate?.waveformTick() } func stop() { stopTimer() - player?.stop() - player = nil + deck.pause() + wasPlaying = false needsDisplay = true } private func startTimer() { stopTimer() timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in - self?.needsDisplay = true - self?.delegate?.waveformTick() + guard let self else { return } + self.needsDisplay = true + self.delegate?.waveformTick() + if self.wasPlaying, !self.deck.isPlaying, + self.duration > 0, self.currentTime >= self.duration - 0.05 { + self.wasPlaying = false + self.stopTimer() + self.delegate?.waveformDidFinish() + } } } private func stopTimer() { timer?.invalidate(); timer = nil } - func audioPlayerDidFinishPlaying(_ p: AVAudioPlayer, successfully flag: Bool) { - stopTimer() - delegate?.waveformDidFinish() - } - // ── waveform peaks (read on a background queue) ────────────────────── private func computePeaks(url: URL) { peaksToken += 1 @@ -189,10 +200,27 @@ final class WaveformView: NSView, AVAudioPlayerDelegate { delegate?.waveformRequestComment(at: t) } else { seek(to: t) + scratchOrigin = t + lastScratchTime = event.timestamp + isScratching = true + deck.beginScratch() } } override func mouseDragged(with event: NSEvent) { - guard duration > 0, !event.modifierFlags.contains(.option) else { return } - seek(to: timeAt(event)) + guard duration > 0, isScratching else { return } + let target = timeAt(event) + let elapsed = max(1.0 / 240.0, event.timestamp - lastScratchTime) + deck.scratch(to: target, movement: target - scratchOrigin, elapsed: elapsed) + scratchOrigin = target + lastScratchTime = event.timestamp + needsDisplay = true + delegate?.waveformTick() + } + override func mouseUp(with event: NSEvent) { + guard isScratching else { return } + isScratching = false + deck.endScratch() + wasPlaying = deck.isPlaying + if wasPlaying { startTimer() } } } diff --git a/juke-wizard/Sources/JukeWizard/main.swift b/juke-wizard/Sources/JukeWizard/main.swift index 5f25025cd1..624301d673 100644 --- a/juke-wizard/Sources/JukeWizard/main.swift +++ b/juke-wizard/Sources/JukeWizard/main.swift @@ -16,6 +16,7 @@ import AppKit final class JukeAppDelegate: NSObject, NSApplicationDelegate { var controller: JukeController? + var controlServer: JukeControlServer? func applicationDidFinishLaunching(_ notification: Notification) { DockIcon.install(prefix: "jukewizard") @@ -67,6 +68,10 @@ final class JukeAppDelegate: NSObject, NSApplicationDelegate { spotifySearch: spotifySearch, playlistName: playlistName, fullLibraryPath: aesthetic) + if let controller { + controlServer = JukeControlServer(controller: controller) + controlServer?.start() + } controller?.showWindow(nil) if controller?.window?.isMiniaturized == true { controller?.window?.deminiaturize(nil) } controller?.window?.makeKeyAndOrderFront(nil) @@ -77,6 +82,8 @@ final class JukeAppDelegate: NSObject, NSApplicationDelegate { DispatchQueue.main.async { [weak self] in self?.controller?.quickOpenFull() } } + func applicationWillTerminate(_ notification: Notification) { controlServer?.stop() } + // Stay resident while its compact spinning CD is present in the menu bar. func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } diff --git a/juke-wizard/bin/jukewizard b/juke-wizard/bin/jukewizard index 837fa65a44..938b2d697f 100755 --- a/juke-wizard/bin/jukewizard +++ b/juke-wizard/bin/jukewizard @@ -20,6 +20,14 @@ set -e HERE="$(cd "$(dirname "$0")" && pwd)" WIZ="$HERE/.." REPO="$(cd "$WIZ/.." && pwd)" + +# Probeable transport commands talk to the already-running native player and +# emit stable JSON. The MCP is deliberately only a thin wrapper over this CLI. +case "${1:-}" in + status|list|play|select|pause|toggle|seek|speed|next|previous|prev) + exec node "$HERE/jukewizard-control.mjs" "$@" + ;; +esac swift build -c release --package-path "$WIZ" 2>&1 | grep -v '^$' | tail -2 || true BIN="$WIZ/.build/release/JukeWizard" diff --git a/juke-wizard/bin/jukewizard-control.mjs b/juke-wizard/bin/jukewizard-control.mjs new file mode 100755 index 0000000000..7ed8336e98 --- /dev/null +++ b/juke-wizard/bin/jukewizard-control.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import { connect } from "node:net"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +const SOCKET = process.env.JUKEWIZARD_SOCKET || join(homedir(), ".config", "jukewizard", "control.sock"); + +export function request(command) { + return new Promise((resolvePromise, reject) => { + const client = connect(SOCKET); + let response = ""; + client.setEncoding("utf8"); + client.setTimeout(3000); + client.on("connect", () => client.end(`${JSON.stringify(command)}\n`)); + client.on("data", (chunk) => { response += chunk; if (response.length > 4_000_000) client.destroy(new Error("response too large")); }); + client.on("timeout", () => client.destroy(new Error("JukeWizard control timed out"))); + client.on("error", (error) => reject(new Error(`JukeWizard is not reachable at ${SOCKET}: ${error.message}`))); + client.on("end", () => { + try { + const value = JSON.parse(response.trim()); + if (!value.ok) reject(new Error(value.error || "JukeWizard command failed")); + else resolvePromise(value); + } catch (error) { reject(error); } + }); + }); +} + +export function parse(argv) { + const [name = "status", ...args] = argv; + if (["status", "list", "pause", "toggle", "next", "previous", "prev"].includes(name)) { + return { command: name === "prev" ? "previous" : name, ...(name === "list" && args[0] ? { limit: Number(args[0]) } : {}) }; + } + if (name === "seek") return { command: "seek", seconds: Number(args[0]) }; + if (name === "speed") return { command: "speed", speed: Number(args[0]) }; + if (name === "play" || name === "select") { + if (!args.length) return { command: name }; + if (args[0] === "--title") return { command: name, title: args.slice(1).join(" ") }; + if (args[0] === "--index") return { command: name, index: Number(args[1]) }; + return { command: name, path: resolve(args.join(" ")) }; + } + throw new Error("usage: jukewizard {status|list [limit]|play [path|--title title|--index n]|select ...|pause|toggle|seek seconds|speed 0.5..1.5|next|previous}"); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + try { console.log(JSON.stringify(await request(parse(process.argv.slice(2))), null, 2)); } + catch (error) { console.error(JSON.stringify({ ok: false, error: error.message })); process.exitCode = 1; } +} diff --git a/juke-wizard/bin/jukewizard-mcp.mjs b/juke-wizard/bin/jukewizard-mcp.mjs new file mode 100755 index 0000000000..6cedcd3a2d --- /dev/null +++ b/juke-wizard/bin/jukewizard-mcp.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { serveStdio } from "../../toolchain/mcp/http-front.mjs"; + +const exec = promisify(execFile); +const cli = join(dirname(fileURLToPath(import.meta.url)), "jukewizard-control.mjs"); +const TOOLS = [ + { name: "jukewizard_status", description: "Return Jukewizard's mode, current track, transport position, speed, and actual playing state.", inputSchema: { type: "object", properties: {} } }, + { name: "jukewizard_list", description: "List the native Jukewizard queue with exact indices, titles, lanes, and paths.", inputSchema: { type: "object", properties: { limit: { type: "integer", minimum: 1, maximum: 1000 } } } }, + { name: "jukewizard_play", description: "Play the current track, or select and play one queue item by exact path, exact unambiguous title, or index.", inputSchema: { type: "object", properties: { path: { type: "string" }, title: { type: "string" }, index: { type: "integer" } } } }, + { name: "jukewizard_pause", description: "Pause library playback.", inputSchema: { type: "object", properties: {} } }, + { name: "jukewizard_toggle", description: "Toggle library playback.", inputSchema: { type: "object", properties: {} } }, + { name: "jukewizard_seek", description: "Seek the current library track to seconds.", inputSchema: { type: "object", properties: { seconds: { type: "number", minimum: 0 } }, required: ["seconds"] } }, + { name: "jukewizard_speed", description: "Set library playback speed from 0.5 through 1.5.", inputSchema: { type: "object", properties: { speed: { type: "number", minimum: 0.5, maximum: 1.5 } }, required: ["speed"] } }, + { name: "jukewizard_next", description: "Select and play the next library track.", inputSchema: { type: "object", properties: {} } }, + { name: "jukewizard_previous", description: "Select and play the previous library track.", inputSchema: { type: "object", properties: {} } }, +]; + +async function run(name, args) { + const command = name.replace("jukewizard_", ""); + const argv = [command]; + if (command === "list" && args.limit) argv.push(String(args.limit)); + if (command === "seek") argv.push(String(args.seconds)); + if (command === "speed") argv.push(String(args.speed)); + if (command === "play") { + if (args.path) argv.push(args.path); + else if (args.title) argv.push("--title", args.title); + else if (Number.isInteger(args.index)) argv.push("--index", String(args.index)); + } + const { stdout } = await exec(process.execPath, [cli, ...argv], { timeout: 5000, maxBuffer: 4_000_000 }); + return [{ type: "text", text: stdout.trim() }]; +} + +async function handleMessage(message) { + const { id, method, params } = message; + try { + if (method === "initialize") return { jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "jukewizard-mcp", version: "1.0.0" } } }; + if (method === "initialized" || method === "notifications/initialized") return null; + if (method === "ping") return { jsonrpc: "2.0", id, result: {} }; + if (method === "tools/list") return { jsonrpc: "2.0", id, result: { tools: TOOLS } }; + if (method === "tools/call") return { jsonrpc: "2.0", id, result: { content: await run(params?.name, params?.arguments || {}) } }; + return { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } }; + } catch (error) { + return { jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: String(error.stderr || error.message || error) }] } }; + } +} + +serveStdio({ handleMessage, banner: "💿 jukewizard MCP started" }); diff --git a/pop/jungle/bin/render.mjs b/pop/jungle/bin/render.mjs index de48d38dac..8df386ebea 100644 --- a/pop/jungle/bin/render.mjs +++ b/pop/jungle/bin/render.mjs @@ -276,6 +276,7 @@ function barB(pat) { // ── main render ─────────────────────────────────────────────────────── function render(slug) { + mkdirSync(`${LANE}/out`, { recursive: true }); const def = DEFAULTS[slug] || DEFAULTS.raggasol; const STYLE = String(flags.style || def.style); const BPM = Number(flags.bpm ?? def.bpm); diff --git a/pop/teknull/bin/remaster-taksmukkeklokken-harsh-c.mjs b/pop/teknull/bin/remaster-taksmukkeklokken-harsh-c.mjs new file mode 100644 index 0000000000..1f53b9c4fa --- /dev/null +++ b/pop/teknull/bin/remaster-taksmukkeklokken-harsh-c.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +// Re-cut and musically rebalance the spatial/orchestral harsh-C mix. +// Keeps the source immutable and adds restrained, transport-locked dance reinforcement. + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, resolve, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, "../../.."); +const OUT = resolve(HERE, "../out"); +const SOURCE = resolve(OUT, "taksmukkeklokken-spatial-orchestral-harsh-c.mp3"); +const TARGET = resolve(OUT, "taksmukkeklokken-spatial-orchestral-harsh-c-remaster.mp3"); +const RECEIPT = resolve(OUT, "taksmukkeklokken-spatial-orchestral-harsh-c-remaster.json"); +const RAW = resolve(OUT, ".taksmukkeklokken-harsh-c-remaster-percussion.f32le"); +const SR = 48_000; +const BPM = 140; +const BEAT = 60 / BPM; +// Bar 10's downbeat (zero-indexed bar 9): the musical boundary nearest 0:16. +const SOURCE_START = 9 * 4 * BEAT; // 15.428571428571429 +const SOURCE_DURATION = 120; +const DURATION = SOURCE_DURATION - SOURCE_START; + +mkdirSync(OUT, { recursive: true }); + +// Ground the inherited orchestration with a centered classic kick and a short, +// cool backbeat. These are reinforcement, not a replacement drum track. +const frames = Math.floor(DURATION * SR); +const pcm = new Float32Array(frames * 2); +let noiseState = 0x51c1c; +const noise = () => { + noiseState = (Math.imul(noiseState, 1664525) + 1013904223) >>> 0; + return noiseState / 0x80000000 - 1; +}; +function addMono(time, length, sample) { + const first = Math.round(time * SR); + const count = Math.min(Math.round(length * SR), frames - first); + for (let i = 0; i < count; i++) { + const value = sample(i / SR, i); + pcm[(first + i) * 2] += value; + pcm[(first + i) * 2 + 1] += value; + } +} +for (let beat = 0; beat * BEAT < DURATION; beat++) { + const at = beat * BEAT; + addMono(at, 0.34, (x) => { + const phase = 2 * Math.PI * (49 * x + 2.7 * (1 - Math.exp(-x / 0.027))); + const click = Math.sin(2 * Math.PI * 1450 * x) * Math.exp(-x / 0.006); + return 0.175 * Math.sin(phase) * Math.exp(-x / 0.115) + 0.016 * click; + }); + if (beat % 4 === 1 || beat % 4 === 3) { + addMono(at, 0.115, (x) => { + const env = Math.exp(-x / 0.032); + const body = Math.sin(2 * Math.PI * 184 * x) * Math.exp(-x / 0.055); + const grit = (noise() - 0.55 * noise()) * env; + return 0.045 * body + 0.072 * grit; + }); + } +} +writeFileSync(RAW, Buffer.from(pcm.buffer)); + +const filter = [ + // Steady the image, mono the sub foundation, and soften the painful bands. + "[0:a]atrim=start=" + SOURCE_START.toFixed(12) + ",asetpts=PTS-STARTPTS,", + "afade=t=in:st=0:d=0.012,afade=t=out:st=" + (DURATION - 0.12).toFixed(6) + ":d=0.12,", + "highpass=f=27,equalizer=f=3150:t=q:w=1.15:g=-2.8,equalizer=f=6900:t=q:w=0.9:g=-2.1,", + "equalizer=f=1150:t=q:w=0.75:g=1.45,stereotools=mlev=1.06:slev=0.78,asplit=2[main][low];", + "[low]lowpass=f=145,pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1,volume=0.24[sub];", + "[main][sub][1:a]amix=inputs=3:weights='1 1 0.78':normalize=0,", + "acompressor=threshold=0.16:ratio=2.1:attack=18:release=150:makeup=1.05,", + "loudnorm=I=-13.0:LRA=6.0:TP=-1.0:linear=false,aresample=48000[out]", +].join(""); + +const ffmpegArgs = [ + "-y", "-hide_banner", "-loglevel", "warning", + "-i", SOURCE, + "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", RAW, + "-filter_complex", filter, "-map", "[out]", "-t", DURATION.toFixed(9), + "-ar", String(SR), "-ac", "2", "-codec:a", "libmp3lame", "-b:a", "320k", TARGET, +]; +const rendered = spawnSync("ffmpeg", ffmpegArgs, { stdio: "inherit" }); +rmSync(RAW, { force: true }); +if (rendered.status !== 0) process.exit(rendered.status ?? 1); + +const sha256 = (path) => createHash("sha256").update(readFileSync(path)).digest("hex"); +writeFileSync(RECEIPT, JSON.stringify({ + schema: "aesthetic.computer/pop-remaster/v1", + source: relative(ROOT, SOURCE), + output: relative(ROOT, TARGET), + sourceSha256: sha256(SOURCE), + outputSha256: sha256(TARGET), + sourceStartSec: SOURCE_START, + sourceStartReason: "140 BPM bar downbeat nearest requested 0:16 (bar 10 begins at 15.428571s)", + durationSec: DURATION, + sampleRate: SR, + channels: 2, + changes: [ + "12 ms boundary fade and 120 ms tail fade", + "centered sub reinforcement below 145 Hz and restrained stereo-side level", + "classic transport-locked kick on quarter notes and tight snare on beats 2/4", + "upper-mid/high resonance cuts at 3.15 kHz and 6.9 kHz", + "broad 1.15 kHz melody-presence lift", + "gentle bus compression and -13 LUFS / -1 dBTP final loudness target", + ], + renderer: relative(ROOT, fileURLToPath(import.meta.url)), + ffmpegArgs, +}, null, 2) + "\n"); +console.log(TARGET); diff --git a/pop/teknull/bin/render-taksmukkeklokken-sinebells.mjs b/pop/teknull/bin/render-taksmukkeklokken-sinebells.mjs new file mode 100644 index 0000000000..9733c55be3 --- /dev/null +++ b/pop/teknull/bin/render-taksmukkeklokken-sinebells.mjs @@ -0,0 +1,256 @@ +#!/usr/bin/env node +// Taksmukkeklokken sine-bell refinement: a bar-10 recut with composed bell lines. + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, "../../.."); +const OUT = resolve(HERE, "../out"); +const GREEK_V2 = process.argv.includes("--greek-theatre-v2"); +const GREEK_THEATRE = GREEK_V2 || process.argv.includes("--greek-theatre"); +const SLOW_BUILD = GREEK_THEATRE || process.argv.includes("--slow-build"); +const SOURCE = resolve(OUT, "taksmukkeklokken-spatial-orchestral-harsh-c.mp3"); +const STEM = GREEK_V2 ? "taksmukkeklokken-sinebells-greektheatre-v2" + : GREEK_THEATRE ? "taksmukkeklokken-sinebells-greektheatre" + : SLOW_BUILD ? "taksmukkeklokken-sinebells-slowbuild" : "taksmukkeklokken-sinebells"; +const TARGET = resolve(OUT, `${STEM}.mp3`); +const RECEIPT = resolve(OUT, `${STEM}.json`); +const RAW = resolve(OUT, `.${STEM}.f32le`); +const SR = 48_000, BPM = 140, BEAT = 60 / BPM, BAR = 4 * BEAT; +const SOURCE_START = 9 * BAR; // original bar 10: 15.428571428571429 s +const DURATION = 120 - SOURCE_START; +const frames = Math.floor(DURATION * SR); +const pcm = new Float32Array(frames * 2); +mkdirSync(OUT, { recursive: true }); + +let seed = 0x51be11; +const noise = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 0x80000000 - 1); +const hz = midi => 440 * 2 ** ((midi - 69) / 12); +function add(time, length, pan, voice) { + const first = Math.max(0, Math.round(time * SR)); + const count = Math.min(Math.round(length * SR), frames - first); + const l = Math.sqrt((1 - pan) * .5), r = Math.sqrt((1 + pan) * .5); + for (let i = 0; i < count; i++) { + const v = voice(i / SR, i); + pcm[(first + i) * 2] += v * l; + pcm[(first + i) * 2 + 1] += v * r; + } +} +function kick(t, gain = 1) { + add(t, .33, 0, x => gain * (.15 * Math.sin(2 * Math.PI * (48 * x + 2.8 * (1 - Math.exp(-x / .026)))) * Math.exp(-x / .12) + + .012 * Math.sin(2 * Math.PI * 1350 * x) * Math.exp(-x / .005))); +} +function snare(t) { + add(t, .105, 0, x => .044 * Math.sin(2 * Math.PI * 178 * x) * Math.exp(-x / .05) + + .052 * (noise() - .5 * noise()) * Math.exp(-x / .027)); +} +function sineBell(t, midi, gain, pan, decay = 1.15) { + const f = hz(midi); + add(t, decay * 4.3, pan, x => { + const attack = 1 - Math.exp(-x / .006); + const env = attack * Math.exp(-x / decay); + // A clean fundamental carries the tune; quiet octave/12th give it identity. + return gain * env * (Math.sin(2 * Math.PI * f * x) + .19 * Math.sin(2 * Math.PI * 2 * f * x + .25) + + .07 * Math.sin(2 * Math.PI * 3 * f * x + .8)); + }); +} +function femBell(t, midi, gain, pan, material = "bronze") { + const f = hz(midi); + const bronze = [[1, 1, 1.75], [2.01, .31, 1.1], [2.72, .18, .72], [4.08, .09, .42]]; + const glass = [[1, 1, 1.35], [2.32, .2, .78], [3.86, .11, .48], [5.41, .045, .3]]; + const modes = material === "bronze" ? bronze : glass; + add(t + (material === "glass" ? .011 : .022), 5.6, pan, x => { + const attack = 1 - Math.exp(-x / (material === "bronze" ? .018 : .009)); + let y = 0; + // Sub-cent platter drift makes the material breathe without detuning the hook. + const drift = 1 + .00045 * Math.sin(2 * Math.PI * .31 * x + midi); + for (const [ratio, amp, decay] of modes) y += amp * Math.sin(2 * Math.PI * f * ratio * drift * x) * Math.exp(-x / decay); + return gain * attack * y; + }); +} + +function protagonist(t, midi, gain = .036) { + // Solo: centered-left, pure and speech-like, with a decisive short release. + sineBell(t, midi, gain, -.13, .72); +} +function coryphaeus(t, midi, gain = .03) { + // Leader's response: warmer and opposite the protagonist in the theatre. + sineBell(t, midi, gain, .2, 1.02); +} + +function grooveBar(t, intensity) { + let previous = 0; + // Authored surface drag: strongest after the downbeat, then it recedes to + // leave the backbeat and phrase ending clear. Differenced noise avoids hiss. + add(t, BAR * .88, 0, x => { + const n = noise(), grain = n - previous; previous = n; + const pulse = .28 + .72 * Math.exp(-((x % BEAT) / .115)); + const phraseFade = Math.max(0, 1 - x / (BAR * .88)); + return intensity * .007 * grain * pulse * (.45 + .55 * phraseFade); + }); + // Two quiet groove-wall contacts, deliberately away from kick/snare attacks. + for (const off of [1.72, 3.68]) add(t + off * BEAT, .012, off < 2 ? -.12 : .12, + x => intensity * .018 * noise() * Math.exp(-x / .0027)); +} + +// Centered classic pulse, with alternating lighter kicks to keep it dance-pop. +for (let b = 0; b * BEAT < DURATION; b++) { + const bar = Math.floor(b / 4), inBar = b % 4; + if (!SLOW_BUILD || bar >= 16) kick(b * BEAT, inBar === 0 ? 1 : .82); + else if (bar >= 8 && inBar % 2 === 0) kick(b * BEAT, inBar === 0 ? .68 : .48); + else if (bar >= 4 && inBar === 0) kick(b * BEAT, .45); + if ((!SLOW_BUILD || bar >= 16) && (inBar === 1 || inBar === 3)) snare(b * BEAT); + else if (SLOW_BUILD && bar >= 12 && inBar === 3) { + // A single cool backbeat foreshadows the full 2/4 snare lane. + add(b * BEAT, .09, 0, x => .032 * (noise() - .5 * noise()) * Math.exp(-x / .024)); + } +} + +// 8-bar harmonic sentence: Am9 | Fmaj9/A | Cmaj9/G | G6/9 | Dm9/F | +// Am/E | Bdim7/F | E7(b9). Hook is stable enough to sing, with two altered +// answers supplying the adventurous harmony. Rests at each phrase end matter. +const roots = [57, 57, 55, 55, 53, 52, 53, 52]; +const hook = [ + [[0,76],[1,79],[2.5,81]], [[0,76],[1.5,74],[2.5,72]], + [[0,76],[1,79],[2.5,83]], [[0,81],[1.5,79],[2.5,76]], + [[0,77],[1,81],[2.5,84]], [[0,76],[1.5,79],[2.5,83]], + [[0,77],[1,80],[2.5,86]], [[0,76],[1,74],[2.5,71]], +]; +const answer = [69,69,67,67,65,64,65,68]; +// Slow-build mode is a new composition, not a postponed version of the hook. +// Each harmony lasts two bars: Am11 | Cmaj7/E | Fmaj9 | E7sus(b9) | +// Dm9 | G13 | Cmaj9 | E7(#5). Its four-note "pressing" cell expands by form. +const slowRoots = [45, 52, 53, 52, 50, 55, 48, 52]; +const slowCells = [ + [69,72,71,76], [67,71,72,76], [69,72,76,79], [68,71,74,77], + [69,72,76,77], [71,74,76,81], [67,71,74,79], [68,72,77,76], +]; +const bars = Math.ceil(DURATION / BAR); +for (let bar = 0; bar < bars; bar++) { + const phase = bar % 8, slowPhase = Math.floor(bar / 2) % 8, base = bar * BAR; + const section = SLOW_BUILD ? (bar < 8 ? 0 : bar < 16 ? 1 : bar < 32 ? 2 : 3) + : (bar < 8 ? 0 : bar < 24 ? 1 : bar < 40 ? 2 : 3); + grooveBar(base, bar % 8 === 7 ? .35 : section === 2 ? .8 : .58); + // Slow build's new pressing-cell melody grows across long harmonic fields. + if (GREEK_THEATRE && bar >= 4 && bar % 16 !== 15) { + const cell = slowCells[slowPhase]; + // V2 reserves the chorus for the late climax. Earlier structural turns are + // solo/source battles and therefore cannot silently inflate voice count. + const structural = GREEK_V2 ? (bar === 40 || bar === 48) + : (bar === 16 || bar === 32 || bar === 40 || bar === 48); + if (structural) { + // Chorus: three separated registers only at a major turn. It speaks once, + // then yields an entire beat of stage space. + protagonist(base, cell[0], .038); + coryphaeus(base, cell[1] + 7, .027); + sineBell(base, cell[2] + 12, .021, 0, .88); + } else if (bar % 4 === 0 || bar % 4 === 1) { + // Episode: protagonist states one line; no simultaneous melodic reply. + protagonist(base + (bar < 12 ? 1.5 : .5) * BEAT, cell[bar % 2 ? 2 : 0], bar < 12 ? .024 : .035); + } else if (bar % 4 === 2) { + // Coryphaeus answers antiphonally after the prior bar's phrase has cleared. + coryphaeus(base + 1.25 * BEAT, cell[3] - (bar % 8 === 6 ? 2 : 0), bar < 16 ? .024 : .031); + } + // Every fourth bar is a stasimon/rest; surface and rhythm carry the scene. + } else if (SLOW_BUILD && bar >= 4 && bar % 16 !== 15) { + const cell = slowCells[slowPhase]; + const count = bar < 8 ? 1 : bar < 16 ? 2 : bar < 32 ? 4 : 5; + const events = [[0,cell[0]],[1.5,cell[1]],[2.5,cell[2]],[3.25,cell[3]],[3.625,cell[0]+12]]; + for (let i = 0; i < count; i++) { + // Second bar of each harmony answers later and in contrary contour. + const [beat, pitch] = events[i]; + const answered = bar % 2 ? (i % 2 ? pitch - 2 : pitch + 3) : pitch; + const climax = bar >= 40 && bar % 4 < 2 ? 12 : 0; + sineBell(base + (beat + (bar % 2 ? .25 : 0)) * BEAT, answered + climax, + bar < 8 ? .02 : bar < 16 ? .027 : bar >= 40 ? .044 : .037, + i % 2 ? .16 : -.1, i >= 3 ? .76 : 1.15); + } + // Original accepted composition remains unchanged in default mode. + } else if (!SLOW_BUILD && bar >= 2 && bar % 8 !== 7) { + for (const [noteIndex, [beat, note]] of hook[phase].entries()) { + if (section === 0 && beat === 1) continue; + const lift = section === 3 && bar % 4 < 2 ? 12 : 0; + const openingGain = section === 2 ? .045 : .038; + sineBell(base + beat * BEAT, note + lift, openingGain, beat === 1 ? .18 : -.12, .9 + .12 * (beat === 2.5)); + } + } + // Contrary-motion counterpoint only in alternating phrases: legible space. + if (!SLOW_BUILD && section > 0 && bar % 8 >= 4 && bar % 8 <= 6) { + sineBell(base + .5 * BEAT, answer[phase], .026, .23, 1.28); + sineBell(base + 2 * BEAT, answer[phase] + (phase === 6 ? 6 : 4), .022, .1, 1.05); + } + // One warm tactile body per bar, below the melodic register. Alternating + // material and side; staggered attack avoids a hard layered clang. + if (bar % 8 !== 7 && (!SLOW_BUILD || bar >= 6) && (!GREEK_THEATRE || bar % 8 === 7 || bar % 4 === 3)) { + const bodyGain = SLOW_BUILD && bar < 10 ? .0045 : SLOW_BUILD && bar < 16 ? .008 : .0125; + femBell(base, SLOW_BUILD ? slowRoots[slowPhase] : roots[phase], bodyGain, bar % 2 ? .2 : -.2, bar % 4 === 2 ? "glass" : "bronze"); + } +} +writeFileSync(RAW, Buffer.from(pcm.buffer)); + +const v2FirstAntagonist = 16 * BAR; +const v2SecondAntagonist = 24 * BAR; +const v2ThirdAntagonist = 32 * BAR; +const v2ClimaxStart = 40 * BAR; +const v2ClimaxEnd = 48 * BAR; +const bedTreatment = GREEK_V2 + // Hard zero until bar 17. Three isolated, sub-bar source utterances follow, + // each separated by at least seven bars of literal inherited-bed silence. + // Only the bar 41–48 climax permits a continuous ensemble field. + ? `asplit=2[antSrc][climaxSrc];` + + `[antSrc]highpass=f=260,lowpass=f=2100,volume='if(between(t,${v2FirstAntagonist.toFixed(6)},${(v2FirstAntagonist+.62).toFixed(6)}),.34*sin(PI*(t-${v2FirstAntagonist.toFixed(6)})/.62)^2,if(between(t,${v2SecondAntagonist.toFixed(6)},${(v2SecondAntagonist+.74).toFixed(6)}),.31*sin(PI*(t-${v2SecondAntagonist.toFixed(6)})/.74)^2,if(between(t,${v2ThirdAntagonist.toFixed(6)},${(v2ThirdAntagonist+.88).toFixed(6)}),.36*sin(PI*(t-${v2ThirdAntagonist.toFixed(6)})/.88)^2,0)))':eval=frame[ant];` + + `[climaxSrc]highpass=f=45,lowpass=f=7600,volume='if(between(t,${v2ClimaxStart.toFixed(6)},${v2ClimaxEnd.toFixed(6)}),.36*min(1,min(max(0,(t-${v2ClimaxStart.toFixed(6)})/.08),max(0,(${v2ClimaxEnd.toFixed(6)}-t)/.12))),0)':eval=frame[climax];` + + `[ant][climax]amix=inputs=2:normalize=0,stereotools=mlev=1.08:slev=.58[bed];` + : GREEK_THEATRE + ? "highpass=f=28,equalizer=f=3150:t=q:w=1.1:g=-4.2,equalizer=f=6900:t=q:w=.9:g=-3.1,lowpass=f=520,stereotools=mlev=1.1:slev=.48,volume=.56[bed];" + : "highpass=f=28,equalizer=f=3150:t=q:w=1.1:g=-3.5,equalizer=f=6900:t=q:w=.9:g=-2.6,equalizer=f=980:t=q:w=.7:g=1.3,stereotools=mlev=1.07:slev=.7,volume=.88[bed];"; +const filter = [ + `[0:a]atrim=start=${SOURCE_START.toFixed(12)},asetpts=PTS-STARTPTS,afade=t=in:st=0:d=0.014,afade=t=out:st=${(DURATION-.14).toFixed(6)}:d=0.14,`, + bedTreatment, + // Bell bus is kept coherent and warm; sub-250 Hz stays mono after summing. + "[1:a]highpass=f=42,equalizer=f=2600:t=q:w=.8:g=-1.6,equalizer=f=720:t=q:w=.7:g=1.4,volume=.92[bells];", + "[bed][bells]amix=inputs=2:weights='1 1':normalize=0,acompressor=threshold=.17:ratio=1.8:attack=22:release=175:makeup=1.03,", + `stereotools=mlev=1.03:slev=.82,loudnorm=I=-13:LRA=6:TP=-1:linear=false,${GREEK_V2 ? "alimiter=limit=.82:attack=5:release=50:level=false,volume=-1.4dB," : ""}aresample=48000[out]` +].join(""); +const ffmpegArgs = ["-y","-hide_banner","-loglevel","warning","-i",SOURCE,"-f","f32le","-ar",String(SR),"-ac","2","-i",RAW, + "-filter_complex",filter,"-map","[out]","-t",DURATION.toFixed(9),"-ar",String(SR),"-ac","2","-codec:a","libmp3lame","-b:a","320k", + "-metadata",`title=${STEM}`,"-metadata","artist=aesthetic.computer",TARGET]; +const result = spawnSync("ffmpeg", ffmpegArgs, { stdio: "inherit" }); +rmSync(RAW, { force: true }); +if (result.status !== 0) process.exit(result.status ?? 1); +const sha256 = p => createHash("sha256").update(readFileSync(p)).digest("hex"); +writeFileSync(RECEIPT, JSON.stringify({ + schema: "aesthetic.computer/pop-remaster/v1", track: STEM, + source: relative(ROOT, SOURCE), output: relative(ROOT, TARGET), sourceSha256: sha256(SOURCE), outputSha256: sha256(TARGET), + renderer: relative(ROOT, fileURLToPath(import.meta.url)), sourceStartSec: SOURCE_START, durationSec: DURATION, sampleRate: SR, channels: 2, + harmony: SLOW_BUILD + ? ["Am11 (2 bars)","Cmaj7/E (2 bars)","Fmaj9 (2 bars)","E7sus(b9) (2 bars)","Dm9 (2 bars)","G13 (2 bars)","Cmaj9 (2 bars)","E7(#5) (2 bars)"] + : ["Am9","Fmaj9/A","Cmaj9/G","G6(add9)","Dm9/F","Am/E","Bdim7/F","E7(b9)"], + arc: SLOW_BUILD + ? ["four-bar material-only threshold","single-note hook fragments and downbeat kick","two-note phrases with gradual FEM bloom and one backbeat","bar-17 full classic drums and complete hook","contrary-motion payoff and octave-bright return"] + : ["two-bar sparse hook entrance","full eight-bar sine hook","alternating contrary-motion answer","octave-bright final return"], + bellDesign: "sine fundamental hook/counterpoint with staggered warm bronze/glass modal FEM-style bodies", + composition: SLOW_BUILD ? "new two-bar harmonic rhythm and four-note pressing-cell development; no reuse of the accepted sinebells hook" : "accepted eight-bar sinebells hook", + dramaturgy: GREEK_THEATRE ? { + normalMelodicVoiceCap: 1, climaxMelodicVoiceCap: 3, + protagonist: "short pure sine, center-left; states episode lines alone", + coryphaeus: "warm sine, right; answers in a later bar", + chorus: "three-register structural utterance only at bars 17, 33, 41, and 49", + stasimon: "every fourth bar clears pitched melody for rhythm and record material" + } : null, + inheritedBedPlan: GREEK_V2 ? { + bars1To16: "hard-muted (0.0); no inherited harmonic or melodic voices", + isolatedAntagonists: ["bar 17: 0.62 s", "bar 25: 0.74 s", "bar 33: 0.88 s"], + silenceBetweenAntagonists: "more than seven bars; source gain exactly 0", + lateClimax: "bars 41–48 only, continuous ensemble at 0.36 gain", + outsideListedWindows: "source gain exactly 0" + } : null, + surface: "bar-authored responsive groove drag, offbeat groove-wall contacts, and sub-cent material drift; no constant hiss", + mix: "narrow coherent bell field, centered drums/sub, reduced 3.15/6.9 kHz harshness, -13 LUFS / -1 dBTP target", + slowBuild: SLOW_BUILD, greekTheatre: GREEK_THEATRE, greekTheatreV2: GREEK_V2, ffmpegArgs +}, null, 2) + "\n"); +console.log(TARGET); diff --git a/pop/wattajetta/bin/render-taksmukkeklokkewattajetta.mjs b/pop/wattajetta/bin/render-taksmukkeklokkewattajetta.mjs new file mode 100644 index 0000000000..5a87686890 --- /dev/null +++ b/pop/wattajetta/bin/render-taksmukkeklokkewattajetta.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +// taksmukkeklokkewattajetta — Taksmukke's wandering clock hands and shifting +// oscillator voices composed inside Wattajetta's water/material flight plan. +// This is a tempo-locked hybrid arrangement, not a concatenation of masters. + +import { mkdirSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LANE = resolve(HERE, ".."); +const OUT = resolve(LANE, "out"); +mkdirSync(OUT, { recursive: true }); + +const SR = 48_000; +const BPM = 138; +const BEAT = 60 / BPM; +const BAR = 4 * BEAT; +const BARS = 96; +const DUR = BARS * BAR + 5.5; +const FRAMES = Math.ceil(DUR * SR); +const BASE = resolve(OUT, "wattajetta.mp3"); +const RAW = resolve(OUT, ".taksmukkeklokkewattajetta-melody.f32le"); +const MP3 = resolve(OUT, "taksmukkeklokkewattajetta.mp3"); +const RECEIPT = resolve(OUT, "taksmukkeklokkewattajetta.production.json"); +const audio = new Float32Array(FRAMES * 2); + +let seed = 0x74616b77; +const rnd = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 4294967296); +const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x)); +const midiHz = (m) => 440 * 2 ** ((m - 69) / 12); + +// Each breath changes harmonic weather. The borrowed bII and dominant b9 are +// deliberate adventure; common tones keep the turns loose instead of abrupt. +const harmony = [ + { name: "Em9", notes: [52, 55, 59, 66] }, + { name: "Cmaj7#11", notes: [48, 52, 59, 66] }, + { name: "Am9", notes: [45, 52, 59, 60] }, + { name: "D13sus", notes: [50, 55, 59, 64] }, + { name: "Gmaj9", notes: [43, 50, 54, 57] }, + { name: "Bbmaj7#11", notes: [46, 50, 57, 64] }, + { name: "F#m11", notes: [42, 49, 52, 59] }, + { name: "B7b9", notes: [47, 51, 57, 60] }, +]; +const timbres = ["sine", "triangle", "hollow-square", "soft-saw"]; +const events = []; + +function osc(kind, phase) { + const p = phase - Math.floor(phase); + if (kind === "sine") return Math.sin(2 * Math.PI * p); + if (kind === "triangle") return 1 - 4 * Math.abs(p - 0.5); + if (kind === "hollow-square") return Math.sin(2 * Math.PI * p) + 0.22 * Math.sin(6 * Math.PI * p); + return 0.72 * Math.sin(2 * Math.PI * p) + 0.2 * Math.sin(4 * Math.PI * p) + 0.08 * Math.sin(6 * Math.PI * p); +} + +function note({ t, dur, midi, gain, pan, voice, glideFrom = midi }) { + const a = Math.min(0.035, dur * 0.18), r = Math.min(0.24, dur * 0.42); + const begin = Math.max(0, Math.floor(t * SR)); + const end = Math.min(FRAMES, Math.ceil((t + dur) * SR)); + const gl = Math.sqrt((1 - pan) * 0.5), gr = Math.sqrt((1 + pan) * 0.5); + let phase = 0; + for (let f = begin; f < end; f++) { + const age = f / SR - t; + const env = age < a ? age / a : age > dur - r ? Math.max(0, (dur - age) / r) : 1; + const slide = Math.min(1, age / Math.min(0.13, dur * 0.35)); + const hz = midiHz(glideFrom + (midi - glideFrom) * (slide * slide * (3 - 2 * slide))); + phase += hz / SR; + const shimmer = 0.84 * osc(voice, phase) + 0.16 * Math.sin(2 * Math.PI * phase * 2.003); + const x = shimmer * env * gain; + audio[f * 2] += x * gl; + audio[f * 2 + 1] += x * gr; + } + events.push({ t: +t.toFixed(4), dur: +dur.toFixed(4), midi, voice, chord: harmony[Math.floor(t / (2 * BAR)) % harmony.length].name }); +} + +// A quiet, breathing voicing underneath every two bars. It leaves the root to +// Wattajetta's sub and choir, so extensions rather than density do the work. +for (let b = 0; b < 80; b += 2) { + const chord = harmony[(b / 2) % harmony.length]; + const voice = timbres[Math.floor(b / 8) % timbres.length]; + for (let n = 1; n < chord.notes.length; n++) + note({ t: b * BAR + 0.08 + n * 0.018, dur: 1.62 * BAR, midi: chord.notes[n] + 12, + gain: 0.026, pan: [-0.48, 0.18, 0.48][n - 1], voice }); +} + +// The loose hand: chord tones plus 9ths/11ths, irregular entrances, occasional +// skipped answers, and small portamenti. Voices rotate at Wattajetta's breaths. +let lastMidi = 71; +const phraseStarts = [4, 8, 16, 20, 24, 32, 36, 48, 52, 56, 64, 68, 72]; +for (const b of phraseStarts) { + const voice = timbres[Math.floor(b / 16) % timbres.length]; + let cursor = b * BAR + (0.2 + rnd() * 0.55) * BEAT; + const count = 5 + Math.floor(rnd() * 5); + for (let i = 0; i < count; i++) { + const chord = harmony[Math.floor(cursor / (2 * BAR)) % harmony.length]; + const palette = [...chord.notes.map((n) => n + 24), chord.notes[1] + 26, chord.notes[2] + 29]; + const candidates = palette.sort((a, c) => Math.abs(a - lastMidi) - Math.abs(c - lastMidi)).slice(0, 4); + let midi = candidates[Math.floor(rnd() * candidates.length)]; + if (rnd() < 0.2) midi += rnd() < 0.5 ? -12 : 12; + midi = clamp(midi, 64, 88); + const durBeats = [0.32, 0.48, 0.72, 1.15, 1.7][Math.floor(rnd() * 5)]; + note({ t: cursor, dur: durBeats * BEAT, midi, glideFrom: rnd() < 0.38 ? lastMidi : midi, + gain: 0.055 + rnd() * 0.025, pan: Math.sin(cursor * 0.73) * 0.62, voice }); + lastMidi = midi; + cursor += [0.5, 0.75, 1, 1.5][Math.floor(rnd() * 4)] * BEAT; + } +} + +// In the mist, the four original oscillator identities answer one another and +// resolve the B7b9 tension back to a bare E/B fifth around the final glass bell. +for (let b = 80; b < 94; b += 2) { + const i = (b - 80) / 2; + note({ t: b * BAR + (i % 2 ? 1.25 : 0.5) * BEAT, dur: (2.1 - i * 0.14) * BEAT, + midi: [76, 79, 78, 71, 72, 66, 64][i], glideFrom: i ? [76, 76, 79, 78, 71, 72, 66][i] : 71, + gain: 0.065 - i * 0.004, pan: i % 2 ? 0.48 : -0.48, voice: timbres[i % timbres.length] }); +} + +// Gentle safety saturation on the newly composed bus only. +for (let i = 0; i < audio.length; i++) audio[i] = Math.tanh(audio[i] * 1.35) / 1.35; +writeFileSync(RAW, Buffer.from(audio.buffer)); + +const ff = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", + "-i", BASE, "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", RAW, + "-filter_complex", "[0:a]volume=0.86[water];[1:a]highpass=f=125,lowpass=f=9500,volume=1.0[clock];[water][clock]amix=inputs=2:duration=longest:normalize=0,alimiter=limit=0.93:attack=5:release=80[out]", + "-map", "[out]", "-ar", String(SR), "-codec:a", "libmp3lame", "-b:a", "320k", + "-metadata", "title=taksmukkeklokkewattajetta", "-metadata", "artist=aesthetic.computer", + "-metadata", "comment=Taksmukke clock hands inside Wattajetta's water-material arc", MP3], { stdio: "inherit" }); +if (ff.status !== 0) process.exit(ff.status ?? 1); + +writeFileSync(RECEIPT, JSON.stringify({ + track: "taksmukkeklokkewattajetta", renderer: "bin/render-taksmukkeklokkewattajetta.mjs", + relationship: "tempo-locked hybrid arrangement; source masters preserved", + transport: { bpm: BPM, meter: "4/4", bars: BARS, durationSec: DUR }, + sourceRhythmSection: "out/wattajetta.mp3", output: "out/taksmukkeklokkewattajetta.mp3", + harmony: harmony.map((h) => h.name), oscillatorRotation: timbres, + structure: ["0-27 glass/drop: drifting extensions", "28-47 bronze/breath: chromatic common-tone turns", "48-63 steel/drop: portamento answers", "64-79 stone/drop: widest harmonic travel", "80-95 mist: four-voice call-and-response resolution"], + melodicEventCount: events.length, events, +}, null, 2) + "\n"); +console.log(`→ ${MP3} · ${events.length} melodic events · ${DUR.toFixed(1)}s`); diff --git a/pop/wattajetta/bin/render-wattajetta-breakfirst.mjs b/pop/wattajetta/bin/render-wattajetta-breakfirst.mjs new file mode 100644 index 0000000000..bcffc32fe0 --- /dev/null +++ b/pop/wattajetta/bin/render-wattajetta-breakfirst.mjs @@ -0,0 +1,254 @@ +#!/usr/bin/env node +// Rebegin Wattajetta at the musical state heard at 1:24 in the accepted +// original master. This is an arrangement of that state, not a trim: long +// phrases keep the groove legible while increasingly hard half-/quarter-beat +// edits turn the steel drop into a crunchier, slicier second composition. + +import { mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, ".."); +const OUT = resolve(ROOT, "out"); +mkdirSync(OUT, { recursive: true }); + +const SR = 48000; +const BPM = 138; +const BEAT = 60 / BPM; +const BAR = 4 * BEAT; +const BARS = 96; +const SOURCE_START = 84; // user's chosen musical downbeat in the original master +const SPATIAL = process.argv.includes("--spatial"); +const sourceMp3 = resolve(OUT, "wattajetta.mp3"); +const decoded = resolve(OUT, ".wattajetta-breakfirst.source.f32"); +const mixed = resolve(OUT, ".wattajetta-breakfirst.mixed.f32"); +const output = resolve(OUT, SPATIAL ? "wattajetta-breakfirst-spatial.mp3" + : "wattajetta-breakfirst.mp3"); + +const decode = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-i", sourceMp3, "-f", "f32le", "-ar", String(SR), "-ac", "2", decoded], + { stdio: "inherit" }); +if (decode.status !== 0) process.exit(decode.status ?? 1); + +const bytes = readFileSync(decoded); +const src = new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4); +const sourceFrames = src.length / 2; +const frames = Math.floor(BARS * BAR * SR); +const out = new Float32Array(frames * 2); +const frame = (seconds) => Math.floor(seconds * SR); +const beatFrame = frame(BEAT); +const barFrame = frame(BAR); +const chosenFrame = frame(SOURCE_START); + +// Short equal-power edges keep a slice percussive without digital clicks. +function copySlice(dst, source, length, gain = 1, reverse = false, edgeMs = 7) { + const n = Math.min(length, frames - dst, reverse ? source + 1 : sourceFrames - source); + const edge = Math.max(1, frame(edgeMs / 1000)); + for (let i = 0; i < n; i++) { + const si = reverse ? source - i : source + i; + if (si < 0 || si >= sourceFrames) break; + const e = Math.min(1, i / edge, (n - 1 - i) / edge); + const g = gain * Math.sin(Math.max(0, e) * Math.PI * 0.5); + out[2 * (dst + i)] += src[2 * si] * g; + out[2 * (dst + i) + 1] += src[2 * si + 1] * g; + } +} + +function straight(dstBar, bars, sourceSec) { + copySlice(dstBar * barFrame, frame(sourceSec), bars * barFrame, 1, false, 2); +} + +// Each bar still states one recognizable source bar. Only selected cells are +// displaced, repeated or reversed; the unfilled cells are intentional air. +const HALF_ORDER = [[0, 1, 3, 2, 4, 5, 6, 7], [0, 2, 1, 3, 4, 6, 5, 7]]; +function halfBeatPhrase(dstBar, bars, sourceSec, sparse = false) { + for (let b = 0; b < bars; b++) { + const order = HALF_ORDER[b % HALF_ORDER.length]; + for (let cell = 0; cell < 8; cell++) { + if (sparse && ((b + cell) % 5 === 3 || cell === 6)) continue; + const sourceCell = order[cell]; + const dst = (dstBar + b) * barFrame + Math.floor(cell * beatFrame / 2); + const from = frame(sourceSec) + b * barFrame + Math.floor(sourceCell * beatFrame / 2); + copySlice(dst, from, Math.floor(beatFrame / 2), cell === 7 ? 1.08 : 1, + sparse && cell === 5 && b % 2 === 1, 5); + } + } +} + +function quarterBeatPhrase(dstBar, bars, sourceSec) { + const cellFrames = Math.floor(beatFrame / 4); + for (let b = 0; b < bars; b++) { + for (let cell = 0; cell < 16; cell++) { + // Kick/downbeat cells remain fixed. Three cells per bar become negative + // space; the remaining edits make a single spicy syncopated answer. + if ([3, 10, 14].includes(cell)) continue; + let sourceCell = cell; + if (cell === 5 || cell === 9) sourceCell -= 1; + if (cell === 7 || cell === 13) sourceCell += 1; + const dst = (dstBar + b) * barFrame + cell * cellFrames; + const from = frame(sourceSec) + b * barFrame + sourceCell * cellFrames; + copySlice(dst, from, cellFrames, cell % 4 === 0 ? 1.08 : 0.96, + cell === 13 && b % 2 === 1, 3); + } + } +} + +// Form: recognition → first cuts → forward travel → air → harder rebegin → +// deepest forward state → knife-work → release. Every return starts on the +// exact 84-second downbeat, so the user's chosen moment is the composition's +// tonic as well as its first sound. +straight(0, 16, SOURCE_START); // actual 1:24 state, uninterrupted +halfBeatPhrase(16, 8, SOURCE_START); // first controlled re-cut +straight(24, 16, SOURCE_START + 16 * BAR); +halfBeatPhrase(40, 8, SOURCE_START + 8 * BAR, true); +straight(48, 8, SOURCE_START); // hard second rebegin +quarterBeatPhrase(56, 8, SOURCE_START + 8 * BAR); +straight(64, 16, SOURCE_START + 24 * BAR); +quarterBeatPhrase(80, 8, SOURCE_START + 28 * BAR); +straight(88, 8, SOURCE_START + 40 * BAR); + +// Parallel industrial bite. It is rhythmically keyed to the existing bars: +// no new free-running voice, just transient emphasis and crunchy material +// contrast. Later acts get more bite while breaths retain their holes. +let heldL = 0, heldR = 0; +const hold = 4; +for (let f = 0; f < frames; f++) { + const b = Math.floor(f / barFrame); + const phase = (f % beatFrame) / beatFrame; + if (f % hold === 0) { heldL = out[2 * f]; heldR = out[2 * f + 1]; } + const bite = b < 16 ? 0.04 : b < 48 ? 0.10 : b < 80 ? 0.16 : 0.20; + const transient = Math.exp(-phase / 0.055); + const drive = 1.8 + 1.2 * transient; + const crushedL = Math.tanh(heldL * drive) / Math.tanh(drive); + const crushedR = Math.tanh(heldR * drive) / Math.tanh(drive); + out[2 * f] = out[2 * f] * (1 - bite) + crushedL * bite * (1 + 0.16 * transient); + out[2 * f + 1] = out[2 * f + 1] * (1 - bite) + crushedR * bite * (1 + 0.16 * transient); +} + +// A coherent spatial stage for the separate audition. Wattajetta arrives as a +// stereo master, so the crossover is deliberately conservative: everything +// below 155 Hz is mono and un-delayed, and sharp drum transients pull the upper +// body toward center. Material chapters own stable anchors. Only selected edit +// passages describe one smooth arc; azimuth is continuously slewed, never +// changed at a block or slice boundary. +if (SPATIAL) { + const dry = out.slice(); + const staged = new Float32Array(out.length); + const delaySize = 128; + const monoDelay = new Float32Array(delaySize); + let delayWrite = 0, lowL = 0, lowR = 0, az = -0.26, depth = 0.16; + let env = 0, previousMono = 0; + const lowA = 1 - Math.exp(-2 * Math.PI * 155 / SR); + const azSlew = 1 - Math.exp(-1 / (0.28 * SR)); + const depthSlew = 1 - Math.exp(-1 / (0.65 * SR)); + for (let f = 0; f < frames; f++) { + const bFloat = f / barFrame; + const b = Math.floor(bFloat); + const barPhase = bFloat - b; + // Steel is left/high and near; the later stone body is right/lower and + // deeper. The hard rebegin returns to steel's known location. + let azTarget = b < 24 ? -0.26 : b < 48 ? 0.24 : b < 64 ? -0.18 : 0.28; + let depthTarget = b < 24 ? 0.14 : b < 48 ? 0.22 : b < 64 ? 0.16 : 0.25; + const edited = (b >= 16 && b < 24) || (b >= 40 && b < 48) + || (b >= 56 && b < 64) || (b >= 80 && b < 88); + if (edited) { + // One deliberate bar-long glide, alternating direction. It crosses no + // more than ~24 degrees and returns to the chapter's material anchor. + const arc = Math.sin(Math.PI * barPhase) * (b % 2 ? -1 : 1); + azTarget += 0.16 * arc; + depthTarget += 0.035 * Math.sin(Math.PI * barPhase); + } + az += (azTarget - az) * azSlew; + depth += (depthTarget - depth) * depthSlew; + + const l = dry[2 * f], r = dry[2 * f + 1]; + lowL += lowA * (l - lowL); lowR += lowA * (r - lowR); + const lowMono = 0.5 * (lowL + lowR); + const highL = l - lowL, highR = r - lowR; + const highMono = 0.5 * (highL + highR); + const originalSide = 0.5 * (highL - highR); + + // Broadband derivative is only a transient detector. Its smoothed value + // centers attacks; it never gates audio or changes delay discontinuously. + const derivative = Math.abs(highMono - previousMono); + previousMono = highMono; + env += (derivative - env) * (derivative > env ? 0.12 : 0.0018); + const centerAttack = Math.min(1, env * 15); + const effectiveAz = az * (1 - 0.72 * centerAttack); + const itd = Math.abs(effectiveAz) * 0.00056 * SR; + monoDelay[delayWrite] = highMono; + const delayedIndex = (delayWrite - Math.floor(itd) + delaySize) % delaySize; + const delayed = monoDelay[delayedIndex]; + delayWrite = (delayWrite + 1) % delaySize; + const near = highMono; + const far = delayed * (0.50 - 0.22 * Math.abs(effectiveAz)); + const placedL = effectiveAz < 0 ? near : far; + const placedR = effectiveAz < 0 ? far : near; + const spatialWet = (0.57 + depth * 0.46) * (1 - 0.55 * centerAttack); + // Retain the accepted stereo detail underneath the listener-relative body. + staged[2 * f] = lowMono + highL * (1 - spatialWet) + + (placedL + originalSide * 0.42) * spatialWet; + staged[2 * f + 1] = lowMono + highR * (1 - spatialWet) + + (placedR - originalSide * 0.42) * spatialWet; + } + + // Four low-level, cross-room early images, following the first-party room + // pattern. Prime-ish delays prevent a metallic comb; returns are high-passed + // by subtraction of a 190 Hz one-pole so the mono low end never enters them. + const roomSource = staged.slice(); + const delays = [0.011, 0.019, 0.031, 0.047].map((s) => frame(s)); + const gains = [0.036, 0.025, 0.017, 0.011]; + const roomHigh = new Float32Array(staged.length); + let roomLowL = 0, roomLowR = 0; + const roomA = 1 - Math.exp(-2 * Math.PI * 190 / SR); + for (let f = 0; f < frames; f++) { + roomLowL += roomA * (roomSource[2 * f] - roomLowL); + roomLowR += roomA * (roomSource[2 * f + 1] - roomLowR); + roomHigh[2 * f] = roomSource[2 * f] - roomLowL; + roomHigh[2 * f + 1] = roomSource[2 * f + 1] - roomLowR; + } + for (let f = 0; f < frames; f++) { + for (let q = 0; q < delays.length; q++) { + const from = f - delays[q]; + if (from < 0) continue; + // Cross returns imply a room wall while remaining too quiet to smear cuts. + const reflectedL = roomHigh[2 * from]; + const reflectedR = roomHigh[2 * from + 1]; + staged[2 * f] += reflectedR * gains[q]; + staged[2 * f + 1] += reflectedL * gains[q]; + } + } + out.set(staged); +} + +// Preserve the chosen source frame at time zero, then taper only the last four +// bars. Re-peak before mastering. +for (let f = frames - 4 * barFrame; f < frames; f++) { + const p = (f - (frames - 4 * barFrame)) / (4 * barFrame); + const g = Math.cos(p * Math.PI * 0.5) ** 2; + out[2 * f] *= g; out[2 * f + 1] *= g; +} +let peak = 0; +for (const x of out) peak = Math.max(peak, Math.abs(x)); +if (peak > 0.94) for (let i = 0; i < out.length; i++) out[i] *= 0.94 / peak; + +writeFileSync(mixed, Buffer.from(out.buffer, out.byteOffset, out.byteLength)); +const master = [ + "highpass=f=25", + "equalizer=f=260:t=q:w=1.0:g=-1.4", + "equalizer=f=2400:t=q:w=0.8:g=1.1", + "acompressor=threshold=-18dB:ratio=2.4:attack=7:release=115:makeup=1.5:knee=5", + "alimiter=limit=0.94:attack=3:release=65", + "loudnorm=I=-12.5:LRA=8:TP=-1.0", +]; +const encode = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", mixed, + "-af", master.join(","), "-c:a", "libmp3lame", "-q:a", "2", + "-metadata", `title=wattajetta — breakfirst${SPATIAL ? " spatial" : ""}`, + "-metadata", "album=pixsies", output], + { stdio: "inherit" }); +for (const path of [decoded, mixed]) { try { unlinkSync(path); } catch {} } +if (encode.status !== 0) process.exit(encode.status ?? 1); +console.log(`✓ ${output} (84.000s becomes bar zero · 96 bars · crunchy controlled slices${SPATIAL ? " · coherent spatial stage" : ""})`); diff --git a/pop/wattajetta/bin/render-wattajetta.mjs b/pop/wattajetta/bin/render-wattajetta.mjs index 2ac7c61877..c9806d5e99 100644 --- a/pop/wattajetta/bin/render-wattajetta.mjs +++ b/pop/wattajetta/bin/render-wattajetta.mjs @@ -18,6 +18,7 @@ // node pop/wattajetta/bin/render-wattajetta.mjs → out/wattajetta.mp3 // node pop/wattajetta/bin/render-wattajetta.mjs --world → separate spatial/rhythmic audition // node pop/wattajetta/bin/render-wattajetta.mjs --next → 1:24 pickup / powersaw audition +// node pop/wattajetta/bin/render-wattajetta.mjs --industrial → disciplined steel/stone cut // node pop/wattajetta/bin/render-wattajetta.mjs --score → print the engine score // // Arc (bars): @@ -46,7 +47,10 @@ mkdirSync(OUT, { recursive: true }); const BELL_CACHE = resolve(OUT, ".wattajetta-bell-cache-v1"); mkdirSync(BELL_CACHE, { recursive: true }); const NEXT = process.argv.includes("--next"); +const INDUSTRIAL = process.argv.includes("--industrial"); const WORLD = NEXT || process.argv.includes("--world"); +const outputStem = INDUSTRIAL ? "wattajetta-industrial" + : NEXT ? "wattajetta-world-v2" : WORLD ? "wattajetta-world" : "wattajetta"; const SR = 48000; const TAU = 2 * Math.PI; @@ -191,7 +195,6 @@ if (WORLD) { sines.push([bar(b), 3.2, 62, 27, -19, 0.08, 2.7, -0.12, 0.12, 1.7, 5]); } } - // ── kicks: halftime 1+3 early (the part we loved), then the flight // ramps — drop C flips to four-on-the-floor halfway, and the steel // and stone drops drive full trance 4/4 ──────────────────────────── @@ -272,15 +275,17 @@ function bellRun(b, d) { let idx = Math.floor(rnd() * DROP_NOTES.length); for (let e = 0; e < 8; e++) { idx = Math.max(0, Math.min(DROP_NOTES.length - 1, idx + (rnd() < 0.5 ? -1 : 1))); - if (rnd() < d.density * (WORLD ? 0.82 : 1)) + if (rnd() < d.density * (WORLD ? 0.82 : INDUSTRIAL ? 0.58 : 1)) bells.push({ t: bar(b) + e * 0.5 * BEAT, note: DROP_NOTES[idx], vel: 0.55 + rnd() * 0.3, pan: (rnd() * 2 - 1) * 0.7, gain: Math.pow(10, d.gainDb / 20), - material: d.mat, geometry: "glass", + material: INDUSTRIAL ? (b < 40 ? "steel" : "stone") : d.mat, + geometry: "glass", dur: WORLD ? [0.42, 0.68, 0.95][Math.floor(rnd() * 3)] + : INDUSTRIAL ? [0.55, 0.8, 1.15][Math.floor(rnd() * 3)] : d.durs[Math.floor(rnd() * d.durs.length)], ...(WORLD ? { renderDur: 0.95 } : {}), crush: WORLD && rnd() < 0.18, @@ -342,8 +347,11 @@ function chimeCluster(t, count, db) { tt += 0.03 + rnd() * 0.09; } } -for (const b of BREATHS) { chimeCluster(bar(b) + BEAT, 5, -20); chimeCluster(bar(b + 2) + 2 * BEAT, 4, -22); } -for (const b of [82, 86, 91]) chimeCluster(bar(b), 3, -23); +for (const b of BREATHS) { + chimeCluster(bar(b) + BEAT, INDUSTRIAL ? 2 : 5, INDUSTRIAL ? -24 : -20); + chimeCluster(bar(b + 2) + 2 * BEAT, INDUSTRIAL ? 2 : 4, INDUSTRIAL ? -25 : -22); +} +if (!INDUSTRIAL) for (const b of [82, 86, 91]) chimeCluster(bar(b), 3, -23); // Sparse upper-register "bing bing" answers: short steel/glass glocks, // locked to E minor pentatonic so sparkle never becomes broadband haze. @@ -435,6 +443,25 @@ flyby(bar(29), -1); flyby(bar(53), 1); flyby(bar(70), -1); +// Industrial cut: a restrained press line occupies one two-bar cell at a +// time. Low steel impacts establish the grid; short pneumatic releases answer +// it. Alternating cells are intentionally empty so the machinery has room. +if (INDUSTRIAL) { + for (let b = 0; b < 80; b += 2) { + const root = ROOTS[(b / 2) % ROOTS.length]; + for (const beat of [0, 2.5]) { + const t = bar(b) + beat * BEAT; + sines.push([t, 0.26, root * 5.1, root * 2.15, -10.5, 0.002, 0.18, + beat ? 0.18 : -0.18, beat ? -0.1 : 0.1, 0, 0]); + noises.push([t, 0.16, 2400, 310, 1.8, -14.5, 0.001, 0.11, + beat ? 0.22 : -0.22]); + } + if (b % 8 === 4) + noises.push([bar(b) + 3.25 * BEAT, 0.42, 5200, 780, 1.15, -19, + 0.008, 0.34, 0.35]); + } +} + // ── spray, whisper level: a steam breath at each drop, an exhale out ── for (const d of DROPS) noises.push([bar(d.a), 2.0, 2000, 300, 0.8, -23, 0.015, 1.8, 0]); noises.push([bar(80), (WORLD ? 8 : 16) * BAR + 2, 3000, 500, 0.8, -27, @@ -455,13 +482,13 @@ const score = [ `noise ${noises.length}`, fmt(noises), ].join("\n") + "\n"; -const scorePath = resolve(OUT, "wattajetta.score.txt"); +const scorePath = resolve(OUT, `${outputStem}.score.txt`); writeFileSync(scorePath, score); if (process.argv.includes("--score")) { console.log(score); process.exit(0); } console.log(`baked ${kicks.length} kicks, ${sines.length} sines, ${noises.length} sprays, ${bells.length} bells`); -const rawPath = resolve(OUT, "wattajetta.f32.raw"); -const kickPath = resolve(OUT, "wattajetta.kick.f32.raw"); +const rawPath = resolve(OUT, `${outputStem}.f32.raw`); +const kickPath = resolve(OUT, `${outputStem}.kick.f32.raw`); const r = spawnSync("node", [resolve(HERE, "../c/run-c.mjs"), scorePath, "--raw", rawPath, "--kickraw", kickPath], { stdio: "inherit" }); if (r.status !== 0) process.exit(1); @@ -885,6 +912,17 @@ if (WORLD) { } } } +if (INDUSTRIAL) { + // One compact transformer gesture every two bars: audible turntablism, + // disciplined like a machine interlock rather than a continuous spray. + for (let b = 2; b < 80; b += 2) { + const tBar = bar(b); + const reverse = (b / 2) % 2 === 0; + scratchVoiceGesture(tBar + 1.5 * BEAT, tBar, + reverse ? 0.58 : 0.32, reverse ? 0.34 : 0.52, + -16.5, reverse ? -0.24 : 0.24, 0.19); + } +} const E = BEAT / 2; // an eighth const SUPER = [ { dur: E * 2, from: 0, to: 0.8 }, { dur: E * 2, from: 0.8, to: 0.05 }, @@ -1451,7 +1489,6 @@ if (NEXT) { } } -const outputStem = NEXT ? "wattajetta-world-v2" : WORLD ? "wattajetta-world" : "wattajetta"; const mixedPath = resolve(OUT, `${outputStem}.mixed.f32.raw`); writeFileSync(mixedPath, Buffer.from(outputMix.buffer, outputMix.byteOffset, outputMix.length * 4)); @@ -1463,6 +1500,7 @@ const MASTER = [ "equalizer=f=50:t=q:w=1.2:g=2.5", // the boom under the kick — no treble boost; laptop speakers read it as tang ...(WORLD ? ["equalizer=f=285:t=q:w=0.85:g=-1.6"] : []), // normalize low-mid buildup for headphones ...(WORLD ? ["highshelf=f=5500:g=-2dB"] : []), // keep the water world, lose excess upper-air haze + ...(INDUSTRIAL ? ["equalizer=f=240:t=q:w=1.1:g=-1.2", "highshelf=f=6200:g=-2.5dB"] : []), "alimiter=limit=0.96:attack=4:release=70", ...(WORLD ? ["volume=-1.5dB"] : []), // leave the spatial audition at about -1 dBTP // The accelerando leaves dead source at the legacy tail. The fixed 2:30 @@ -1473,7 +1511,7 @@ const mp3 = resolve(OUT, `${outputStem}.mp3`); const ff = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", mixedPath, "-af", MASTER.join(","), "-c:a", "libmp3lame", "-q:a", "2", - "-metadata", `title=${NEXT ? "wattajetta — vinyl material audition" : WORLD ? "wattajetta — world audition" : "wattajetta"}`, + "-metadata", `title=${INDUSTRIAL ? "wattajetta — industrial" : NEXT ? "wattajetta — vinyl material audition" : WORLD ? "wattajetta — world audition" : "wattajetta"}`, "-metadata", "album=pixsies", mp3], { stdio: "inherit" }); if (ff.status !== 0) { console.error("✗ ffmpeg failed"); process.exit(1); } @@ -1487,4 +1525,4 @@ if (WORLD) { console.log(`✓ ${wav} (24-bit spatial audition)`); } for (const p of [rawPath, kickPath, mixedPath]) { try { unlinkSync(p); } catch {} } -console.log(`✓ ${mp3} (glass↔staccato transmogrification · rhythmic scratch voice · water world)`); +console.log(`✓ ${mp3} (${INDUSTRIAL ? "steel/stone press line · disciplined scratches · negative space" : "glass↔staccato transmogrification · rhythmic scratch voice · water world"})`);