rvlib-shipkit 2.2.0 → 2.4.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.
Files changed (140) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +5 -2
  12. package/src/cli/cmd_Commands.ts +9 -0
  13. package/src/cli/cmd_Review.ts +974 -0
  14. package/src/cli/cmd_Sound.ts +209 -0
  15. package/src/cli/cmd_Time.ts +112 -0
  16. package/src/cli/cmd_Trailer.ts +264 -6
  17. package/src/cli/kit/verb.ts +22 -4
  18. package/src/cli.ts +8 -4
  19. package/src/collections/collection-types.ts +6 -1
  20. package/src/collections/providers.ts +3 -0
  21. package/src/collections/review.ts +70 -0
  22. package/src/core/app-icon.ts +10 -6
  23. package/src/core/layout.ts +2 -0
  24. package/src/reflect/prompt-corpus.ts +15 -119
  25. package/src/reflect/transcript.ts +254 -0
  26. package/src/repo-config.ts +3 -0
  27. package/src/review/anim.ts +228 -0
  28. package/src/review/auto.ts +141 -0
  29. package/src/review/format.ts +158 -0
  30. package/src/review/http.ts +23 -0
  31. package/src/review/marks.ts +43 -0
  32. package/src/review/media.ts +45 -0
  33. package/src/review/model.ts +896 -0
  34. package/src/review/project.ts +126 -0
  35. package/src/review/role.ts +48 -0
  36. package/src/review/routes.ts +239 -0
  37. package/src/review/store.ts +1011 -0
  38. package/src/review/threads.ts +237 -0
  39. package/src/review/trailer-desk.ts +385 -0
  40. package/src/review/trailer-model.ts +183 -0
  41. package/src/review/trailer-routes.ts +136 -0
  42. package/src/review/unit.ts +183 -0
  43. package/src/review/watchers.ts +54 -0
  44. package/src/serve/embed.ts +35 -0
  45. package/src/serve/hub-app/activity-view.tsx +1 -1
  46. package/src/serve/hub-app/activity.css +0 -4
  47. package/src/serve/hub-app/admin-svc.ts +1 -1
  48. package/src/serve/hub-app/admin-view.tsx +1 -1
  49. package/src/serve/hub-app/dock-view.tsx +5 -4
  50. package/src/serve/hub-app/hash-route.ts +36 -1
  51. package/src/serve/hub-app/header-bar.tsx +3 -1
  52. package/src/serve/hub-app/home-st.ts +17 -3
  53. package/src/serve/hub-app/home-strip.tsx +3 -2
  54. package/src/serve/hub-app/home.html +1 -0
  55. package/src/serve/hub-app/hub-app.tsx +80 -11
  56. package/src/serve/hub-app/hub.css +83 -14
  57. package/src/serve/hub-app/icons.tsx +15 -0
  58. package/src/serve/hub-app/index.html +1 -0
  59. package/src/serve/hub-app/insights-view.tsx +4 -3
  60. package/src/serve/hub-app/keys.ts +14 -10
  61. package/src/serve/hub-app/layout-st.ts +63 -2
  62. package/src/serve/hub-app/md.ts +32 -0
  63. package/src/serve/hub-app/planned-view.tsx +3 -2
  64. package/src/serve/hub-app/planned.css +0 -1
  65. package/src/serve/hub-app/project-list.css +26 -6
  66. package/src/serve/hub-app/project-list.tsx +46 -10
  67. package/src/serve/hub-app/projects-svc.ts +7 -2
  68. package/src/serve/hub-app/repo-sections.ts +15 -3
  69. package/src/serve/hub-app/repo-st.ts +61 -4
  70. package/src/serve/hub-app/review/clip-st.ts +258 -0
  71. package/src/serve/hub-app/review/kind-view.tsx +240 -0
  72. package/src/serve/hub-app/review/kit.tsx +113 -0
  73. package/src/serve/hub-app/review/review-answer.tsx +269 -0
  74. package/src/serve/hub-app/review/review-board.tsx +46 -0
  75. package/src/serve/hub-app/review/review-list.tsx +156 -0
  76. package/src/serve/hub-app/review/review-page.tsx +96 -0
  77. package/src/serve/hub-app/review/review-st.ts +902 -0
  78. package/src/serve/hub-app/review/review-views.tsx +648 -0
  79. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  80. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  81. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  82. package/src/serve/hub-app/review.css +389 -0
  83. package/src/serve/hub-app/theme-st.ts +56 -0
  84. package/src/serve/hub-app/tree-build.ts +4 -0
  85. package/src/serve/hub-app/tree-row.tsx +1 -1
  86. package/src/serve/hub-app/tree-svc.ts +4 -0
  87. package/src/serve/hub-app/wiki-st.ts +190 -0
  88. package/src/serve/hub-app/wikis-view.tsx +134 -5
  89. package/src/serve/hub-runtime.ts +9 -7
  90. package/src/serve/project-gutter-route.ts +25 -1
  91. package/src/serve/project-gutter.ts +1 -1
  92. package/src/serve/same-origin.ts +5 -1
  93. package/src/serve/serve-hub.ts +15 -0
  94. package/src/shipkit-files.ts +9 -5
  95. package/src/shot/client.ts +17 -5
  96. package/src/shot/shooter.ts +19 -3
  97. package/src/shot/shot-ask.ts +9 -1
  98. package/src/shot/stepped-clip.ts +53 -0
  99. package/src/trailer/adapter.ts +90 -6
  100. package/src/trailer/build.ts +459 -59
  101. package/src/trailer/ffmpeg.ts +22 -0
  102. package/src/trailer/fonts.ts +55 -0
  103. package/src/trailer/footage.ts +112 -43
  104. package/src/trailer/grammar.ts +864 -127
  105. package/src/trailer/graph.ts +308 -49
  106. package/src/trailer/holes.ts +72 -0
  107. package/src/trailer/load-unit.ts +20 -3
  108. package/src/trailer/look.ts +20 -3
  109. package/src/trailer/motion.ts +207 -0
  110. package/src/trailer/passage.ts +129 -0
  111. package/src/trailer/plan.ts +82 -23
  112. package/src/trailer/prune.ts +204 -0
  113. package/src/trailer/score/compose.ts +256 -0
  114. package/src/trailer/score/dsp.ts +272 -0
  115. package/src/trailer/score/gm-render.ts +43 -0
  116. package/src/trailer/score/gm.ts +90 -0
  117. package/src/trailer/score/midi.ts +143 -0
  118. package/src/trailer/score/palette.ts +188 -0
  119. package/src/trailer/score/pattern.ts +98 -0
  120. package/src/trailer/score/score.ts +228 -0
  121. package/src/trailer/score/sound.ts +123 -0
  122. package/src/trailer/score/synth.ts +383 -0
  123. package/src/trailer/sound/audio.ts +148 -0
  124. package/src/trailer/sound/brand.ts +42 -0
  125. package/src/trailer/sound/comfy.ts +38 -0
  126. package/src/trailer/sound/eleven.ts +72 -0
  127. package/src/trailer/sound/fluid.ts +71 -0
  128. package/src/trailer/sound/gen.ts +139 -0
  129. package/src/trailer/sound/index.ts +60 -0
  130. package/src/trailer/sound/presets.ts +69 -0
  131. package/src/trailer/sound/run.ts +189 -0
  132. package/src/trailer/states.ts +76 -0
  133. package/src/trailer/stitch.ts +92 -9
  134. package/src/trailer/titles.ts +471 -150
  135. package/src/trailer/url.ts +18 -3
  136. package/src/trailer/vocab.ts +34 -4
  137. package/src/trailer/words.ts +275 -0
  138. package/src/wiki/graph-mount.ts +3 -0
  139. package/src/wiki/graph-page.ts +45 -9
  140. package/src/wiki/graph-pages.ts +128 -0
@@ -0,0 +1,204 @@
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'
5
+ import { basename, join, relative } from 'node:path'
6
+ import { cutFiles, load } from './build.ts'
7
+ import { SCORE, musicCues } from './grammar.ts'
8
+ import { pieces } from './plan.ts'
9
+
10
+ /** the renders kept per section: the one the cut plays and the one before it, so undoing an edit renders nothing */
11
+ const KEEP_PER_SECTION = 2
12
+ /** a file younger than this is never deleted */
13
+ export const RENDER_GRACE_MS = 60 * 60_000
14
+ /** the folders the trailer's code writes; any other folder of the cache is dead and goes whole */
15
+ const FOLDERS = ['footage', 'pieces', 'sound', 'score', 'titles']
16
+
17
+ /** what the cuts use, by file name in each folder; `broken` names the cuts that do not read */
18
+ export interface Live {
19
+ footage: Set<string>
20
+ /** the render stems of pieces/, pieces/mix/ and pieces/vertical/ */
21
+ stems: Set<string>
22
+ sound: Set<string>
23
+ score: Set<string>
24
+ titles: Set<string>
25
+ broken: string[]
26
+ }
27
+
28
+ /** the live set: each cut read, the name of every cache file its build reads, by the build's own key functions */
29
+ export async function liveSet(root: string): Promise<Live> {
30
+ const live: Live = { footage: new Set(), stems: new Set(), sound: new Set(), score: new Set(), titles: new Set(), broken: [] }
31
+ const [{ fileOf, takesOfLoaded, titleRequest }, { trailerCache }, { scoreWavFile }, { scoreModuleFile, scoreModule }, titles, { lookOf }, { loadBrand, identityLogo }, fonts, { titleMotions }, { resolveStyles }] = await Promise.all([
32
+ import('./build.ts'),
33
+ import('./footage.ts'),
34
+ import('./score/sound.ts'),
35
+ import('./score/score.ts'),
36
+ import('./titles.ts'),
37
+ import('./look.ts'),
38
+ import('../assets/run-assets.ts'),
39
+ import('./fonts.ts'),
40
+ import('./graph.ts'),
41
+ import('./grammar.ts'),
42
+ ])
43
+ const brand = await loadBrand(root)
44
+ const look = typeof brand === 'string' ? null : lookOf(brand, await identityLogo(root))
45
+ for (const file of cutFiles(root)) {
46
+ // whatever a cut throws (its parse, the unit's take, its score), it is named and the keyed folders stay
47
+ try {
48
+ const l = await load(file, root, { check: false })
49
+ const why = l.errors[0]
50
+ if (why) {
51
+ live.broken.push(`${relative(root, file)}:${why.line}: ${why.message}`)
52
+ continue
53
+ }
54
+ const t = l.t
55
+ const names = { footage: takesOfLoaded(l).map((tk) => basename(tk.file)), stems: [] as string[], titles: [] as string[], score: [] as string[], sound: [] as string[] }
56
+ for (const p of pieces(t)) names.stems.push(`${t.name}-${p.name}`)
57
+ names.stems.push(`mix-${t.name}`, `${t.name}-vertical`)
58
+ if (look) {
59
+ const dir = trailerCache(root, 'titles')
60
+ names.titles.push(basename(titles.cardFile(dir, t.size, look)))
61
+ resolveStyles(t, (typeof brand === 'object' && brand?.trailer?.styles) || {})
62
+ const motions = titleMotions(t)
63
+ t.shots.forEach((s, i) =>
64
+ s.titles.forEach((ti, j) => {
65
+ const f = ti.fx?.font
66
+ const font = f === undefined ? null : fonts.isFontFile(f) ? fileOf(f, l) : fonts.googleFile(f, ti.fx?.weight ?? 400)
67
+ names.titles.push(basename(titles.titleFile(dir, { ...titleRequest(l, ti, look, motions[i]![j]!), font })))
68
+ }),
69
+ )
70
+ }
71
+ if (t.score && l.score) {
72
+ names.score.push(basename(scoreModuleFile(scoreModule(t.score, t.sections).text, trailerCache(root, 'score'))))
73
+ const sound = trailerCache(root, 'sound')
74
+ names.sound.push(basename(scoreWavFile(l.score, l.score.seconds, sound)))
75
+ for (const { cue, at, end } of musicCues(t)) if (cue.song === SCORE) names.sound.push(basename(scoreWavFile(l.score, cue.from + end - at, sound)))
76
+ }
77
+ for (const k of ['footage', 'stems', 'titles', 'score', 'sound'] as const) for (const n of names[k]) live[k].add(n)
78
+ } catch (e) {
79
+ live.broken.push(`${relative(root, file)}: ${(e as Error).message}`)
80
+ }
81
+ }
82
+ return live
83
+ }
84
+
85
+ /** a file the prune deletes: its bytes, and whether another hard link keeps them on disk */
86
+ interface Doomed {
87
+ file: string
88
+ bytes: number
89
+ linked: boolean
90
+ }
91
+
92
+ const doomed = (file: string): Doomed => {
93
+ const s = statSync(file)
94
+ return { file, bytes: s.size, linked: s.nlink > 1 }
95
+ }
96
+
97
+ /** the files of `dir` that `keep` refuses, past the grace */
98
+ export function unlisted(dir: string, keep: (name: string) => boolean, now = Date.now()): Doomed[] {
99
+ if (!existsSync(dir)) return []
100
+ return readdirSync(dir, { withFileTypes: true })
101
+ .filter((e) => e.isFile() && !keep(e.name))
102
+ .map((e) => join(dir, e.name))
103
+ .filter((f) => now - statSync(f).mtimeMs > RENDER_GRACE_MS)
104
+ .map(doomed)
105
+ }
106
+
107
+ /** a file stays when a cut names it, and every file named after it (its stamp `<file>.json`, its box, its levels) */
108
+ export const keepListed =
109
+ (names: ReadonlySet<string>) =>
110
+ (name: string): boolean =>
111
+ names.has(name) || [...names].some((n) => name.startsWith(`${n}.`))
112
+
113
+ const RENDER_RE = /^(.+)-([0-9a-f]{16})(?:\.part)?(?:\.\w+)+$/
114
+
115
+ /** the renders of `dir` that go: every render of a stem no cut has (a renamed section, a shot's preview), and past the
116
+ * newest `keep` of each live stem (a cache hit touches it). `stems` null: no stem is dropped */
117
+ export function oldRenders(dir: string, stems: ReadonlySet<string> | null, keep = KEEP_PER_SECTION, now = Date.now()): Doomed[] {
118
+ if (!existsSync(dir)) return []
119
+ const byStem = new Map<string, Map<string, number>>()
120
+ for (const e of readdirSync(dir, { withFileTypes: true }).filter((x) => x.isFile())) {
121
+ const m = RENDER_RE.exec(e.name)
122
+ if (!m) continue
123
+ const keys = byStem.get(m[1]!) ?? new Map<string, number>()
124
+ keys.set(m[2]!, Math.max(keys.get(m[2]!) ?? 0, statSync(join(dir, e.name)).mtimeMs))
125
+ byStem.set(m[1]!, keys)
126
+ }
127
+ const kept = new Set<string>()
128
+ for (const [stem, keys] of byStem) {
129
+ if (stems !== null && !stems.has(stem)) continue
130
+ for (const [k] of [...keys].sort((x, y) => y[1] - x[1]).slice(0, keep)) kept.add(`${stem}-${k}`)
131
+ }
132
+ return unlisted(
133
+ dir,
134
+ (name) => {
135
+ const m = RENDER_RE.exec(name)
136
+ return m === null ? stems === null : kept.has(`${m[1]}-${m[2]}`)
137
+ },
138
+ now,
139
+ )
140
+ }
141
+
142
+ /** what one folder frees */
143
+ export interface Freed {
144
+ folder: string
145
+ files: number
146
+ /** bytes the disk gets back */
147
+ bytes: number
148
+ /** bytes another hard link keeps */
149
+ linked: number
150
+ }
151
+
152
+ export interface Pruned {
153
+ freed: Freed[]
154
+ /** the cuts that do not read: the folders keyed on what the cuts use were left alone */
155
+ broken: string[]
156
+ }
157
+
158
+ function filesUnder(dir: string): string[] {
159
+ return readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? filesUnder(join(dir, e.name)) : [join(dir, e.name)]))
160
+ }
161
+
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)
168
+ const whole = live.broken.length === 0
169
+ // titles/ holds the vertical's drawings too: only a title's and a card's files are the prune's
170
+ const ownTitle = (n: string): boolean => /^(title|card)-[0-9a-f]{16}\./.test(n)
171
+ const plan: [string, Doomed[]][] = [
172
+ ['footage/', whole ? unlisted(join(cache, 'footage'), keepListed(live.footage), now) : []],
173
+ ['pieces/', ['', 'mix', 'vertical'].flatMap((d) => oldRenders(join(cache, 'pieces', d), whole ? live.stems : null, KEEP_PER_SECTION, now))],
174
+ ['sound/', whole ? unlisted(join(cache, 'sound'), keepListed(live.sound), now) : []],
175
+ ['score/', whole ? unlisted(join(cache, 'score'), keepListed(live.score), now) : []],
176
+ ['titles/', whole ? unlisted(join(cache, 'titles'), (n) => !ownTitle(n) || keepListed(live.titles)(n), now) : []],
177
+ ]
178
+ const dead = existsSync(cache) ? readdirSync(cache, { withFileTypes: true }).filter((e) => e.isDirectory() && !FOLDERS.includes(e.name)) : []
179
+ 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) {
182
+ 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 })
184
+ }
185
+ return {
186
+ 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) })),
187
+ broken: live.broken,
188
+ }
189
+ }
190
+
191
+ const gb = (n: number): string => (n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : `${(n / 1e6).toFixed(1)} MB`)
192
+
193
+ /** the report, a line a folder; `short` folds it into one line for the end of a build */
194
+ export function sayPruned(p: Pruned, dry: boolean, short = false): string[] {
195
+ const total = p.freed.reduce((n, f) => n + f.bytes, 0)
196
+ const files = p.freed.reduce((n, f) => n + f.files, 0)
197
+ if (short) return [`cache pruned: ${files} files, ${gb(total)} freed${p.broken.length > 0 ? ` (a cut does not read, the keyed folders left alone: ${p.broken[0]})` : ''}`]
198
+ return [
199
+ dry ? 'trailer cache, a dry run: nothing deleted' : 'trailer cache pruned',
200
+ ...p.freed.map((f) => ` ${f.folder.padEnd(20)} ${String(f.files).padStart(5)} files ${gb(f.bytes).padStart(9)}${f.linked > 0 ? ` (and ${gb(f.linked)} a hard link keeps)` : ''}`),
201
+ ` ${'total'.padEnd(20)} ${String(files).padStart(5)} files ${gb(total).padStart(9)}${dry ? ' would be freed' : ' freed'}`,
202
+ ...(p.broken.length > 0 ? [` footage, sound, score, titles and unknown sections left alone: a cut does not read (${p.broken.join('; ')})`] : []),
203
+ ]
204
+ }
@@ -0,0 +1,256 @@
1
+ // composition helpers for a score's `code` and `play` lines: they only build pattern strings in the grammar of
2
+ // pattern.ts, so a score stays plain note data once evaluated. Chords are written per bar, bars split by '|', a bar
3
+ // holding several chords splits evenly: 'C | Am | F G | C'.
4
+
5
+ import { noteToMidi } from './pattern.ts'
6
+
7
+ const NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
8
+
9
+ export function midiToNote(midi: number): string {
10
+ const oct = Math.floor(midi / 12) - 1
11
+ if (oct < 0 || oct > 8) throw new Error(`note ${midi} outside octaves 0..8`)
12
+ return `${NAMES[midi % 12]}${oct}`
13
+ }
14
+
15
+ /** joins pattern fragments into one row string */
16
+ export function seq(...parts: string[]): string {
17
+ return parts.join(' ')
18
+ }
19
+
20
+ export function rep(part: string, n: number): string {
21
+ return Array.from({ length: n }, () => part).join(' ')
22
+ }
23
+
24
+ function splitMods(tok: string): { mods: string; body: string } {
25
+ const i = tok.lastIndexOf(':')
26
+ return i === -1 ? { mods: '', body: tok } : { mods: tok.slice(0, i + 1), body: tok.slice(i + 1) }
27
+ }
28
+
29
+ /** shifts every note token by `semis` (plain rows or `mel` shorthand), modifiers, holds, rests and drums kept */
30
+ export function transpose(text: string, semis: number): string {
31
+ return text
32
+ .split(/\s+/)
33
+ .filter((t) => t !== '')
34
+ .map((tok) => {
35
+ const { mods, body } = splitMods(tok)
36
+ const [note = '', len] = body.split('/')
37
+ const midis = note.split('+').map(noteToMidi)
38
+ if (midis.some((m) => m === null)) return tok
39
+ return mods + midis.map((m) => midiToNote(m! + semis)).join('+') + (len === undefined ? '' : `/${len}`)
40
+ })
41
+ .join(' ')
42
+ }
43
+
44
+ /**
45
+ * durations shorthand: `E5/4` is E5 held 4 rows, `-/2` two rows of silence,
46
+ * a bare token lasts `unit` rows. Modifiers glue before the note:
47
+ * `v10:E5/4`, `@bell:C6/2`. Drum letters work the same way.
48
+ */
49
+ export function mel(text: string, unit = 1): string {
50
+ const out: string[] = []
51
+ for (const tok of text.split(/\s+/)) {
52
+ if (tok === '' || tok === '|') continue
53
+ const slash = tok.lastIndexOf('/')
54
+ const head = slash === -1 ? tok : tok.slice(0, slash)
55
+ const len = slash === -1 ? unit : Number(tok.slice(slash + 1))
56
+ if (!Number.isInteger(len) || len < 1) throw new Error(`mel token "${tok}": bad length`)
57
+ const { body } = splitMods(head)
58
+ out.push(head)
59
+ for (let i = 1; i < len; i++) out.push(body === '-' ? '-' : '.')
60
+ }
61
+ return out.join(' ')
62
+ }
63
+
64
+ // chord qualities as semitone stacks above the root
65
+ const QUALITIES: Record<string, number[]> = {
66
+ '': [0, 4, 7],
67
+ m: [0, 3, 7],
68
+ '7': [0, 4, 7, 10],
69
+ m7: [0, 3, 7, 10],
70
+ maj7: [0, 4, 7, 11],
71
+ dim: [0, 3, 6],
72
+ dim7: [0, 3, 6, 9],
73
+ aug: [0, 4, 8],
74
+ sus4: [0, 5, 7],
75
+ sus2: [0, 2, 7],
76
+ add9: [0, 4, 7, 14],
77
+ m9: [0, 3, 7, 10, 14],
78
+ '6': [0, 4, 7, 9],
79
+ m6: [0, 3, 7, 9],
80
+ '5': [0, 7],
81
+ }
82
+
83
+ export interface Chord {
84
+ /** pitch class 0..11 of the bass note (the slash note when given) */
85
+ bass: number
86
+ root: number
87
+ tones: number[]
88
+ }
89
+
90
+ /** 'C', 'F#m7', 'Bbmaj7', 'G/B' */
91
+ export function parseChord(sym: string): Chord {
92
+ const [main = '', slash] = sym.split('/')
93
+ const m = /^([A-G])([#b]?)(.*)$/.exec(main)
94
+ if (!m) throw new Error(`chord "${sym}": no root`)
95
+ const pc = (l: string, a: string) => ((noteToMidi(`${l}${a}4`) ?? 0) + 120) % 12
96
+ const root = pc(m[1] ?? 'C', m[2] ?? '')
97
+ const q = QUALITIES[m[3] ?? '']
98
+ if (!q) throw new Error(`chord "${sym}": unknown quality "${m[3]}"`)
99
+ let bass = root
100
+ if (slash !== undefined) {
101
+ const s = /^([A-G])([#b]?)$/.exec(slash)
102
+ if (!s) throw new Error(`chord "${sym}": bad slash bass`)
103
+ bass = pc(s[1] ?? 'C', s[2] ?? '')
104
+ }
105
+ return { root, bass, tones: q }
106
+ }
107
+
108
+ interface Seg {
109
+ chord: Chord
110
+ rows: number
111
+ }
112
+
113
+ /** the chord chart as segments; `bar` is rows per bar */
114
+ export function chart(text: string, bar = 16): Seg[] {
115
+ const segs: Seg[] = []
116
+ for (const b of text.split('|')) {
117
+ const syms = b.split(/\s+/).filter((s) => s !== '')
118
+ if (syms.length === 0) continue
119
+ if (bar % syms.length !== 0) throw new Error(`chart bar "${b}" does not split ${bar} rows evenly`)
120
+ for (const s of syms) segs.push({ chord: parseChord(s), rows: bar / syms.length })
121
+ }
122
+ return segs
123
+ }
124
+
125
+ /** lowest midi of pitch class `pc` at or above the C of `oct` */
126
+ function at(pc: number, oct: number): number {
127
+ return 12 * (oct + 1) + pc
128
+ }
129
+
130
+ export type BassStyle =
131
+ | 'hold' // root held the whole segment
132
+ | 'half' // root, then fifth at half
133
+ | 'quarter' // root on every beat
134
+ | 'octave' // eighths bouncing root, octave
135
+ | 'drive' // straight eighths on the root, octave jump on the last
136
+ | 'gallop' // eighth + two sixteenths per beat
137
+ | 'walk' // quarters root, third, fifth, a step to the next root
138
+ | 'waltz' // root on the beat, chord fifth on beats 2 and 3 (3 beat bars)
139
+ | 'pulse' // root on beats 1 and 3, rests between
140
+
141
+ /** a bass line over a chart, in octave `oct` (2 = the usual register) */
142
+ export function bass(text: string, style: BassStyle, oct = 2, bar = 16, beat = 4): string {
143
+ const segs = chart(text, bar)
144
+ const out: string[] = []
145
+ segs.forEach((seg, i) => {
146
+ const r = at(seg.chord.bass, oct)
147
+ const fifth = at(seg.chord.root, oct) + 7
148
+ const third = at(seg.chord.root, oct) + (seg.chord.tones[1] ?? 4)
149
+ const next = segs[(i + 1) % segs.length]?.chord.bass ?? seg.chord.bass
150
+ const rows: string[] = []
151
+ const put = (row: number, midi: number | '-') => {
152
+ rows[row] = midi === '-' ? '-' : midiToNote(midi)
153
+ }
154
+ for (let k = 0; k < seg.rows; k++) rows.push('.')
155
+ switch (style) {
156
+ case 'hold':
157
+ put(0, r)
158
+ break
159
+ case 'half':
160
+ put(0, r)
161
+ if (seg.rows >= 2) put(seg.rows / 2, fifth > r + 9 ? fifth - 12 : fifth)
162
+ break
163
+ case 'quarter':
164
+ for (let k = 0; k < seg.rows; k += beat) put(k, r)
165
+ break
166
+ case 'octave':
167
+ for (let k = 0; k < seg.rows; k += beat / 2) put(k, (k / (beat / 2)) % 2 === 0 ? r : r + 12)
168
+ break
169
+ case 'drive':
170
+ for (let k = 0; k < seg.rows; k += beat / 2) put(k, k === seg.rows - beat / 2 && seg.rows >= bar ? r + 12 : r)
171
+ break
172
+ case 'gallop':
173
+ for (let k = 0; k < seg.rows; k += beat) {
174
+ put(k, r)
175
+ if (k + 2 < seg.rows) put(k + 2, r)
176
+ if (k + 3 < seg.rows) put(k + 3, k + beat >= seg.rows ? r + 12 : r)
177
+ }
178
+ break
179
+ case 'walk': {
180
+ const target = at(next, oct)
181
+ const approach = target > r ? target - 1 : target + 1
182
+ const steps = [r, third, fifth > r + 9 ? fifth - 12 : fifth, approach]
183
+ for (let k = 0, j = 0; k < seg.rows; k += beat, j++) put(k, steps[Math.min(j, 3)] ?? r)
184
+ if (seg.rows <= beat * 2) {
185
+ rows.fill('.')
186
+ put(0, r)
187
+ if (seg.rows > beat) put(beat, approach)
188
+ }
189
+ break
190
+ }
191
+ case 'waltz':
192
+ for (let k = 0; k < seg.rows; k += beat) put(k, k === 0 ? r : fifth > r + 9 ? fifth - 12 : fifth)
193
+ break
194
+ case 'pulse':
195
+ for (let k = 0; k < seg.rows; k += beat * 2) {
196
+ put(k, r)
197
+ if (k + beat < seg.rows) put(k + beat, '-')
198
+ }
199
+ break
200
+ }
201
+ out.push(...rows)
202
+ })
203
+ return out.join(' ')
204
+ }
205
+
206
+ /**
207
+ * an arpeggio over a chart: `shape` indexes the chord tones upward (0 root,
208
+ * 1 third, 2 fifth, 3 the root an octave up for a triad...), one index per
209
+ * step of `step` rows, cycling through the segment.
210
+ */
211
+ export function arp(text: string, shape: number[], oct = 4, step = 2, bar = 16): string {
212
+ const out: string[] = []
213
+ for (const seg of chart(text, bar)) {
214
+ const n = seg.chord.tones.length
215
+ for (let k = 0, j = 0; k < seg.rows; k += step, j++) {
216
+ const idx = shape[j % shape.length] ?? 0
217
+ const iv = (seg.chord.tones[idx % n] ?? 0) + 12 * Math.floor(idx / n)
218
+ out.push(midiToNote(at(seg.chord.root, oct) + iv))
219
+ for (let h = 1; h < step && k + h < seg.rows; h++) out.push('.')
220
+ }
221
+ }
222
+ return out.join(' ')
223
+ }
224
+
225
+ /**
226
+ * one voice of a held chord: tone `which` of each chord, placed at or above
227
+ * `floor` (a midi note), re-struck per segment. Stack two or three pad tracks
228
+ * on different tones for a full chord.
229
+ */
230
+ export function voice(text: string, which: number, floor: number, bar = 16, restrike = 0): string {
231
+ const out: string[] = []
232
+ for (const seg of chart(text, bar)) {
233
+ const n = seg.chord.tones.length
234
+ const iv = (seg.chord.tones[which % n] ?? 0) + 12 * Math.floor(which / n)
235
+ let m = seg.chord.root + iv
236
+ while (m < floor) m += 12
237
+ const note = midiToNote(m)
238
+ for (let k = 0; k < seg.rows; k++) out.push(k === 0 || (restrike > 0 && k % restrike === 0) ? note : '.')
239
+ }
240
+ return out.join(' ')
241
+ }
242
+
243
+ /**
244
+ * each chord of a chart whole, as one chord token (`D4+F4+A4`): every tone placed at or above `floor` (a midi note),
245
+ * stacked upward, re-struck per segment and every `restrike` rows. For strings, brass, an organ or a stab.
246
+ */
247
+ export function chords(text: string, floor: number, bar = 16, restrike = 0): string {
248
+ const out: string[] = []
249
+ for (const seg of chart(text, bar)) {
250
+ let m = seg.chord.root
251
+ while (m < floor) m += 12
252
+ const tok = seg.chord.tones.map((iv) => midiToNote(m + iv)).join('+')
253
+ for (let k = 0; k < seg.rows; k++) out.push(k === 0 || (restrike > 0 && k % restrike === 0) ? tok : '.')
254
+ }
255
+ return out.join(' ')
256
+ }