From 0a58d81f1e3fe3ccd6b7fe11ea47f626055b8814 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 18 Aug 2026 11:19:32 -0400 Subject: [PATCH] loner: waiting splits like sitting, the peak goes on the beat, and a calibration clip for the sync question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @jeffrey: "can we align wait and ting on the half bar, like we did with sitting · and align the peaks · and lets lose the pick up kick". waiting splits into wait · ing on her own two events (12.70 and 13.70 in the take), two beats each, so "ing" lands on the half bar exactly as "ting" does. Both syllables keep their own note — wait C#4, ing C4. PEAK ON THE BEAT. The warp put each word's voiced ONSET on its slot, which is where the note starts but not where the ear hears the hit: a sung syllable swells, and a word whose peak is 60 ms into the vowel reads late even though its onset is exact. The anticipation now runs to the peak, with everything before it playing 1:1 ahead of the beat. Capped at 90 ms, and the cap is the interesting part. For attack-peaked words it does nothing and the peak lands on the beat — up +5 ms, my +10 ms, self +10 ms, i +10 ms, in +25 ms. For a HELD word the loudest moment is mid-sustain, half a bar in, and aligning that would start the word a bar early; the cap keeps those on onset alignment instead. So "stone" and "of" still show their peak well after the slot, and should. The pickup kick comes off — the /s/ has that beat to itself. Also bin/synccal.py: the audio-versus-picture question needs a measurement I cannot take from here, so it makes one you can read. A dry click on every beat, a circle that flashes on it, and the compensation stepping 0 → 25 → 50 → 75 → 100 → 125 ms three bars at a time with the number printed large. Whichever section locks is SYNC_MS. --- pop/loner/bin/halo3.py | 33 +++++++-- pop/loner/bin/synccal.py | 135 ++++++++++++++++++++++++++++++++++ pop/loner/c/loner-chart.h | 5 +- pop/loner/c/lonerremix.c | 4 +- pop/loner/vox4/.chart.json | 93 ++++++++++++----------- pop/loner/vox4/.manifest.json | 3 +- 6 files changed, 219 insertions(+), 54 deletions(-) create mode 100644 pop/loner/bin/synccal.py diff --git a/pop/loner/bin/halo3.py b/pop/loner/bin/halo3.py index 45bab6ea87..73f20c943a 100644 --- a/pop/loner/bin/halo3.py +++ b/pop/loner/bin/halo3.py @@ -66,6 +66,7 @@ HALO_DARK_HZ = 5500.0 HALO_BREATH_X = 1.5 UNVOICED_W = 0.18 # consonant share of any stretch SILENT_W = 0.04 # a pause inside a word hardly stretches at all +PEAK_LEAD_MAX_S = 0.09 # how far a word's peak may pull its start early HOLD_RATIO = 1.8 # stretch beyond this → flat tone + vibrato BPM = 122.0 @@ -276,12 +277,15 @@ CHART = { # very as TWO notes, and "pa" held a # full bar because at 2 beats it was # too fast and abrupt - 12: 4.0, 13: 4.0, 14: 2.0, 15: 2.0, - 16: 2.0, 17: 1.5, 18: 1.5 }, + # waiting splits like sitting did, so + # "ing" lands on the half bar + 12: 4.0, 13: 2.0, 14: 2.0, 15: 2.0, + 16: 2.0, 17: 2.0, 18: 1.5, 19: 1.5 }, # words whose syllables carry a melody must not # be flattened to one tone by THE HOLD # patiently is pa·tient·ly, three notes - "sylls": { 12: [(None, "ve"), (15.05, "ry")], + "sylls": { 11: [(None, "wait"), (13.70, "ing")], + 12: [(None, "ve"), (15.05, "ry")], 13: [(None, "pa"), (16.80, "tient"), (17.70, "ly")] }, # "pa" stays on the F4 she sings — the leap up @@ -558,13 +562,32 @@ def build_warp(a, unit_src, beats, dursb, gapsb=None, rest_src=None, lead_b=0.0, e = np.sqrt((xs[:nf * spf].reshape(nf, spf) ** 2).mean(axis=1)) quiet = e <= (np.max(np.abs(xs)) or 1.0) * 10.0 ** (TRIM_GATE_DB / 20.0) w[:nf][quiet] = SILENT_W - ants = [] # consonant frames per unit + # PEAK ON THE BEAT. @jeffrey: "align the peaks". The warp put each + # word's voiced ONSET on its slot, which is where the note starts — + # but not where the ear hears the hit. A sung syllable swells, and the + # beat is felt at the loudest moment, so a word whose peak is 60 ms + # into the vowel reads as 60 ms late even though its onset is exact. + # The anticipation now runs to the PEAK, with everything before it + # playing 1:1 ahead of the beat the way a singer leans in. Capped, so + # a late-peaking word cannot drag its start into the word before it. + xs, fsr = a["x"], a["fs"] + spf = int(round(fsr * FRAME_S)) + nfr = min(F, len(xs) // spf) + fen = np.sqrt((xs[:nfr * spf].reshape(nfr, spf) ** 2).mean(axis=1)) + lead_cap = int(round(PEAK_LEAD_MAX_S / FRAME_S)) + ants = [] # frames ahead of the beat for (s0, s1) in unit_src: v0 = s0 lim = min(s0 + int(0.20 / FRAME_S), s1 - 1, F - 1) while v0 < lim and not a["voiced"][v0]: v0 += 1 - ants.append(v0 - s0 if a["voiced"][min(v0, F - 1)] else 0) + if not a["voiced"][min(v0, F - 1)]: + v0 = s0 + hi = min(s1, nfr, v0 + int(0.35 / FRAME_S)) # look in the attack + if hi > v0: + pk = v0 + int(np.argmax(fen[v0:hi])) + v0 = min(pk, v0 + lead_cap) + ants.append(max(0, v0 - s0)) pre = list(range(0, unit_src[0][0] + ants[0])) # pre + consonant, 1:1 rise = None if lead_b > 0.0 and len(pre): diff --git a/pop/loner/bin/synccal.py b/pop/loner/bin/synccal.py new file mode 100644 index 0000000000..81b140d30b --- /dev/null +++ b/pop/loner/bin/synccal.py @@ -0,0 +1,135 @@ +# synccal.py — find the display-latency offset by eye instead of by guess. +# +# @jeffrey: "the audio still comes sooner than visuals · still feels off". +# The study file measures exact (both streams at PTS 0, the kick at +# 0.0010 s in the WAV and 0.0010 s decoded back out of the MP4, roll +# geometry +3.0 ms mean), so what is left is the display pipeline — and +# how many milliseconds THAT is depends on the screen and the player, not +# on us. Guessing at it one render at a time is slow and inconclusive. +# +# So: a click track, a big shape that flashes on the beat, and the +# compensation stepping through a set of values every four bars with the +# number printed large. Watch it once, say which section locks, and that +# number becomes SYNC_MS for every score video. +# +# python3 pop/loner/bin/synccal.py → out/synccal.mp4 +# +# The click is deliberately dry and percussive: a transient is the only +# thing the eye and ear can compare precisely. + +import math, multiprocessing, os, subprocess, wave +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +HERE = os.path.dirname(os.path.abspath(__file__)) +LANE = os.path.dirname(HERE) +OUT = os.path.join(LANE, "out") +SEG = os.path.join(OUT, ".synccal") + +W, H, FPS = 1280, 720, 60 +BPM, SR = 122.0, 48000 +SPB = 60.0 / BPM +OFFSETS = [0, 25, 50, 75, 100, 125] # ms of picture lead, per section +BARS_EACH = 3 +BEATS = len(OFFSETS) * BARS_EACH * 4 +DUR = BEATS * SPB + 1.0 + +BG, FG, HOT, DIM = (16, 15, 22), (238, 234, 230), (255, 92, 162), (120, 118, 130) +F = lambda s: ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", s) +f_big, f_lab, f_sm = F(230), F(54), F(30) + + +def click_track(path): + """A dry click on every beat — transients only, nothing to smear.""" + n = int(DUR * SR) + y = np.zeros(n) + rng = np.random.default_rng(3) + for b in range(BEATS): + i0 = int(b * SPB * SR) + m = int(0.035 * SR) + u = np.arange(m) / SR + env = np.exp(-u * 260) + tick = (np.sin(2 * np.pi * 1800 * u) * 0.7 + rng.standard_normal(m) * 0.35) + body = np.sin(2 * np.pi * (70 + 220 * np.exp(-u * 90)) * u) * np.exp(-u * 55) + hit = (tick * env + body * 0.9) * (1.0 if b % 4 else 1.0) + y[i0:i0 + m] += hit[:max(0, min(m, n - i0))] + y /= max(1e-9, np.abs(y).max()) / 0.89 + with wave.open(path, "wb") as f: + f.setnchannels(1); f.setsampwidth(2); f.setframerate(SR) + f.writeframes((y * 32767).astype("= 0 + r = 150 + d.ellipse([W // 2 - r, 250 - r, W // 2 + r, 250 + r], + fill=HOT if lit else (34, 32, 42)) + lab = f"{OFFSETS[sec]} ms" + d.text((W // 2 - d.textlength(lab, font=f_big) / 2, 400), lab, + font=f_big, fill=FG if lit else DIM) + d.text((40, 30), "sync calibration — which section locks?", + font=f_lab, fill=FG) + d.text((40, H - 60), + "each section holds one compensation for 3 bars · " + "pick the one where flash and click are simultaneous", + font=f_sm, fill=DIM) + for k, o in enumerate(OFFSETS): # a progress ladder + x = 40 + k * 90 + d.rectangle([x, H - 110, x + 74, H - 92], + fill=HOT if k == sec else (44, 42, 54)) + return np.asarray(img, dtype=np.uint8).tobytes() + + +def encode(job): + k, a, b = job + seg = os.path.join(SEG, f"s{k:02d}.mp4") + p = subprocess.Popen( + ["ffmpeg", "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", + "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-", "-c:v", "libx264", + "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", + "-threads", "1", seg], stdin=subprocess.PIPE) + for i in range(a, b): + p.stdin.write(render_frame(i)) + p.stdin.close(); p.wait() + return seg + + +if __name__ == "__main__": + try: + multiprocessing.set_start_method("fork", force=True) + except RuntimeError: + pass + os.makedirs(SEG, exist_ok=True) + for f in os.listdir(SEG): + os.remove(os.path.join(SEG, f)) + aud = os.path.join(SEG, "click.wav") + click_track(aud) + frames = int(DUR * FPS) + workers = max(1, min(os.cpu_count() or 4, 8)) + edges = [round(frames * k / workers) for k in range(workers + 1)] + jobs = [(k, edges[k], edges[k + 1]) for k in range(workers) + if edges[k + 1] > edges[k]] + with multiprocessing.Pool(len(jobs)) as pool: + segs = pool.map(encode, jobs) + lst = os.path.join(SEG, "l.txt") + with open(lst, "w") as fh: + for s in segs: + fh.write(f"file '{os.path.basename(s)}'\n") + out = os.path.join(OUT, "synccal.mp4") + subprocess.run(["ffmpeg", "-y", "-v", "error", "-f", "concat", "-safe", "0", + "-i", lst, "-i", aud, "-c:v", "copy", "-c:a", "aac", + "-b:a", "192k", "-shortest", out], check=True) + print(f"✓ {out} · {frames} frames · {DUR:.1f}s · sections {OFFSETS} ms") diff --git a/pop/loner/c/loner-chart.h b/pop/loner/c/loner-chart.h index cba9f1e425..92fa172f4f 100644 --- a/pop/loner/c/loner-chart.h +++ b/pop/loner/c/loner-chart.h @@ -26,7 +26,8 @@ static const ChartNote w_whole_line_notes[] = { { 22.00, 3.00, 10 }, { 25.00, 3.00, 5 }, { 28.00, 4.00, 2 }, - { 32.00, 4.00, 3 }, + { 32.00, 2.00, 3 }, + { 34.00, 2.00, 2 }, { 36.00, 2.00, 0 }, { 38.00, 2.00, -2 }, { 40.00, 2.00, 7 }, @@ -92,7 +93,7 @@ static const ChartNote w_n_for_time_to_pass_notes[] = { }; static const ChartPhrase CHART[] = { - { "w-whole-line", 0.490, 58.50, 23, w_whole_line_notes }, + { "w-whole-line", 0.490, 58.50, 24, w_whole_line_notes }, { "w-sitting-curled", 0.020, 11.00, 5, w_sitting_curled_notes }, { "w-i-think", 0.020, 3.50, 2, w_i_think_notes }, { "w-of-a-stone", 0.000, 8.00, 3, w_of_a_stone_notes }, diff --git a/pop/loner/c/lonerremix.c b/pop/loner/c/lonerremix.c index 8a8f520905..ad3e3f5ebc 100644 --- a/pop/loner/c/lonerremix.c +++ b/pop/loner/c/lonerremix.c @@ -642,9 +642,7 @@ int main(void) { // floor alone tells you where the bar is but not where you are // inside it; offbeat air-hats give the 8ths, and quiet 16th ticks // give the subdivision the words are actually being placed on. - // a kick on the PICKUP beat too — @jeffrey: "pre bar 0 the sss - // sound is there, anyway we should have a kick there" - kick(fmax(0.0, off - BEAT), 0.95); + // (no kick on the pickup — the /s/ has it to itself) for (int bar = 0; bar < kickBars; bar++) { double t = off + at(bar); for (int b = 0; b < 4; b++) kick(t + b * BEAT, 0.95); diff --git a/pop/loner/vox4/.chart.json b/pop/loner/vox4/.chart.json index 25131d612f..bc02544b7a 100644 --- a/pop/loner/vox4/.chart.json +++ b/pop/loner/vox4/.chart.json @@ -4,43 +4,43 @@ "beats": 58.5, "voiced": [ [ - 0.0, - 8.0418 + -0.8235, + 8.0113 ], [ - 8.4993, - 11.895 + 8.4688, + 11.7323 ], [ - 11.9967, - 17.7612 + 11.834, + 17.5782 ], [ - 17.995, - 24.6542 + 17.812, + 24.4305 ], [ - 24.9998, - 31.6997 + 24.8168, + 31.5167 ], [ - 32.0047, - 41.8968 + 31.8217, + 41.785 ], [ - 41.9985, - 44.9367 + 41.8867, + 44.7537 ], [ - 44.9977, - 46.8988 + 44.8147, + 46.7158 ], [ - 47.0005, - 55.0627 + 46.8175, + 54.8695 ], [ - 55.2558, + 55.0728, 59.3123 ] ], @@ -50,7 +50,7 @@ "dur": 2.0, "st": 7, "t": "sitting\u00b7a", - "lead": 0.0407 + "lead": 0.2237 }, { "beat": 2.0, @@ -64,147 +64,154 @@ "dur": 2.5, "st": 3, "t": "curled", - "lead": 0.0 + "lead": 0.183 }, { "beat": 6.5, "dur": 1.5, "st": 2, "t": "up", - "lead": 0.0 + "lead": 0.1423 }, { "beat": 8.5, "dur": 2.0, "st": 0, "t": "in", - "lead": 0.0 + "lead": 0.0305 }, { "beat": 10.5, "dur": 1.5, "st": 5, "t": "myself\u00b7a", - "lead": 0.0 + "lead": 0.0712 }, { "beat": 12.0, "dur": 2.0, "st": 2, "t": "myself\u00b7b", - "lead": 0.1017 + "lead": 0.2643 }, { "beat": 14.0, "dur": 2.0, "st": -2, "t": "i", - "lead": 0.0 + "lead": 0.1525 }, { "beat": 16.0, "dur": 2.0, "st": -5, "t": "think", - "lead": 0.0 + "lead": 0.183 }, { "beat": 18.0, "dur": 4.0, "st": 12, "t": "of", - "lead": 0.2033 + "lead": 0.3863 }, { "beat": 22.0, "dur": 3.0, "st": 10, "t": "a", - "lead": 0.0 + "lead": 0.183 }, { "beat": 25.0, "dur": 3.0, "st": 5, "t": "stone", - "lead": 0.0508 + "lead": 0.2338 }, { "beat": 28.0, "dur": 4.0, "st": 2, "t": "just", - "lead": 0.0 + "lead": 0.183 }, { "beat": 32.0, - "dur": 4.0, + "dur": 2.0, "st": 3, - "t": "waiting", - "lead": 0.244 + "t": "wait", + "lead": 0.427 + }, + { + "beat": 34.0, + "dur": 2.0, + "st": 2, + "t": "ing", + "lead": 0.0915 }, { "beat": 36.0, "dur": 2.0, "st": 0, "t": "ve", - "lead": 0.0 + "lead": 0.183 }, { "beat": 38.0, "dur": 2.0, "st": -2, "t": "ry", - "lead": 0.0 + "lead": 0.0813 }, { "beat": 40.0, "dur": 2.0, "st": 7, "t": "pa", - "lead": 0.0 + "lead": 0.1017 }, { "beat": 42.0, "dur": 1.5, "st": 5, "t": "tient", - "lead": 0.0915 + "lead": 0.2033 }, { "beat": 43.5, "dur": 1.5, "st": 3, "t": "ly", - "lead": 0.0 + "lead": 0.183 }, { "beat": 45.0, "dur": 2.0, "st": 5, "t": "for", - "lead": 0.0508 + "lead": 0.2338 }, { "beat": 47.0, "dur": 4.5, "st": 7, "t": "time", - "lead": 0.0712 + "lead": 0.2542 }, { "beat": 51.5, "dur": 4.0, "st": 3, "t": "to", - "lead": 0.0 + "lead": 0.183 }, { "beat": 55.5, "dur": 3.0, "st": 3, "t": "pass", - "lead": 0.0 + "lead": 0.1728 } ] }, diff --git a/pop/loner/vox4/.manifest.json b/pop/loner/vox4/.manifest.json index 39f452396c..df18b17e56 100644 --- a/pop/loner/vox4/.manifest.json +++ b/pop/loner/vox4/.manifest.json @@ -16,7 +16,8 @@ "a -40ms", "stone -45ms", "just +80ms", - "waiting -100ms", + "wait -100ms", + "ing -20ms", "ve +65ms", "ry +160ms", "pa +80ms", -- 2.51.2