From c717fc06daa21548f7aa1904be09df20d6714d52 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 31 Aug 2026 16:38:36 -0400 Subject: [PATCH 1/2] Tighten Cult intro and lyric releases --- pop/cult/bin/cut-final.sh | 11 ++-- pop/cult/bin/cut-v10.sh | 19 +++--- pop/cult/viz/review-score.py | 108 +++++++++++++++++++++++++---------- 3 files changed, 94 insertions(+), 44 deletions(-) diff --git a/pop/cult/bin/cut-final.sh b/pop/cult/bin/cut-final.sh index 4cc3e3dc38..15931ace0f 100644 --- a/pop/cult/bin/cut-final.sh +++ b/pop/cult/bin/cut-final.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # cut-final.sh — the release master (pop / DistroKid), proper loudness. # -# Chain: fresh C render → bar-8 trim → the Peep-pass space (bass shelf + +# Chain: fresh render → four-bar intro edit → the Peep-pass space (bass shelf + # cathedral convolution, same as cut-v10) → MEASURE → one static dB to # −11.5 LUFS integrated → true-peak limiter with a −1.0 dBTP ceiling. # Never a second loudnorm. Deliverables: @@ -21,9 +21,12 @@ FULL="$OUT/cult-remix-v10-full.wav" TRIM="$OUT/.final-trim.wav" SPACE="$OUT/.final-space.wav" -echo "→ trim to bar 8 (15.95 s in, 20 ms fade)" -ffmpeg -y -v error -ss 15.95 -i "$FULL" \ - -af "afade=t=in:st=0:d=0.02" -c:a pcm_s24le "$TRIM" +echo "→ four-bar intro edit (keep bars 8–11, cut bars 12–23)" +ffmpeg -y -v error -i "$FULL" -filter_complex \ + "[0:a]atrim=start=15.95:end=24,asetpts=PTS-STARTPTS[a];\ +[0:a]atrim=start=48,asetpts=PTS-STARTPTS[b];\ +[a][b]concat=n=2:v=0:a=1,afade=t=in:st=0:d=0.02[out]" \ + -map "[out]" -c:a pcm_s24le "$TRIM" echo "→ cathedral + low shelf" ffmpeg -y -v error -i "$TRIM" -i "$LANE/samples/cathedral-ir.wav" -filter_complex \ diff --git a/pop/cult/bin/cut-v10.sh b/pop/cult/bin/cut-v10.sh index 25a748ea31..0c671a2bb8 100644 --- a/pop/cult/bin/cut-v10.sh +++ b/pop/cult/bin/cut-v10.sh @@ -1,11 +1,9 @@ #!/usr/bin/env bash # cut-v10.sh — master the v10 render and cut the mp3. # -# v10 starts when the kicks start: the full render still scores the eight -# carrier bars (their drone and dial tails ring under the entrance), and -# the cut begins 50 ms before bar 8's downbeat with a 20 ms fade, so the -# first thing that lands is the watery-hole metallic ring, then the kick. Trim FIRST, then measure — loudnorm's -# integrated reading must not include sixteen seconds we don't ship. +# v10 starts when the kicks start, keeps four bars of the three-voice intro, +# then cuts render bars 12–23 so THE MESSAGE arrives at 0:08 instead of 0:32. +# The full renderer and receipt remain archival; this is the release edit. # # Same mastering law as v3/v5/v9: MEASURE → one static dB → true-peak # limiter. Never a second loudnorm (it rides gain and manufactures clicks). @@ -19,10 +17,13 @@ FULL="$OUT/cult-remix-v10-full.wav" TRIM="$OUT/cult-remix-v10-full-trim.wav" SRC="$OUT/cult-remix-v10-full-master.wav" -if [ ! -f "$TRIM" ] || [ "$FULL" -nt "$TRIM" ]; then - echo "→ trim to bar 8 (15.95 s in, 20 ms fade)" - ffmpeg -y -v error -ss 15.95 -i "$FULL" \ - -af "afade=t=in:st=0:d=0.02" -c:a pcm_s24le "$TRIM" +if [ ! -f "$TRIM" ] || [ "$FULL" -nt "$TRIM" ] || [ "$0" -nt "$TRIM" ]; then + echo "→ four-bar intro edit (keep bars 8–11, cut bars 12–23)" + ffmpeg -y -v error -i "$FULL" -filter_complex \ + "[0:a]atrim=start=15.95:end=24,asetpts=PTS-STARTPTS[a];\ +[0:a]atrim=start=48,asetpts=PTS-STARTPTS[b];\ +[a][b]concat=n=2:v=0:a=1,afade=t=in:st=0:d=0.02[out]" \ + -map "[out]" -c:a pcm_s24le "$TRIM" fi SPACE="$OUT/.v10-space.wav" diff --git a/pop/cult/viz/review-score.py b/pop/cult/viz/review-score.py index ec15b6e488..8b8bfc2e9a 100644 --- a/pop/cult/viz/review-score.py +++ b/pop/cult/viz/review-score.py @@ -13,7 +13,8 @@ # · out/cult-remix-v10.events.json — the score receipt (regenerate with # `node pop/cult/bin/render10.mjs`); word mapping ported from # bin/transcript.mjs. Receipt times are FULL-render seconds; the -# shipped cut starts TRIM=15.95 s in (bar 8 of the render = 0:00). +# release edit starts TRIM=15.95 s in, keeps render bars 8–11, then +# removes bars 12–23 so bar 24 (THE MESSAGE) lands at about 0:08. # · out/stems/v10-*.wav — TRUE per-bus stems from # `node pop/cult/bin/render10.mjs --stems` (vox / tube / music / # drums / signal). The music and drums buses are band-split into @@ -81,6 +82,26 @@ EIGHTH = BEAT / 2 TRIM = 15.95 # shipped t = full-render t − TRIM GRID0 = 8 * BAR - TRIM # 0.05 — bar 8's downbeat in shipped time BAR0 = 8 # ruler numbers speak render bars (hook = 29) +INTRO_CUT = (12 * BAR, 24 * BAR) # remove full-render bars 12–23 (24 s) + +def ship_time(full_t): + """Map full-render receipt time into the four-bar-intro release edit.""" + if full_t < INTRO_CUT[0]: + return full_t - TRIM + if full_t < INTRO_CUT[1]: + return None + return full_t - TRIM - (INTRO_CUT[1] - INTRO_CUT[0]) + +def ship_span(full_t, dur): + """Map one event, preserving only the portions outside the intro cut.""" + full_end = full_t + dur + if full_t < INTRO_CUT[0]: + return full_t - TRIM, min(full_end, INTRO_CUT[0]) - TRIM + if full_t < INTRO_CUT[1]: + if full_end <= INTRO_CUT[1]: + return None + return ship_time(INTRO_CUT[1]), ship_time(full_end) + return ship_time(full_t), ship_time(full_end) # ---------------------------------------------------------------- audio in def load(path, af=None, ss=None): @@ -106,14 +127,20 @@ def fit(sig): sig = np.concatenate([sig, np.zeros(N - len(sig), np.float32)]) return sig[:N] -def stem(name, af=None): # bus stem, trimmed to the shipped cut - return fit(load(os.path.join(STEMS, f"v10-{name}.wav"), af=af, ss=TRIM)) +def intro_cut(sig): + a = int(round((INTRO_CUT[0] - TRIM) * sr)) + b = int(round((INTRO_CUT[1] - TRIM) * sr)) + return np.concatenate([sig[:a], sig[b:]]) + +def stem(name, af=None): # bus stem, trimmed + four-bar-intro edit + sig = load(os.path.join(STEMS, f"v10-{name}.wav"), af=af, ss=TRIM) + return fit(intro_cut(sig)) # ---------------------------------------------------------------- receipt receipt = json.load(open(EVENTS_JSON)) EVENTS = receipt["events"] -EXPLOSIONS = [{**e, "t": e["t"] - TRIM} for e in EVENTS - if e.get("voice") == "spatial-explosion" and e["t"] >= TRIM] +EXPLOSIONS = [{**e, "t": ship_time(e["t"])} for e in EVENTS + if e.get("voice") == "spatial-explosion" and ship_time(e["t"]) is not None] WHO_COL = {"camille": (255, 140, 190), "alex": (150, 225, 130), "jeffrey": (130, 175, 255), None: (225, 222, 230)} @@ -147,11 +174,12 @@ for e in EVENTS: w = words_of(s) if w is None: continue - t0 = e["t"] - TRIM - if t0 < -0.3: - continue dur = float(e.get("dur", 0.3)) or 0.3 - WORDS.append({"t0": max(0.0, t0), "t1": max(0.0, t0) + dur, "word": w, + span = ship_span(e["t"], dur) + if span is None or span[1] < -0.3: + continue + t0, t1 = span + WORDS.append({"t0": max(0.0, t0), "t1": max(0.0, t1), "word": w, "who": e.get("who"), "midi": e.get("midi"), "voice": e["voice"], "prio": PRIO.get(e["voice"], 1)}) WORDS.sort(key=lambda e: e["t0"]) @@ -187,10 +215,11 @@ for e in EVENTS: lbl = "guitar" if lbl is None: continue - t0 = e["t"] - TRIM - if t0 < -0.3: - continue dur = float(e.get("dur", 0.3)) or 0.3 + span = ship_span(e["t"], dur) + if span is None or span[1] < -0.3: + continue + t0, t1 = span m = INSTR_MIDI.get(lbl, 56) if lbl == "accordion": m = CHORD_MIDI.get(s.split("-")[-1], m) @@ -200,7 +229,7 @@ for e in EVENTS: m = min(69, max(43, e["midi"] + 19)) # spread the low notes in-lane if INSTR and INSTR[-1]["word"] == lbl and abs(INSTR[-1]["t0"] - max(0.0, t0)) < 0.12: continue # unison doubles draw once - INSTR.append({"t0": max(0.0, t0), "t1": max(0.0, t0) + dur, "word": lbl, + INSTR.append({"t0": max(0.0, t0), "t1": max(0.0, t1), "word": lbl, "who": None, "midi": m, "voice": "instr", "prio": 0}) INSTR.sort(key=lambda e: e["t0"]) print(f"{len(INSTR)} instrument events (violin / guitars / boings / accordion / watery-hole)", @@ -212,7 +241,7 @@ print(f"{len(INSTR)} instrument events (violin / guitars / boings / accordion / print("decoding stems...", flush=True) EV_WORDS = [e for e in WORDS if e["voice"] in ("lead", "dot", "cult", "alt", "stretch", "material")] -KARAOKE = [e for e in WORDS if e["voice"] != "material"] # grains are texture +KARAOKE = [e for e in WORDS if e["voice"] not in ("material", "sub")] EV_DASH = [e for e in WORDS if e["voice"] in ("dash", "bassdash", "sosdash")] # ---------------------------------------------------------------- tokens @@ -272,6 +301,19 @@ for tk in TOKENS: } else: tk["rail_spans"][None] = {"t0": tk["t0"], "t1": tk["t1"]} + if tk["word"] == "dot": + max_hold = 0.48 + elif tk["display_whos"]: + # The pitched samples include long ringing tails. Those tails belong + # in the sound, but keeping the colored word active for all of them + # makes the singer appear to hold text after the utterance is over. + max_hold = 2.20 if " " in tk["word"] else 1.25 + else: + max_hold = None + if max_hold is not None: + for span in tk["rail_spans"].values(): + span["t1"] = min(span["t1"], span["t0"] + max_hold) + tk["t1"] = max(span["t1"] for span in tk["rail_spans"].values()) print(f"{len(TOKENS)} lyric tokens " f"({sum(1 for tk in TOKENS if len(tk['whos']) > 1)} multi-voice)", flush=True) @@ -519,20 +561,20 @@ PAD_L = PLAY_X - GUT PAD_R = W - PLAY_X STRIP_W = SWm + PAD_L + PAD_R -# Acts in shipped time (render bar 8 = 0:00; receipt narrative − TRIM). +# Acts in release time. Render bars 12–23 are absent from this edit. ACTS = [ (0.00, "II THREE VOICES", (64, 190, 180)), - (32.05, "III THE MESSAGE", (235, 150, 70)), - (64.05, "IV THE SECRET", (205, 75, 85)), - (80.05, "V THE REPLY", (120, 200, 120)), - (112.05, "VI IT SPREADS", (170, 130, 230)), - (136.05, "VII THE WHOLE MESSAGE", (255, 210, 90)), - (176.05, "VIII RECOGNITION", (150, 160, 200)), - (192.05, "IX CARRIER OFF", (150, 150, 155)), + (ship_time(24 * BAR), "III THE MESSAGE", (235, 150, 70)), + (ship_time(40 * BAR), "IV THE SECRET", (205, 75, 85)), + (ship_time(48 * BAR), "V THE REPLY", (120, 200, 120)), + (ship_time(64 * BAR), "VI IT SPREADS", (170, 130, 230)), + (ship_time(76 * BAR), "VII THE WHOLE MESSAGE", (255, 210, 90)), + (ship_time(96 * BAR), "VIII RECOGNITION", (150, 160, 200)), + (ship_time(104 * BAR), "IX CARRIER OFF", (150, 150, 155)), ] ACT_END = [a[0] for a in ACTS[1:]] + [DUR] -MARKS = [(0.05, "watery-hole"), (20.30, "whistlegraph.org"), - (42.05, "bar 29: the sentence lands")] +MARKS = [(0.05, "watery-hole"), + (ship_time(29 * BAR), "bar 29: the sentence lands")] f_title = ImageFont.truetype(FONT_B, 48) f_lbl = ImageFont.truetype(FONT_B, 24) @@ -573,22 +615,26 @@ for li in range(NLANE): y0 = LBL_BAND + lane_y[li] sd.rectangle([x0m, y0, x1m, y0 + LANE_DEFS[li][4] - 1], fill=LANE_BG) -# beat grid + ruler (bar numbers speak RENDER bars: shipped 0:00 = bar 8) +# Beat grid + ruler. Spacing stays continuous while the labels jump from +# render bar 11 to bar 24 at the release edit. ry = STRIP_H - RULER_H -k = 0 +render_bar = BAR0 while True: - bt = GRID0 + k * BAR + bt = ship_time(render_bar * BAR) + if bt is None: + render_bar += 1 + continue if bt >= DUR: break x = sx(bt) - heavy = (k % 4 == 0) + heavy = (render_bar % 4 == 0) gcol = GRID_HVY if heavy else GRID_LT sd.line([x, LBL_BAND, x, ry - 1], fill=gcol) tick = TICK_HVY if heavy else TICK_LT sd.line([x, ry, x, ry + (12 if heavy else 8)], fill=tick, width=2 if heavy else 1) - sd.text((x + 4, ry + 8), str(k + BAR0), font=f_bar, + sd.text((x + 4, ry + 8), str(render_bar), font=f_bar, fill=BNUM_HVY if heavy else BNUM_LT) - k += 1 + render_bar += 1 # act boundaries: heavy colored line + act name in the label band for (t0, name, col) in ACTS: @@ -781,7 +827,7 @@ SCR_LABEL = {"camille": "CAMILLE", "alex": "ALEX", "jeffrey": "JEFFREY", None: "WORDS"} LPPS = 150.0 SCR_LOOK = (SCR_X1 - NOW_X) / LPPS # ≈ 7.0 s of upcoming lyric -SCR_FADE = 1.6 # seconds to fall away after t1 +SCR_FADE = 0.65 # release promptly after the sung tail _meas = ImageDraw.Draw(Image.new("RGB", (8, 8))) SCR_FONT_SIZES = (38, 34, 30, 26, 22, 19) -- 2.51.2 From 9f9d0adcb3404a883e0c2a51d642c566e0e00783 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 31 Aug 2026 16:55:24 -0400 Subject: [PATCH 2/2] Separate Cult lyric performer tracks --- pop/cult/viz/review-score.py | 179 +++++++++++++---------------------- 1 file changed, 64 insertions(+), 115 deletions(-) diff --git a/pop/cult/viz/review-score.py b/pop/cult/viz/review-score.py index 8b8bfc2e9a..681970590b 100644 --- a/pop/cult/viz/review-score.py +++ b/pop/cult/viz/review-score.py @@ -33,10 +33,9 @@ # words/phrases approach a fixed now-line from the right, light up in # their singer's color while sung, then fade and sink away leftward. # · MULTI-VOICE KARAOKE — overlapping word events with different `who` -# performers draw as a STACK of name-colored rows (camille / alex / -# jeffrey top-to-bottom by register), so the dashStack's -# three-people-one-pitch-28-ms-apart signature is visible, not -# collapsed into one word. +# performers draw in separate, name-colored bands (camille / alex / +# jeffrey / source words). Each band gains collision-free subtracks as +# needed, so simultaneous parts stay distinct without clipping phonics. # · VOWEL STRETCHING + PHONICS FILL — held words elongate their stressed # vowel to match the sung duration ("daaaaaaaash", "ruuuuun reeeaaal # faaaast") in both the scroller and the stack, and the letters light @@ -583,24 +582,6 @@ f_mark = ImageFont.truetype(FONT_R, 20) f_bar = ImageFont.truetype(FONT_R, 20) f_act = ImageFont.truetype(FONT_B, 25) f_tc = ImageFont.truetype(FONT_B, 56) -f_who = ImageFont.truetype(FONT_B, 36) -# multi-voice stack: the row font shrinks as more voices sing at once; -# F_SIZES is also the step-down ladder for rows whose STRETCHED word -# (daaaaaaaash) would otherwise run under the act card -F_SIZES = [88, 64, 52, 42, 34, 28] -F_BANK = {s: ImageFont.truetype(FONT_B, s) for s in F_SIZES} -F_STACK = {1: F_BANK[88], 2: F_BANK[64], 3: F_BANK[52], 4: F_BANK[28]} -KAR_MAX_W = 1150 # stack text right edge stays off the act card -def fit_stack_font(dd, text, font): - while dd.textlength(text, font=font) > KAR_MAX_W: - smaller = [s for s in F_SIZES if s < font.size] - if not smaller: - break - font = F_BANK[smaller[0]] - return font -f_name = ImageFont.truetype(FONT_B, 24) -f_scr = ImageFont.truetype(FONT_B, 38) # scroller: upcoming / past -f_scra = ImageFont.truetype(FONT_B, 48) # scroller: the word being sung def sx(t): # strip x for time t return PAD_L + int(round(t * PPS)) @@ -783,35 +764,6 @@ def karaoke_fill(dd, x, y, disp, font, col, p, k=1.0): dd.text((x, y), disp[:kk], font=font, fill=blend(BG, col, k), anchor="lm") return dd.textlength(disp[:kk], font=font) -# ── multi-voice stack (the big karaoke, one row per live voice) ────── -# Live tokens group by word; a word with several `who` performers expands -# to one name-colored row per person (camille/alex/jeffrey, register -# order). If the moment is too crowded, multi-voice words COMPRESS to a -# single chorus row — the word drawn once per performer, offset copies in -# each color, all names listed — so nobody is ever dropped. -KAR_TOP = STRIP_BOT + 14 -def rows_at(t): - candidates = [] - for tk in TOKENS: - for who, span in tk["rail_spans"].items(): - if span["t0"] <= t < max(span["t1"], span["t0"] + MIN_SHOW): - candidates.append({"word": tk["word"], - "whos": [who] if who else [], - "alt": tk["alt"], "tk": tk, - "span": span, "prio": tk["prio"]}) - candidates.sort(key=lambda r: (-r["prio"], -r["span"]["t0"], - WHO_ORDER.get(r["whos"][0], 9) if r["whos"] else 9)) - rows, seen = [], set() - for row in candidates: - key = (row["word"], row["whos"][0] if row["whos"] else None) - if key in seen: - continue - seen.add(key) - rows.append(row) - rows.sort(key=lambda r: (WHO_ORDER.get(r["whos"][0], 9) if r["whos"] else 9, - -r["prio"])) - return rows[:4] - # ── lyrics scroller (teleprompter band along the bottom) ───────────── # Time maps to x exactly like the timeline above. Camille, Alex, Jeffrey, # and unattributed/source words own fixed vertical rails, so three people @@ -819,54 +771,81 @@ def rows_at(t): # offset drawings fighting for the same row. NOW_X = 350 SCR_X0, SCR_X1 = 190, 1404 # labels own the left; act card owns the right -SCR_Y0, SCR_Y1 = H - 174, H - 42 +SCR_Y0, SCR_Y1 = STRIP_BOT + 8, H - 42 SCR_RAILS = ("camille", "alex", "jeffrey", None) -SCR_ROW_Y = {"camille": H - 150, "alex": H - 118, - "jeffrey": H - 86, None: H - 54} SCR_LABEL = {"camille": "CAMILLE", "alex": "ALEX", "jeffrey": "JEFFREY", None: "WORDS"} +SCR_BAND_H = (SCR_Y1 - SCR_Y0) / len(SCR_RAILS) LPPS = 150.0 SCR_LOOK = (SCR_X1 - NOW_X) / LPPS # ≈ 7.0 s of upcoming lyric SCR_FADE = 0.65 # release promptly after the sung tail _meas = ImageDraw.Draw(Image.new("RGB", (8, 8))) -SCR_FONT_SIZES = (38, 34, 30, 26, 22, 19) +SCR_FONT_SIZES = (30, 28, 26, 24, 22, 20, 18, 16, 14) SCR_FONT_BANK = {s: ImageFont.truetype(FONT_B, s) for s in SCR_FONT_SIZES} rail_items = {r: [] for r in SCR_RAILS} for tk in TOKENS: tk["rails"] = list(tk["rail_spans"]) tk["scr_font"] = {} + tk["scr_slot"] = {} for rail in tk["rails"]: rail_items[rail].append((tk, tk["rail_spans"][rail])) -for rail, items in rail_items.items(): - items.sort(key=lambda item: item[1]["t0"]) - for i, (tk, span) in enumerate(items): - # Fit to the time cell before the next word on this SAME singer rail. - # Font size is precomputed, so rows cannot flicker or collide. - avail = ((items[i + 1][1]["t0"] - span["t0"]) * LPPS - 10 - if i + 1 < len(items) else SCR_X1 - NOW_X) - cell = max(8, avail) + +def layout_rail(items, max_size): + """Color one rail into non-overlapping subtracks using full text widths.""" + slot_until = [] + layout = [] + for tk, span in items: font = SCR_FONT_BANK[SCR_FONT_SIZES[-1]] for size in SCR_FONT_SIZES: + if size > max_size: + continue candidate = SCR_FONT_BANK[size] - if _meas.textlength(tk["disp"], font=candidate) <= cell: + if _meas.textlength(tk["disp"], font=candidate) <= 520: font = candidate break + cell = _meas.textlength(tk["disp"], font=font) + 18 + slot = next((i for i, until in enumerate(slot_until) + if span["t0"] >= until - 1e-6), None) + if slot is None: + slot = len(slot_until) + slot_until.append(0.0) + slot_until[slot] = span["t0"] + cell / LPPS + layout.append((tk, font, cell, slot)) + return layout, max(1, len(slot_until)) + +SCR_SLOT_COUNTS = {} +for rail, items in rail_items.items(): + items.sort(key=lambda item: item[1]["t0"]) + for max_size in SCR_FONT_SIZES: + layout, slots = layout_rail(items, max_size) + vertical_capacity = max(1, int(SCR_BAND_H // (max_size + 8))) + if slots <= vertical_capacity or max_size == SCR_FONT_SIZES[-1]: + break + SCR_SLOT_COUNTS[rail] = slots + for tk, font, cell, slot in layout: tk["scr_font"][rail] = font + tk["scr_slot"][rail] = slot tk.setdefault("scr_cell", {})[rail] = cell - -for tk in TOKENS: - tk["w"] = max(_meas.textlength(tk["disp"], font=f) - for f in tk["scr_font"].values()) +print("lyric subtracks: " + ", ".join( + f"{SCR_LABEL[rail].lower()}={SCR_SLOT_COUNTS[rail]}" for rail in SCR_RAILS), + flush=True) def draw_scroller(img, dd, t): - for rail in SCR_RAILS: - yc = SCR_ROW_Y[rail] + for ri, rail in enumerate(SCR_RAILS): + band_top = SCR_Y0 + ri * SCR_BAND_H + band_bot = SCR_Y0 + (ri + 1) * SCR_BAND_H + yc = (band_top + band_bot) / 2 col = ink_of(WHO_COL[rail]) dd.text((42, yc), SCR_LABEL[rail], font=f_tiny, fill=blend(BG, col, 0.78), anchor="lm") - dd.line([SCR_X0 - 12, yc + 15, SCR_X1, yc + 15], + dd.line([SCR_X0 - 12, band_bot - 1, SCR_X1, band_bot - 1], fill=blend(BG, col, 0.16), width=1) + slots = SCR_SLOT_COUNTS[rail] + for slot in range(1, slots): + sy = band_top + slot * SCR_BAND_H / slots + dd.line([SCR_X0, sy, SCR_X1, sy], + fill=blend(BG, col, 0.08), width=1) dd.line([NOW_X, SCR_Y0, NOW_X, SCR_Y1], fill=blend(BG, PLAYHEAD, 0.55), width=2) for tk in TOKENS: @@ -887,17 +866,23 @@ def draw_scroller(img, dd, t): k = 0.75 * (1.0 - past) k *= max(0.0, min(1.0, (SCR_X1 - x) / 130.0)) p = prog(tk, t, span) - yc = SCR_ROW_Y[rail] font = tk["scr_font"][rail] base = ink_of(WHO_COL[rail]) - # Draw into the exact time cell and paste it back. This hard clip - # is what makes adjacent lyric tracks incapable of overlapping, - # even when a melisma is still active at the next word's attack. + ri = SCR_RAILS.index(rail) + slots = SCR_SLOT_COUNTS[rail] + slot = tk["scr_slot"][rail] + band_top = SCR_Y0 + ri * SCR_BAND_H + slot_top = band_top + slot * SCR_BAND_H / slots + slot_bot = band_top + (slot + 1) * SCR_BAND_H / slots + yc = (slot_top + slot_bot) / 2 + # Each repeated vowel owns its full measured width. Overlapping + # utterances move to separate subtracks instead of being clipped + # at the next onset. x0 = max(SCR_X0, int(math.floor(x))) x1 = min(SCR_X1, int(math.ceil(x + tk["scr_cell"][rail]))) if x1 <= x0: continue - y0, y1 = int(yc - 22), int(yc + 23) + y0, y1 = int(math.floor(slot_top + 1)), int(math.ceil(slot_bot - 1)) tile = img.crop((x0, y0, x1, y1)) td = ImageDraw.Draw(tile) tx, ty = x - x0, yc - y0 @@ -908,7 +893,7 @@ def draw_scroller(img, dd, t): fill=blend(BG, base, k), anchor="lm") lw = 0 if active: # one swept underline on each performer rail - uy = ty + font.size // 2 + 2 + uy = min(y1 - y0 - 4, int(ty + font.size / 2 + 1)) td.rectangle([tx, uy, tx + max(2, lw), uy + 3], fill=base) img.paste(tile, (x0, y0)) @@ -1024,44 +1009,8 @@ for f in range(NF): col = blend(BG, MARK_COL, min(0.72, 0.18 + abs(spring) * 0.42 + fracture * 0.25)) dd.ellipse([PLAY_X - radius, cy - radius, PLAY_X + radius, cy + radius], outline=col, width=max(1, int(2 + fracture * 5))) - # karaoke: EVERY live voice as its own name-colored row — the - # dashStack's three simultaneous people stack camille/alex/jeffrey - # top-to-bottom by register instead of collapsing into one word - rows = rows_at(t) - if rows: - font0 = F_STACK[len(rows)] - rh = font0.size + 18 - y = KAR_TOP + rh // 2 + 4 - for row in rows: - whos = row["whos"] - rtk = row["tk"] - disp = rtk["disp"] # the vowel-stretched word - p = prog(rtk, t, row["span"]) # this singer's receipt timing - font = fit_stack_font(dd, disp, font0) - uy = y + font.size // 2 + 5 - if len(whos) > 1: # chorus row: every performer, one word - lw = 0 - for i, who in enumerate(whos): - dd.text((66, y + (i - (len(whos) - 1) / 2) * 20), who, - font=f_name, fill=ink_of(WHO_COL[who]), anchor="lm") - lw = karaoke_fill(dd, 250 + i * 3, - y + (i - (len(whos) - 1) / 2) * 10, - disp, font, ink_of(WHO_COL[who]), p) - dd.rectangle([250, uy + 10, 250 + max(2, lw), uy + 14], - fill=ink_of(WHO_COL[whos[0]])) - elif whos: - col = ink_of(WHO_COL[whos[0]]) - dd.text((66, y), whos[0], font=f_name, fill=col, anchor="lm") - lw = karaoke_fill(dd, 250, y, disp, font, col, p) - dd.rectangle([250, uy, 250 + max(2, lw), uy + 4], fill=col) - else: - if row["alt"]: - dd.text((66, y), "another cult", font=f_name, - fill=MUTE_GRAY, anchor="lm") - lw = karaoke_fill(dd, 250, y, disp, font, INK, p) - dd.rectangle([250, uy, 250 + max(2, lw), uy + 4], fill=INK) - y += rh - # upcoming-lyrics teleprompter + # Full-height performer matrix: all live and upcoming voices share the + # same time axis, with collision-free subtracks inside each singer band. draw_scroller(img, dd, t) # act card: current act lit, with a progress bar ai = act_index(t) -- 2.51.2