anvilsign in

collin/strudel-claude

pre-demo / node-tui / highlights.ts
1// The follow-along animation: characters light up as the events they produced
2// are sounding.
3//
4// This is the same mechanism strudel.cc uses, not an approximation of it. The
5// transpiler records, for every value in the source, which characters produced
6// it; the scheduler's haps carry those ranges through on `context.locations`. So
7// "which characters are sounding right now" is a real query against the running
8// pattern, not a guess from the text.
9//
10// Two timescales, deliberately:
11//
12// - The *query* is windowed and cached. Asking the pattern for its events is
13// the expensive part, and re-running it every frame would burn CPU on work
14// whose answer barely changes. We ask for a cycle or so ahead, a few times a
15// second.
16// - The *filter* runs every frame, against the cached window. Deciding which
17// of ~50 cached haps overlaps `now` is trivial, so the animation stays
18// smooth even though the query behind it is coarse.
19//
20// That split is also what makes the fade possible: each hap knows its own start
21// and end, so intensity can be computed continuously between queries.
22import type { Pattern } from '@strudel/core';
23
24/** A cached event: when it sounds, and which characters produced it. */
25interface CachedHap {
26 begin: number;
27 end: number;
28 locations: Array<readonly [number, number]>;
29 color?: string;
30}
31
32/** A character range to paint, with the colour it should be painted right now. */
33export interface Span {
34 start: number;
35 end: number;
36 background: string;
37 foreground: string;
38}
39
40/** How far ahead of `now` to query, in cycles. */
41const LOOKAHEAD_CYCLES = 1.5;
42/** Re-query once the cached window has less than this much left. */
43const REFRESH_MARGIN = 0.5;
44
45// Brightest on the attack, fading across the event's length, so a long note
46// reads as decaying rather than as a static block. A pattern rendered this way
47// looks like music even in a screenshot.
48const ATTACK_INTENSITY = 0.92;
49const FADE_DEPTH = 0.62;
50
51/**
52 * Number of distinct brightness levels in the fade.
53 *
54 * A continuously-computed fade produces a different colour on every frame, and
55 * Ink repaints the entire screen whenever anything changes — so a smooth fade
56 * means a full-screen redraw at the frame rate, which reads as flicker on a
57 * terminal that doesn't support synchronized output. Quantising to a handful of
58 * steps means the colour only changes a few times across a note: still a visible
59 * decay, a fraction of the repaints. Below about six steps the fade starts to
60 * look like blinking rather than decay.
61 */
62const FADE_STEPS = 10;
63
64/**
65 * Colours for haps that don't carry one. Chosen to stay distinguishable on both
66 * light and dark terminals, and picked per source-range so a given character
67 * keeps the same colour every time it fires — the eye tracks a stable colour far
68 * better than one that changes each cycle.
69 */
70const PALETTE = ['#ff6b6b', '#ffd93d', '#6bcB77', '#4d96ff', '#c77dff', '#ff9f45'];
71
72function paletteFor(offset: number): string {
73 return PALETTE[offset % PALETTE.length] as string;
74}
75
76function parseHex(hex: string): [number, number, number] {
77 const clean = hex.replace('#', '');
78 const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean;
79 const n = Number.parseInt(full, 16);
80 return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
81}
82
83const toHex = (rgb: [number, number, number]): string =>
84 `#${rgb.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('')}`;
85
86const blend = (
87 color: [number, number, number],
88 base: [number, number, number],
89 amount: number,
90): [number, number, number] => [
91 color[0] * amount + base[0] * (1 - amount),
92 color[1] * amount + base[1] * (1 - amount),
93 color[2] * amount + base[2] * (1 - amount),
94];
95
96// Terminal backgrounds are usually dark, so fading toward black is the safe
97// default. Foreground is chosen from the *blended* luminance rather than the
98// base colour, so text stays readable at every point in the fade.
99const BASE: [number, number, number] = [0, 0, 0];
100
101const luminance = ([r, g, b]: [number, number, number]): number =>
102 (0.299 * r + 0.587 * g + 0.114 * b) / 255;
103
104/**
105 * A windowed view of the running pattern's events.
106 *
107 * Not pure — it caches — but the impurity is confined here rather than spread
108 * through the component tree as refs.
109 */
110export interface HapWindow {
111 /** Character ranges sounding at `now`, with their current colours. */
112 spansAt(pattern: Pattern | undefined, now: number): Span[];
113 /** Forget the cache — call when the pattern is replaced. */
114 reset(): void;
115}
116
117export function createHapWindow(): HapWindow {
118 let cached: CachedHap[] = [];
119 let cachedFor: Pattern | undefined;
120 let queriedUntil = 0;
121
122 function refill(pattern: Pattern, now: number): void {
123 // Query from a little before `now`, not from `now`: a sustained note that
124 // began earlier is still sounding, and starting the window at the playhead
125 // would drop its highlight the moment we re-query.
126 const from = now - 0.5;
127 const to = now + LOOKAHEAD_CYCLES;
128
129 let haps;
130 try {
131 haps = pattern.queryArc(from, to);
132 } catch {
133 // Mid-eval, or a pattern that can't be queried at this point. Keep what we
134 // have and try again next tick rather than blanking the animation.
135 return;
136 }
137
138 cached = [];
139 for (const hap of haps) {
140 const locations = hap.context?.locations;
141 if (!hap.whole || !locations?.length) continue;
142 const entry: CachedHap = {
143 begin: hap.whole.begin.valueOf(),
144 end: hap.whole.end.valueOf(),
145 locations: locations.map(({ start, end }) => [start, end] as const),
146 };
147 const color = hap.value['color'];
148 if (typeof color === 'string') entry.color = color;
149 cached.push(entry);
150 }
151 queriedUntil = to;
152 }
153
154 return {
155 reset(): void {
156 cached = [];
157 cachedFor = undefined;
158 queriedUntil = 0;
159 },
160
161 spansAt(pattern: Pattern | undefined, now: number): Span[] {
162 if (!pattern) return [];
163
164 // A re-eval swaps the pattern object; the old window describes code that
165 // is no longer playing, so it goes rather than being merged.
166 if (pattern !== cachedFor) {
167 cached = [];
168 cachedFor = pattern;
169 queriedUntil = 0;
170 }
171 // Also refill on a backwards jump — restarting the transport rewinds
172 // `now` behind everything we hold.
173 if (now + REFRESH_MARGIN > queriedUntil || now < queriedUntil - LOOKAHEAD_CYCLES - 1) {
174 refill(pattern, now);
175 }
176
177 const spans: Span[] = [];
178 for (const hap of cached) {
179 if (now < hap.begin || now >= hap.end) continue;
180
181 const raw = Math.min(Math.max((now - hap.begin) / Math.max(hap.end - hap.begin, 1e-6), 0), 1);
182 const progress = Math.round(raw * FADE_STEPS) / FADE_STEPS;
183 const intensity = ATTACK_INTENSITY - FADE_DEPTH * progress;
184
185 for (const [start, end] of hap.locations) {
186 const rgb = parseHex(hap.color ?? paletteFor(start));
187 const background = blend(rgb, BASE, intensity);
188 spans.push({
189 start,
190 end,
191 background: toHex(background),
192 foreground: luminance(background) > 0.5 ? '#000000' : '#ffffff',
193 });
194 }
195 }
196 return spans;
197 },
198 };
199}