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,188 @@
1
+ import type { InstrumentDef } from './pattern.ts'
2
+
3
+ /** one cycle of a wave from its harmonic levels (the fundamental first), for an instrument's `wave` */
4
+ export function additive(levels: number[], samples = 64): number[] {
5
+ return Array.from({ length: samples }, (_, i) =>
6
+ levels.reduce((sum, level, k) => sum + level * Math.sin((2 * Math.PI * (k + 1) * i) / samples), 0),
7
+ )
8
+ }
9
+
10
+ // the default instrument palette; a score spreads one and overrides what it needs: `lead: { ...INST.lead, duty: 0.25 }`.
11
+ // With no `export const instruments` in its code, a score plays these
12
+ export const INST = {
13
+ lead: {
14
+ osc: 'pulse',
15
+ duty: 0.5,
16
+ vol: 0.4,
17
+ env: { a: 0.015, d: 0.15, s: 0.65, r: 0.1 },
18
+ vibrato: { delay: 0.18, depth: 0.12, rate: 5.5 },
19
+ reverb: 0.2,
20
+ },
21
+ lead25: {
22
+ osc: 'pulse',
23
+ duty: 0.25,
24
+ vol: 0.38,
25
+ env: { a: 0.015, d: 0.12, s: 0.6, r: 0.1 },
26
+ vibrato: { delay: 0.2, depth: 0.12, rate: 5.5 },
27
+ reverb: 0.2,
28
+ },
29
+ flute: {
30
+ osc: 'triangle',
31
+ vol: 0.55,
32
+ env: { a: 0.03, d: 0.2, s: 0.75, r: 0.15 },
33
+ vibrato: { delay: 0.25, depth: 0.15, rate: 5 },
34
+ reverb: 0.35,
35
+ },
36
+ ocarina: {
37
+ osc: 'triangle',
38
+ vol: 0.5,
39
+ env: { a: 0.045, d: 0.2, s: 0.8, r: 0.2 },
40
+ vibrato: { delay: 0.3, depth: 0.12, rate: 4.8 },
41
+ reverb: 0.35,
42
+ },
43
+ // odd harmonics, as a clarinet's
44
+ reed: {
45
+ osc: 'wave',
46
+ wave: additive([1, 0.05, 0.45, 0.04, 0.2, 0.02, 0.08]),
47
+ tone: 2600,
48
+ vol: 0.4,
49
+ env: { a: 0.03, d: 0.2, s: 0.75, r: 0.14 },
50
+ vibrato: { delay: 0.3, depth: 0.1, rate: 5 },
51
+ reverb: 0.3,
52
+ },
53
+ // a soft electric piano: a round body, a touch of the fourth harmonic for the tine
54
+ keys: {
55
+ osc: 'wave',
56
+ wave: additive([1, 0.4, 0.1, 0.14, 0.03]),
57
+ vol: 0.34,
58
+ env: { a: 0.004, d: 0.55, s: 0.1, r: 0.3 },
59
+ reverb: 0.3,
60
+ },
61
+ glock: {
62
+ osc: 'wave',
63
+ wave: additive([1, 0, 0, 0.25]),
64
+ vol: 0.3,
65
+ env: { a: 0.002, d: 0.9, s: 0, r: 0.6 },
66
+ reverb: 0.45,
67
+ },
68
+ cello: {
69
+ osc: 'saw',
70
+ tone: 1500,
71
+ vol: 0.26,
72
+ env: { a: 0.09, d: 0.3, s: 0.85, r: 0.3 },
73
+ detune: 7,
74
+ unison: 2,
75
+ vibrato: { delay: 0.35, depth: 0.1, rate: 5 },
76
+ reverb: 0.35,
77
+ },
78
+ bell: { osc: 'sine', vol: 0.4, env: { a: 0.002, d: 0.6, s: 0.1, r: 0.5 }, reverb: 0.4 },
79
+ pluck: { osc: 'pulse', duty: 0.125, vol: 0.3, env: { a: 0.002, d: 0.12, s: 0.15, r: 0.05 }, reverb: 0.2 },
80
+ arp: { osc: 'pulse', duty: 0.25, vol: 0.22, env: { a: 0.002, d: 0.08, s: 0.3, r: 0.03 }, pan: 0.25, reverb: 0.15 },
81
+ harmony: {
82
+ osc: 'pulse',
83
+ duty: 0.25,
84
+ vol: 0.28,
85
+ env: { a: 0.01, d: 0.15, s: 0.55, r: 0.1 },
86
+ pan: -0.3,
87
+ reverb: 0.25,
88
+ },
89
+ pad: {
90
+ osc: 'saw',
91
+ vol: 0.16,
92
+ env: { a: 0.35, d: 0.4, s: 0.7, r: 0.6 },
93
+ detune: 8,
94
+ unison: 2,
95
+ filter: { type: 'lowpass', freq: 1400, q: 0.7 },
96
+ reverb: 0.5,
97
+ },
98
+ strings: {
99
+ osc: 'saw',
100
+ vol: 0.2,
101
+ env: { a: 0.12, d: 0.3, s: 0.8, r: 0.3 },
102
+ detune: 6,
103
+ unison: 2,
104
+ filter: { type: 'lowpass', freq: 2600, q: 0.8 },
105
+ vibrato: { delay: 0.3, depth: 0.08, rate: 5 },
106
+ reverb: 0.4,
107
+ },
108
+ brass: {
109
+ osc: 'saw',
110
+ vol: 0.3,
111
+ env: { a: 0.03, d: 0.2, s: 0.7, r: 0.12 },
112
+ filter: { type: 'lowpass', freq: 2200, q: 1.2 },
113
+ vibrato: { delay: 0.25, depth: 0.1, rate: 5.5 },
114
+ reverb: 0.25,
115
+ },
116
+ bass: { osc: 'triangle', vol: 0.65, env: { a: 0.002, d: 0.06, s: 0.9, r: 0.05 } },
117
+ bassPulse: {
118
+ osc: 'pulse',
119
+ duty: 0.5,
120
+ vol: 0.3,
121
+ env: { a: 0.002, d: 0.1, s: 0.7, r: 0.04 },
122
+ filter: { type: 'lowpass', freq: 900 },
123
+ },
124
+ bassSaw: {
125
+ osc: 'saw',
126
+ vol: 0.32,
127
+ env: { a: 0.002, d: 0.12, s: 0.6, r: 0.05 },
128
+ filter: { type: 'lowpass', freq: 700, q: 2 },
129
+ },
130
+ kick: {
131
+ osc: 'sine',
132
+ vol: 0.75,
133
+ env: { a: 0, d: 0.16, s: 0, r: 0.03 },
134
+ sweep: { semis: -24, time: 0.07 },
135
+ pitch: 52,
136
+ },
137
+ snare: {
138
+ osc: 'noise',
139
+ noise: 'white',
140
+ vol: 0.35,
141
+ env: { a: 0, d: 0.12, s: 0, r: 0.04 },
142
+ filter: { type: 'bandpass', freq: 1800, q: 0.8 },
143
+ reverb: 0.15,
144
+ },
145
+ hat: {
146
+ osc: 'noise',
147
+ noise: 'white',
148
+ vol: 0.1,
149
+ env: { a: 0, d: 0.03, s: 0, r: 0.012 },
150
+ filter: { type: 'highpass', freq: 6500 },
151
+ },
152
+ openhat: {
153
+ osc: 'noise',
154
+ noise: 'white',
155
+ vol: 0.09,
156
+ env: { a: 0, d: 0.18, s: 0, r: 0.06 },
157
+ filter: { type: 'highpass', freq: 5500 },
158
+ },
159
+ crash: {
160
+ osc: 'noise',
161
+ noise: 'white',
162
+ vol: 0.18,
163
+ env: { a: 0, d: 0.9, s: 0, r: 0.3 },
164
+ filter: { type: 'highpass', freq: 4000 },
165
+ reverb: 0.3,
166
+ },
167
+ tom: { osc: 'sine', vol: 0.55, env: { a: 0, d: 0.18, s: 0, r: 0.05 }, sweep: { semis: -7, time: 0.15 } },
168
+ shaker: {
169
+ osc: 'noise',
170
+ noise: 'white',
171
+ vol: 0.08,
172
+ env: { a: 0.005, d: 0.04, s: 0, r: 0.01 },
173
+ filter: { type: 'highpass', freq: 8000 },
174
+ },
175
+ // a pure sine under the bass register, felt more than heard; play it in octave 1 or 2
176
+ sub: { osc: 'sine', vol: 0.7, env: { a: 0.01, d: 0.2, s: 0.85, r: 0.12 } },
177
+ clap: {
178
+ osc: 'noise',
179
+ noise: 'white',
180
+ vol: 0.3,
181
+ env: { a: 0.002, d: 0.14, s: 0, r: 0.06 },
182
+ filter: { type: 'bandpass', freq: 1200, q: 1.4 },
183
+ reverb: 0.3,
184
+ },
185
+ } satisfies Record<string, InstrumentDef>
186
+
187
+ /** the drum kit letters most songs use */
188
+ export const KIT = { k: 'kick', s: 'snare', h: 'hat', o: 'openhat', c: 'crash', x: 'shaker' }
@@ -0,0 +1,98 @@
1
+ // the score's note grammar and its instruments: one token a grid row, an instrument as plain data the synth plays.
2
+ // Pure: no audio here (synth.ts renders)
3
+
4
+ /** oscillator families: chiptune roots (pulse, wave, noise) plus the smoother modern ones; `gm` a General MIDI
5
+ * program the soundfont plays (gm.ts) */
6
+ export type Osc = 'pulse' | 'saw' | 'triangle' | 'sine' | 'wave' | 'noise' | 'gm'
7
+
8
+ export interface InstrumentDef {
9
+ osc: Osc
10
+ /** 0..1 */
11
+ vol: number
12
+ /** ADSR in seconds, sustain as a 0..1 level */
13
+ env: { a: number; d: number; s: number; r: number }
14
+ /** pulse only: duty cycle 0.05..0.95 */
15
+ duty?: number
16
+ /** wave only: one cycle of samples in -1..1, any length */
17
+ wave?: number[]
18
+ /** pulse, saw and wave: the frequency in Hz above which the harmonics fall away, default 3000; higher is brighter */
19
+ tone?: number
20
+ /** noise only: 'white' hiss or 'metal' (short period, pitched) */
21
+ noise?: 'white' | 'metal'
22
+ /** detune in cents, and extra unison voices spread by it */
23
+ detune?: number
24
+ unison?: number
25
+ filter?: { type: 'lowpass' | 'highpass' | 'bandpass'; freq: number; q?: number }
26
+ /** depth in semitones, rate in Hz, delay in seconds from the note start */
27
+ vibrato?: { delay: number; depth: number; rate: number }
28
+ /** portamento between consecutive notes, seconds */
29
+ glide?: number
30
+ /** -1 left .. 1 right */
31
+ pan?: number
32
+ /** 0..1 send to the score's reverb */
33
+ reverb?: number
34
+ /** pitch slide from each note start: `semis` over `time` seconds (kicks, toms, zaps) */
35
+ sweep?: { semis: number; time: number }
36
+ /** the midi note a drum letter plays this instrument at, default 60 */
37
+ pitch?: number
38
+ /** gm only: the program, or a drum key of a kit, the soundfont plays */
39
+ gm?: { program: number; drum?: number }
40
+ /** what the score's .mid writes it as: a GM program, a drum's key for a drum instrument */
41
+ midi?: number
42
+ }
43
+
44
+ // one token a row, whitespace separated, '|' ignored:
45
+ // C4 C#4 Db4 note (octave 0..8) . hold the previous note
46
+ // - silence k drum letter (from `drums`)
47
+ // D4+F4+A4 a chord: its notes start and hold together
48
+ // zero length modifiers glued before a row token: `@lead:C4` switches instrument, `v8:C4` sets the volume 0..15
49
+ // (15 = the instrument's vol) from that note on
50
+ type Row = { kind: 'note'; midi: number; chord?: number[] } | { kind: 'hold' } | { kind: 'off' } | { kind: 'drum'; letter: string }
51
+
52
+ export interface RowEvent {
53
+ row: Row
54
+ instrument?: string
55
+ vol?: number
56
+ }
57
+
58
+ const NOTE_RE = /^([A-G])([#b]?)(\d)$/
59
+ const SEMI: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }
60
+
61
+ export function noteToMidi(token: string): number | null {
62
+ const m = NOTE_RE.exec(token)
63
+ if (!m) return null
64
+ const [, letter, acc, oct] = m
65
+ const base = SEMI[letter ?? '']
66
+ if (base === undefined || oct === undefined) return null
67
+ return 12 * (Number(oct) + 1) + base + (acc === '#' ? 1 : acc === 'b' ? -1 : 0)
68
+ }
69
+
70
+ export function midiToHz(midi: number): number {
71
+ return 440 * 2 ** ((midi - 69) / 12)
72
+ }
73
+
74
+ export function parsePattern(text: string, drums: Record<string, string> = {}): RowEvent[] {
75
+ const out: RowEvent[] = []
76
+ for (const raw of text.split(/\s+/)) {
77
+ if (raw === '' || raw === '|') continue
78
+ const parts = raw.split(':')
79
+ const body = parts.pop() ?? ''
80
+ const ev: RowEvent = { row: { kind: 'off' } }
81
+ for (const mod of parts) {
82
+ if (mod.startsWith('@')) ev.instrument = mod.slice(1)
83
+ else if (/^v\d{1,2}$/.test(mod)) ev.vol = Number(mod.slice(1))
84
+ else throw new Error(`pattern token "${raw}": unknown modifier "${mod}"`)
85
+ }
86
+ if (body === '.') ev.row = { kind: 'hold' }
87
+ else if (body === '-') ev.row = { kind: 'off' }
88
+ else if (drums[body] !== undefined) ev.row = { kind: 'drum', letter: body }
89
+ else {
90
+ const ms = body.split('+').map(noteToMidi)
91
+ if (ms.some((m) => m === null)) throw new Error(`pattern token "${raw}": not a note, a chord (D4+F4+A4), a drum letter, '.' or '-'`)
92
+ const [midi, ...more] = ms as number[]
93
+ ev.row = more.length > 0 ? { kind: 'note', midi: midi!, chord: more } : { kind: 'note', midi: midi! }
94
+ }
95
+ out.push(ev)
96
+ }
97
+ return out
98
+ }
@@ -0,0 +1,228 @@
1
+ // a trailer's score: its `code` block and `play` lines made one module, each section's tracks read in the pattern
2
+ // grammar and timed at the section's own tempo, into the notes the synth plays. A track's instrument, volume and held
3
+ // note carry across sections. The module is loaded synchronously (require), so a check reads every error at its line
4
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import { type Score, SCORE_LEAD, type Section, type TrailerError } from '../grammar.ts'
7
+ import { gm, gmName } from './gm.ts'
8
+ import { INST } from './palette.ts'
9
+ import { type InstrumentDef, parsePattern, type RowEvent } from './pattern.ts'
10
+
11
+ export interface ScheduledNote {
12
+ track: string
13
+ /** the grid row it starts on, counted over the whole score */
14
+ row: number
15
+ rows: number
16
+ /** null for a drum hit, which plays its instrument's `pitch` */
17
+ midi: number | null
18
+ instrument: string
19
+ /** 0..1, the v modifier times the track volume */
20
+ vol: number
21
+ /** the track's note before, for glide */
22
+ from: number | null
23
+ /** seconds from the render's start, its lead counted */
24
+ time: number
25
+ dur: number
26
+ }
27
+
28
+ export interface CompiledScore {
29
+ instruments: Record<string, InstrumentDef>
30
+ reverb?: { seconds: number; mix: number }
31
+ /** every note sorted by time */
32
+ notes: ScheduledNote[]
33
+ /** the score's length, its lead counted */
34
+ seconds: number
35
+ /** where it cuts dead, its lead counted; null: it rings out */
36
+ stop: number | null
37
+ /** each section's span in the render, its lead counted */
38
+ sections: { name: string; at: number; seconds: number; notes: number; bpm: number; meter: number }[]
39
+ }
40
+
41
+ const here = import.meta.dir
42
+ // the helpers every `code` block and `play` line reads without importing them
43
+ const PRELUDE = [
44
+ `import { arp, bass, chart, chords, mel, midiToNote, parseChord, rep, seq, transpose, voice } from ${JSON.stringify(join(here, 'compose.ts'))}`,
45
+ `import { additive, INST, KIT } from ${JSON.stringify(join(here, 'palette.ts'))}`,
46
+ `import { gm } from ${JSON.stringify(join(here, 'gm.ts'))}`,
47
+ "const rest = (n: number): string => Array.from({ length: n }, () => '-').join(' ')",
48
+ 'export const __plays: (() => unknown)[] = []',
49
+ 'const __play = (i: number, f: () => unknown): void => void (__plays[i] = f)',
50
+ ]
51
+
52
+ type Play = Section['plays'][number]
53
+
54
+ /** the score as one module: the prelude, the code block, each play as a function; `lineOf` maps its lines to the file's */
55
+ export function scoreModule(s: Score, sections: readonly Section[]): { text: string; lineOf: number[]; plays: Play[] } {
56
+ const out: string[] = []
57
+ const lineOf: number[] = []
58
+ const push = (text: string, line: number): void => {
59
+ out.push(text)
60
+ lineOf.push(line)
61
+ }
62
+ for (const l of PRELUDE) push(l, s.line)
63
+ // the code's lines from its own line on, the blank ones kept: a template string may hold one
64
+ for (const [i, l] of (s.code?.text.split('\n') ?? []).entries()) if (i >= (s.code?.line ?? 0)) push(l, i + 1)
65
+ const plays = sections.flatMap((sec) => sec.plays)
66
+ plays.forEach((pl, i) => {
67
+ // a plain quoted string is a melody
68
+ const expr = /^"[^"\\]*"$/.test(pl.expr) ? `mel(${pl.expr})` : pl.expr
69
+ const [first, ...more] = expr.split('\n')
70
+ push(`__play(${i}, () => (${first ?? ''}`, pl.line)
71
+ for (const [k, l] of more.entries()) push(l, pl.line + 1 + k)
72
+ push('))', pl.line)
73
+ })
74
+ return { text: `${out.join('\n')}\n`, lineOf, plays }
75
+ }
76
+
77
+ /** the module's line a load error names: a syntax error carries its place, a throw at the top level its stack */
78
+ function errorLine(e: unknown, file: string): number | null {
79
+ const errs = (e as { errors?: { position?: { line?: number } }[] }).errors
80
+ const pos = errs?.[0]?.position?.line ?? (e as { position?: { line?: number } }).position?.line
81
+ if (pos !== undefined) return pos
82
+ const m = new RegExp(`${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:(\\d+)`).exec((e as Error).stack ?? '')
83
+ return m ? Number(m[1]) : null
84
+ }
85
+
86
+ const firstLine = (e: unknown): string => {
87
+ const errs = (e as { errors?: unknown[] }).errors
88
+ return String(errs?.[0] ?? e).split('\n')[0]!.replace(/^(BuildMessage|Error): /, '')
89
+ }
90
+
91
+ /** the file a score's module text is kept in */
92
+ export const scoreModuleFile = (text: string, dir: string): string => join(dir, `${Bun.hash(text).toString(36)}.ts`)
93
+
94
+ /** the score's notes, or every error on its line; `dir` keeps the module files, each named by its text */
95
+ export function compileScore(s: Score, sections: readonly Section[], dir: string): { score: CompiledScore | null; errors: TrailerError[] } {
96
+ const errors: TrailerError[] = []
97
+ const { text, lineOf, plays } = scoreModule(s, sections)
98
+ mkdirSync(dir, { recursive: true })
99
+ const file = scoreModuleFile(text, dir)
100
+ if (!existsSync(file)) writeFileSync(file, text)
101
+ let mod: { instruments?: unknown; __plays: (() => unknown)[] }
102
+ try {
103
+ mod = require(file)
104
+ } catch (e) {
105
+ const at = errorLine(e, file)
106
+ const line = at !== null ? (lineOf[at - 1] ?? s.line) : (s.code?.line ?? s.line)
107
+ return { score: null, errors: [{ line, message: `the score's TypeScript fails: ${firstLine(e)}` }] }
108
+ }
109
+ const instruments = mod.instruments ?? INST
110
+ if (typeof instruments !== 'object' || instruments === null)
111
+ return { score: null, errors: [{ line: s.code?.line ?? s.line, message: 'the code block\'s instruments is no record: export const instruments = { … }' }] }
112
+ const inst: Record<string, InstrumentDef> = { ...(instruments as Record<string, InstrumentDef>) }
113
+ const names = (): string => `${Object.keys(inst).filter((k) => !gmName(k)).join(', ')}, or gm:<name> (sk sound gm)`
114
+ // a `gm:<name>` track or drum is the General MIDI instrument of that name
115
+ for (const name of [...s.tracks.map((tr) => tr.instrument), ...Object.values(s.drums), ...sections.flatMap((sec) => Object.values(sec.drums))]) {
116
+ const g = gmName(name)
117
+ if (g === null || inst[name]) continue
118
+ try {
119
+ inst[name] = gm(g)
120
+ } catch {
121
+ // an unknown name stays missing: the checks below name it at its line
122
+ }
123
+ }
124
+ const values = new Map(plays.map((pl, i) => [pl, mod.__plays[i]]))
125
+
126
+ const tracks = s.tracks
127
+ for (const tr of tracks) if (!inst[tr.instrument]) errors.push({ line: tr.line, message: `track ${tr.name}: no instrument ${tr.instrument} (${names()})` })
128
+ for (const [letter, name] of Object.entries(s.drums)) if (!inst[name]) errors.push({ line: s.line, message: `drums ${letter}=${name}: no instrument ${name} (${names()})` })
129
+
130
+ const state = new Map(tracks.map((tr) => [tr.name, { instrument: tr.instrument, vol: 15, open: [] as ScheduledNote[], prev: null as number | null }]))
131
+ const notes: ScheduledNote[] = []
132
+ const spans: CompiledScore['sections'] = []
133
+ let row0 = 0
134
+ const close = (st: { open: ScheduledNote[] }, time: number, row: number): void => {
135
+ for (const n of st.open) {
136
+ n.dur = time - n.time
137
+ n.rows = row - n.row
138
+ }
139
+ st.open = []
140
+ }
141
+ for (const sec of sections) {
142
+ const { bpm, grid, meter } = sec.clock ?? s.clock
143
+ const rowsExact = (sec.seconds * bpm * grid) / 60
144
+ const rows = Math.round(rowsExact)
145
+ const spr = 60 / bpm / grid
146
+ const bars = (n: number): string => `${+(n / grid / meter).toFixed(3)} bars`
147
+ const before = notes.length
148
+ spans.push({ name: sec.name, at: SCORE_LEAD + sec.at, seconds: sec.seconds, notes: 0, bpm, meter })
149
+ if (Math.abs(rowsExact - rows) > 1e-6) {
150
+ errors.push({ line: sec.line, message: `section ${sec.name}: ${+sec.seconds.toFixed(4)} s is ${+rowsExact.toFixed(3)} rows of its grid (${grid} a beat), never a whole number` })
151
+ continue
152
+ }
153
+ if (s.stop && sec.at >= s.stop.at - 1e-9 && sec.plays.length > 0) {
154
+ for (const pl of sec.plays) errors.push({ line: pl.line, message: `play ${pl.track}: section ${sec.name} starts after the score stops (line ${s.stop.line}), so it is never heard` })
155
+ continue
156
+ }
157
+ const drums = { ...s.drums, ...sec.drums }
158
+ for (const [letter, name] of Object.entries(sec.drums)) if (!inst[name]) errors.push({ line: sec.line, message: `drums ${letter}=${name}: no instrument ${name} (${names()})` })
159
+ const byTrack = new Map<string, { line: number; text: string }>()
160
+ for (const pl of sec.plays) {
161
+ let v: unknown
162
+ try {
163
+ v = values.get(pl)?.()
164
+ } catch (e) {
165
+ errors.push({ line: pl.line, message: `play ${pl.track}: ${firstLine(e)}` })
166
+ continue
167
+ }
168
+ if (pl.track === '*') {
169
+ if (typeof v !== 'object' || v === null || Object.values(v).some((x) => typeof x !== 'string')) {
170
+ errors.push({ line: pl.line, message: 'play * wants a record of tracks, each its rows as text' })
171
+ continue
172
+ }
173
+ for (const [k, x] of Object.entries(v as Record<string, string>)) {
174
+ if (!state.has(k)) errors.push({ line: pl.line, message: `play *: no track ${k} (${tracks.map((x) => x.name).join(', ')})` })
175
+ else if (byTrack.has(k)) errors.push({ line: pl.line, message: `play *: section ${sec.name} already plays ${k}` })
176
+ else byTrack.set(k, { line: pl.line, text: x })
177
+ }
178
+ } else if (typeof v !== 'string') errors.push({ line: pl.line, message: `play ${pl.track}: wants its rows as text, got ${typeof v}` })
179
+ else if (byTrack.has(pl.track)) errors.push({ line: pl.line, message: `play ${pl.track}: section ${sec.name} already plays it` })
180
+ else byTrack.set(pl.track, { line: pl.line, text: v })
181
+ }
182
+ for (const tr of tracks) {
183
+ const st = state.get(tr.name)!
184
+ const src = byTrack.get(tr.name)
185
+ let events: RowEvent[]
186
+ try {
187
+ events = src ? parsePattern(src.text, drums) : Array.from({ length: rows }, (): RowEvent => ({ row: { kind: 'off' } }))
188
+ } catch (e) {
189
+ errors.push({ line: src!.line, message: `play ${tr.name}: ${firstLine(e)}` })
190
+ continue
191
+ }
192
+ if (events.length !== rows) {
193
+ errors.push({ line: src!.line, message: `play ${tr.name}: ${events.length} rows, ${bars(events.length)}; section ${sec.name} is ${rows} rows, ${bars(rows)}` })
194
+ continue
195
+ }
196
+ events.forEach((ev, r) => {
197
+ const time = SCORE_LEAD + sec.at + r * spr
198
+ if (ev.instrument !== undefined) {
199
+ if (!inst[ev.instrument]) errors.push({ line: src!.line, message: `play ${tr.name}: no instrument ${ev.instrument} (${names()})` })
200
+ st.instrument = ev.instrument
201
+ }
202
+ if (ev.vol !== undefined) st.vol = Math.max(0, Math.min(15, ev.vol))
203
+ const k = ev.row
204
+ if (k.kind === 'hold') return
205
+ close(st, time, row0 + r)
206
+ if (k.kind === 'off') return
207
+ const vol = (st.vol / 15) * tr.volume
208
+ if (k.kind === 'drum') st.open = [{ track: tr.name, row: row0 + r, rows: 1, midi: null, instrument: drums[k.letter] ?? st.instrument, vol, from: null, time, dur: spr }]
209
+ else {
210
+ const one = (midi: number, from: number | null): ScheduledNote => ({ track: tr.name, row: row0 + r, rows: 1, midi, instrument: st.instrument, vol, from, time, dur: spr })
211
+ st.open = [one(k.midi, st.prev), ...(k.chord ?? []).map((m) => one(m, null))]
212
+ st.prev = k.midi
213
+ }
214
+ notes.push(...st.open)
215
+ })
216
+ }
217
+ spans.at(-1)!.notes = notes.length - before
218
+ row0 += rows
219
+ }
220
+ const last = sections.at(-1)
221
+ const seconds = SCORE_LEAD + (last ? last.at + last.seconds : 0)
222
+ for (const st of state.values()) close(st, seconds, row0)
223
+ if (errors.length > 0) return { score: null, errors: dedupe(errors) }
224
+ notes.sort((a, b) => a.time - b.time)
225
+ return { score: { instruments: inst, ...(s.reverb ? { reverb: s.reverb } : {}), notes, seconds, stop: s.stop ? SCORE_LEAD + s.stop.at : null, sections: spans }, errors: [] }
226
+ }
227
+
228
+ const dedupe = (es: TrailerError[]): TrailerError[] => [...new Map(es.map((e) => [`${e.line} ${e.message}`, e])).values()]
@@ -0,0 +1,123 @@
1
+ // a score as a wav in the cache, keyed on its notes, instruments, room and the synth's own code, and what it sounds
2
+ // like as numbers: each section's loudness and peak, since nobody hears a build. Silence under notes refuses
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { fluidIdentity } from '../sound/fluid.ts'
6
+ import { gmInstruments, gmStems } from './gm-render.ts'
7
+ import type { CompiledScore } from './score.ts'
8
+ import { HOT, RATE, type Rendered, renderScore } from './synth.ts'
9
+
10
+ /** the synth's sources: an edit to any renders every score again */
11
+ const SYNTH = ['synth.ts', 'dsp.ts', 'pattern.ts'].map((f) => join(import.meta.dir, f))
12
+ /** what renders a GM track besides: read only by a score that has one */
13
+ const GM = ['gm-render.ts', 'midi.ts', '../sound/fluid.ts'].map((f) => join(import.meta.dir, f))
14
+
15
+ /** where the render stops playing notes: its length, or the stop before it */
16
+ const endOf = (score: CompiledScore, seconds: number): number => Math.min(Math.ceil(seconds), score.stop ?? Number.POSITIVE_INFINITY)
17
+
18
+ export interface Level {
19
+ name: string
20
+ /** seconds in the render */
21
+ at: number
22
+ seconds: number
23
+ notes: number
24
+ /** dBFS, the louder channel */
25
+ rms: number
26
+ peak: number
27
+ /** the share of its samples at the soft ceiling */
28
+ hot: number
29
+ }
30
+
31
+ /** below this RMS a section is silence */
32
+ export const SILENT_DB = -60
33
+ /** past this share of samples at the ceiling, a section distorts */
34
+ const HOT_SHARE = 0.002
35
+
36
+ const db = (x: number): number => (x > 0 ? 20 * Math.log10(x) : -120)
37
+
38
+ /** each span's RMS, peak and share of samples at the ceiling */
39
+ export function levels(r: Rendered, spans: readonly { name: string; at: number; seconds: number; notes: number }[]): Level[] {
40
+ return spans.map((s) => {
41
+ const a = Math.max(0, Math.floor(s.at * r.rate))
42
+ const b = Math.min(r.left.length, Math.ceil((s.at + s.seconds) * r.rate))
43
+ const m = spanStats(r.left, r.right, a, b)
44
+ return { ...s, rms: +db(m.rms).toFixed(1), peak: +db(m.peak).toFixed(1), hot: +m.hot.toFixed(4) }
45
+ })
46
+ }
47
+
48
+ function spanStats(l: Float32Array, r: Float32Array, a: number, b: number): { rms: number; peak: number; hot: number } {
49
+ let sl = 0
50
+ let sr = 0
51
+ let peak = 0
52
+ let hot = 0
53
+ for (let i = a; i < b; i++) {
54
+ const x = l[i]!
55
+ const y = r[i]!
56
+ sl += x * x
57
+ sr += y * y
58
+ const p = Math.max(Math.abs(x), Math.abs(y))
59
+ if (p > peak) peak = p
60
+ if (p >= HOT) hot++
61
+ }
62
+ const n = Math.max(1, b - a)
63
+ return { rms: Math.sqrt(Math.max(sl, sr) / n), peak, hot: hot / n }
64
+ }
65
+
66
+ /** a level as one line; `bad` names what is wrong with it */
67
+ export function levelLine(x: Level): { line: string; bad: 'silent' | 'hot' | null } {
68
+ const bad = x.notes > 0 && x.rms < SILENT_DB ? 'silent' : x.hot > HOT_SHARE ? 'hot' : null
69
+ const why = bad === 'silent' ? `: SILENT, ${x.notes} notes nobody hears` : bad === 'hot' ? `: HOT, ${(x.hot * 100).toFixed(1)}% of it at the ceiling, lower the score's volume or its loudest tracks` : ''
70
+ return { line: `score ${x.name.padEnd(14)} ${x.at.toFixed(2).padStart(6)} s ${x.seconds.toFixed(2).padStart(6)} s ${String(x.notes).padStart(4)} notes rms ${x.rms.toFixed(1).padStart(6)} dBFS peak ${x.peak.toFixed(1).padStart(6)} dBFS${why}`, bad }
71
+ }
72
+
73
+ /** 16 bit stereo PCM */
74
+ export function wavOf(r: Rendered): Buffer {
75
+ const n = r.left.length
76
+ const out = new Int16Array(n * 2)
77
+ for (let i = 0; i < n; i++) {
78
+ out[i * 2] = Math.round(Math.max(-1, Math.min(1, r.left[i]!)) * 32767)
79
+ out[i * 2 + 1] = Math.round(Math.max(-1, Math.min(1, r.right[i]!)) * 32767)
80
+ }
81
+ const pcm = Buffer.from(out.buffer)
82
+ const h = Buffer.alloc(44)
83
+ h.write('RIFF', 0)
84
+ h.writeUInt32LE(36 + pcm.length, 4)
85
+ h.write('WAVEfmt ', 8)
86
+ h.writeUInt32LE(16, 16)
87
+ h.writeUInt16LE(1, 20)
88
+ h.writeUInt16LE(2, 22)
89
+ h.writeUInt32LE(r.rate, 24)
90
+ h.writeUInt32LE(r.rate * 4, 28)
91
+ h.writeUInt16LE(4, 32)
92
+ h.writeUInt16LE(16, 34)
93
+ h.write('data', 36)
94
+ h.writeUInt32LE(pcm.length, 40)
95
+ return Buffer.concat([h, pcm])
96
+ }
97
+
98
+ /** the file the score's wav of `seconds` (rounded up) is kept in: its notes, instruments, room and the synth's code */
99
+ export function scoreWavFile(score: CompiledScore, seconds: number, dir: string): string {
100
+ const h = new Bun.CryptoHasher('sha1').update(JSON.stringify([score.instruments, score.reverb ?? null, score.notes, score.stop, score.sections, Math.ceil(seconds), RATE]))
101
+ for (const f of SYNTH) h.update(readFileSync(f))
102
+ if (gmInstruments(score, endOf(score, seconds)).length > 0) {
103
+ h.update(fluidIdentity())
104
+ for (const f of GM) h.update(readFileSync(f))
105
+ }
106
+ return join(dir, `score-${h.digest('hex').slice(0, 16)}.wav`)
107
+ }
108
+
109
+ /** the score's wav of `seconds` (rounded up) in `dir`, rendered when missing; its levels kept beside it */
110
+ export function scoreWav(score: CompiledScore, seconds: number, dir: string): { file: string; made: boolean; levels: Level[]; ms: number } {
111
+ const s = Math.ceil(seconds)
112
+ const file = scoreWavFile(score, seconds, dir)
113
+ const meta = `${file}.levels.json`
114
+ if (existsSync(file) && existsSync(meta)) return { file, made: false, levels: JSON.parse(readFileSync(meta, 'utf8')) as Level[], ms: 0 }
115
+ const t0 = performance.now()
116
+ const r = renderScore(score, s, RATE, gmStems(score, endOf(score, s), RATE))
117
+ const lv = levels(r, score.sections)
118
+ mkdirSync(dir, { recursive: true })
119
+ writeFileSync(`${file}.part`, wavOf(r))
120
+ writeFileSync(meta, JSON.stringify(lv))
121
+ renameSync(`${file}.part`, file)
122
+ return { file, made: true, levels: lv, ms: performance.now() - t0 }
123
+ }