| 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. |
| 22 | import type { Pattern } from '@strudel/core'; |
| 23 | |
| 24 | /** A cached event: when it sounds, and which characters produced it. */ |
| 25 | interface 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. */ |
| 33 | export 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. */ |
| 41 | const LOOKAHEAD_CYCLES = 1.5; |
| 42 | /** Re-query once the cached window has less than this much left. */ |
| 43 | const 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. |
| 48 | const ATTACK_INTENSITY = 0.92; |
| 49 | const 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 | */ |
| 62 | const 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 | */ |
| 70 | const PALETTE = ['#ff6b6b', '#ffd93d', '#6bcB77', '#4d96ff', '#c77dff', '#ff9f45']; |
| 71 | |
| 72 | function paletteFor(offset: number): string { |
| 73 | return PALETTE[offset % PALETTE.length] as string; |
| 74 | } |
| 75 | |
| 76 | function 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 | |
| 83 | const 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 | |
| 86 | const 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. |
| 99 | const BASE: [number, number, number] = [0, 0, 0]; |
| 100 | |
| 101 | const 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 | */ |
| 110 | export 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 | |
| 117 | export 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 | } |