rvlib-shipkit 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -629,10 +629,11 @@ function shelve(l: Loaded, takes: readonly Take[]): void {
629
629
 
630
630
  /** the cache trimmed after a stitch, said in one line */
631
631
  async function pruneAfter(root: string, say: Say): Promise<void> {
632
- const { prune, sayPruned } = await import('./prune.ts')
632
+ const { pruneIfMoved, sayPruned } = await import('./prune.ts')
633
633
  // the cut is written: a prune that fails is said, never the build's failure
634
634
  try {
635
- for (const line of sayPruned(await prune(root, { dry: false }), false, true)) say(line)
635
+ const p = await pruneIfMoved(root)
636
+ for (const line of p === null ? ['cache prune skipped: nothing it reads moved since the last one'] : sayPruned(p, false, true)) say(line)
636
637
  } catch (e) {
637
638
  say(`WARN the cache prune failed, the cut is written: ${(e as Error).message}`)
638
639
  }
@@ -167,11 +167,12 @@ export interface Title {
167
167
  export type TitlePlace = 'top' | 'middle' | 'bottom'
168
168
  export const placeOf = (ti: Pick<Title, 'place' | 'style'>): TitlePlace => ti.place ?? (ti.style === 'short' ? 'middle' : ti.style === 'progress' ? 'top' : 'bottom')
169
169
 
170
- /** a colour as a line writes it: `#rgb`, `#rrggbb`, or one of the look's (titles.ts resolves it) */
170
+ /** a colour as a line writes it: `#rgb`, `#rrggbb`, `#rrggbbaa`, or one of the look's (titles.ts resolves it) */
171
171
  export const LOOK_COLOURS = ['light', 'edge', 'accent'] as const
172
172
 
173
173
  /** a title's look over its style's: its face (a font file or a Google Fonts family), weight, size in pixels of a frame
174
- * 1080 tall, fill, and its outline, shadow and glow, each a size in those pixels and a colour */
174
+ * 1080 tall, fill (a gradient's colours each `colour[@share]`, top to bottom), and its outline, inner stroke, drop,
175
+ * shadow (and its fall) and glow, each a size in those pixels and a colour; a logo's layout (LOGO_WORDS) */
175
176
  export interface TitleFx {
176
177
  font?: string
177
178
  weight?: number
@@ -179,10 +180,25 @@ export interface TitleFx {
179
180
  color?: string
180
181
  gradient?: string[]
181
182
  outline?: { px: number; color?: string }
182
- shadow?: { px: number; color?: string }
183
+ inner?: { px: number; color?: string }
184
+ drop?: { px: number; color?: string }
185
+ shadow?: { px: number; color?: string; dy?: number }
183
186
  glow?: { px: number; color?: string }
187
+ /** the widest share of the frame's width the word takes */
188
+ fit?: number
189
+ /** the subtitle's height to the word's */
190
+ sub?: number
191
+ /** the space between the word and its subtitle, a share of the word's height */
192
+ gap?: number
193
+ /** the word's middle at this share of the frame's height (default: the whole block's middle at 0.48) */
194
+ middle?: number
195
+ /** the soft shade round the logo, its radius a share of the frame's longer side; 0 for none */
196
+ shade?: number
184
197
  }
185
198
 
199
+ /** the look words only a logo title reads */
200
+ export const LOGO_WORDS = ['fit', 'sub', 'gap', 'middle', 'shade'] as const
201
+
186
202
  /** how a made sound is made (design call 9): its seed, the voice or engine it is made `by`, its length (`for`), an sfx
187
203
  * trimmed to its first `hit` */
188
204
  export interface SoundGen {
@@ -403,8 +419,8 @@ interface Word {
403
419
 
404
420
  /** a line's words: a quoted run is one word (quoted), `#` outside quotes ends the line. Only double quotes quote, as in
405
421
  * the full language, so a word opening on a single quote is refused: it would reach a command with the quotes inside */
406
- /** a hex colour at `k` (`#rgb`, `#rrggbb`, then the word's end or a comma): a word, never a comment */
407
- const colourAt = (line: string, k: number): boolean => /^#([0-9a-f]{3}|[0-9a-f]{6})(?=[\s,"]|$)/i.test(line.slice(k))
422
+ /** a hex colour at `k` (`#rgb`, `#rrggbb`, `#rrggbbaa`, then the word's end, a comma or a gradient place `@`): a word, never a comment */
423
+ const colourAt = (line: string, k: number): boolean => /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})(?=[\s,"@]|$)/i.test(line.slice(k))
408
424
 
409
425
  export function tokens(line: string): { words: Word[] } | { error: string } {
410
426
  const words: Word[] = []
@@ -511,9 +527,9 @@ const sizeWord = (what: string, w: Word | undefined, lo: number, hi: number): [n
511
527
  return [x, y]
512
528
  }
513
529
 
514
- /** a colour word: `#rgb`, `#rrggbb`, or the look's light, edge or accent */
530
+ /** a colour word: `#rgb`, `#rrggbb`, `#rrggbbaa` (its last two digits the alpha), or the look's light, edge or accent */
515
531
  const colour = (k: string, text: string | undefined): string => {
516
- if (!text || !/^(#[0-9a-f]{3}|#[0-9a-f]{6}|light|edge|accent)$/i.test(text)) return bad(`${k} wants a colour: #rgb, #rrggbb, ${LOOK_COLOURS.join(', ')}${text ? `, got ${text}` : ''}`)
532
+ if (!text || !/^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8}|light|edge|accent)$/i.test(text)) return bad(`${k} wants a colour: #rgb, #rrggbb, #rrggbbaa, ${LOOK_COLOURS.join(', ')}${text ? `, got ${text}` : ''}`)
517
533
  return text
518
534
  }
519
535
  /** `<px>[,<colour>]`: an outline, a shadow or a glow, its size in pixels of a frame 1080 tall */
@@ -524,8 +540,19 @@ const sized = (w: Word | undefined, k: string): { px: number; color?: string } =
524
540
  if (!Number.isFinite(px) || px < 0 || px > 300 || more !== undefined) return bad(`${k} wants <px>[,<colour>], its size 0 to 300, got ${w.text}`)
525
541
  return { px, ...(c !== undefined ? { color: colour(k, c) } : {}) }
526
542
  }
543
+ /** `<px>[,<colour>[,<fall>]]`: a shadow's blur, its colour and how far it falls (default a third of its blur) */
544
+ const shadowWord = (w: Word | undefined, k: string): { px: number; color?: string; dy?: number } => {
545
+ const [head, ...fall] = w && !w.quoted ? w.text.split(',') : []
546
+ const wrong = (): never => bad(`${k} wants <px>[,<colour>[,<fall>]], its fall 0 to 300, got ${w!.text}`)
547
+ if (fall.length > 2) return wrong()
548
+ const base = sized(w && fall.length === 2 ? { ...w, text: `${head},${fall[0]}` } : w, k)
549
+ if (fall.length < 2) return base
550
+ const dy = Number(fall[1])
551
+ if (!/^\d+(\.\d+)?$/.test(fall[1]!) || dy > 300) return wrong()
552
+ return { ...base, dy }
553
+ }
527
554
  /** a title's look words, in TitleFx */
528
- const FX_WORDS = ['font', 'weight', 'size', 'color', 'gradient', 'outline', 'shadow', 'glow'] as const
555
+ const FX_WORDS = ['font', 'weight', 'size', 'color', 'gradient', 'outline', 'inner', 'drop', 'shadow', 'glow', ...LOGO_WORDS] as const
529
556
  const TITLE_WORDS: Record<string, (w: Word | undefined, k: string) => unknown> = {
530
557
  style: (w, k) => word(k, w, /^[a-z][a-z0-9-]*$/),
531
558
  at: when,
@@ -544,12 +571,23 @@ const TITLE_WORDS: Record<string, (w: Word | undefined, k: string) => unknown> =
544
571
  color: (w, k) => colour(k, w && !w.quoted ? w.text : undefined),
545
572
  gradient: (w, k) => {
546
573
  const cs = w && !w.quoted ? w.text.split(',') : []
547
- if (cs.length < 2 || cs.length > 4) bad(`${k} wants two to four colours, top to bottom: light,accent`)
548
- return cs.map((c) => colour(k, c))
574
+ if (cs.length < 2 || cs.length > 4) bad(`${k} wants two to four colours, top to bottom: light,accent (a colour's place colour@0.6)`)
575
+ return cs.map((c) => {
576
+ const [col, at] = c.split('@')
577
+ if (at !== undefined && !/^(0(\.\d+)?|1(\.0+)?)$/.test(at)) bad(`${k}: a colour's place is a share from 0 to 1, got ${c}`)
578
+ return at === undefined ? colour(k, col) : `${colour(k, col)}@${at}`
579
+ })
549
580
  },
550
581
  outline: sized,
551
- shadow: sized,
582
+ inner: sized,
583
+ drop: sized,
584
+ shadow: shadowWord,
552
585
  glow: sized,
586
+ fit: (w, k) => num(k, w, 0.1, 1),
587
+ sub: (w, k) => num(k, w, 0.05, 1),
588
+ gap: (w, k) => num(k, w, 0, 1),
589
+ middle: (w, k) => num(k, w, 0, 1),
590
+ shade: (w, k) => num(k, w, 0, 2),
553
591
  }
554
592
 
555
593
  /** a title's words, in order, each `+<preset>` in place; `in` and `out` take a move and, when the next word is a
@@ -589,6 +627,11 @@ function readTitleWords(raw: Word[], presets: ReadonlyMap<string, Word[]>, what
589
627
  /** a title's look words out of read values */
590
628
  const fxOf = (v: Record<string, unknown>): TitleFx => Object.fromEntries(FX_WORDS.filter((k) => v[k] !== undefined).map((k) => [k, v[k]])) as TitleFx
591
629
  const builtInStyle = (st: string): st is TitleStyle => (TITLE_STYLES as readonly string[]).includes(st)
630
+ /** a logo word on a title of another style fails: nothing would read it */
631
+ const logoOnly = (style: TitleStyle, fx: TitleFx | undefined): void => {
632
+ const odd = LOGO_WORDS.filter((w) => fx?.[w] !== undefined)
633
+ if (style !== 'logo' && odd.length > 0) bad(`title ${odd.join(', ')}: a logo's words, this title is ${style}`)
634
+ }
592
635
 
593
636
  /** the brand's text styles (`trailer.styles`, each a title's words by name) laid under each title: the defaults of
594
637
  * its built in style, then the brand style it names, then its own words, a later one winning. A brand style names
@@ -621,6 +664,7 @@ export function resolveStyles(t: Pick<Trailer, 'shots'>, styles: Readonly<Record
621
664
  const under = { ...defaults, ...named }
622
665
  ti.style = base as TitleStyle
623
666
  const fx = { ...fxOf(under), ...ti.fx }
667
+ logoOnly(ti.style, fx)
624
668
  if (Object.keys(fx).length > 0) ti.fx = fx
625
669
  if (under.place && !ti.place) ti.place = under.place as 'top' | 'bottom'
626
670
  if (under.in && !ti.in) ti.in = under.in as Move
@@ -1323,6 +1367,7 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
1323
1367
  ...(p.out ? { out: p.out as Move } : {}),
1324
1368
  }
1325
1369
  const fx = fxOf(p)
1370
+ if (!ti.named) logoOnly(ti.style, fx)
1326
1371
  if (Object.keys(fx).length > 0) ti.fx = fx
1327
1372
  if (ti.knock !== undefined && ti.out) bad('title: knock is its exit, give out or knock, not both')
1328
1373
  ti.at = landAt(p.at, n, s, (v) => (ti.at = v))
@@ -8,18 +8,26 @@ export interface TrailerLookInput {
8
8
  light?: string
9
9
  /** the dark edge under words over footage (default: the ground's edge) */
10
10
  edge?: string
11
+ /** the soft band of shade behind a `line` title's words (default: the edge) */
12
+ band?: string
11
13
  /** the key colour: the caption's dot, a `line` title's `*word*`, the corner chip (default: the palette's accent) */
12
14
  accent?: string
13
- /** the caption's plate (default: the palette's bg, an accent outline, the palette's ink) */
14
- plate?: { fill?: string; line?: string; ink?: string }
15
- /** the corner chip (default: the accent, ink of the palette's bg) */
16
- chip?: { fill?: string; ink?: string }
15
+ /** the caption's plate (default: the palette's bg, an accent outline, the palette's ink) and the soft drop under it
16
+ * and under the chip (default: the edge) */
17
+ plate?: { fill?: string; line?: string; ink?: string; shadow?: string }
18
+ /** the corner chip (default: the accent, ink of the palette's bg, an outline a shade darker than its fill) */
19
+ chip?: { fill?: string; ink?: string; line?: string }
17
20
  /** the face: a font file (repo relative, .ttf .otf .woff2) or a family the machine has, and its heavy weight */
18
21
  font?: { file?: string; family?: string; weight?: number }
19
- /** the logo the `logo` title draws over its words, repo relative (default: logo.png at the web root, when present) */
20
- logo?: string
21
- /** the words of a short: the `short` and `progress` titles and the vertical's words, each value the look's when left out */
22
- short?: { font?: { file?: string; family?: string; weight?: number }; light?: string; edge?: string; accent?: string }
22
+ /** the logo the `logo` title draws over its words, repo relative (default: logo.png at the web root, when present);
23
+ * `false`: the word alone */
24
+ logo?: string | false
25
+ /** a `logo` title's subtitle: its colour (default: the light) and the colour of its soft glow (default: the edge) */
26
+ subtitle?: { color?: string; glow?: string }
27
+ /** the words of a short: the `short` and `progress` titles and the vertical's words, each value the look's when left
28
+ * out; `band` the shade behind them (default: their edge), `lit` the glow round a progress bar's newest notch
29
+ * (default: their accent) */
30
+ short?: { font?: { file?: string; family?: string; weight?: number }; light?: string; edge?: string; accent?: string; band?: string; lit?: string }
23
31
  /** the vertical short's look (a `vertical` cut), each part `false` to leave it out (vertical.ts) */
24
32
  vertical?: VerticalLookInput
25
33
  /** text styles by name, each a title line's words (`style big font "Bebas Neue" in letters`): a title's `style`
@@ -44,13 +52,15 @@ export interface TrailerLook {
44
52
  ground: [string, string]
45
53
  light: string
46
54
  edge: string
55
+ band: string
47
56
  accent: string
48
- plate: { fill: string; line: string; ink: string }
49
- chip: { fill: string; ink: string }
57
+ plate: { fill: string; line: string; ink: string; shadow: string }
58
+ chip: { fill: string; ink: string; line: string }
50
59
  font: { file: string | null; family: string; weight: number }
51
60
  logo: string | null
61
+ subtitle: { color: string; glow: string }
52
62
  /** the short's words: their face and colours */
53
- short: { font: { file: string | null; family: string; weight: number }; light: string; edge: string; accent: string }
63
+ short: { font: { file: string | null; family: string; weight: number }; light: string; edge: string; accent: string; band: string; lit: string }
54
64
  }
55
65
 
56
66
  export interface BrandLike {
@@ -108,20 +118,25 @@ export function lookOf(brand: BrandLike | null, webLogo: string | null = null):
108
118
  const light = i.light ?? (luminance(p.ink) > 0.45 ? p.ink : '#ffffff')
109
119
  const edge = i.edge ?? (luminance(ground[1]) < 0.2 ? ground[1] : darker(p.bg, 0.8))
110
120
  const accent = i.accent ?? p.accent
121
+ const chipFill = i.chip?.fill ?? p.accent
111
122
  const sh = i.short ?? {}
123
+ const shortEdge = sh.edge ?? edge
124
+ const shortAccent = sh.accent ?? accent
112
125
  const shortFont = sh.font ? { file: sh.font.file ?? null, family: sh.font.family ?? (sh.font.file ? 'TrailerShortFace' : font.family), weight: sh.font.weight ?? font.weight } : font
113
126
  return {
114
127
  ground,
115
128
  light,
116
129
  edge,
130
+ band: i.band ?? edge,
117
131
  accent,
118
- plate: { fill: i.plate?.fill ?? alpha(p.bg, 0.94), line: i.plate?.line ?? p.accent, ink: i.plate?.ink ?? p.ink },
119
- chip: { fill: i.chip?.fill ?? p.accent, ink: i.chip?.ink ?? (luminance(p.accent) > 0.4 ? darker(p.bg, luminance(p.bg) > 0.5 ? 0.85 : 0.2) : '#ffffff') },
132
+ plate: { fill: i.plate?.fill ?? alpha(p.bg, 0.94), line: i.plate?.line ?? p.accent, ink: i.plate?.ink ?? p.ink, shadow: i.plate?.shadow ?? alpha(edge, 0.4) },
133
+ chip: { fill: chipFill, ink: i.chip?.ink ?? (luminance(p.accent) > 0.4 ? darker(p.bg, luminance(p.bg) > 0.5 ? 0.85 : 0.2) : '#ffffff'), line: i.chip?.line ?? darker(chipFill, 0.15) },
120
134
  font: { file: i.font?.file ?? null, family: i.font?.family ?? (i.font?.file ? 'TrailerFace' : HOUSE_FAMILIES), weight: i.font?.weight ?? 800 },
121
- logo: i.logo ?? webLogo,
122
- short: { font: shortFont, light: sh.light ?? light, edge: sh.edge ?? edge, accent: sh.accent ?? accent },
135
+ logo: i.logo === false ? null : (i.logo ?? webLogo),
136
+ subtitle: { color: i.subtitle?.color ?? light, glow: i.subtitle?.glow ?? edge },
137
+ short: { font: shortFont, light: sh.light ?? light, edge: shortEdge, accent: shortAccent, band: sh.band ?? shortEdge, lit: sh.lit ?? shortAccent },
123
138
  }
124
139
  }
125
140
 
126
141
  /** the look a short's words wear: the look with the short's face and colours in place */
127
- export const shortLook = (look: TrailerLook): TrailerLook => ({ ...look, font: look.short.font, light: look.short.light, edge: look.short.edge, accent: look.short.accent })
142
+ export const shortLook = (look: TrailerLook): TrailerLook => ({ ...look, font: look.short.font, light: look.short.light, edge: look.short.edge, accent: look.short.accent, band: look.short.band })
@@ -1,7 +1,8 @@
1
1
  // the trailer's cache kept small (wiki build/trailer.md, The cache): what the cuts of every trailer folder still use
2
- // stays, the rest goes. Runs after every stitch and by hand (`sk trailer prune [--dry]`). What a cut uses is named by the
3
- // build's own key functions; a file younger than an hour is never deleted: another build may be writing or reading it
4
- import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
2
+ // stays, the rest goes. Runs after a stitch when what it reads moved, and by hand (`sk trailer prune [--dry]`). What a
3
+ // cut uses is named by the build's own key functions; a file younger than an hour is never deleted: another build may
4
+ // be writing or reading it
5
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
5
6
  import { basename, join, relative } from 'node:path'
6
7
  import { cutFiles, load } from './build.ts'
7
8
  import { SCORE, musicCues } from './grammar.ts'
@@ -159,12 +160,10 @@ function filesUnder(dir: string): string[] {
159
160
  return readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? filesUnder(join(dir, e.name)) : [join(dir, e.name)]))
160
161
  }
161
162
 
162
- /** plans every folder's prune, then deletes unless `dry` */
163
- export async function prune(root: string, o: { dry: boolean; now?: number }): Promise<Pruned> {
164
- const now = o.now ?? Date.now()
165
- const [{ trailerCache }, { viewsToDrop }] = await Promise.all([import('./footage.ts'), import('../review/trailer-desk.ts')])
166
- const cache = join(trailerCache(root, 'footage'), '..')
167
- const live = await liveSet(root)
163
+ const cacheOf = async (root: string): Promise<string> => join((await import('./footage.ts')).trailerCache(root, 'footage'), '..')
164
+
165
+ /** each folder's files that go, as of `now`; `now` Infinity names the files only their age keeps too */
166
+ function planOf(cache: string, live: Live, views: readonly string[], now: number): [string, Doomed[]][] {
168
167
  const whole = live.broken.length === 0
169
168
  // titles/ holds the vertical's drawings too: only a title's and a card's files are the prune's
170
169
  const ownTitle = (n: string): boolean => /^(title|card)-[0-9a-f]{16}\./.test(n)
@@ -177,10 +176,58 @@ export async function prune(root: string, o: { dry: boolean; now?: number }): Pr
177
176
  ]
178
177
  const dead = existsSync(cache) ? readdirSync(cache, { withFileTypes: true }).filter((e) => e.isDirectory() && !FOLDERS.includes(e.name)) : []
179
178
  for (const d of dead) plan.push([`${d.name}/ (dead)`, filesUnder(join(cache, d.name)).map(doomed)])
180
- plan.push(['trailer-view/', viewsToDrop(root).filter((f) => now - statSync(f).mtimeMs > RENDER_GRACE_MS).map(doomed)])
181
- if (!o.dry) {
179
+ plan.push(['trailer-view/', views.filter((f) => existsSync(f) && now - statSync(f).mtimeMs > RENDER_GRACE_MS).map(doomed)])
180
+ return plan
181
+ }
182
+
183
+ /** what the last whole prune read, and when the first file it spared for its age comes of age */
184
+ interface Stamp {
185
+ key: string
186
+ until: number
187
+ }
188
+
189
+ const stampFile = (cache: string): string => join(cache, 'prune.json')
190
+
191
+ /** what the prune reads that a build can change: every cut's text, every file name in the cache's folders, the views
192
+ * to drop. The same key and no spared file of age: the prune would find what it found */
193
+ async function pruneKey(root: string, cache: string): Promise<string> {
194
+ const [{ cutFiles }, { viewsToDrop }] = await Promise.all([import('./build.ts'), import('../review/trailer-desk.ts')])
195
+ const h = new Bun.CryptoHasher('sha1')
196
+ for (const f of cutFiles(root)) h.update(`${f}\0`).update(readFileSync(f)).update('\0')
197
+ const folders = existsSync(cache) ? readdirSync(cache, { withFileTypes: true }).filter((e) => e.isDirectory()) : []
198
+ for (const d of folders.map((e) => e.name).sort()) h.update(`${d}/\0${filesUnder(join(cache, d)).sort().join('\0')}\0`)
199
+ h.update(viewsToDrop(root).sort().join('\0'))
200
+ return h.digest('hex')
201
+ }
202
+
203
+ /** the prune after a build: skipped while nothing it reads moved and no file it spared has come of age */
204
+ export async function pruneIfMoved(root: string, now = Date.now()): Promise<Pruned | null> {
205
+ const cache = await cacheOf(root)
206
+ let s: Stamp | null = null
207
+ try {
208
+ s = JSON.parse(readFileSync(stampFile(cache), 'utf8')) as Stamp
209
+ } catch {}
210
+ if (s !== null && now < s.until && s.key === (await pruneKey(root, cache))) return null
211
+ return prune(root, { dry: false, now })
212
+ }
213
+
214
+ /** plans every folder's prune, then deletes unless `dry`; a whole prune writes its stamp */
215
+ export async function prune(root: string, o: { dry: boolean; now?: number }): Promise<Pruned> {
216
+ const now = o.now ?? Date.now()
217
+ const { viewsToDrop } = await import('../review/trailer-desk.ts')
218
+ const cache = await cacheOf(root)
219
+ const live = await liveSet(root)
220
+ const views = viewsToDrop(root)
221
+ const plan = planOf(cache, live, views, now)
222
+ if (!o.dry && existsSync(cache)) {
182
223
  for (const [, ds] of plan) for (const d of ds) rmSync(d.file, { force: true })
183
- for (const d of dead) rmSync(join(cache, d.name), { recursive: true, force: true })
224
+ for (const d of readdirSync(cache, { withFileTypes: true }).filter((e) => e.isDirectory() && !FOLDERS.includes(e.name))) rmSync(join(cache, d.name), { recursive: true, force: true })
225
+ rmSync(stampFile(cache), { force: true })
226
+ if (live.broken.length === 0) {
227
+ const spared = planOf(cache, live, views, Number.POSITIVE_INFINITY).flatMap(([, ds]) => ds.filter((d) => existsSync(d.file)))
228
+ const until = Math.min(Number.MAX_SAFE_INTEGER, ...spared.map((d) => statSync(d.file).mtimeMs + RENDER_GRACE_MS))
229
+ writeFileSync(stampFile(cache), JSON.stringify({ key: await pruneKey(root, cache), until } satisfies Stamp))
230
+ }
184
231
  }
185
232
  return {
186
233
  freed: plan.map(([folder, ds]) => ({ folder, files: ds.length, bytes: ds.reduce((n, d) => n + (d.linked ? 0 : d.bytes), 0), linked: ds.reduce((n, d) => n + (d.linked ? d.bytes : 0), 0) })),
@@ -126,45 +126,105 @@ function staticCurve(c: CompressorDef): { saturate: (x: number) => number; makeu
126
126
  return { saturate, makeup: (1 / saturate(1)) ** 0.6 }
127
127
  }
128
128
 
129
- /** a stereo compressor in place, 6 ms of lookahead: the gain follows the louder channel, falling at `attack` and
130
- * rising at `release`, in dB */
129
+ /** the release a browser compressor takes at 15, 10, 5 and 0 dB from its target, shares of its release time */
130
+ const RELEASE_ZONES = [0.09, 0.16, 0.42, 0.98] as const
131
+ /** a browser compressor sets its envelope's rate once a division of this many frames */
132
+ const DIVISION = 32
133
+
134
+ /** a stereo compressor in place, as a browser's (its dynamics compressor kernel): 6 ms of lookahead; a detector on the
135
+ * louder channel's attenuation that lets go over 2.5 ms; a gain that attacks at a rate set by the deepest attenuation
136
+ * it chases and releases faster the more it compresses (a curve through the four release zones); a sine warp on the
137
+ * gain, the makeup on top */
131
138
  export function compress(l: Float32Array, r: Float32Array, rate: number, c: CompressorDef): void {
132
139
  const { saturate, makeup } = staticCurve(c)
133
- const n = l.length
134
- // the curve sampled in dB of input, 0.05 dB a step, from -60 dB up to +24 dB
140
+ // the attenuation in dB of input, 0.05 dB a step, from -60 dB up to +24 dB
135
141
  const lo = -60
136
142
  const step = 0.05
137
143
  const table = new Float32Array(Math.ceil((24 - lo) / step) + 2)
138
144
  for (let i = 0; i < table.length; i++) {
139
145
  const x = lin(lo + i * step)
140
- table[i] = dbOf(saturate(x) / x)
146
+ table[i] = -dbOf(saturate(x) / x)
141
147
  }
142
- compressRun(l, r, n, Math.round(0.006 * rate), Math.exp(-1 / (Math.max(1e-4, c.attack) * rate)), Math.exp(-1 / (Math.max(1e-4, c.release) * rate)), table, lo, step, makeup)
148
+ const [y1, y2, y3, y4] = RELEASE_ZONES.map((z) => z * rate * c.release) as [number, number, number, number]
149
+ // the 4th order polynomial through (0, y1), (1, y2), (2, y3), (3, y4), the browser's own coefficients
150
+ const poly: Poly = [
151
+ 0.9999999999999998 * y1 + 1.8432219684323923e-16 * y2 - 1.9373394351676423e-16 * y3 + 8.824516011816245e-18 * y4,
152
+ -1.5788320352845888 * y1 + 2.3305837032074286 * y2 - 0.9141194204840429 * y3 + 0.1623677525612032 * y4,
153
+ 0.5334142869106424 * y1 - 1.272736789213631 * y2 + 0.9258856042207512 * y3 - 0.18656310191776226 * y4,
154
+ 0.08783463138207234 * y1 - 0.1694162967925622 * y2 + 0.08588057951595272 * y3 - 0.00429891410546283 * y4,
155
+ -0.042416883008123074 * y1 + 0.1115693827987602 * y2 - 0.09764676325265872 * y3 + 0.028494263462021576 * y4,
156
+ ]
157
+ compressRun(l, r, { delay: Math.round(0.006 * rate), attack: Math.max(0.001, c.attack) * rate, detectorRelease: 0.0025 * rate, poly, table, lo, step, makeup })
158
+ }
159
+
160
+ type Poly = [number, number, number, number, number]
161
+
162
+ interface Kernel {
163
+ /** frames of lookahead */
164
+ delay: number
165
+ /** frames */
166
+ attack: number
167
+ detectorRelease: number
168
+ poly: Poly
169
+ /** attenuation in dB by input dB, from `lo` a `step` apart */
170
+ table: Float32Array
171
+ lo: number
172
+ step: number
173
+ makeup: number
143
174
  }
144
175
 
145
- function compressRun(l: Float32Array, r: Float32Array, n: number, delay: number, att: number, rel: number, table: Float32Array, lo: number, step: number, makeup: number): void {
146
- const dl = new Float32Array(delay)
147
- const dr = new Float32Array(delay)
148
- let env = 0
176
+ function compressRun(l: Float32Array, r: Float32Array, k: Kernel): void {
177
+ const n = l.length
178
+ const dl = new Float32Array(k.delay)
179
+ const dr = new Float32Array(k.delay)
180
+ const { table, lo, step } = k
149
181
  const last = table.length - 1
150
- for (let i = 0; i < n; i++) {
151
- const x = Math.max(Math.abs(l[i]!), Math.abs(r[i]!))
152
- let target = 0
153
- if (x > 1e-3) {
154
- const p = (20 * Math.log10(x) - lo) / step
155
- const j = Math.min(last - 1, Math.max(0, p | 0))
156
- const f = Math.min(1, Math.max(0, p - j))
157
- target = table[j]! + (table[j + 1]! - table[j]!) * f
182
+ const [ka, kb, kc, kd, ke] = k.poly
183
+ let detector = 0
184
+ let gain = 1
185
+ let maxAttackDb = -1
186
+ for (let d0 = 0; d0 < n; d0 += DIVISION) {
187
+ // pre warped so the sine warp below lands on the detector's gain
188
+ const desired = Math.asin(Math.min(1, detector)) / (Math.PI / 2)
189
+ let envRate: number
190
+ if (desired > gain) {
191
+ maxAttackDb = -1
192
+ let diff = dbOf(gain / desired)
193
+ if (!Number.isFinite(diff)) diff = -1
194
+ const x = 0.25 * (Math.min(0, Math.max(-12, diff)) + 12)
195
+ const frames = ka + kb * x + kc * x * x + kd * x * x * x + ke * x * x * x * x
196
+ envRate = lin(5 / frames)
197
+ } else {
198
+ let diff = desired > 0 ? dbOf(gain / desired) : 1
199
+ if (!Number.isFinite(diff)) diff = 1
200
+ if (maxAttackDb === -1 || maxAttackDb < diff) maxAttackDb = diff
201
+ envRate = 1 - (0.25 / Math.max(0.5, maxAttackDb)) ** (1 / k.attack)
202
+ }
203
+ const end = Math.min(n, d0 + DIVISION)
204
+ for (let i = d0; i < end; i++) {
205
+ const x = Math.max(Math.abs(l[i]!), Math.abs(r[i]!))
206
+ let att = 1
207
+ let attDb = 0
208
+ if (x > 1e-4) {
209
+ const p = (20 * Math.log10(x) - lo) / step
210
+ const j = Math.min(last - 1, Math.max(0, p | 0))
211
+ const f = Math.min(1, Math.max(0, p - j))
212
+ attDb = table[j]! + (table[j + 1]! - table[j]!) * f
213
+ att = lin(-attDb)
214
+ }
215
+ const release = lin(Math.max(2, attDb) / k.detectorRelease) - 1
216
+ detector = Math.min(1, detector + (att - detector) * (att > detector ? release : 1))
217
+ if (envRate < 1) gain += (desired - gain) * envRate
218
+ else gain = Math.min(1, gain * envRate)
219
+ const g = k.makeup * Math.sin((Math.PI / 2) * gain)
220
+ const at = i % k.delay
221
+ const yl = dl[at]!
222
+ const yr = dr[at]!
223
+ dl[at] = l[i]!
224
+ dr[at] = r[i]!
225
+ l[i] = yl * g
226
+ r[i] = yr * g
158
227
  }
159
- env = target < env ? target + (env - target) * att : target + (env - target) * rel
160
- const g = makeup * 10 ** (env / 20)
161
- const k = i % delay
162
- const yl = dl[k]!
163
- const yr = dr[k]!
164
- dl[k] = l[i]!
165
- dr[k] = r[i]!
166
- l[i] = yl * g
167
- r[i] = yr * g
168
228
  }
169
229
  }
170
230
 
@@ -3,7 +3,7 @@
3
3
 
4
4
  /** oscillator families: chiptune roots (pulse, wave, noise) plus the smoother modern ones; `gm` a General MIDI
5
5
  * program the soundfont plays (gm.ts) */
6
- export type Osc = 'pulse' | 'saw' | 'triangle' | 'sine' | 'wave' | 'noise' | 'gm'
6
+ export type Osc = 'pulse' | 'saw' | 'triangle' | 'square' | 'sine' | 'wave' | 'noise' | 'gm'
7
7
 
8
8
  export interface InstrumentDef {
9
9
  osc: Osc
@@ -74,6 +74,14 @@ function sawCoefficients(): Coefficients {
74
74
  return { real, imag }
75
75
  }
76
76
 
77
+ /** the browser's own square wave, which a score names by its web audio type; never softened */
78
+ function squareCoefficients(): Coefficients {
79
+ const real = new Float64Array(HARMONICS + 1)
80
+ const imag = new Float64Array(HARMONICS + 1)
81
+ for (let k = 1; k <= HARMONICS; k += 2) imag[k] = 4 / (Math.PI * k)
82
+ return { real, imag }
83
+ }
84
+
77
85
  function triangleCoefficients(): Coefficients {
78
86
  const real = new Float64Array(HARMONICS + 1)
79
87
  const imag = new Float64Array(HARMONICS + 1)
@@ -186,6 +194,7 @@ class Waves {
186
194
  : osc === 'saw' ? sawCoefficients()
187
195
  : osc === 'wave' ? waveCoefficients(inst.wave!)
188
196
  : osc === 'triangle' ? triangleCoefficients()
197
+ : osc === 'square' ? squareCoefficients()
189
198
  : { real: new Float64Array(2), imag: Float64Array.of(0, 1) }
190
199
  t = tableOf(c, bandHz(band), tone, this.rate)
191
200
  this.tables.set(key, t)
@@ -204,11 +213,21 @@ interface Pitch {
204
213
 
205
214
  const pitchAt = (p: Pitch, t: number): number => (p.over <= 0 || t >= p.over ? p.to : p.from * (p.to / p.from) ** (Math.max(0, t) / p.over))
206
215
 
207
- /** one oscillator voice added into `buf`: a table read at a moving pitch, its detune and vibrato in cents */
208
- function oscillatorRun(buf: Float32Array, n: number, t0: number, rate: number, table: Float32Array, p: Pitch, cents: number, vib: InstrumentDef['vibrato'] | null, gain: number): void {
216
+ /** a browser renders in blocks of this many frames */
217
+ const QUANTUM = 128
218
+ /** an oscillator's frequency before its first automation event: the browser's default */
219
+ const DEFAULT_HZ = 440
220
+
221
+ /** one oscillator voice added into `buf`: a table read at a moving pitch, its detune and vibrato in cents. `lead` is
222
+ * how far into its 128 frame block the note starts: in that first block a browser oscillator reads its frequency from
223
+ * the block's start, so it runs at the default 440 Hz for `lead` frames and its pitch curve lags by `lead` frames. That
224
+ * sets the phase every later frame carries, and two notes of one pitch that overlap add or cancel by it */
225
+ function oscillatorRun(buf: Float32Array, n: number, t0: number, rate: number, table: Float32Array, p: Pitch, cents: number, vib: InstrumentDef['vibrato'] | null, gain: number, lead: number): void {
209
226
  const LN = Math.LN2 / 1200
210
227
  const steady = p.over <= 0 && !vib
211
228
  const fixed = (p.to * Math.exp(cents * LN)) / rate
229
+ const early = (DEFAULT_HZ * Math.exp(cents * LN)) / rate
230
+ const first = lead > 0 ? QUANTUM - lead : 0
212
231
  let phase = Math.max(0, t0) * (p.over <= 0 ? p.to : p.from) * Math.exp(cents * LN)
213
232
  phase -= Math.floor(phase)
214
233
  for (let i = 0; i < n; i++) {
@@ -216,9 +235,11 @@ function oscillatorRun(buf: Float32Array, n: number, t0: number, rate: number, t
216
235
  const j = x | 0
217
236
  const v = table[j]!
218
237
  buf[i] = buf[i]! + gain * (v + (table[j + 1]! - v) * (x - j))
238
+ const at = i < first ? i - lead : i
219
239
  let inc = fixed
220
- if (!steady) {
221
- const t = t0 + i / rate
240
+ if (at < 0) inc = early
241
+ else if (!steady) {
242
+ const t = t0 + at / rate
222
243
  let c = cents
223
244
  if (vib) {
224
245
  const on = Math.max(0, vib.delay)
@@ -232,15 +253,21 @@ function oscillatorRun(buf: Float32Array, n: number, t0: number, rate: number, t
232
253
  }
233
254
  }
234
255
 
235
- /** a noise buffer looped at a moving playback rate, added into `buf` */
236
- function noiseRun(buf: Float32Array, n: number, t0: number, rate: number, src: Float32Array, p: Pitch): void {
256
+ /** a noise buffer looped at a moving playback rate, added into `buf`. A browser buffer source's playbackRate is k-rate:
257
+ * held for each 128 frame block at the block's start. In the block the note starts inside (`lead` frames in) it is the
258
+ * default 1; the note's events take hold at the next block, so its sweep starts there and still ends at its own time */
259
+ function noiseRun(buf: Float32Array, n: number, t0: number, rate: number, src: Float32Array, p: Pitch, lead: number): void {
260
+ const first = (QUANTUM - lead) % QUANTUM
261
+ const sweep: Pitch = { from: p.from, to: p.to, over: p.over - first / rate }
237
262
  let pos = 0
263
+ let inc = 1
238
264
  const len = src.length
239
265
  for (let i = 0; i < n; i++) {
266
+ if (i >= first && (i - first) % QUANTUM === 0) inc = pitchAt(sweep, t0 + (i - first) / rate)
240
267
  const j = pos | 0
241
268
  const v = src[j % len]!
242
269
  buf[i] = buf[i]! + v + (src[(j + 1) % len]! - v) * (pos - j)
243
- pos += pitchAt(p, t0 + i / rate)
270
+ pos += inc
244
271
  if (pos >= len) pos -= len
245
272
  }
246
273
  }
@@ -264,18 +291,20 @@ function playNote(bus: Bus, waves: Waves, inst: InstrumentDef, note: ScheduledNo
264
291
  if (peak <= 0) return
265
292
  const pts = envPoints(inst.env, note.dur, peak)
266
293
  const length = (pts[pts.length - 1]?.[0] ?? note.dur) + 0.02
267
- const i0 = Math.max(0, Math.ceil(note.time * rate))
268
- const i1 = Math.min(bus.l.length, Math.ceil((note.time + length) * rate))
294
+ // on its sample frame, as the game rounds a note's time: a hair past one (a sum's float noise) is that frame
295
+ const start = Math.round(note.time * rate)
296
+ const i0 = Math.max(0, start)
297
+ const i1 = Math.min(bus.l.length, Math.ceil((start / rate + length) * rate))
269
298
  const n = i1 - i0
270
299
  if (n <= 0) return
271
300
  if (scratch.buf.length < n) scratch.buf = new Float32Array(n)
272
301
  const buf = scratch.buf
273
302
  buf.fill(0, 0, n)
274
- const t0 = i0 / rate - note.time
303
+ const t0 = (i0 - start) / rate
275
304
  if (inst.osc === 'noise') {
276
305
  const r0 = Math.max(0.05, Math.min(8, hz / NOISE_REF_HZ))
277
306
  const pitch: Pitch = inst.sweep ? { from: r0, to: Math.max(0.02, r0 * 2 ** (inst.sweep.semis / 12)), over: Math.max(1e-4, inst.sweep.time) } : { from: r0, to: r0, over: 0 }
278
- noiseRun(buf, n, t0, rate, inst.noise === 'metal' ? waves.metal : waves.white, pitch)
307
+ noiseRun(buf, n, t0, rate, inst.noise === 'metal' ? waves.metal : waves.white, pitch, i0 % QUANTUM)
279
308
  } else {
280
309
  const voices = Math.max(1, Math.min(4, inst.unison ?? (inst.detune ? 2 : 1)))
281
310
  const spread = inst.detune ?? 0
@@ -288,7 +317,7 @@ function playNote(bus: Bus, waves: Waves, inst: InstrumentDef, note: ScheduledNo
288
317
  const table = waves.table(inst, hz)
289
318
  for (let v = 0; v < voices; v++) {
290
319
  const cents = voices === 1 ? spread : spread * ((2 * v) / (voices - 1) - 1)
291
- oscillatorRun(buf, n, t0, rate, table, pitch, cents, vib, 1 / Math.sqrt(voices))
320
+ oscillatorRun(buf, n, t0, rate, table, pitch, cents, vib, 1 / Math.sqrt(voices), i0 % QUANTUM)
292
321
  }
293
322
  }
294
323
  applyEnvelope(buf, n, t0, rate, pts)