diff --git a/pop/sailor-song/bin/lyric-bounds.py b/pop/sailor-song/bin/lyric-bounds.py index eb7c0758bd..b5f78ecb49 100644 --- a/pop/sailor-song/bin/lyric-bounds.py +++ b/pop/sailor-song/bin/lyric-bounds.py @@ -124,7 +124,7 @@ env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=160); et = librosa.fra # has ended (the newest events.json is the mix the video plays). (v103: "the second long cuts off a few seconds early") EV = json.load(open(os.path.join(OUTD, newest))).get("events", []); VOC = {"vox", "sister", "chorale", "mirror", "aaa", "ooo", "halo"} ext = 0 -for j in range(len(WORDS)): +for j in range(len(WORDS) - 1): # (not the last word: the final 'out' keeps her own utterance's length — jeffrey) if ends[j] - starts[j] < 1.2: continue a, b = regOf(starts[j]), regOf(ends[j]); ring = b while True: # chain outward: a layer that starts before the ring ends (+0.3 s) extends it (the ooo's carry the second long into the break) diff --git a/pop/sailor-song/bin/orchestra.mjs b/pop/sailor-song/bin/orchestra.mjs index 24bbfecb0b..888645d684 100644 --- a/pop/sailor-song/bin/orchestra.mjs +++ b/pop/sailor-song/bin/orchestra.mjs @@ -172,7 +172,7 @@ for (const b of bars) { // high thirds. The break: a swell. Bars 73–76: violins tremolo alone, soft. 77–80 and the finale: full tremolo. if (chorus || s === "bridge" || s === "outro" || s === "verse2" || s === "break") { const hi = tones(c, 71, 88), lo = tones(c, 64, 80); - const vel = s === "chorus1" ? 78 * swellIn : s === "chorus2" ? 90 * swellIn : early ? 62 + 10 * grow(n, 73, 77) : s === "bridge" ? 84 + 24 * grow(n, 77, 81) : finale ? 104 : release ? 56 : 60; + const vel = s === "chorus1" ? 88 * swellIn : s === "chorus2" ? 100 * swellIn : early ? 66 + 12 * grow(n, 73, 77) : s === "bridge" ? 94 + 26 * grow(n, 77, 81) : finale ? 114 : release ? 56 : 62; // v108: power — played harder if (chorus || finale) { const run = [...hi.slice(0, 4), ...hi.slice(1, 3).reverse()]; for (let j = 0; j < nb; j++) for (let q = 0; q < 2; q++) { const i = (j * 2 + q) % run.length, t = bt[j] + (bt[j + 1] - bt[j]) * q / 2; diff --git a/pop/sailor-song/bin/perf-relight.mjs b/pop/sailor-song/bin/perf-relight.mjs index 5ae1da8888..aff3f4aed4 100644 --- a/pop/sailor-song/bin/perf-relight.mjs +++ b/pop/sailor-song/bin/perf-relight.mjs @@ -153,7 +153,7 @@ const coverGrade = (a) => { if (a <= 0.002) return; const ia = 1 - a; // and only where the depth is continuous (the silhouette's depth cliff would otherwise draw a dark outline) — // plus a soft frontal spot on her face. All of it arrives with the arrangement. Absent the clip, no stage light. ── const DEPTH = resolve(LANE, "src/depth-take.mp4"); -const STAGE = !LYRIC_ONLY && !arg("no-stage") && existsSync(DEPTH) && (() => { try { return execFileSync("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=nb_frames", "-of", "csv=p=0", DEPTH], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim() !== ""; } catch { return false; } })(); // a clip still being written is not a clip +const STAGE = !LYRIC_ONLY && !!arg("stage") && existsSync(DEPTH) && (() => { try { return execFileSync("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=nb_frames", "-of", "csv=p=0", DEPTH], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim() !== ""; } catch { return false; } })(); // a clip still being written is not a clip let depthAt = () => {}; const DN = STAGE ? new Float32Array(W * H) : null; // the shading gain per pixel, 1 = untouched if (STAGE) { const [DW, DH] = execFileSync("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", DEPTH]).toString().trim().split(",").map(Number); const DFIFO = join(mkdtempSync(join(tmpdir(), "relight-depth-")), "depth.gray"); execFileSync("mkfifo", [DFIFO]); @@ -161,7 +161,7 @@ if (STAGE) { const [DW, DH] = execFileSync("ffprobe", ["-v", "error", "-select_s const DFD = openSync(DFIFO, "r"), draw = Buffer.alloc(DW * DH), dq = new Float32Array(DW * DH), gq = new Float32Array(DW * DH); let dIdx = -1, dEnd = false, dLast = -2; const readDepth = () => { let got = 0; while (got < draw.length) { const n = readSync(DFD, draw, got, draw.length - got, null); if (n <= 0) { dEnd = true; return false; } got += n; } return true; }; const Lk = [-0.35, -0.55, 0.75], Lr = [0.7, -0.2, 0.6]; for (const L of [Lk, Lr]) { const n = Math.hypot(...L); L[0] /= n; L[1] /= n; L[2] /= n; } - const RELIEF = 120 * (DW / 960), K = 0.65, CLIFF = 0.06; // depth units per pixel of slope; the key's strength; a gradient past this is a silhouette, not a face + const RELIEF = 90 * (DW / 960), K = 0.28, CLIFF = 0.06; // v103: toned down ("too extreme") // depth units per pixel of slope; the key's strength; a gradient past this is a silhouette, not a face depthAt = (take) => { const k = Math.round(take * MFPS); while (dIdx < k && !dEnd) { if (!readDepth()) break; dIdx++; } if (dIdx === dLast) return; dLast = dIdx; for (let i = 0; i < DW * DH; i++) dq[i] = draw[i] / 255; // the shading at depth resolution: Sobel normals, two lights, the cliff test @@ -252,8 +252,13 @@ CHUNKS.forEach((C, i) => { const P = CHUNKS[i - 1], N = CHUNKS[i + 1]; // the next line comes in as soon as this one's last word is done (v103: "as soon as that extended long ends, swap — not // de-highlight and wait"); only across a long instrumental gap (> 4 s) does it hold off until 1.5 s before its first word // in sequence, never overlapping: the old line is gone in 0.15 s, the new one arrives over the next 0.2 s - if (N) { C.outA = N.a - C.b > 4 ? N.a - 1.5 : Math.min(C.b + 0.05, N.a - 0.35); C.outB = Math.min(N.a - 0.2, C.outA + 0.15); } else { C.outA = C.b + 0.1; C.outB = C.b + 0.5; } - C.inA = P ? P.outB : C.a - 0.6; C.inB = P ? Math.min(C.a, P.outB + 0.2) : C.a - 0.25; }); + // and never before the last word has finished (v103: "some words don't fully complete"): when lines run together the + // out and in squeeze into whatever gap there is, the new line finishing its fade-in a touch after its first word if it must + // the dead zone (v103: "readability vs timing"): a finished line holds 0.35 s before it slides, when the gap allows; a + // shorter gap shares itself out (hold 40%, out 25%, in the rest); under 0.2 s it is a hard cut + if (N) { const gap = N.a - C.b; if (gap > 4) { C.outA = N.a - 1.5; C.outB = C.outA + 0.15; } else if (gap >= 0.9) { C.outA = C.b + 0.35; C.outB = C.outA + 0.15; } else if (gap >= 0.2) { C.outA = C.b + gap * 0.4; C.outB = C.outA + gap * 0.25; } else { C.outA = C.b; C.outB = C.b + 0.06; } } + else { C.outA = C.b + 0.1; C.outB = C.b + 0.5; } + C.inA = P ? P.outB : C.a - 0.6; C.inB = P ? (C.a - P.outB < 0.2 ? P.outB + 0.06 : Math.min(C.a, P.outB + 0.2)) : C.a - 0.25; }); const easeIO = (f) => f * f * (3 - 2 * f); // a chunk's alpha and x-shift now: in from the right, out to the left const chunkState = (C, now) => { if (now < C.inA || now > C.outB) return null; @@ -287,9 +292,16 @@ const glyphO = (layer, g, cx, cy, sc, rot, r, gg, b, a) => { const w = g.w, h = const c = (layer[o] * (1 - fu) + layer[o + 1] * fu) * (1 - fv) + (layer[o + GA.W] * (1 - fu) + layer[o + GA.W + 1] * fu) * fv; if (c > 2) pxO(X, Y, r, gg, b, a * c / 255); } }; // the composite, up to the output size (bilinear; an exact 2× when it is one) -const upscale = () => { if (!OB) { ob = fb; return; } ob = OB; const fx = W / OW, fy = H / OH; - for (let y = 0; y < OH; y++) { const v = Math.min(H - 1.001, Math.max(0, (y + 0.5) * fy - 0.5)), v0 = v | 0, fv = v - v0, r0 = v0 * W * 3, r1 = (v0 + 1) * W * 3, q = y * OW * 3; - for (let x = 0; x < OW; x++) { const u = Math.min(W - 1.001, Math.max(0, (x + 0.5) * fx - 0.5)), u0 = u | 0, fu = u - u0, a = r0 + u0 * 3, b = r1 + u0 * 3, o = q + x * 3; +// the zoom bumps (v103: "zoom-bump the video on that glitch so it feels like we sit into the chorus, BOOM"): a push-in +// that ratchets up in three steps through the ki-ki-ki, peaks on the downbeat, and eases back; about her head +const BUMPS = [{ t: 38.444, pre: 0.3, amt: 0.09 }, { t: 84.898, pre: 0.12, amt: 0.05 }]; +const zoomAt = (rn) => { let z = 1; for (const b of BUMPS) { const d = rn - b.t; + if (d >= -b.pre && d < 0) { const f = 1 + d / b.pre; z += b.amt * 0.55 * (Math.floor(f * 3) / 3 + ((f * 3) % 1) * 0.35); } + else if (d >= 0 && d < 0.5) { const f = d / 0.5; z += b.amt * (1 - f) * (1 - f); } } return z; }; +let ZOOM = 1; const ZB = Buffer.alloc(W * H * 3); +const upscale = () => { const z = ZOOM; if (!OB && z === 1) { ob = fb; return; } ob = OB || ZB; const fx = W / OW / z, fy = H / OH / z, cx = W * 0.4, cy = H * 0.3, ox = cx - cx / z, oy = cy - cy / z; + for (let y = 0; y < OH; y++) { const v = Math.min(H - 1.001, Math.max(0, oy + (y + 0.5) * fy - 0.5)), v0 = v | 0, fv = v - v0, r0 = v0 * W * 3, r1 = (v0 + 1) * W * 3, q = y * OW * 3; + for (let x = 0; x < OW; x++) { const u = Math.min(W - 1.001, Math.max(0, ox + (x + 0.5) * fx - 0.5)), u0 = u | 0, fu = u - u0, a = r0 + u0 * 3, b = r1 + u0 * 3, o = q + x * 3; for (let c = 0; c < 3; c++) ob[o + c] = (fb[a + c] * (1 - fu) + fb[a + 3 + c] * fu) * (1 - fv) + (fb[b + c] * (1 - fu) + fb[b + 3 + c] * fu) * fv; } } }; // a filled ellipse with a 1.5 px antialiased edge, over everything (the ball) const blob = (cx, cy, rx, ry, r, g, b, a = 1) => { for (let y = Math.floor(cy - ry - 1); y <= cy + ry + 1; y++) for (let x = Math.floor(cx - rx - 1); x <= cx + rx + 1; x++) { @@ -315,32 +327,39 @@ function drawFrame(fi) { // before she reaches for the camera const arrive = Math.min(1, Math.max(0, (now - T0 - 10.8) / (38.4 - 10.8))), arriveS = arrive * arrive * (3 - 2 * arrive); const lightsOn = arriveS * (1 - Math.min(1, Math.max(0, (take - (CAM_MOVE - 2.3)) / 2.0))); + const toBlack = Math.min(1, Math.max(0, (take - (CAM_MOVE - 1.1)) / 0.9)); // v103 "align the ending": black by the time she reaches, the music's tail under it const room = 1 + (0.72 + 0.28 * amb - 1) * lightsOn; // v103: a simple grade — the room breathes with the sections, never goes out; gone by the end const lampBloom = Math.min(0.5, env(KICKS, now, 0.08, 0.03)) * lightsOn, lampOn = (0.12 * amb + 0.2 * lampBloom) * lightsOn; // a burst, not a whiteout const bellRaw = env(BELLS, now, 1.4, 0.1), windowGlow = Math.min(0.15, 0.08 * bellRaw) * lightsOn, windowOn = (0.15 + 0.1 * amb) * lightsOn; const padLevel = Math.min(1, sounding(PADS, now) * 0.6) * lightsOn; + // a held 'long' (the rainbow word): the room's lights flicker through the same colours (v103) + const arpEnv = Math.min(1, env(ARPS, now, 0.8) * 0.35), longW = WORDS.find((w) => /^long\W*$/i.test(w.text) && w.b - w.a > 1.2 && now >= w.a + 0.25 && now < w.b); + const longOn = longW && arpEnv > 0.05 ? Math.min(1, arpEnv * 3) * lightsOn : 0, flickHz = 6 + 10 * arpEnv; + const wheel = (ph) => { const hh = ((ph % 1) + 1) % 1, i6 = (hh * 6) | 0, fr = hh * 6 - i6, Q = 255 * (1 - 0.9 * fr), T = 255 * (1 - 0.9 * (1 - fr)), lo = 25; return [[255, T, lo], [Q, 255, lo], [lo, 255, T], [lo, Q, 255], [T, lo, 255], [255, lo, Q]][i6]; }; if (!LYRIC_ONLY) { coverGrade(arriveS); // 1. the room: multiplicative gain (ambient × tint, lifted by the lamp's pool and the window), her kept close to natural const T = tint.map((t) => 1 + (0.55 + 0.45 * t - 1) * lightsOn); // the chord's tint at half strength, gone by the end const fgGain = 0.92 + 0.08 * room, fgTint = T.map((t) => 0.8 + 0.2 * t); // she stays nearly natural, just a little of the room's colour - const spotR2 = (0.17 * W) ** 2, spotK = 0.22 * lightsOn, stageK = lightsOn; // the face spot and the bump key arrive with the rest + const spotR2 = (0.2 * W) ** 2, spotK = STAGE ? 0.1 * lightsOn : 0, stageK = lightsOn; // the face spot and the bump key arrive with the rest for (let o = 0, p = 0, y = 0, x = 0; o < W * H; o++, p += 3, x = ++x === W ? (y++, 0) : x) { const m = M[o], lamp = LAMP[o], win = WINDOW[o]; const g = room + lampOn * lamp + windowOn * win * (1 - room) + windowGlow * win * 0.6; const dx = x - faceX, dy = y - faceY, spot = 1 + spotK * Math.max(0, 1 - (dx * dx + dy * dy) / spotR2), fg = fgGain * spot * (STAGE ? 1 + (DN[o] - 1) * stageK : 1); const gr = (g * T[0] + lampBloom * lamp * 0.1) * (1 - m) + fg * fgTint[0] * m, gg = (g * T[1] + lampBloom * lamp * 0.08) * (1 - m) + fg * fgTint[1] * m, gb = (g * T[2] + lampBloom * lamp * 0.06) * (1 - m) + fg * fgTint[2] * m; fb[p] = Math.min(255, fb[p] * gr); fb[p + 1] = Math.min(255, fb[p + 1] * gg); fb[p + 2] = Math.min(255, fb[p + 2] * gb); } // 2. the lamp's bloom and the window's glow, added to the room + if (longOn > 0) { const L = LIGHTS.lamp, lx = (L.x - VX) * sx, ly = (L.y - VY) * sy, c = wheel(now * flickHz / 6 + 0.5), blink = Math.sin(2 * Math.PI * now * flickHz) > 0 ? 1 : 0.4; glow(lx, ly, Math.round(L.r * 1.5 * sx), c[0], c[1], c[2], 0.16 * longOn * blink); } if (lampBloom > 0.02) { const L = LIGHTS.lamp, lx = (L.x - VX) * sx, ly = (L.y - VY) * sy, R0 = Math.round(L.r * 1.6 * sx); glow(lx, ly, R0, 255, 228, 180, 0.18 * lampBloom); glow(lx, ly, Math.round(R0 * 2.4), 255, 220, 170, 0.04 * lampBloom); } if (windowGlow > 0.02) { const Wn = LIGHTS.window; for (let y = Math.max(0, (Wn.y0 - VY) * sy); y < Math.min(H, (Wn.y1 - VY) * sy); y += 2) for (let x = Math.max(0, (Wn.x0 - VX) * sx); x < Math.min(W, (Wn.x1 - VX) * sx); x += 2) { const k = WINDOW[(y | 0) * W + (x | 0)] * 0.12 * windowGlow; add(x, y, 225, 238, 255, k); add(x + 1, y, 225, 238, 255, k); add(x, y + 1, 225, 238, 255, k); add(x + 1, y + 1, 225, 238, 255, k); } } // 3. the fairy lights: a bead of light each, glowing with the pads, a handful flashing on each hat const flash = new Float32Array(FAIRY.length); for (let i = HATS.cur || 0; i < HATS.length && HATS[i].t <= now; i++) { const dt = now - HATS[i].t; if (dt < 0.35) for (const p of HATS[i].pts) flash[p] = Math.max(flash[p], HATS[i].g * Math.exp(-dt / 0.07)); } if (lightsOn > 0) for (const f of FAIRY) { const base = (0.08 + 0.3 * padLevel + 0.25 * amb) * lightsOn, fl = flash[f.i] * 1.2 * lightsOn; - glow(f.x, f.y, Math.round(7 * sx), 170, 150, 255, 0.5 * base); glow(f.x, f.y, 2, 235, 230, 255, 0.7 * base); + let cr = 170, cg = 150, cb = 255; if (longOn > 0) { const c = wheel(now * flickHz / 6 + f.i * 0.07), blink = Math.sin(2 * Math.PI * now * flickHz + f.i * 0.9) > 0 ? 1 : 0.35; cr += (c[0] - cr) * longOn; cg += (c[1] - cg) * longOn; cb += (c[2] - cb) * longOn; glow(f.x, f.y, Math.round(9 * sx), c[0], c[1], c[2], 0.5 * longOn * blink); } + glow(f.x, f.y, Math.round(7 * sx), cr, cg, cb, 0.5 * base); glow(f.x, f.y, 2, 235, 230, 255, 0.7 * base); if (fl > 0.02) { glow(f.x, f.y, Math.round(12 * sx), 210, 200, 255, 0.6 * fl); glow(f.x, f.y, 3, 255, 255, 255, 0.9 * fl); } } } // 4. the singalong: the chunk being sung (and the one leaving); letters fill in syllable by syllable, the sung // syllable pops, the sung word a touch larger, a held syllable kerns outward and sways; the ball rides its one path. - upscale(); // the room is done at the base size; the words go on at the output size + ZOOM = zoomAt(now - T0); upscale(); // the room is done at the base size; the words go on at the output size // the dance (v103: "start formal, black and white, less shaky; as the ornament comes in, increase the shake and the // colour, so the captions surprise the way the arrangement does"): 0 through the intro and verse 1, a first step // when the kick lands, half at chorus 1, full from chorus 2, resting in the break, back up through the bridge @@ -349,7 +368,7 @@ function drawFrame(fi) { if (!NO_LYRIC) { const k = Math.min(1, 0.3 + 0.7 * amb) * (1 + 0.5 * lampBloom), fl = Math.min(1, bellRaw) * 120, SH = hue.map((h) => Math.min(255, (h * k + fl) * dance)), off = 0.07 * GA.px * (CHUNKS[0]?.sc ?? SC_MAX); // the shadow tight under the glyph (v103: "too far from the captions") // the "longs" (v103: "when arpeggiating should blink colors rapidly — psychic effects"): a held syllable's letters run // the hue wheel, each letter a step behind the last, blinking at 6 Hz, 16 Hz with the arp under it - const arp = Math.min(1, sounding(ARPS, now) * 0.5), psyHz = 6 + 10 * arp; + const arp = Math.min(1, env(ARPS, now, 0.8) * 0.35), psyHz = 6 + 10 * arp; // an envelope (0.8 s decay): the rainbow rides through the thinner arpeggio in the break const psychic = (i, wild) => { const hh = (now * psyHz / 6 + i * 0.13) % 1, i6 = (hh * 6) | 0, fr = hh * 6 - i6, Q = 255 * (1 - 0.9 * fr), T = 255 * (1 - 0.9 * (1 - fr)), lo = 25; const c = [[255, T, lo], [Q, 255, lo], [lo, 255, T], [lo, Q, 255], [T, lo, 255], [255, lo, Q]][i6], blink = 0.55 + 0.45 * (Math.sin(2 * Math.PI * now * psyHz + i * 0.9) > 0 ? 1 : 0.35); return c.map((v) => Math.round((250 * (1 - wild) + v * blink * wild))); }; @@ -362,12 +381,12 @@ function drawFrame(fi) { const prevOn = timed ? false : (() => { const p = C.words[wi - 1]; return p && p.w ? now >= p.w.b : wordOn; })(); const wildW = wildOf(w), kern = wildW * 0.05 * GA.px; let pen = w.x + st.dx - kern * sc * (w.gl.length - 1) / 2; for (const q of w.gl) { const gg = q.g, t = timed ? w.w.syl[q.si] : null; - const lit = t ? t.a + (t.b - t.a) * q.sk / q.sn : 1e9, on = timed ? now >= lit : prevOn; + const lit = t ? t.a + Math.min(0.6, (t.b - t.a) * 0.8) * q.sk / q.sn : 1e9, on = timed ? now >= lit : prevOn; // the last letter lit by 80% of the syllable const held = t && now >= t.a && now < t.b && t.b - t.a > 0.6 && now - t.a > 0.25, wild = held ? dance * Math.abs(Math.sin(Math.PI * (now - t.a - 0.25) * 3.2)) : 0; const sylPop = t && now >= t.a ? 1 + 0.14 * dance * Math.max(0, 1 - (now - t.a) / 0.15) : 1, chPop = on && t ? 1 + (0.04 + 0.08 * dance) * Math.max(0, 1 - (now - lit) / 0.1) : 1; const s2 = sc * sylPop * chPop * (cur ? 1 + (0.06 + 0.05 * lampBloom) * dance : 1) * (1 + 0.12 * wild), sway = 0.6 * dance * Math.sin(2 * Math.PI * now / q.per + q.ph); const lat = wild * em * 0.05 * Math.sin(now * 23 + q.ph * 5), cx = pen + gg.adv * sc / 2 + lat, cy = ROW - (GA.ascent - GA.H / 2) * s2 + (q.jy * dance + sway) * sc - wild * em * 0.07 * Math.sin(now * 40 + q.ph * 7), gx = cx + (gg.w / 2 + gg.dx - gg.adv / 2) * s2, rot = q.rot * dance * (1 + 2.5 * wild); - const isLong = /^long\OW*$/i.test(w.tok) && w.w && w.w.b - w.w.a > 1.2, fill = on ? (isLong && cur && arp > 0.05 ? psychic(w.gl.indexOf(q), Math.min(1, arp * 3)) : WHITE) : GREY; // the rainbow: only the long 'long's (held > 1.2 s), under the arpeggio + const isLong = /^long\W*$/i.test(w.tok) && w.w && w.w.b - w.w.a > 1.2, fill = on ? (isLong && cur && arp > 0.05 ? psychic(w.gl.indexOf(q), Math.min(1, arp * 3)) : WHITE) : GREY; // the rainbow: only the long 'long's (held > 1.2 s), under the arpeggio glyphO(GOUTER, gg, gx + off, cy + off, s2, rot, ...SH, al); glyphO(GOUTER, gg, gx, cy, s2, rot, ...BLACK, al); glyphO(GFILL, gg, gx, cy, s2, rot, ...fill, al); pen += gg.adv * sc + kern * sc; } } } // the ball: between landing j and j+1 it sits DWELL then flies one gravity arc, each end riding its own chunk's slide @@ -385,6 +404,7 @@ function drawFrame(fi) { let ballRGB = WHITE; if (chroma) { const hh = (now * 1.5) % 1, i6 = (hh * 6) | 0, fr = hh * 6 - i6, Q = 255 * (1 - 0.85 * fr), T = 255 * (1 - 0.85 * (1 - fr)), lo = 255 * 0.15; ballRGB = [[255, T, lo], [Q, 255, lo], [lo, 255, T], [lo, Q, 255], [T, lo, 255], [255, lo, Q]][i6].map(Math.round); } if (al > 0) { blobO(bx + off, by + off, br * sqx, br * sqy, ...SH, al); blobO(bx, by, br * sqx + 1.6, br * sqy + 1.6, ...BLACK, al); blobO(bx, by, br * sqx, br * sqy, ...ballRGB, al); } } } + if (toBlack > 0) { const k = 1 - toBlack; for (let i = 0; i < ob.length; i++) ob[i] *= k; } // the end: to black } // ── the pipes; the VHS lives on the encoder's input ── diff --git a/pop/sailor-song/c/cut.sh b/pop/sailor-song/c/cut.sh index 59a9d5e8ae..69d731e053 100755 --- a/pop/sailor-song/c/cut.sh +++ b/pop/sailor-song/c/cut.sh @@ -47,11 +47,11 @@ measure() { ffmpeg -hide_banner -nostats -i "$1" -af "$2${2:+,}ebur128=peak=true echo "→ density + loudness taken at the ceiling (measured, not guessed)" GAIN2="${GAIN2:-0.0}" -for pass in 1 2 3; do +for pass in 1 2 3 4; do I=$(measure "$PRE" "$(density "$GAIN2")") echo " pass $pass: GAIN2=${GAIN2} dB → ${I} LUFS" - awk -v i="$I" 'BEGIN{exit !(i > -12.3 && i < -11.7)}' && break # v39: −12 LUFS — "the mix feels a little maxed out" - GAIN2=$(awk -v g="$GAIN2" -v i="$I" 'BEGIN{printf "%.2f", g + (-12 - i)}') + awk -v i="$I" 'BEGIN{exit !(i > -11.6 && i < -11.0)}' && break # v108: −11.3 LUFS — "the mix is a little tame now" (v39 was −12) + GAIN2=$(awk -v g="$GAIN2" -v i="$I" 'BEGIN{printf "%.2f", g + (-11.3 - i)}') done ffmpeg -y -v error -i "$PRE" -af "$(density "$GAIN2")" -ar 48000 -c:a pcm_s24le "$MASTER" diff --git a/pop/sailor-song/c/sailorremix.c b/pop/sailor-song/c/sailorremix.c index fcd7153524..a64206ab49 100644 --- a/pop/sailor-song/c/sailorremix.c +++ b/pop/sailor-song/c/sailorremix.c @@ -90,9 +90,9 @@ #include "sailor-chart.h" #define SR 48000 -#define VERSION "v107" +#define VERSION "v113" #define EAGER 0.009 // v73: "eager" placement — percussion pushes ~9 ms ahead of the grid -#define PIANO_ON 0 // v71: "let's be rid of the piano" — the sampler stays, the part is off +#define PIANO_ON 1 // v112: ON — "I wanted the piano" (v71 had turned it off) #define SHIFT27 0.0 // v53: how much earlier everything after bar 27 plays, now that the regularizer fits it to four beats #define KICK_ONLY 0 // v48: "try removing all percussion other than kick" — claps, snares, hats, toms, shaker, congas, taiko off #define END_BAR 999 // v23: "the song should end how her video ends" — the record runs to the end of her take (v9 cut at 82 + 1.5 s) @@ -517,6 +517,11 @@ static double v_sis_hum(const ChartBar *b, int x) { (void)x; int s = section_of( static int rec_pos(const ChartBar *b) { return (int)(b - CHART_BARS) + 1; } static double v_far(const ChartBar *b, int x) { (void)x; return rec_pos(b) < pos_n(11) ? 1 : rec_pos(b) < pos_n(44) ? 1 - evo(b->n, 13, 25) : 0; } // far = quiet static double v_cath(const ChartBar *b, int x) { (void)x; return 0.6 * evo(rec_pos(b), pos_n(22), CHART_NBARS - 6) * (section_of(b->n) == BRIDGE ? 0.3 : 1); } // v32: the cat-and-mouse verse is close — the stone steps back // v22b: "we still need her to have an up front voice" — half the stone (was 1.3) +// v108: POWER — "the mix is a little tame now; bring more instruments up — more power, in amplitude / velocity": the band +// (everything but her voice) lifts by section, +2 dB in the choruses, +2.5 in the finale, her voice half a dB with it +static double v_power(const ChartBar *b, int x) { (void)x; int s = section_of(b->n), n = b->n; + switch (s) { case INTRO: return 1; case VERSE1: return 1 + 0.1 * evo(n, 20, 27); case CHORUS1: return 1.26; case VERSE2: return 1.12; + case CHORUS2: return 1.32; case BREAK: return 1.2; case BRIDGE: return 1.24 + 0.08 * evo(n, 77, 81); case OUTRO: return n >= 83 ? 1 : 1.35; } return 1; } static double v_warm(const ChartBar *b, int x) { (void)x; if (section_of(b->n) == BRIDGE) return 0.65; return 1 - 0.6 * evo(rec_pos(b), pos_n(20), CHART_NBARS - 6); } // v32: "bring her voice back in" for the bridge — at the mic again // v25: she stays closer all the way (was 0.85) static double v_thick(const ChartBar *b, int x) { (void)x; int s = section_of(b->n); return s == INTRO ? 0 : s == VERSE1 ? 0.25 * evo(b->n, 16, 24) : s == BREAK ? 0 : s == VERSE2 ? 0.18 : 0.2; } // v15: the doubles trimmed so the lead stays in front; v26: thinner still static double v_harm_g(const ChartBar *b, int h) { return harm_at(b->n, h).g; } @@ -882,6 +887,9 @@ static Biquad bq(int type, double f, double q, double db) { double A = pow(10, db / 40), w = 2 * PI * f / SR, c = cos(w), sn = sin(w), al = sn / (2 * q), b0, b1, b2, a0, a1, a2; if (type == 0) { b0 = (1 + c) / 2; b1 = -(1 + c); b2 = b0; a0 = 1 + al; a1 = -2 * c; a2 = 1 - al; } // highpass else if (type == 1) { b0 = 1 + al * A; b1 = -2 * c; b2 = 1 - al * A; a0 = 1 + al / A; a1 = -2 * c; a2 = 1 - al / A; } // bell + else if (type == 3) { double sq = 2 * sqrt(A) * al; // low shelf (v109) + b0 = A * ((A + 1) - (A - 1) * c + sq); b1 = 2 * A * ((A - 1) - (A + 1) * c); b2 = A * ((A + 1) - (A - 1) * c - sq); + a0 = (A + 1) + (A - 1) * c + sq; a1 = -2 * ((A - 1) + (A + 1) * c); a2 = (A + 1) + (A - 1) * c - sq; } else { double sq = 2 * sqrt(A) * al; // high shelf b0 = A * ((A + 1) + (A - 1) * c + sq); b1 = -2 * A * ((A - 1) + (A + 1) * c); b2 = A * ((A + 1) + (A - 1) * c - sq); a0 = (A + 1) - (A - 1) * c + sq; a1 = 2 * ((A - 1) - (A + 1) * c); a2 = (A + 1) - (A - 1) * c - sq; } @@ -1116,7 +1124,7 @@ int main(void) { const double eag = (1 + 0.09 * ph / 3.0) * curveE(b->n); // v73: builds across each phrase; v75: × the record's energy curve if (dz[0] && gKick > 0) for (int j = 0; j < nb; j++) { if (pickup_bar(b->n) && j >= pickup_j(b)) continue; if ((s == VERSE1 || s == VERSE2) && j % 2) continue; if (s == VERSE2 && j == 2 && b->n < 48) continue; // v49: the verses on her 1 and 3; v75: verse 2 on 1 only until 48 // v22: her 1 and 3 in the verses; v27: and in the finale (half-time, big) // (v49: bars 73–76 keep the floor too — steady; the orchestra is what thins there) - double g = 0.95 * dz[0] * (j % 2 ? 0.9 : 1) * gKick * hill * kickLift * (0.7 + 0.3 * curveE(b->n)); // v75: the kick rides the curve too (gently) + double g = 1.08 * dz[0] * (j % 2 ? 0.9 : 1) * gKick * hill * kickLift * (0.65 + 0.35 * curveE(b->n)); // v108: harder, and more of the curve // v75: the kick rides the curve too (gently) // v46: the hit's own life — downbeats hardest and longest, 3 a touch under, 2 and 4 softer and shorter; half-time // sections ring longer, the floor is tighter, the finale biggest; ±8 % velocity, ±15 % attack and decay per hit const double vel = (j == 0 ? 1.0 : j == 2 ? 0.92 : 0.82) * (1 + 0.08 * rnd()); @@ -1124,7 +1132,7 @@ int main(void) { const double dec = decS * (j % 2 ? 0.85 : 1) * (1 + 0.15 * rnd()), atk = (j == 0 ? 1.1 : 0.9) * (1 + 0.15 * rnd()); // (v49: no double time — "when the kick starts doubling up i don't like it") dance_kick_v(hum_t2(bt[j], 2), g, vel, atk, dec); ev(bt[j], "kick", 0.1, g * vel, -1); } // v46: ±2 ms — the kick stays on her hand - if (dz[1] && gKick > 0 && !(s == BRIDGE && b->n < 77)) for (int j = 0; j < nb; j++) { if (pickup_bar(b->n) && j >= pickup_j(b)) continue; if (halfTime ? j != 2 : !(j % 2)) continue; clap(hum_t2(bt[j], 4), hum_g2(0.75 * dz[1] * swell * gDrop * hill * gKick * eag, 0.1)); clap(hum_t2(bt[j] + 0.012, 4), hum_g2(0.4 * dz[1] * swell * gDrop * hill * gKick, 0.15)); ev(bt[j], "clap", 0.1, dz[1], -1); // v38: a second clap 12 ms late — wider; v46: humanized + if (dz[1] && gKick > 0 && !(s == BRIDGE && b->n < 77)) for (int j = 0; j < nb; j++) { if (pickup_bar(b->n) && j >= pickup_j(b)) continue; if (halfTime ? j != 2 : !(j % 2)) continue; clap(hum_t2(bt[j], 4), hum_g2(0.85 * dz[1] * swell * gDrop * hill * gKick * eag, 0.1)); clap(hum_t2(bt[j] + 0.012, 4), hum_g2(0.4 * dz[1] * swell * gDrop * hill * gKick, 0.15)); ev(bt[j], "clap", 0.1, dz[1], -1); // v38: a second clap 12 ms late — wider; v46: humanized snare_hit(hum_t2(bt[j], 3), hum_g2((halfTime ? 0.5 : 0.7) * dz[1] * gDrop * hill * kickLift * eag, 0.12)); ev(bt[j], "snare", 0.1, dz[1], -1); } // v30: a snare under every clap // v30: TOM FILLS — hi → mid → low → floor in 16ths: on the last beat of bridge/finale phrases, and on beat 3 of the bar // before each lift (beat 4 is the dropout); the taiko sample steps back to support them @@ -1401,14 +1409,20 @@ int main(void) { // each grain a scale step higher (0 2 4 5 7 9), panned side to side, a trap hat under each, a tom under the last — the // hesitation becomes a ki-ki-ki run that lands on KISS. The band still stops dead around it; the stutter bus is not gated. { const double kiss = KISS_T[0], kT = 60.435, down = bar_n(28)->t; const ChartBar *pb = bar_n(27); const double beat = pb->dur / pb->nb, s32 = beat / 8; - static const int ST[6] = { 0, 2, 4, 5, 7, 9 }; const double GL = 0.055; const double src0 = (kiss + 0.012) * SR; - int h = 0; for (double t = kT + 0.045; t < down - 0.015 && h < 6; t += s32, h++) { - double ratio = pow(2, ST[h] / 12.0), g = 0.5 + 0.5 * h / 5.0; long a = at(t); int n = (int)(GL * SR); + // v113: "even cuter / more musical, start a bit earlier, add a bell — ding!!! fem bell, strong one": the run now starts a 16th + // BEFORE her hesitation (over the tail of "you"), five grains up the major pentatonic (0 2 4 7 9), and one strong fem bell an + // octave above her "kiss" note rings from the first grain through the stop into the drop + static const int ST[6] = { 0, 2, 4, 7, 9, 12 }; const double GL = 0.055; const double src0 = (kiss + 0.012) * SR; + int kissM = 63; for (int k = 0; k < CHART_NNOTES; k++) if (fabs(CHART_NOTES[k].t - kiss) < 0.12) { kissM = CHART_NOTES[k].midi; break; } + const double runT0 = kT - 2 * s32; + fem_bell(runT0, kissM + 12, 0.6); ev(runT0, "bell", 1.2, 0.6, kissM + 12); + int h = 0; for (double t = runT0; t < down - 0.015 && h < 6; t += s32, h++) { + double ratio = pow(2, ST[h] / 12.0), g = (0.5 + 0.5 * h / 5.0) * 4.5; long a = at(t); int n = (int)(GL * SR); // v111: ×4.5 (+13 dB) — her voice is ×3.9 in the mix bus and the grains were raw; it sat 11 dB under for (int i = 0; i < n; i++) { double pp = src0 + i * ratio; long j = (long)pp; double f = pp - j; double v = sample(vox.L, vox.n, j) * (1 - f) + sample(vox.L, vox.n, j + 1) * f; double w = fmin(1, i / (0.003 * SR)) * fmin(1, (n - i) / (0.010 * SR)); add(stM, a + i, v * w * g); } - trap_hat(t, 0.22 * g, h % 2 ? 0.45 : -0.45); ev(t, "stutter", GL, g, -1); } - if (h > 0) tom(kT + 0.045 + (h - 1) * s32, 120, 0.45, 0.3); } + trap_hat(t, 0.12 * g, h % 2 ? 0.45 : -0.45); ev(t, "stutter", GL, g, -1); } + if (h > 0) tom(runT0 + (h - 1) * s32, 120, 0.7, 0.3); } for (int q = 0; q < 0; q++) { const ChartBar *pb = bar_n(INTO[q] - 1); if (!pb || pb->nb < 4) continue; // v28: OFF — "i don't like the added glitchiness on the k-kiss"; the dropout and the whip do the work const double src = KISS_T[q], t0 = pb->beats[pb->nb - 2], step = (pb->beats[pb->nb] - t0) / 8; long sa = at(src); // v27: the LAST two beats (bar 27 has five) for (int h = 0; h < 8; h++) { double t = t0 + h * step, g = 0.3 + 0.7 * h / 7.0; long a = at(t); int n = (int)(SL * SR); @@ -1536,7 +1550,7 @@ int main(void) { free(gh); free(gl); free(gm); } // ── mix ── - float *sendA = automate(v_send, 0), *airA = automate(v_air, 0), *thickA = automate(v_thick, 0), *warmA = automate(v_warm, 0); + float *sendA = automate(v_send, 0), *powerA = automate(v_power, 0), *airA = automate(v_air, 0), *thickA = automate(v_thick, 0), *warmA = automate(v_warm, 0); float *herA = automate(v_her, 0), *acgA = automate(v_acg, 0), *elgA = automate(v_elg, 0); float *farA = automate(v_far, 0), *cathA = automate(v_cath, 0), *roomA = automate(v_room, 0), *dissA = automate(v_dissolve, 0); double dsL = 0, dsR = 0; // v41: the dissolve lowpass state @@ -1629,7 +1643,7 @@ int main(void) { env = pw > env ? aA * env + (1 - aA) * pw : aR * env + (1 - aR) * pw; double lv = sqrt(env), gt = lv > 0.06 ? pow(lv / 0.06, 1.0 / 2.5 - 1) : 1; // v25: 2.5:1 (was 4:1) — "less processed" gcur = gt < gcur ? gt : gcur + (gt - gcur) * 0.0005; - double v0 = (x + ghost) * gcur * (3.7 + 0.25 * warmA[i]) * (1 - pumpVA[i] * fmin(1, e)); // v77: +1 dB; v101: 0.15 pump where the floor is four-on-the-floor, 0.36 elsewhere // v68: she NEEDS to be stronger // v61: she is the loudest thing (2.2 → 2.8) // v30: she pumps with the kick, ~−4 dB (v25: −2); v33: + the ghost // v19: her voice pumps with the kick, audibly (was 0.2) + double v0 = (x + ghost) * gcur * (3.9 + 0.25 * warmA[i]) * (1 - pumpVA[i] * fmin(1, e)); // v77: +1 dB; v101: 0.15 pump where the floor is four-on-the-floor, 0.36 elsewhere // v68: she NEEDS to be stronger // v61: she is the loudest thing (2.2 → 2.8) // v30: she pumps with the kick, ~−4 dB (v25: −2); v33: + the ghost // v19: her voice pumps with the kick, audibly (was 0.2) // v15: a hair louder when close vlp += (v0 - vlp) * k7; // her voice rides the kick's pump, lightly vwarm += (v0 - vwarm) * k350; const double warm = warmA[i]; @@ -1677,7 +1691,7 @@ int main(void) { // v22: the scream rides with her (not keyed), two copies 9 and 14 ms late, wide double scl = sample(scr, N, i - 432) * screamA[i] * 0.22, scrr = sample(scr, N, i - 672) * screamA[i] * 0.22; // v22: the orchestra — pads keyed to her (hollow) and pumped; the timpani and pizz untouched by her voice - double ol = (orKL[i] * pump * vd + orDL[i] * hpump) * 0.6, orr = (orKR[i] * pump * vd + orDR[i] * hpump) * 0.6; // v61: under her // v24: from the spatial buses + double ol = (orKL[i] * pump * vd + orDL[i] * hpump) * 0.72, orr = (orKR[i] * pump * vd + orDR[i] * hpump) * 0.72; // v108: 0.6 → 0.72 // v61: under her // v24: from the spatial buses double wb = wubM[i] * pump * 0.55; // v24: the wub, pumped with the kick, mono (bass rule) // v41: everything that is not her closes through a lowpass across the finale (8 kHz → 300 Hz by bar 84) — the dissolve double roomGl = 0, roomGr = 0; @@ -1685,6 +1699,7 @@ int main(void) { gbL += (gl - gbL) * k3kG; gbR += (gr - gbR) * k3kG; roomGl = (gl + (gl - gbL) * 1.0) * d; roomGr = (gr + (gr - gbR) * 1.0) * d; gl *= 1 - d; gr *= 1 - d; } double bandL = hL[i] * hpump * hd + hv * 0.6 * hpump + gl + dlp * 1.3 + pcL[i] * 1.2 + htL[i] * 1.3 + pnL[i] * pump * vd * 0.9 + hsL[i] * 1.0 + vwL[i] * hpump * hd * 1.1 + (tL[i] + rsL[i]) * 1.3 + pL[i] * 0.9 + b808[i] * 0.42 + sl * 0.85 + pl + exL[i] * 0.5 + jl + chl + jd + ssl + ol + wb + melL[i] * pump; double bandR = hR[i] * hpump * hd + hv * 0.6 * hpump + gr + drp * 1.3 + pcR[i] * 1.2 + htR[i] * 1.3 + pnR[i] * pump * vd * 0.9 + hsR[i] * 1.0 + vwR[i] * hpump * hd * 1.1 + (tR[i] + rsR[i]) * 1.3 + pR[i] * 0.9 + b808[i] * 0.42 + sr * 0.85 + pr + exR[i] * 0.5 + jr + chr + jd + ssr + orr + wb + melR[i] * pump; + bandL *= powerA[i]; bandR *= powerA[i]; // v108: power if (dissA[i] > 0.001) { double d = dissA[i], fc = 8000 * pow(300.0 / 8000, d), kd = 1 - exp(-2 * PI * fc / SR); dsL += (bandL - dsL) * kd; dsR += (bandR - dsR) * kd; bandL = dsL; bandR = dsR; } const double airDuck = (1 - 0.9 * fmin(1, venv / 0.012)) * (tt < bar_n(20)->t ? 0.5 : 1); // v83: ducks fully even under her quiet verse-1 singing; verse 1's floor halved L[i] = (float)(v + thL + scl + stM[i] * 1.5 + bandL + radL[i] * airDuck + roomGl); @@ -1717,10 +1732,15 @@ int main(void) { float *ch[2] = { L, R }; for (int c = 0; c < 2; c++) { double x = ch[c][i]; h[c][0] += (x - h[c][0]) * kh; double y = x - h[c][0]; h[c][1] += (y - h[c][1]) * kh; y -= h[c][1]; l[c][0] += (y - l[c][0]) * kl; l[c][1] += (l[c][0] - l[c][1]) * kl; ch[c][i] = (float)(x * (1 - k) + l[c][1] * 1.6 * k); } } } + // v109: THE TILT — "some of the audio mix seems muffled": measured over chorus 1 the mix had nearly all its energy under + // 200 Hz, 1–4 kHz −10 dB and 4–10 kHz −16.5 dB against the total. A master tilt before the print: low shelf −2.5 dB at 160 Hz, + // presence shelf +2.5 dB from 3.2 kHz, air +1.5 dB from 9 kHz + { Biquad lsL = bq(3, 160, 0.7, -2.5), lsR = bq(3, 160, 0.7, -2.5), hsL = bq(2, 3200, 0.7, 2.5), hsR = bq(2, 3200, 0.7, 2.5), aL = bq(2, 9000, 0.7, 1.5), aR = bq(2, 9000, 0.7, 1.5); + for (long i = 0; i < N; i++) { L[i] = (float)bq_run(&aL, bq_run(&hsL, bq_run(&lsL, L[i]))); R[i] = (float)bq_run(&aR, bq_run(&hsR, bq_run(&lsR, R[i]))); } } // v60: THE WAX PRINT — pop/lib/substrate.mjs "vinyl": tube drive 1.8 (tanh, normalized), an even-harmonic bias, a soft hiss // v60b: "things are maxing out" — the sum is brought to a 0.5 peak BEFORE the print (it was going in hot), drive 1.2 (was 1.8) { double pk = 1e-6; for (long i = 0; i < N; i++) { pk = fmax(pk, fabs(L[i])); pk = fmax(pk, fabs(R[i])); } const double pre = 0.5 / pk; - const double drive = 1.2, nrm = 1 / tanh(drive), bias = 0.30 * 0.12, hiss = 0.0009; double hl = 0, hr = 0; const double kh = 1 - exp(-2 * PI * 6000 / SR); // v92: the wax hiss back, dialed way down (−12 dB from v90's half), rising in over her first bars — it was the "noise at the start" all along, from sample 0 + const double drive = 1.05, nrm = 1 / tanh(drive), bias = 0.30 * 0.12, hiss = 0.0009; double hl = 0, hr = 0; const double kh = 1 - exp(-2 * PI * 6000 / SR); // v92: the wax hiss back, dialed way down (−12 dB from v90's half), rising in over her first bars — it was the "noise at the start" all along, from sample 0 for (long i = 0; i < N; i++) { double l = L[i] * pre, r = R[i] * pre; l = tanh(l * drive) * nrm + bias * l * fabs(l); r = tanh(r * drive) * nrm + bias * r * fabs(r); double hin = fmin(1, fmax(0, ((double)i / SR - firstWordT) / 12.0)); hin *= hin; // v102: from her word (not the first twang), squared over 12 s // v92: no hiss before her — it rises over her first eight seconds hl += (rnd() - hl) * kh; hr += (rnd() - hr) * kh; L[i] = (float)(l * 0.9 + hl * hiss * hin); R[i] = (float)(r * 0.9 + hr * hiss * hin); } }