From 4dc8e164c813a97b8fe31ac700374e9d1070ba1f Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 14 Jun 2026 17:43:40 -0700 Subject: [PATCH] =?UTF-8?q?cornerfive:=20whistlegraph=E2=86=92/pop=20pipel?= =?UTF-8?q?ine=20+=20JukeWizard=20master=20juke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new /pop remix built from the whistlegraph "five in the corner x2" TikTok, plus the tooling to make more like it. toolchain/whistlegraph/ — download + analyze whistlegraph TikToks • grab.mjs: yt-dlp a clip (or --latest from @whistlegraph) → wav → librosa analysis (tempo/key/melody/onsets) → INDEX.json • analyze.py: pyin melody readout (notes + cents + onsets) • registered as the /whistlegraph-pop skill pop/cornerfive/ — the track (1:24, 73.2 BPM): kick + sine-bell "sister" line + the whistle vocal, nothing else. • sync-whistle.mjs: rubberband per-note beat-align to the grid + cents-shift into tune, keeping the whistle's OWN observed melody (no key-snap, no WORLD resynth) • cornerfive.mjs: four-on-floor kick, sine bells that follow the vocal melody, vocal looped through the whole track, ducked to kick juke-wizard/ — JukeWizard is now the MASTER JUKE: always loads the full /pop library, and --select jumps to + plays a specific track (added to the queue if it's a fresh draft). Launcher passes the library first and the requested file as --select. --- .claude/skills/whistlegraph-pop/SKILL.md | 35 ++ .../Sources/JukeWizard/JukeController.swift | 12 +- juke-wizard/Sources/JukeWizard/main.swift | 15 +- juke-wizard/bin/jukewizard | 32 +- pop/cornerfive/.gitignore | 4 + pop/cornerfive/README.md | 45 ++ pop/cornerfive/bin/cornerfive.mjs | 238 ++++++++ pop/cornerfive/bin/sync-whistle.mjs | 126 +++++ pop/cornerfive/src/whistle-synced.notes.json | 257 +++++++++ pop/cornerfive/src/whistle.analysis.json | 506 ++++++++++++++++++ toolchain/whistlegraph/README.md | 35 ++ toolchain/whistlegraph/analyze.py | 119 ++++ toolchain/whistlegraph/downloads/.gitignore | 7 + toolchain/whistlegraph/downloads/INDEX.json | 22 + ...tlegraph-7651341048442047775.analysis.json | 506 ++++++++++++++++++ toolchain/whistlegraph/grab.mjs | 153 ++++++ 16 files changed, 2102 insertions(+), 10 deletions(-) create mode 100644 .claude/skills/whistlegraph-pop/SKILL.md create mode 100644 pop/cornerfive/.gitignore create mode 100644 pop/cornerfive/README.md create mode 100644 pop/cornerfive/bin/cornerfive.mjs create mode 100644 pop/cornerfive/bin/sync-whistle.mjs create mode 100644 pop/cornerfive/src/whistle-synced.notes.json create mode 100644 pop/cornerfive/src/whistle.analysis.json create mode 100644 toolchain/whistlegraph/README.md create mode 100644 toolchain/whistlegraph/analyze.py create mode 100644 toolchain/whistlegraph/downloads/.gitignore create mode 100644 toolchain/whistlegraph/downloads/INDEX.json create mode 100644 toolchain/whistlegraph/downloads/whistlegraph-7651341048442047775.analysis.json create mode 100644 toolchain/whistlegraph/grab.mjs diff --git a/.claude/skills/whistlegraph-pop/SKILL.md b/.claude/skills/whistlegraph-pop/SKILL.md new file mode 100644 index 000000000..c4229ecc2 --- /dev/null +++ b/.claude/skills/whistlegraph-pop/SKILL.md @@ -0,0 +1,35 @@ +--- +name: whistlegraph-pop +description: Download a whistlegraph TikTok and read its musicality (tempo, key, whistled-melody note sequence) as the front of the /pop track pipeline. Use when @jeffrey says "grab this whistlegraph", "analyze this tiktok's audio", "turn this whistlegraph into a pop track", or pastes a tiktok.com/@whistlegraph link. +--- + +# whistlegraph → /pop + +Turns a whistled TikTok into a musical readout you can compose a /pop +track from. Tooling lives in `toolchain/whistlegraph/` (see its README). + +## Steps + +1. **Grab + analyze.** From a tiktok URL (or `--latest N` from the + @whistlegraph account): + ```bash + node toolchain/whistlegraph/grab.mjs + ``` + This downloads the mp4, extracts a mono WAV, and runs `analyze.py` + (pop `.venv` librosa) → tempo, key/scale, whistled-melody note + sequence, onsets → `downloads/.analysis.json` + `INDEX.json`. + +2. **Read the melody musically.** The pyin track quantizes a portamento + whistle, so expect neighbor-note jitter (the `centsOff` column shows + the glides) — read the *skeleton*, not every blip. Identify the hook, + its repeats (titles like "… x2" mean the phrase repeats), the longest + held notes (structural anchors), and the cadence. + +3. **Hand off to /pop.** Use `key`, `tempoBPM`, and `melody[]` to compose + **bottom-up from AC instruments — never Suno end-to-end** (see + `pop/SCORE.md`, `pop/lib/`). The whistled line is the lead/topline. + +## Notes +- `*.mp4`/`*.wav` are gitignored (reproducible from URL); `*.analysis.json` + and `INDEX.json` are tracked. +- `node toolchain/whistlegraph/grab.mjs --list` summarizes the corpus. diff --git a/juke-wizard/Sources/JukeWizard/JukeController.swift b/juke-wizard/Sources/JukeWizard/JukeController.swift index 1736ef57f..e9b7cdc4f 100644 --- a/juke-wizard/Sources/JukeWizard/JukeController.swift +++ b/juke-wizard/Sources/JukeWizard/JukeController.swift @@ -30,6 +30,7 @@ final class JukeController: NSWindowController, NSWindowDelegate, WaveformViewDelegate, NSTextViewDelegate { let library: Library let watchDirs: [String] + let selectPath: String? var current: Int = -1 var watchTimer: Timer? var watchMtimes: [String: Date] = [:] @@ -54,9 +55,10 @@ final class JukeController: NSWindowController, NSWindowDelegate, let sidebarW: CGFloat = 290 - init(library: Library, watch: [String]) { + init(library: Library, watch: [String], select selectArg: String? = nil) { self.library = library self.watchDirs = watch + self.selectPath = selectArg let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 1120, height: 720), styleMask: [.titled, .closable, .miniaturizable, .resizable], @@ -68,7 +70,13 @@ final class JukeController: NSWindowController, NSWindowDelegate, window.delegate = self setupUI() relayout() - if !library.tracks.isEmpty { select(0, autoplay: false) } + // open on the requested track (and play it) if given; else the top. + if let sp = selectPath { + let want = URL(fileURLWithPath: (sp as NSString).expandingTildeInPath).standardizedFileURL.path + if let idx = library.tracks.firstIndex(where: { $0.url.standardizedFileURL.path == want }) { + select(idx, autoplay: true) + } else if !library.tracks.isEmpty { select(0, autoplay: false) } + } else if !library.tracks.isEmpty { select(0, autoplay: false) } armWatch() installKeyMonitor() } diff --git a/juke-wizard/Sources/JukeWizard/main.swift b/juke-wizard/Sources/JukeWizard/main.swift index efe78dc17..b0e8fa182 100644 --- a/juke-wizard/Sources/JukeWizard/main.swift +++ b/juke-wizard/Sources/JukeWizard/main.swift @@ -20,9 +20,11 @@ final class JukeAppDelegate: NSObject, NSApplicationDelegate { let args = Array(CommandLine.arguments.dropFirst()) var watch: [String] = [] var paths: [String] = [] + var selectPath: String? = nil 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 } paths.append(args[i]); i += 1 } if paths.isEmpty { @@ -30,12 +32,21 @@ final class JukeAppDelegate: NSObject, NSApplicationDelegate { if FileManager.default.fileExists(atPath: master) { paths = [master] } } let library = Library(inputs: paths) + // Make sure the requested track is in the queue even if it's a draft + // not yet in the library index — so we can always select + play it. + if let sp = selectPath { + let u = URL(fileURLWithPath: (sp as NSString).expandingTildeInPath) + if !library.tracks.contains(where: { $0.url.standardizedFileURL.path == u.standardizedFileURL.path }) { + let lane = u.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent + library.addFile(u, lane: lane) + } + } guard !library.tracks.isEmpty else { print("JukeWizard: no tracks found.") - print("usage: jukewizard [ ...] [--watch ]") + print("usage: jukewizard [ ...] [--select ] [--watch ]") NSApp.terminate(nil); return } - controller = JukeController(library: library, watch: watch) + controller = JukeController(library: library, watch: watch, select: selectPath) controller?.showWindow(nil) NSApp.activate(ignoringOtherApps: true) } diff --git a/juke-wizard/bin/jukewizard b/juke-wizard/bin/jukewizard index a3368999d..ad47755b5 100755 --- a/juke-wizard/bin/jukewizard +++ b/juke-wizard/bin/jukewizard @@ -1,9 +1,15 @@ #!/bin/sh # juke-wizard/bin/jukewizard — build (cached) + launch JukeWizard. # +# JukeWizard is the MASTER JUKE: it ALWAYS loads the whole /pop library so +# every track is visible. Open it with no args to browse, or pass a specific +# track to jump straight to it — the full library still loads underneath. +# # usage: -# juke-wizard/bin/jukewizard [ ...] [--watch ] -# (no args → opens ~/Desktop/MASTER-playlist.m3u8 if present) +# juke-wizard/bin/jukewizard # browse the whole /pop library +# juke-wizard/bin/jukewizard # load library + select/play that track +# juke-wizard/bin/jukewizard # load library + add these too +# juke-wizard/bin/jukewizard ... --watch # auto-pop new renders # # Runs from anywhere; resolves the package from its own location. set -e @@ -11,9 +17,23 @@ HERE="$(cd "$(dirname "$0")" && pwd)" WIZ="$HERE/.." REPO="$(cd "$WIZ/.." && pwd)" swift build -c release --package-path "$WIZ" 2>&1 | grep -v '^$' | tail -2 || true -# default: open the whole /pop library (status + metadata). Pass args to override. + +BIN="$WIZ/.build/release/JukeWizard" +LIB="$REPO/pop/out/pop-library.json" +node "$REPO/pop/bin/pop-library.mjs" >/dev/null 2>&1 || true # refresh the index + +# No args → just the master library. if [ "$#" -eq 0 ]; then - node "$REPO/pop/bin/pop-library.mjs" >/dev/null 2>&1 || true # refresh the index - exec "$WIZ/.build/release/JukeWizard" "$REPO/pop/out/pop-library.json" + exec "$BIN" "$LIB" +fi + +# A specific audio file → load the master library AND select/play it +# (absolutized so it matches the library entry; added to the queue if it's a +# fresh draft not in the index yet). +if [ -f "$1" ]; then + case "$1" in /*) SEL="$1";; *) SEL="$(pwd)/$1";; esac + exec "$BIN" "$LIB" "$@" --select "$SEL" fi -exec "$WIZ/.build/release/JukeWizard" "$@" + +# A folder / playlist / flags → load the master library and add them too. +exec "$BIN" "$LIB" "$@" diff --git a/pop/cornerfive/.gitignore b/pop/cornerfive/.gitignore new file mode 100644 index 000000000..52560eb5d --- /dev/null +++ b/pop/cornerfive/.gitignore @@ -0,0 +1,4 @@ +# media — reproducible from code + the whistlegraph URL +out/ +src/*.wav +!src/*.analysis.json diff --git a/pop/cornerfive/README.md b/pop/cornerfive/README.md new file mode 100644 index 000000000..e6b7c50ee --- /dev/null +++ b/pop/cornerfive/README.md @@ -0,0 +1,45 @@ +# cornerfive + +A /pop remix built around the whistlegraph **"five in the corner x2"** +([tiktok](https://www.tiktok.com/@whistlegraph/video/7651341048442047775)). +The whistle *is* the song — kick + a sine-bell "sister" line + the vocal, +nothing else. + +## Source + +- `src/whistle.wav` — the whistle stem (mono 44.1k, pulled via + `toolchain/whistlegraph/grab.mjs`, gitignored) +- `src/whistle.analysis.json` — librosa readout (tempo, onsets, the + observed melody with per-note pitch + cents) + +## Pipeline + +```bash +node bin/sync-whistle.mjs # beat-align + shift the whistle → src/whistle-synced.wav +node bin/cornerfive.mjs # kick + sister bells + looped vocal → out/cornerfive.mp3 +``` + +**1. `sync-whistle.mjs`** — keeps the whistle's **own observed melody** +(no key-snapping, no WORLD resynth). Per note it uses rubberband +(pitch-preserving) to: +- **beat-align** — stretch each onset onto the 16th-note grid +- **shift** — nudge each note into tune by its detected cents + +Writes `src/whistle-synced.wav` + `src/whistle-synced.notes.json` (the +melody the sister line follows). + +**2. `cornerfive.mjs`** — renders `out/cornerfive.mp3`, **1:24** at +**73.2 BPM**: +- **kick** — four-on-the-floor (+ a push on the "and of 3"), whole track +- **sine bells** — pure-sine bell + soft octave-down sub, playing the + vocal's exact melody like a "sister", read from the notes sidecar +- **vocal** — the synced whistle looped end-to-end through the whole + track, high-passed + bright, light reverb, sidechain-ducked under the + kick +- `--raw` falls back to the untouched original whistle recording + +## Notes + +`out/` and `src/*.wav` are media (gitignored, reproducible from the +whistlegraph URL + the scripts); the `*.json` melody/analysis sidecars and +the code are the source of truth. diff --git a/pop/cornerfive/bin/cornerfive.mjs b/pop/cornerfive/bin/cornerfive.mjs new file mode 100644 index 000000000..b2721c9eb --- /dev/null +++ b/pop/cornerfive/bin/cornerfive.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node +// cornerfive.mjs — a /pop remix of the whistlegraph "five in the corner x2". +// +// The whistled TikTok (src/whistle.wav, analyzed in src/whistle.analysis.json) +// is the topline — the "vocal" — and everything under it is composed +// bottom-up from AC instruments: a downtempo boom-bap bed in B major / +// G# minor at 73.2 BPM, chosen so four bars (13.11s) land exactly on the +// length of one whistle pass, for seamless looping. +// +// Bed instruments (all synthesized here, no samples but the whistle): +// • kick — pitched sine drop + click +// • clap — layered noise bursts on the 2 & 4 backbeat +// • hat — high-passed noise, 8ths with offbeat accent +// • bass — lowpassed saw, syncopated roots G#–E–B–F# +// • pad — detuned-saw triads (vi–IV–I–V), slow attack +// • bells — sine arpeggio sparkle in the back half +// The whistle gets a high-pass, slap delay, and a Schroeder reverb, and +// is sidechain-ducked under the kick so it breathes with the groove. +// +// Usage: node bin/cornerfive.mjs (writes out/cornerfive.mp3) + +import { readWavMono } from "../../lib/wav.mjs"; +import { spawnSync } from "node:child_process"; +import { writeFileSync, readFileSync, existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, ".."); +const SRC_SYNCED = resolve(ROOT, "src/whistle-synced.wav"); // rubberband grid+key +const SRC_NOTES = resolve(ROOT, "src/whistle-synced.notes.json"); // the melody +const SRC_RAW = resolve(ROOT, "src/whistle.wav"); // the real recording +const OUT_WAV = resolve(ROOT, "out/cornerfive.wav"); +const OUT_MP3 = resolve(ROOT, "out/cornerfive.mp3"); + +// Default to the SYNCED + tuned whistle (rubberband, pitch-preserving). +// Pass --raw to use the untouched original recording instead. +const WANT_RAW = process.argv.includes("--raw"); + +const SR = 44100; +const BPM = 73.2; +const BEAT = 60 / BPM; // 0.8197 s +const BAR = 4 * BEAT; // 3.2787 s +const STEP = BAR / 16; // 16th note +const TARGET_SEC = 84; // 1:24 +const BARS = Math.ceil(TARGET_SEC / BAR); +const N = Math.ceil(TARGET_SEC * SR); +const mix = new Float32Array(N); + +// ── tiny DSP toolkit ─────────────────────────────────────────────────── +const TAU = Math.PI * 2; +let seed = 0x5f3759df; +function rnd() { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; return ((seed >>> 0) / 0xffffffff) * 2 - 1; } +const clamp = (x, a, b) => (x < a ? a : x > b ? b : x); +const softclip = (x) => Math.tanh(x); +function add(buf, pos, val) { const i = pos | 0; if (i >= 0 && i < buf.length) buf[i] += val; } + +// add a windowed voice rendered by fn(t) -> sample, with AD envelope +function voice(buf, startSec, durSec, attack, release, gain, fn) { + const s0 = Math.floor(startSec * SR); + const len = Math.floor(durSec * SR); + const aN = Math.max(1, Math.floor(attack * SR)); + const rN = Math.max(1, Math.floor(release * SR)); + for (let k = 0; k < len; k++) { + const t = k / SR; + let env = 1; + if (k < aN) env = k / aN; + else if (k > len - rN) env = Math.max(0, (len - k) / rN); + add(buf, s0 + k, fn(t) * env * gain); + } +} + +const noteHz = (m) => 440 * Math.pow(2, (m - 69) / 12); + +// ── kick ───────────────────────────────────────────────────────────────── +function kick(buf, at, gain = 1) { + voice(buf, at, 0.40, 0.001, 0.34, gain, (t) => { + const f = 165 * Math.exp(-t * 38) + 48; // snappy drop → deep sub + const click = Math.exp(-t * 1300) * rnd() * 0.7; // beater click + const body = Math.sin(TAU * f * t + 7 * Math.exp(-t * 55)); + return softclip(body * 1.5) + click; // saturated punch + }); +} + +// ── sine bell — pure sine + soft octave, bell decay (the "sister" line) ── +function bell(buf, at, midi, gain = 0.3) { + const f = noteHz(midi); + voice(buf, at, 1.3, 0.003, 1.25, gain, (t) => { + const e = Math.exp(-t * 3.0); + return (Math.sin(TAU * f * t) + 0.3 * Math.sin(TAU * 2 * f * t) * Math.exp(-t * 6)) * e; + }); + // gentle sub an octave down for a little low-end body (not boomy) + voice(buf, at, 0.9, 0.003, 0.85, gain * 0.35, (t) => Math.sin(TAU * (f / 2) * t) * Math.exp(-t * 4)); +} + +// ── kick across the whole track ───────────────────────────────────────── +const KICK = [1,0,0,0, 1,0,0,0, 1,0,1,0, 1,0,0,0]; // four-on-floor + push on "and of 3" +const kickTimes = []; +for (let bar = 0; bar < BARS; bar++) { + for (let s = 0; s < 16; s++) { + if (!KICK[s]) continue; + const at = bar * BAR + s * STEP; + if (at >= TARGET_SEC) break; + kick(mix, at, 1.0); kickTimes.push(at); + } +} + +// ── the whistle "vocal": tuned → time-snapped → up-front reverb ───────── +const useSynced = !WANT_RAW && existsSync(SRC_SYNCED); +console.log(useSynced + ? "→ loading SYNCED whistle (rubberband grid-sync + autotuned to its own melody)…" + : "→ loading ORIGINAL whistle (true to the recording)…"); +const { samples: wSrc, sampleRate: wSr } = readWavMono(useSynced ? SRC_SYNCED : SRC_RAW); +// resample to SR if needed (linear) +let wSamples = wSrc; +if (wSr !== SR) { + const ratio = SR / wSr, out = new Float32Array(Math.floor(wSrc.length * ratio)); + for (let i = 0; i < out.length; i++) { + const x = i / ratio, i0 = x | 0, f = x - i0; + out[i] = (wSrc[i0] || 0) * (1 - f) + (wSrc[i0 + 1] || 0) * f; + } + wSamples = out; +} + +// The tuned whistle is already time-warped onto the grid + pitch-snapped by +// bin/tune-whistle.mjs — one continuous stream, no slicing here (that's what +// kept it buttery instead of choppy). Just place it as-is. +const w = wSamples; + +// normalize + high-pass (rumble out) + high-shelf air (clarity / presence) +let peak = 0; for (const v of w) peak = Math.max(peak, Math.abs(v)); +const wg = peak > 0 ? 0.9 / peak : 1; +const wet = new Float32Array(w.length); +{ + let prev = 0, hp = 0, lp = 0; + const a = Math.exp(-TAU * 250 / SR); + for (let i = 0; i < w.length; i++) { + const x = w[i] * wg; + hp = a * (hp + x - prev); prev = x; // one-pole high-pass ~250 Hz + lp += 0.25 * (hp - lp); // split lows/highs + const high = hp - lp; // upper band + wet[i] = hp + 0.7 * high; // +high-shelf → clearer, more present + } +} +// Schroeder reverb (4 combs + 2 allpass), summed wet+dry +function reverb(input, mixWet = 0.32) { + const combs = [1116, 1188, 1277, 1356].map((d) => Math.floor(d * SR / 44100)); + const fb = 0.78; + const allp = [556, 441].map((d) => Math.floor(d * SR / 44100)); + const cb = combs.map((d) => new Float32Array(d)); + const cbi = combs.map(() => 0); + const ab = allp.map((d) => new Float32Array(d)); + const abi = allp.map(() => 0); + const out = new Float32Array(input.length + SR); // tail + for (let i = 0; i < out.length; i++) { + const x = i < input.length ? input[i] : 0; + let acc = 0; + for (let c = 0; c < cb.length; c++) { + const buf = cb[c]; const idx = cbi[c]; + const y = buf[idx]; buf[idx] = x + y * fb; cbi[c] = (idx + 1) % buf.length; acc += y; + } + acc /= cb.length; + for (let a = 0; a < ab.length; a++) { + const buf = ab[a]; const idx = abi[a]; + const y = buf[idx]; const z = -0.5 * acc + y; buf[idx] = acc + 0.5 * y; + abi[a] = (idx + 1) % buf.length; acc = z; + } + out[i] = (i < input.length ? input[i] : 0) * (1 - mixWet) + acc * mixWet; + } + return out; +} +const whistle = reverb(wet, 0.10); // nearly dry → clearest +// slap delay (one tap, ~1/8 note) — very subtle +const slap = Math.floor(STEP * 2 * SR); +for (let i = whistle.length - 1; i >= slap; i--) whistle[i] += whistle[i - slap] * 0.09; + +// sidechain envelope: the VOCAL ducks under every kick, so the whistle +// pumps with the beat. 1 = open, dips to 0.4 on each kick then recovers. +const duck = new Float32Array(N).fill(1); +const dDur = Math.floor(0.18 * SR); +for (const kt of kickTimes) { + const s0 = Math.floor(kt * SR); + for (let k = 0; k < dDur; k++) { + const i = s0 + k; if (i >= N) break; + const d = 0.40 + 0.60 * (k / dDur); // dip to -8dB then recover + if (d < duck[i]) duck[i] = d; + } +} + +// loop the whistle through the WHOLE track, ducked under the kick; the sine +// bells follow the same melody, an octave-spanning "sister" line. +const loopLen = w.length / SR; // one synced pass (~13.1s) +const melody = useSynced && existsSync(SRC_NOTES) + ? JSON.parse(readFileSync(SRC_NOTES, "utf8")).notes : []; +for (let t0 = 0; t0 < TARGET_SEC; t0 += loopLen) { + const off = Math.floor(t0 * SR); + for (let i = 0; i < whistle.length; i++) { + const di = off + i; if (di >= N) break; + add(mix, di, whistle[i] * 1.2 * duck[di]); + } + for (const nt of melody) { // sister bells track the vocal + const at = t0 + nt.startSec; + if (at >= TARGET_SEC) break; + bell(mix, at, nt.midi, 0.26); + } +} + +// ── master: soft-clip, normalize, write WAV ───────────────────────────── +let mpeak = 0; for (let i = 0; i < N; i++) { mix[i] = softclip(mix[i] * 1.1); mpeak = Math.max(mpeak, Math.abs(mix[i])); } +const norm = mpeak > 0 ? 0.97 / mpeak : 1; +// gentle fade in/out +const fadeN = Math.floor(0.5 * SR); +function writeWav(path, data) { + const n = data.length, buf = Buffer.alloc(44 + n * 2); + buf.write("RIFF", 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write("WAVE", 8); + buf.write("fmt ", 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); + buf.writeUInt16LE(1, 22); buf.writeUInt32LE(SR, 24); buf.writeUInt32LE(SR * 2, 28); + buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34); buf.write("data", 36); buf.writeUInt32LE(n * 2, 40); + for (let i = 0; i < n; i++) { + let v = data[i] * norm; + if (i < fadeN) v *= i / fadeN; + if (i > n - fadeN) v *= (n - i) / fadeN; + buf.writeInt16LE(Math.round(clamp(v, -1, 1) * 32767), 44 + i * 2); + } + writeFileSync(path, buf); +} +writeWav(OUT_WAV, mix); +console.log(`✓ wav → ${OUT_WAV.replace(process.env.HOME, "~")} (${(N / SR).toFixed(1)}s)`); + +// ── encode mp3 (loudnorm to streaming target) ─────────────────────────── +console.log("→ ffmpeg → mp3…"); +const r = spawnSync("ffmpeg", [ + "-hide_banner", "-loglevel", "error", "-y", "-i", OUT_WAV, + "-af", "loudnorm=I=-11:TP=-1.0:LRA=11", + "-c:a", "libmp3lame", "-b:a", "320k", OUT_MP3, +], { stdio: "inherit" }); +if (r.status !== 0) { console.error("✗ ffmpeg failed"); process.exit(1); } +console.log(`✓ mp3 → ${OUT_MP3.replace(process.env.HOME, "~")}`); diff --git a/pop/cornerfive/bin/sync-whistle.mjs b/pop/cornerfive/bin/sync-whistle.mjs new file mode 100644 index 000000000..8b11a0ffd --- /dev/null +++ b/pop/cornerfive/bin/sync-whistle.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +// sync-whistle.mjs — lock the whistle to cornerfive's grid AND key, with +// rubberband (pitch-preserving). For each detected note we: +// • stretch it (rubberband --time) so its onset→next-onset span lands +// exactly on the 16th-note grid → tight sync, no drift +// • pitch-shift it (rubberband --pitch) onto the nearest B-major tone +// rubberband preserves timbre/formants, so it stays true to the whistle +// while being synced + tuned — no WORLD resynth, no choppy reglue. +// +// Output: src/whistle-synced.wav + src/whistle-synced.notes.json (the grid +// onsets + pitches, so the remix renderer can chop on phrase boundaries). +// +// Usage: node bin/sync-whistle.mjs [--quantize 16|8] + +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { readWavMono } from "../../lib/wav.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, ".."); +const ANALYSIS = resolve(ROOT, "src/whistle.analysis.json"); +const IN_WAV = resolve(ROOT, "src/whistle.wav"); +const OUT_WAV = resolve(ROOT, "src/whistle-synced.wav"); // beat-aligned + shifted +const OUT_NOTES = resolve(ROOT, "src/whistle-synced.notes.json"); +const TMP = resolve(ROOT, "src/.slices"); + +const BPM = 73.2; +const STEP = (4 * 60 / BPM) / 16; + +const flags = {}; +for (let i = 2; i < process.argv.length; i++) + if (process.argv[i].startsWith("--")) flags[process.argv[i].slice(2)] = process.argv[i + 1]; +const QGRID = STEP * (flags.quantize === "8" ? 2 : 1); // quantize unit + +// Autotune target = the whistle's OWN observed melody (each note locked to +// the nearest semitone of what it actually whistled — chromatic, no key). +const NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; +const nameOf = (m) => `${NAMES[((m % 12) + 12) % 12]}${Math.floor(m / 12) - 1}`; + +function writeWav(path, samples, sr) { + const n = samples.length, buf = Buffer.alloc(44 + n * 2); + buf.write("RIFF", 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write("WAVE", 8); + buf.write("fmt ", 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); + buf.writeUInt16LE(1, 22); buf.writeUInt32LE(sr, 24); buf.writeUInt32LE(sr * 2, 28); + buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34); buf.write("data", 36); buf.writeUInt32LE(n * 2, 40); + for (let i = 0; i < n; i++) buf.writeInt16LE(Math.round(Math.max(-1, Math.min(1, samples[i])) * 32767), 44 + i * 2); + writeFileSync(path, buf); +} + +// ── merge adjacent same-pitch notes (keep the ORIGINAL melody) ────────── +const { melody } = JSON.parse(readFileSync(ANALYSIS, "utf8")); +const notes = []; +for (const n of melody) { + const tgt = n.midi; // its OWN observed pitch + const last = notes[notes.length - 1]; + if (last && last.tgt === tgt) continue; + notes.push({ tgt, src: n.startSec, cents: n.centsOff || 0 }); +} + +// grid-quantize onsets, strictly increasing +let prev = -Infinity; +for (const n of notes) { + let q = Math.round(n.src / QGRID) * QGRID; + if (q <= prev) q = prev + QGRID; + n.dst = q; + prev = q; +} + +const { samples: x, sampleRate: SR } = readWavMono(IN_WAV); +const srcDur = x.length / SR; +const lastSeg = Math.max(STEP, Math.round((srcDur - notes[notes.length - 1].src) / QGRID) * QGRID); +const dstDurTotal = notes[notes.length - 1].dst + lastSeg; + +rmSync(TMP, { recursive: true, force: true }); +mkdirSync(TMP, { recursive: true }); + +const synced = new Float32Array(Math.ceil(dstDurTotal * SR) + SR); +const xf = Math.floor(0.008 * SR); // 8ms join crossfade + +// ── rubberband each note: beat-align (stretch to grid) + shift (tune to +// the semitone center via cents). Keeps the ORIGINAL melody/contour — no +// WORLD f0-replace, no flattening. ──────────────────────────────────────── +console.log(`→ beat-aligning + shifting ${notes.length} notes (rubberband, original melody)…`); +for (let i = 0; i < notes.length; i++) { + const srcStart = notes[i].src; + const srcEnd = i + 1 < notes.length ? notes[i + 1].src : srcDur; + const dstStart = notes[i].dst; + const dstEnd = i + 1 < notes.length ? notes[i + 1].dst : dstDurTotal; + const sLen = Math.max(0.02, srcEnd - srcStart); + const dLen = Math.max(0.02, dstEnd - dstStart); + + const a = Math.floor(srcStart * SR), b = Math.min(x.length, Math.floor(srcEnd * SR)); + const slice = x.subarray(a, b); + const inP = resolve(TMP, `in-${i}.wav`), outP = resolve(TMP, `out-${i}.wav`); + writeWav(inP, slice, SR); + + const ratio = Math.max(0.25, Math.min(4, dLen / sLen)); // beat-align + const shift = (-notes[i].cents / 100).toFixed(3); // nudge into tune + const r = spawnSync("rubberband", [ + "--time", ratio.toFixed(4), "--pitch", shift, "-q", inP, outP, + ], { encoding: "utf8" }); + let piece; + if (r.status === 0) piece = readWavMono(outP).samples; + else { console.warn(` ⚠ rubberband note ${i} failed, using dry slice`); piece = slice; } + + const off = Math.floor(dstStart * SR); + for (let k = 0; k < piece.length; k++) { + let g = 1; + if (k < xf) g = k / xf; + if (piece.length - k < xf) g = (piece.length - k) / xf; + const di = off + k; if (di >= synced.length) break; + synced[di] += piece[k] * g; + } +} +rmSync(TMP, { recursive: true, force: true }); +writeWav(OUT_WAV, synced, SR); + +writeFileSync(OUT_NOTES, JSON.stringify({ + tuning: "original melody (beat-aligned + cents-shifted)", bpm: BPM, + quantize: flags.quantize === "8" ? 8 : 16, + notes: notes.map((n) => ({ note: nameOf(n.tgt), midi: n.tgt, startSec: +n.dst.toFixed(3) })), +}, null, 2) + "\n"); +console.log(`✓ beat-aligned + shifted → ${OUT_WAV.replace(process.env.HOME, "~")} (${dstDurTotal.toFixed(1)}s)`); +console.log(` ${notes.map((n) => nameOf(n.tgt)).join(" ")}`); diff --git a/pop/cornerfive/src/whistle-synced.notes.json b/pop/cornerfive/src/whistle-synced.notes.json new file mode 100644 index 000000000..b746b0c21 --- /dev/null +++ b/pop/cornerfive/src/whistle-synced.notes.json @@ -0,0 +1,257 @@ +{ + "tuning": "original melody (beat-aligned + cents-shifted)", + "bpm": 73.2, + "quantize": 16, + "notes": [ + { + "note": "D4", + "midi": 62, + "startSec": 0 + }, + { + "note": "B3", + "midi": 59, + "startSec": 0.205 + }, + { + "note": "C4", + "midi": 60, + "startSec": 0.41 + }, + { + "note": "B3", + "midi": 59, + "startSec": 0.615 + }, + { + "note": "C4", + "midi": 60, + "startSec": 0.82 + }, + { + "note": "E4", + "midi": 64, + "startSec": 1.025 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 1.23 + }, + { + "note": "F4", + "midi": 65, + "startSec": 2.049 + }, + { + "note": "F#4", + "midi": 66, + "startSec": 2.254 + }, + { + "note": "D4", + "midi": 62, + "startSec": 2.869 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 3.484 + }, + { + "note": "D4", + "midi": 62, + "startSec": 3.893 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 4.098 + }, + { + "note": "E4", + "midi": 64, + "startSec": 4.098 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 4.303 + }, + { + "note": "E4", + "midi": 64, + "startSec": 4.508 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 4.713 + }, + { + "note": "D4", + "midi": 62, + "startSec": 4.918 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 5.123 + }, + { + "note": "D4", + "midi": 62, + "startSec": 5.328 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 5.533 + }, + { + "note": "B3", + "midi": 59, + "startSec": 5.738 + }, + { + "note": "C4", + "midi": 60, + "startSec": 5.943 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 6.148 + }, + { + "note": "B3", + "midi": 59, + "startSec": 6.352 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 6.557 + }, + { + "note": "B3", + "midi": 59, + "startSec": 6.762 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 6.967 + }, + { + "note": "B3", + "midi": 59, + "startSec": 7.172 + }, + { + "note": "C4", + "midi": 60, + "startSec": 7.377 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 7.582 + }, + { + "note": "C4", + "midi": 60, + "startSec": 7.787 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 7.992 + }, + { + "note": "G3", + "midi": 55, + "startSec": 8.197 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 8.402 + }, + { + "note": "B3", + "midi": 59, + "startSec": 8.607 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 8.811 + }, + { + "note": "B3", + "midi": 59, + "startSec": 9.016 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 9.221 + }, + { + "note": "F4", + "midi": 65, + "startSec": 9.836 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 10.246 + }, + { + "note": "D4", + "midi": 62, + "startSec": 10.451 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 10.656 + }, + { + "note": "D4", + "midi": 62, + "startSec": 11.27 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 11.475 + }, + { + "note": "D4", + "midi": 62, + "startSec": 11.885 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 12.09 + }, + { + "note": "D4", + "midi": 62, + "startSec": 12.295 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 12.5 + }, + { + "note": "G3", + "midi": 55, + "startSec": 12.91 + } + ] +} diff --git a/pop/cornerfive/src/whistle.analysis.json b/pop/cornerfive/src/whistle.analysis.json new file mode 100644 index 000000000..4d6215dfb --- /dev/null +++ b/pop/cornerfive/src/whistle.analysis.json @@ -0,0 +1,506 @@ +{ + "durationSec": 13.12, + "tempoBPM": 74.9, + "key": "D# minor", + "keyConfidence": 0.467, + "beatCount": 12, + "onsetCount": 48, + "noteCount": 63, + "pitchRange": [ + "G3", + "G#4" + ], + "melody": [ + { + "note": "D4", + "midi": 62, + "startSec": 0.023, + "durSec": 0.058, + "centsOff": -4.1 + }, + { + "note": "B3", + "midi": 59, + "startSec": 0.226, + "durSec": 0.145, + "centsOff": 13.8 + }, + { + "note": "C4", + "midi": 60, + "startSec": 0.377, + "durSec": 0.058, + "centsOff": -12.3 + }, + { + "note": "B3", + "midi": 59, + "startSec": 0.627, + "durSec": 0.058, + "centsOff": 8.6 + }, + { + "note": "C4", + "midi": 60, + "startSec": 0.755, + "durSec": 0.25, + "centsOff": 10.2 + }, + { + "note": "E4", + "midi": 64, + "startSec": 1.097, + "durSec": 0.075, + "centsOff": -6.4 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 1.178, + "durSec": 0.058, + "centsOff": -4.1 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 1.265, + "durSec": 0.052, + "centsOff": -37.0 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 1.341, + "durSec": 0.093, + "centsOff": -2.1 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 1.457, + "durSec": 0.395, + "centsOff": 7.0 + }, + { + "note": "F4", + "midi": 65, + "startSec": 2.096, + "durSec": 0.116, + "centsOff": 32.1 + }, + { + "note": "F#4", + "midi": 66, + "startSec": 2.218, + "durSec": 0.505, + "centsOff": 0.1 + }, + { + "note": "D4", + "midi": 62, + "startSec": 2.92, + "durSec": 0.151, + "centsOff": -6.5 + }, + { + "note": "D4", + "midi": 62, + "startSec": 3.123, + "durSec": 0.192, + "centsOff": 15.3 + }, + { + "note": "D4", + "midi": 62, + "startSec": 3.367, + "durSec": 0.197, + "centsOff": 17.9 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 3.57, + "durSec": 0.058, + "centsOff": -2.3 + }, + { + "note": "D4", + "midi": 62, + "startSec": 3.872, + "durSec": 0.052, + "centsOff": -6.0 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 3.93, + "durSec": 0.099, + "centsOff": -3.9 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 4.069, + "durSec": 0.075, + "centsOff": 31.4 + }, + { + "note": "E4", + "midi": 64, + "startSec": 4.151, + "durSec": 0.093, + "centsOff": 3.8 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 4.249, + "durSec": 0.058, + "centsOff": 24.1 + }, + { + "note": "E4", + "midi": 64, + "startSec": 4.313, + "durSec": 0.081, + "centsOff": -29.0 + }, + { + "note": "E4", + "midi": 64, + "startSec": 4.452, + "durSec": 0.093, + "centsOff": 13.2 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 4.551, + "durSec": 0.122, + "centsOff": -14.5 + }, + { + "note": "D4", + "midi": 62, + "startSec": 4.778, + "durSec": 0.11, + "centsOff": -34.5 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 4.894, + "durSec": 0.075, + "centsOff": 37.9 + }, + { + "note": "D4", + "midi": 62, + "startSec": 4.975, + "durSec": 0.064, + "centsOff": -26.7 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 5.045, + "durSec": 0.145, + "centsOff": 18.5 + }, + { + "note": "B3", + "midi": 59, + "startSec": 5.23, + "durSec": 0.058, + "centsOff": 32.3 + }, + { + "note": "C4", + "midi": 60, + "startSec": 5.294, + "durSec": 0.221, + "centsOff": 2.4 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 5.712, + "durSec": 0.058, + "centsOff": 6.8 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 5.799, + "durSec": 0.087, + "centsOff": 6.2 + }, + { + "note": "B3", + "midi": 59, + "startSec": 5.892, + "durSec": 0.058, + "centsOff": -37.7 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 5.956, + "durSec": 0.104, + "centsOff": -2.9 + }, + { + "note": "B3", + "midi": 59, + "startSec": 6.066, + "durSec": 0.197, + "centsOff": -21.3 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 6.269, + "durSec": 0.075, + "centsOff": 30.7 + }, + { + "note": "B3", + "midi": 59, + "startSec": 6.351, + "durSec": 0.226, + "centsOff": -8.3 + }, + { + "note": "C4", + "midi": 60, + "startSec": 6.583, + "durSec": 0.261, + "centsOff": 2.0 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 6.85, + "durSec": 0.07, + "centsOff": -28.9 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 6.931, + "durSec": 0.163, + "centsOff": -17.8 + }, + { + "note": "C4", + "midi": 60, + "startSec": 7.1, + "durSec": 0.087, + "centsOff": 28.7 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 7.541, + "durSec": 0.104, + "centsOff": -9.2 + }, + { + "note": "G3", + "midi": 55, + "startSec": 7.697, + "durSec": 0.058, + "centsOff": 35.0 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 7.802, + "durSec": 0.157, + "centsOff": 15.7 + }, + { + "note": "B3", + "midi": 59, + "startSec": 7.964, + "durSec": 0.075, + "centsOff": -31.4 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 8.046, + "durSec": 0.122, + "centsOff": 36.4 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 8.185, + "durSec": 0.331, + "centsOff": 9.3 + }, + { + "note": "B3", + "midi": 59, + "startSec": 8.522, + "durSec": 0.54, + "centsOff": 9.7 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 9.131, + "durSec": 0.203, + "centsOff": 11.9 + }, + { + "note": "F4", + "midi": 65, + "startSec": 9.787, + "durSec": 0.325, + "centsOff": -1.8 + }, + { + "note": "F4", + "midi": 65, + "startSec": 10.124, + "durSec": 0.075, + "centsOff": -2.9 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 10.321, + "durSec": 0.11, + "centsOff": 13.5 + }, + { + "note": "D4", + "midi": 62, + "startSec": 10.437, + "durSec": 0.087, + "centsOff": -25.0 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 10.53, + "durSec": 0.401, + "centsOff": 12.1 + }, + { + "note": "D4", + "midi": 62, + "startSec": 11.273, + "durSec": 0.11, + "centsOff": 5.0 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 11.389, + "durSec": 0.075, + "centsOff": -37.9 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 11.511, + "durSec": 0.134, + "centsOff": -17.1 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 11.697, + "durSec": 0.11, + "centsOff": -0.5 + }, + { + "note": "D4", + "midi": 62, + "startSec": 11.813, + "durSec": 0.058, + "centsOff": 14.1 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 11.877, + "durSec": 0.279, + "centsOff": -8.7 + }, + { + "note": "D4", + "midi": 62, + "startSec": 12.161, + "durSec": 0.052, + "centsOff": 32.0 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 12.295, + "durSec": 0.331, + "centsOff": -10.3 + }, + { + "note": "G3", + "midi": 55, + "startSec": 12.846, + "durSec": 0.255, + "centsOff": 35.0 + } + ], + "onsetsSec": [ + 0.058, + 0.453, + 0.592, + 1.057, + 1.765, + 1.95, + 2.113, + 2.635, + 2.682, + 2.763, + 2.879, + 2.926, + 3.553, + 3.669, + 3.796, + 4.54, + 4.795, + 5.306, + 5.538, + 5.642, + 6.072, + 6.118, + 6.177, + 6.258, + 6.594, + 6.815, + 6.896, + 6.989, + 7.105, + 7.221, + 7.477, + 7.535, + 7.581, + 8.475, + 8.568, + 8.731, + 9.079, + 9.125, + 9.404, + 9.462, + 10.205, + 10.263, + 10.948, + 11.064, + 11.169, + 11.32, + 12.771, + 12.922 + ] +} \ No newline at end of file diff --git a/toolchain/whistlegraph/README.md b/toolchain/whistlegraph/README.md new file mode 100644 index 000000000..4785ed068 --- /dev/null +++ b/toolchain/whistlegraph/README.md @@ -0,0 +1,35 @@ +# whistlegraph toolchain + +Pull a whistlegraph TikTok and read its musicality — the front of the +pipeline that turns a whistled clip into a [/pop](../../pop/) track. + +## Usage + +```bash +cd toolchain/whistlegraph + +node grab.mjs # download + analyze one clip +node grab.mjs --latest 3 # newest 3 from @whistlegraph +node grab.mjs --account @someone --latest +node grab.mjs --redo # refetch + re-analyze +node grab.mjs --list # summarize downloads/INDEX.json +``` + +Each run: +1. `yt-dlp` → `downloads/-.mp4` +2. `ffmpeg` → mono 44.1k `.wav` +3. `analyze.py` (pop `.venv` librosa) → tempo, key, whistled-melody note + sequence + onsets → `.analysis.json` +4. updates `downloads/INDEX.json` + +## What's tracked + +`*.mp4` / `*.wav` are gitignored (reproducible from the URL). The small +`*.analysis.json` and `INDEX.json` are committed so the melody read +survives without re-pulling from TikTok. + +## → /pop + +Take the `key`, `tempoBPM`, and `melody[]` from the analysis JSON and +compose bottom-up from AC instruments (never Suno end-to-end). See +`pop/SCORE.md` and `pop/lib/`. diff --git a/toolchain/whistlegraph/analyze.py b/toolchain/whistlegraph/analyze.py new file mode 100644 index 000000000..af73aa03b --- /dev/null +++ b/toolchain/whistlegraph/analyze.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# analyze.py — musicality readout for a whistlegraph clip. +# +# Reads a WAV (mono) and prints tempo, key/scale estimate, the whistled +# melody as a note sequence (pyin pitch track quantized to MIDI), and +# onset times. Designed as the "what is this song" pass before porting a +# whistlegraph into a /pop track. +# +# Usage: pop/.venv/bin/python analyze.py [--json out.json] + +import sys, json, argparse +import numpy as np +import librosa + +NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + +def midi_to_name(m): + m = int(round(m)) + return f"{NOTE_NAMES[m % 12]}{m // 12 - 1}" + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("wav") + ap.add_argument("--json") + args = ap.parse_args() + + y, sr = librosa.load(args.wav, sr=None, mono=True) + dur = len(y) / sr + + # ── tempo + beats ──────────────────────────────────────────────── + tempo, beats = librosa.beat.beat_track(y=y, sr=sr, units="time") + tempo = float(np.atleast_1d(tempo)[0]) + + # ── key / scale via chroma (Krumhansl-Schmuckler style profile) ── + chroma = librosa.feature.chroma_cqt(y=y, sr=sr) + chroma_mean = chroma.mean(axis=1) + maj = np.array([6.35,2.23,3.48,2.33,4.38,4.09,2.52,5.19,2.39,3.66,2.29,2.88]) + minr = np.array([6.33,2.68,3.52,5.38,2.60,3.53,2.54,4.75,3.98,2.69,3.34,3.17]) + def best(profile): + scores = [np.corrcoef(np.roll(profile, i), chroma_mean)[0,1] for i in range(12)] + k = int(np.argmax(scores)); return k, scores[k] + kmaj, smaj = best(maj); kmin, smin = best(minr) + if smaj >= smin: + key, scale, kscore = NOTE_NAMES[kmaj], "major", smaj + else: + key, scale, kscore = NOTE_NAMES[kmin], "minor", smin + + # ── whistled melody: pyin pitch track ─────────────────────────── + f0, voiced, vprob = librosa.pyin( + y, sr=sr, fmin=200, fmax=2400, # whistle sits high + frame_length=2048, hop_length=256) + times = librosa.times_like(f0, sr=sr, hop_length=256) + + # quantize voiced frames to MIDI, then collapse runs into notes + notes = [] + cur = None + for t, hz, v in zip(times, f0, voiced): + if not v or np.isnan(hz): + if cur: notes.append(cur); cur = None + continue + m = librosa.hz_to_midi(hz) + q = int(round(m)) + if cur and abs(q - cur["midi"]) == 0: + cur["end"] = float(t); cur["frames"] += 1; cur["cents"].append(float((m - q) * 100)) + else: + if cur: notes.append(cur) + cur = {"midi": q, "start": float(t), "end": float(t), "frames": 1, "cents": [float((m - q) * 100)]} + if cur: notes.append(cur) + # drop blips shorter than ~50ms + notes = [n for n in notes if (n["end"] - n["start"]) >= 0.05] + melody = [{ + "note": midi_to_name(n["midi"]), + "midi": n["midi"], + "startSec": round(n["start"], 3), + "durSec": round(n["end"] - n["start"], 3), + "centsOff": round(float(np.mean(n["cents"])), 1), + } for n in notes] + + # ── onsets ─────────────────────────────────────────────────────── + onset_t = librosa.onset.onset_detect(y=y, sr=sr, units="time", backtrack=True) + + pitched = f0[~np.isnan(f0)] + rng = (librosa.hz_to_midi(np.nanmin(pitched)), librosa.hz_to_midi(np.nanmax(pitched))) if len(pitched) else (None, None) + + out = { + "durationSec": round(dur, 2), + "tempoBPM": round(tempo, 1), + "key": f"{key} {scale}", + "keyConfidence": round(float(kscore), 3), + "beatCount": len(beats), + "onsetCount": len(onset_t), + "noteCount": len(melody), + "pitchRange": [midi_to_name(rng[0]), midi_to_name(rng[1])] if rng[0] else None, + "melody": melody, + "onsetsSec": [round(float(t), 3) for t in onset_t], + } + + # ── pretty print ───────────────────────────────────────────────── + print(f" duration : {out['durationSec']}s") + print(f" tempo : {out['tempoBPM']} BPM ({out['beatCount']} beats)") + print(f" key : {out['key']} (conf {out['keyConfidence']})") + print(f" pitch range: {out['pitchRange'][0]} … {out['pitchRange'][1]}" if out['pitchRange'] else " pitch range: —") + print(f" onsets : {out['onsetCount']}") + print(f" melody : {out['noteCount']} notes") + seq = " ".join(f"{n['note']}" for n in melody) + print(f" {seq}") + print(" ── note timeline ──") + for n in melody: + bar = "▮" * max(1, int(n["durSec"] / 0.06)) + off = f" ({n['centsOff']:+.0f}¢)" if abs(n["centsOff"]) >= 15 else "" + print(f" {n['startSec']:>5.2f}s {n['note']:<4} {n['durSec']:.2f}s {bar}{off}") + + if args.json: + with open(args.json, "w") as f: + json.dump(out, f, indent=2) + print(f" → json {args.json}") + +if __name__ == "__main__": + main() diff --git a/toolchain/whistlegraph/downloads/.gitignore b/toolchain/whistlegraph/downloads/.gitignore new file mode 100644 index 000000000..1925d67cc --- /dev/null +++ b/toolchain/whistlegraph/downloads/.gitignore @@ -0,0 +1,7 @@ +# heavy media — reproducible from URL via grab.mjs +*.mp4 +*.wav +*.m4a +# keep the small analysis + index +!*.analysis.json +!INDEX.json diff --git a/toolchain/whistlegraph/downloads/INDEX.json b/toolchain/whistlegraph/downloads/INDEX.json new file mode 100644 index 000000000..13ef5cefc --- /dev/null +++ b/toolchain/whistlegraph/downloads/INDEX.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "clips": [ + { + "id": "7651341048442047775", + "title": "five in the corner x2", + "account": "whistlegraph", + "url": "https://www.tiktok.com/@whistlegraph/video/7651341048442047775", + "durationSec": 13.12, + "tempoBPM": 74.9, + "key": "D# minor", + "keyConfidence": 0.467, + "noteCount": 63, + "pitchRange": [ + "G3", + "G#4" + ], + "mp4": "~/aesthetic-computer/toolchain/whistlegraph/downloads/whistlegraph-7651341048442047775.mp4", + "analysis": "~/aesthetic-computer/toolchain/whistlegraph/downloads/whistlegraph-7651341048442047775.analysis.json" + } + ] +} diff --git a/toolchain/whistlegraph/downloads/whistlegraph-7651341048442047775.analysis.json b/toolchain/whistlegraph/downloads/whistlegraph-7651341048442047775.analysis.json new file mode 100644 index 000000000..4d6215dfb --- /dev/null +++ b/toolchain/whistlegraph/downloads/whistlegraph-7651341048442047775.analysis.json @@ -0,0 +1,506 @@ +{ + "durationSec": 13.12, + "tempoBPM": 74.9, + "key": "D# minor", + "keyConfidence": 0.467, + "beatCount": 12, + "onsetCount": 48, + "noteCount": 63, + "pitchRange": [ + "G3", + "G#4" + ], + "melody": [ + { + "note": "D4", + "midi": 62, + "startSec": 0.023, + "durSec": 0.058, + "centsOff": -4.1 + }, + { + "note": "B3", + "midi": 59, + "startSec": 0.226, + "durSec": 0.145, + "centsOff": 13.8 + }, + { + "note": "C4", + "midi": 60, + "startSec": 0.377, + "durSec": 0.058, + "centsOff": -12.3 + }, + { + "note": "B3", + "midi": 59, + "startSec": 0.627, + "durSec": 0.058, + "centsOff": 8.6 + }, + { + "note": "C4", + "midi": 60, + "startSec": 0.755, + "durSec": 0.25, + "centsOff": 10.2 + }, + { + "note": "E4", + "midi": 64, + "startSec": 1.097, + "durSec": 0.075, + "centsOff": -6.4 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 1.178, + "durSec": 0.058, + "centsOff": -4.1 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 1.265, + "durSec": 0.052, + "centsOff": -37.0 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 1.341, + "durSec": 0.093, + "centsOff": -2.1 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 1.457, + "durSec": 0.395, + "centsOff": 7.0 + }, + { + "note": "F4", + "midi": 65, + "startSec": 2.096, + "durSec": 0.116, + "centsOff": 32.1 + }, + { + "note": "F#4", + "midi": 66, + "startSec": 2.218, + "durSec": 0.505, + "centsOff": 0.1 + }, + { + "note": "D4", + "midi": 62, + "startSec": 2.92, + "durSec": 0.151, + "centsOff": -6.5 + }, + { + "note": "D4", + "midi": 62, + "startSec": 3.123, + "durSec": 0.192, + "centsOff": 15.3 + }, + { + "note": "D4", + "midi": 62, + "startSec": 3.367, + "durSec": 0.197, + "centsOff": 17.9 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 3.57, + "durSec": 0.058, + "centsOff": -2.3 + }, + { + "note": "D4", + "midi": 62, + "startSec": 3.872, + "durSec": 0.052, + "centsOff": -6.0 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 3.93, + "durSec": 0.099, + "centsOff": -3.9 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 4.069, + "durSec": 0.075, + "centsOff": 31.4 + }, + { + "note": "E4", + "midi": 64, + "startSec": 4.151, + "durSec": 0.093, + "centsOff": 3.8 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 4.249, + "durSec": 0.058, + "centsOff": 24.1 + }, + { + "note": "E4", + "midi": 64, + "startSec": 4.313, + "durSec": 0.081, + "centsOff": -29.0 + }, + { + "note": "E4", + "midi": 64, + "startSec": 4.452, + "durSec": 0.093, + "centsOff": 13.2 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 4.551, + "durSec": 0.122, + "centsOff": -14.5 + }, + { + "note": "D4", + "midi": 62, + "startSec": 4.778, + "durSec": 0.11, + "centsOff": -34.5 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 4.894, + "durSec": 0.075, + "centsOff": 37.9 + }, + { + "note": "D4", + "midi": 62, + "startSec": 4.975, + "durSec": 0.064, + "centsOff": -26.7 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 5.045, + "durSec": 0.145, + "centsOff": 18.5 + }, + { + "note": "B3", + "midi": 59, + "startSec": 5.23, + "durSec": 0.058, + "centsOff": 32.3 + }, + { + "note": "C4", + "midi": 60, + "startSec": 5.294, + "durSec": 0.221, + "centsOff": 2.4 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 5.712, + "durSec": 0.058, + "centsOff": 6.8 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 5.799, + "durSec": 0.087, + "centsOff": 6.2 + }, + { + "note": "B3", + "midi": 59, + "startSec": 5.892, + "durSec": 0.058, + "centsOff": -37.7 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 5.956, + "durSec": 0.104, + "centsOff": -2.9 + }, + { + "note": "B3", + "midi": 59, + "startSec": 6.066, + "durSec": 0.197, + "centsOff": -21.3 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 6.269, + "durSec": 0.075, + "centsOff": 30.7 + }, + { + "note": "B3", + "midi": 59, + "startSec": 6.351, + "durSec": 0.226, + "centsOff": -8.3 + }, + { + "note": "C4", + "midi": 60, + "startSec": 6.583, + "durSec": 0.261, + "centsOff": 2.0 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 6.85, + "durSec": 0.07, + "centsOff": -28.9 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 6.931, + "durSec": 0.163, + "centsOff": -17.8 + }, + { + "note": "C4", + "midi": 60, + "startSec": 7.1, + "durSec": 0.087, + "centsOff": 28.7 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 7.541, + "durSec": 0.104, + "centsOff": -9.2 + }, + { + "note": "G3", + "midi": 55, + "startSec": 7.697, + "durSec": 0.058, + "centsOff": 35.0 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 7.802, + "durSec": 0.157, + "centsOff": 15.7 + }, + { + "note": "B3", + "midi": 59, + "startSec": 7.964, + "durSec": 0.075, + "centsOff": -31.4 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 8.046, + "durSec": 0.122, + "centsOff": 36.4 + }, + { + "note": "A#3", + "midi": 58, + "startSec": 8.185, + "durSec": 0.331, + "centsOff": 9.3 + }, + { + "note": "B3", + "midi": 59, + "startSec": 8.522, + "durSec": 0.54, + "centsOff": 9.7 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 9.131, + "durSec": 0.203, + "centsOff": 11.9 + }, + { + "note": "F4", + "midi": 65, + "startSec": 9.787, + "durSec": 0.325, + "centsOff": -1.8 + }, + { + "note": "F4", + "midi": 65, + "startSec": 10.124, + "durSec": 0.075, + "centsOff": -2.9 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 10.321, + "durSec": 0.11, + "centsOff": 13.5 + }, + { + "note": "D4", + "midi": 62, + "startSec": 10.437, + "durSec": 0.087, + "centsOff": -25.0 + }, + { + "note": "C#4", + "midi": 61, + "startSec": 10.53, + "durSec": 0.401, + "centsOff": 12.1 + }, + { + "note": "D4", + "midi": 62, + "startSec": 11.273, + "durSec": 0.11, + "centsOff": 5.0 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 11.389, + "durSec": 0.075, + "centsOff": -37.9 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 11.511, + "durSec": 0.134, + "centsOff": -17.1 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 11.697, + "durSec": 0.11, + "centsOff": -0.5 + }, + { + "note": "D4", + "midi": 62, + "startSec": 11.813, + "durSec": 0.058, + "centsOff": 14.1 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 11.877, + "durSec": 0.279, + "centsOff": -8.7 + }, + { + "note": "D4", + "midi": 62, + "startSec": 12.161, + "durSec": 0.052, + "centsOff": 32.0 + }, + { + "note": "D#4", + "midi": 63, + "startSec": 12.295, + "durSec": 0.331, + "centsOff": -10.3 + }, + { + "note": "G3", + "midi": 55, + "startSec": 12.846, + "durSec": 0.255, + "centsOff": 35.0 + } + ], + "onsetsSec": [ + 0.058, + 0.453, + 0.592, + 1.057, + 1.765, + 1.95, + 2.113, + 2.635, + 2.682, + 2.763, + 2.879, + 2.926, + 3.553, + 3.669, + 3.796, + 4.54, + 4.795, + 5.306, + 5.538, + 5.642, + 6.072, + 6.118, + 6.177, + 6.258, + 6.594, + 6.815, + 6.896, + 6.989, + 7.105, + 7.221, + 7.477, + 7.535, + 7.581, + 8.475, + 8.568, + 8.731, + 9.079, + 9.125, + 9.404, + 9.462, + 10.205, + 10.263, + 10.948, + 11.064, + 11.169, + 11.32, + 12.771, + 12.922 + ] +} \ No newline at end of file diff --git a/toolchain/whistlegraph/grab.mjs b/toolchain/whistlegraph/grab.mjs new file mode 100644 index 000000000..7099fae64 --- /dev/null +++ b/toolchain/whistlegraph/grab.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +// grab.mjs — download a whistlegraph TikTok and read its musicality, as +// the first step in porting a whistlegraph into a /pop track. +// +// Given a video URL (or the @whistlegraph account), it: +// 1. yt-dlp downloads the mp4 (best quality, no watermark when offered) +// 2. ffmpeg extracts a mono 44.1k WAV for analysis +// 3. analyze.py prints tempo / key / whistled-melody note sequence and +// writes .analysis.json +// 4. updates downloads/INDEX.json so the corpus is browsable +// +// The mp4 / wav are gitignored (downloads/.gitignore); the per-clip +// .analysis.json and INDEX.json are small and tracked so the melody read +// is reproducible without re-pulling from TikTok. +// +// Usage: +// node grab.mjs # one clip +// node grab.mjs --latest [N] # newest N from @whistlegraph (default 1) +// node grab.mjs --account @handle ... # a different account +// node grab.mjs --redo # re-download + re-analyze +// node grab.mjs --list # print INDEX.json summary + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const DOWNLOADS = resolve(HERE, "downloads"); +const INDEX = resolve(DOWNLOADS, "INDEX.json"); +const ANALYZE = resolve(HERE, "analyze.py"); +const PY = resolve(HERE, "../../pop/.venv/bin/python"); +const HOME = process.env.HOME || ""; + +// ── flags ────────────────────────────────────────────────────────────── +const argv = process.argv.slice(2); +const url = argv.find((a) => /^https?:/.test(a)); +const flags = {}; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (!a.startsWith("--")) continue; + const k = a.slice(2), n = argv[i + 1]; + if (n !== undefined && !n.startsWith("--") && !/^https?:/.test(n)) { flags[k] = n; i++; } + else flags[k] = true; +} +const ACCOUNT = typeof flags.account === "string" ? flags.account.replace(/^@/, "") : "whistlegraph"; +const REDO = !!flags.redo; + +function run(cmd, args, opts = {}) { + const r = spawnSync(cmd, args, { stdio: "inherit", ...opts }); + if (r.status !== 0) throw new Error(`${cmd} ${args.join(" ")} → exit ${r.status}`); +} +function capture(cmd, args) { + const r = spawnSync(cmd, args, { encoding: "utf8" }); + if (r.status !== 0) throw new Error(`${cmd} ${args.join(" ")} → exit ${r.status}\n${r.stderr}`); + return r.stdout; +} +const tilde = (p) => p.replace(HOME, "~"); + +mkdirSync(DOWNLOADS, { recursive: true }); + +// ── --list: just dump the index ──────────────────────────────────────── +if (flags.list && !url && !flags.latest) { + if (!existsSync(INDEX)) { console.log("no clips yet"); process.exit(0); } + const idx = JSON.parse(readFileSync(INDEX, "utf8")); + for (const c of idx.clips ?? []) { + console.log(` ${c.id} ${c.title}`); + console.log(` ${c.tempoBPM} BPM · ${c.key} · ${c.noteCount} notes · ${c.durationSec}s`); + } + process.exit(0); +} + +// ── resolve target URLs ──────────────────────────────────────────────── +let urls = []; +if (url) { + urls = [url]; +} else if (flags.latest) { + const n = Number.isFinite(+flags.latest) && +flags.latest > 0 ? +flags.latest : 1; + console.log(`→ fetching newest ${n} from @${ACCOUNT}…`); + const ids = capture("yt-dlp", [ + "--flat-playlist", "--no-warnings", + "--playlist-end", String(n), + "--print", "url", + `https://www.tiktok.com/@${ACCOUNT}`, + ]).trim().split("\n").filter(Boolean); + urls = ids; +} else { + console.error("usage: grab.mjs | --latest [N] [--account @handle] | --list"); + process.exit(2); +} + +// ── per-clip pipeline ────────────────────────────────────────────────── +function grabOne(clipUrl) { + console.log(`\n→ ${clipUrl}`); + const meta = JSON.parse(capture("yt-dlp", ["-J", "--no-warnings", "--no-playlist", clipUrl])); + const id = meta.id; + const title = (meta.title || meta.description || "untitled").replace(/\s+/g, " ").trim(); + const base = resolve(DOWNLOADS, `${ACCOUNT}-${id}`); + const mp4 = `${base}.mp4`; + const wav = `${base}.wav`; + const json = `${base}.analysis.json`; + + console.log(` id : ${id}`); + console.log(` title : ${title}`); + + if (!existsSync(mp4) || REDO) { + run("yt-dlp", ["--no-warnings", "--no-playlist", "-o", mp4, clipUrl]); + } else { + console.log(` mp4 : present, reusing (--redo to refetch)`); + } + + if (!existsSync(wav) || REDO) { + run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", + "-i", mp4, "-vn", "-ac", "1", "-ar", "44100", "-c:a", "pcm_s16le", wav]); + } + + run(PY, [ANALYZE, wav, "--json", json]); + const analysis = JSON.parse(readFileSync(json, "utf8")); + + return { + id, title, + account: ACCOUNT, + url: clipUrl, + durationSec: analysis.durationSec, + tempoBPM: analysis.tempoBPM, + key: analysis.key, + keyConfidence: analysis.keyConfidence, + noteCount: analysis.noteCount, + pitchRange: analysis.pitchRange, + mp4: tilde(mp4), + analysis: tilde(json), + }; +} + +const results = []; +for (const u of urls) { + try { results.push(grabOne(u)); } + catch (e) { console.error(` ✗ ${e.message}`); } +} + +// ── update INDEX.json (tracked) ──────────────────────────────────────── +let index = { version: 1, clips: [] }; +if (existsSync(INDEX)) { + try { index = JSON.parse(readFileSync(INDEX, "utf8")); } catch { /* recreate */ } +} +if (!Array.isArray(index.clips)) index.clips = []; +for (const r of results) { + index.clips = index.clips.filter((c) => c.id !== r.id); + index.clips.push(r); +} +index.clips.sort((a, b) => String(b.id).localeCompare(String(a.id))); +writeFileSync(INDEX, JSON.stringify(index, null, 2) + "\n"); +console.log(`\n✓ ${results.length} clip(s) → ${tilde(INDEX)} (${index.clips.length} total)`); -- 2.51.2