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",