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,272 @@
1
+ // the signal blocks the score's synth is made of, each a pure function over Float32Arrays: the ADSR points, the biquad
2
+ // (the Web Audio formulas, so an instrument sounds as it does in a browser synth), the compressor, the soft ceiling and
3
+ // an FFT convolution for the room. Hot loops sit in top level functions over their arrays, no closure per sample
4
+
5
+ /** [seconds from note start, gain] points; the last one is 0 at release end. A note released during its attack or
6
+ * decay releases from the level it reached, never from the peak, so a short note never clicks */
7
+ export function envPoints(env: { a: number; d: number; s: number; r: number }, dur: number, peak: number): [number, number][] {
8
+ const a = Math.max(0.001, env.a)
9
+ const d = Math.max(0, env.d)
10
+ const s = Math.max(0, Math.min(1, env.s)) * peak
11
+ const r = Math.max(0.005, env.r)
12
+ const off = Math.max(0, dur)
13
+ const pts: [number, number][] = [[0, 0]]
14
+ if (off <= a) pts.push([off, peak * (off / a)])
15
+ else {
16
+ pts.push([a, peak])
17
+ if (off <= a + d) pts.push([off, d === 0 ? s : peak + (s - peak) * ((off - a) / d)])
18
+ else {
19
+ pts.push([a + d, s])
20
+ pts.push([off, s])
21
+ }
22
+ }
23
+ const last = pts[pts.length - 1]
24
+ pts.push([(last?.[0] ?? off) + r, 0])
25
+ return pts.filter((p, i) => i === 0 || p[0] !== pts[i - 1]?.[0] || p[1] !== pts[i - 1]?.[1])
26
+ }
27
+
28
+ /** multiplies `buf` (its first sample at `t0` seconds after the note start) by the envelope's points */
29
+ export function applyEnvelope(buf: Float32Array, n: number, t0: number, rate: number, pts: readonly [number, number][]): void {
30
+ let k = 1
31
+ for (let i = 0; i < n; i++) {
32
+ const t = t0 + i / rate
33
+ while (k < pts.length - 1 && t > pts[k]![0]) k++
34
+ const p = pts[k - 1]!
35
+ const q = pts[k]!
36
+ const g = t >= q[0] ? q[1] : q[0] === p[0] ? q[1] : p[1] + (q[1] - p[1]) * ((t - p[0]) / (q[0] - p[0]))
37
+ buf[i] = buf[i]! * (t < 0 ? 0 : g)
38
+ }
39
+ }
40
+
41
+ export type FilterType = 'lowpass' | 'highpass' | 'bandpass'
42
+
43
+ /** a biquad in place, Web Audio's coefficients: lowpass and highpass read q in dB, bandpass as a plain q */
44
+ export function biquad(buf: Float32Array, n: number, rate: number, type: FilterType, freq: number, q: number): void {
45
+ const f = Math.max(1, Math.min(rate / 2 - 1, freq))
46
+ const w0 = (2 * Math.PI * f) / rate
47
+ const cw = Math.cos(w0)
48
+ const sw = Math.sin(w0)
49
+ let b0: number, b1: number, b2: number
50
+ let alpha: number
51
+ if (type === 'bandpass') {
52
+ alpha = sw / (2 * Math.max(1e-4, q))
53
+ b0 = alpha
54
+ b1 = 0
55
+ b2 = -alpha
56
+ } else {
57
+ alpha = sw / (2 * 10 ** (q / 20))
58
+ const sign = type === 'lowpass' ? 1 : -1
59
+ b0 = (1 - sign * cw) / 2
60
+ b1 = sign * (1 - sign * cw)
61
+ b2 = b0
62
+ }
63
+ const a0 = 1 + alpha
64
+ biquadRun(buf, n, b0 / a0, b1 / a0, b2 / a0, (-2 * cw) / a0, (1 - alpha) / a0)
65
+ }
66
+
67
+ function biquadRun(buf: Float32Array, n: number, b0: number, b1: number, b2: number, a1: number, a2: number): void {
68
+ let z1 = 0
69
+ let z2 = 0
70
+ for (let i = 0; i < n; i++) {
71
+ const x = buf[i]!
72
+ const y = b0 * x + z1
73
+ z1 = b1 * x - a1 * y + z2
74
+ z2 = b2 * x - a2 * y
75
+ buf[i] = y
76
+ }
77
+ }
78
+
79
+ const CEILING = 0.98
80
+ const KNEE = 0.7
81
+
82
+ /** linear below the knee, tanh into the ceiling above it (slope 1 at the knee, so the join is smooth) */
83
+ export function softClip(x: number): number {
84
+ const a = Math.abs(x)
85
+ if (a <= KNEE) return x
86
+ return Math.sign(x) * (KNEE + (CEILING - KNEE) * Math.tanh((a - KNEE) / (CEILING - KNEE)))
87
+ }
88
+
89
+ export interface CompressorDef {
90
+ /** dB */
91
+ threshold: number
92
+ /** dB */
93
+ knee: number
94
+ ratio: number
95
+ /** seconds */
96
+ attack: number
97
+ release: number
98
+ }
99
+
100
+ const lin = (db: number): number => 10 ** (db / 20)
101
+ const dbOf = (x: number): number => 20 * Math.log10(Math.max(1e-12, x))
102
+
103
+ /** the static curve of a browser compressor: a knee shaped by `k`, then the ratio's line; the knee's `k` is searched so
104
+ * the curve meets the ratio's slope at the knee's top, and the output is lifted by its makeup gain */
105
+ function staticCurve(c: CompressorDef): { saturate: (x: number) => number; makeup: number } {
106
+ const linThr = lin(c.threshold)
107
+ const kneeThrDb = c.threshold + c.knee
108
+ const kneeThr = lin(kneeThrDb)
109
+ const slope = 1 / c.ratio
110
+ const kneeCurve = (x: number, k: number): number => (x < linThr ? x : linThr + (1 - Math.exp(-k * (x - linThr))) / k)
111
+ const slopeAt = (x: number, k: number): number => {
112
+ if (x < linThr) return 1
113
+ const x2 = x * 1.001
114
+ return (dbOf(kneeCurve(x2, k)) - dbOf(kneeCurve(x, k))) / (dbOf(x2) - dbOf(x))
115
+ }
116
+ let lo = 0.1
117
+ let hi = 10000
118
+ let k = 5
119
+ for (let i = 0; i < 15; i++) {
120
+ if (slopeAt(kneeThr, k) < slope) hi = k
121
+ else lo = k
122
+ k = Math.sqrt(lo * hi)
123
+ }
124
+ const yKneeDb = dbOf(kneeCurve(kneeThr, k))
125
+ const saturate = (x: number): number => (x < kneeThr ? kneeCurve(x, k) : lin(yKneeDb + slope * (dbOf(x) - kneeThrDb)))
126
+ return { saturate, makeup: (1 / saturate(1)) ** 0.6 }
127
+ }
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 */
131
+ export function compress(l: Float32Array, r: Float32Array, rate: number, c: CompressorDef): void {
132
+ 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
135
+ const lo = -60
136
+ const step = 0.05
137
+ const table = new Float32Array(Math.ceil((24 - lo) / step) + 2)
138
+ for (let i = 0; i < table.length; i++) {
139
+ const x = lin(lo + i * step)
140
+ table[i] = dbOf(saturate(x) / x)
141
+ }
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)
143
+ }
144
+
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
149
+ 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
158
+ }
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
+ }
169
+ }
170
+
171
+ /** an in place radix 2 FFT; `inverse` scales by 1/n */
172
+ export function fft(re: Float64Array, im: Float64Array, inverse: boolean): void {
173
+ const n = re.length
174
+ for (let i = 1, j = 0; i < n; i++) {
175
+ let bit = n >> 1
176
+ for (; j & bit; bit >>= 1) j ^= bit
177
+ j ^= bit
178
+ if (i < j) {
179
+ const tr = re[i]!
180
+ re[i] = re[j]!
181
+ re[j] = tr
182
+ const ti = im[i]!
183
+ im[i] = im[j]!
184
+ im[j] = ti
185
+ }
186
+ }
187
+ const sign = inverse ? 1 : -1
188
+ for (let len = 2; len <= n; len <<= 1) {
189
+ const ang = (sign * 2 * Math.PI) / len
190
+ const wr = Math.cos(ang)
191
+ const wi = Math.sin(ang)
192
+ const half = len >> 1
193
+ for (let i = 0; i < n; i += len) {
194
+ let cr = 1
195
+ let ci = 0
196
+ for (let k = 0; k < half; k++) {
197
+ const a = i + k
198
+ const b = a + half
199
+ const xr = re[b]! * cr - im[b]! * ci
200
+ const xi = re[b]! * ci + im[b]! * cr
201
+ re[b] = re[a]! - xr
202
+ im[b] = im[a]! - xi
203
+ re[a] = re[a]! + xr
204
+ im[a] = im[a]! + xi
205
+ const nr = cr * wr - ci * wi
206
+ ci = cr * wi + ci * wr
207
+ cr = nr
208
+ }
209
+ }
210
+ }
211
+ if (inverse) for (let i = 0; i < n; i++) {
212
+ re[i] = re[i]! / n
213
+ im[i] = im[i]! / n
214
+ }
215
+ }
216
+
217
+ /** adds `gain` times (inL, inR) convolved with the stereo impulse (irL, irR) to (outL, outR): overlap add, both
218
+ * channels in one complex transform a block */
219
+ export function convolveInto(inL: Float32Array, inR: Float32Array, irL: Float32Array, irR: Float32Array, outL: Float32Array, outR: Float32Array, gain: number): void {
220
+ const m = irL.length
221
+ let size = 1
222
+ while (size < 4 * m) size <<= 1
223
+ const block = size - m + 1
224
+ // the two impulses' spectra from one transform of irL + i irR, split by conjugate symmetry
225
+ const hr = new Float64Array(size)
226
+ const hi = new Float64Array(size)
227
+ hr.set(irL)
228
+ hi.set(irR)
229
+ fft(hr, hi, false)
230
+ const lr = new Float64Array(size)
231
+ const li = new Float64Array(size)
232
+ const rr = new Float64Array(size)
233
+ const ri = new Float64Array(size)
234
+ for (let k = 0; k < size; k++) {
235
+ const j = (size - k) % size
236
+ lr[k] = (hr[k]! + hr[j]!) / 2
237
+ li[k] = (hi[k]! - hi[j]!) / 2
238
+ rr[k] = (hi[k]! + hi[j]!) / 2
239
+ ri[k] = (hr[j]! - hr[k]!) / 2
240
+ }
241
+ const xr = new Float64Array(size)
242
+ const xi = new Float64Array(size)
243
+ const yr = new Float64Array(size)
244
+ const yi = new Float64Array(size)
245
+ const n = outL.length
246
+ for (let at = 0; at < inL.length; at += block) {
247
+ const len = Math.min(block, inL.length - at)
248
+ xr.fill(0)
249
+ xi.fill(0)
250
+ let loud = false
251
+ for (let i = 0; i < len; i++) {
252
+ xr[i] = inL[at + i]!
253
+ xi[i] = inR[at + i]!
254
+ if (xr[i] !== 0 || xi[i] !== 0) loud = true
255
+ }
256
+ if (!loud) continue
257
+ fft(xr, xi, false)
258
+ spectrumTimes(xr, xi, lr, li, yr, yi)
259
+ fft(yr, yi, true)
260
+ for (let i = 0; i < size && at + i < n; i++) outL[at + i] = outL[at + i]! + gain * yr[i]!
261
+ spectrumTimes(xr, xi, rr, ri, yr, yi)
262
+ fft(yr, yi, true)
263
+ for (let i = 0; i < size && at + i < n; i++) outR[at + i] = outR[at + i]! + gain * yi[i]!
264
+ }
265
+ }
266
+
267
+ function spectrumTimes(ar: Float64Array, ai: Float64Array, br: Float64Array, bi: Float64Array, or: Float64Array, oi: Float64Array): void {
268
+ for (let k = 0; k < ar.length; k++) {
269
+ or[k] = ar[k]! * br[k]! - ai[k]! * bi[k]!
270
+ oi[k] = ar[k]! * bi[k]! + ai[k]! * br[k]!
271
+ }
272
+ }
@@ -0,0 +1,43 @@
1
+ // a score's General MIDI tracks as stems: each GM instrument's notes written as a .mid, rendered by FluidSynth with
2
+ // its own room off, read back for the synth to mix into the score's bus (its pan, its send to the room, the master)
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { fluidRender, readWav } from '../sound/fluid.ts'
7
+ import { stemMidi } from './midi.ts'
8
+ import type { CompiledScore } from './score.ts'
9
+
10
+ /** fluidsynth's gain for a stem: a GM instrument at vol 0.8 then sits near a synth one of the palette (measured on one
11
+ * arpeggio: GM piano -26.9 dBFS at gain 1, the palette's keys -19.5) */
12
+ const STEM_GAIN = 2.2
13
+
14
+ export interface Stem {
15
+ instrument: string
16
+ left: Float32Array
17
+ right: Float32Array
18
+ }
19
+
20
+ /** the score's GM instruments that play a note before `end` */
21
+ export const gmInstruments = (score: CompiledScore, end: number): string[] =>
22
+ [...new Set(score.notes.filter((n) => n.time < end && score.instruments[n.instrument]?.osc === 'gm').map((n) => n.instrument))]
23
+
24
+ /** one stem a GM instrument, `rate` samples a second, its notes up to `end` seconds of the render */
25
+ export function gmStems(score: CompiledScore, end: number, rate: number): Stem[] {
26
+ const names = gmInstruments(score, end)
27
+ if (names.length === 0) return []
28
+ const dir = mkdtempSync(join(tmpdir(), 'sk-gm-'))
29
+ try {
30
+ return names.map((name, i) => {
31
+ const v = score.instruments[name]!.gm!
32
+ const mid = join(dir, `${i}.mid`)
33
+ const wav = join(dir, `${i}.wav`)
34
+ writeFileSync(mid, stemMidi(score.notes.filter((n) => n.instrument === name), v, end))
35
+ fluidRender(mid, wav, { rate, room: false, gain: STEM_GAIN })
36
+ const w = readWav(wav)
37
+ if (w.rate !== rate) throw new Error(`fluidsynth rendered ${name} at ${w.rate} Hz, not ${rate}`)
38
+ return { instrument: name, left: w.left, right: w.right }
39
+ })
40
+ } finally {
41
+ rmSync(dir, { recursive: true, force: true })
42
+ }
43
+ }
@@ -0,0 +1,90 @@
1
+ // the General MIDI instruments a score track plays through the soundfont: the 128 programs and the drum keys by name,
2
+ // and `gm()`, the instrument data a `gm:<name>` track or a code block names. Pure: FluidSynth renders them (gm-render.ts)
3
+ import type { InstrumentDef } from './pattern.ts'
4
+
5
+ /** the 128 programs in order, each a short kebab name */
6
+ export const GM_PROGRAMS = [
7
+ 'acoustic-grand', 'bright-acoustic', 'electric-grand', 'honky-tonk', 'electric-piano-1', 'electric-piano-2', 'harpsichord', 'clavinet',
8
+ 'celesta', 'glockenspiel', 'music-box', 'vibraphone', 'marimba', 'xylophone', 'tubular-bells', 'dulcimer',
9
+ 'drawbar-organ', 'percussive-organ', 'rock-organ', 'church-organ', 'reed-organ', 'accordion', 'harmonica', 'tango-accordion',
10
+ 'nylon-guitar', 'steel-guitar', 'jazz-guitar', 'clean-guitar', 'muted-guitar', 'overdrive-guitar', 'distortion-guitar', 'guitar-harmonics',
11
+ 'acoustic-bass', 'finger-bass', 'pick-bass', 'fretless-bass', 'slap-bass-1', 'slap-bass-2', 'synth-bass-1', 'synth-bass-2',
12
+ 'violin', 'viola', 'cello', 'contrabass', 'tremolo-strings', 'pizzicato-strings', 'harp', 'timpani',
13
+ 'strings', 'slow-strings', 'synth-strings-1', 'synth-strings-2', 'choir-aahs', 'voice-oohs', 'synth-choir', 'orchestra-hit',
14
+ 'trumpet', 'trombone', 'tuba', 'muted-trumpet', 'french-horn', 'brass-section', 'synth-brass-1', 'synth-brass-2',
15
+ 'soprano-sax', 'alto-sax', 'tenor-sax', 'baritone-sax', 'oboe', 'english-horn', 'bassoon', 'clarinet',
16
+ 'piccolo', 'flute', 'recorder', 'pan-flute', 'blown-bottle', 'shakuhachi', 'whistle', 'ocarina',
17
+ 'square-lead', 'saw-lead', 'calliope-lead', 'chiff-lead', 'charang-lead', 'voice-lead', 'fifths-lead', 'bass-lead',
18
+ 'new-age-pad', 'warm-pad', 'polysynth-pad', 'choir-pad', 'bowed-pad', 'metallic-pad', 'halo-pad', 'sweep-pad',
19
+ 'rain-fx', 'soundtrack-fx', 'crystal-fx', 'atmosphere-fx', 'brightness-fx', 'goblins-fx', 'echoes-fx', 'sci-fi-fx',
20
+ 'sitar', 'banjo', 'shamisen', 'koto', 'kalimba', 'bagpipe', 'fiddle', 'shanai',
21
+ 'tinkle-bell', 'agogo', 'steel-drums', 'woodblock', 'taiko-drum', 'melodic-tom', 'synth-drum', 'reverse-cymbal',
22
+ 'guitar-fret-noise', 'breath-noise', 'seashore', 'bird-tweet', 'telephone-ring', 'helicopter', 'applause', 'gunshot',
23
+ ] as const
24
+
25
+ /** the drum keys of channel 10 (35 to 81), by name */
26
+ export const GM_DRUMS: Readonly<Record<string, number>> = {
27
+ 'kick-2': 35, kick: 36, 'side-stick': 37, snare: 38, clap: 39, 'electric-snare': 40, 'low-floor-tom': 41, 'closed-hat': 42,
28
+ 'high-floor-tom': 43, 'pedal-hat': 44, 'low-tom': 45, 'open-hat': 46, 'low-mid-tom': 47, 'high-mid-tom': 48, crash: 49, 'high-tom': 50,
29
+ ride: 51, china: 52, 'ride-bell': 53, tambourine: 54, splash: 55, cowbell: 56, 'crash-2': 57, vibraslap: 58,
30
+ 'ride-2': 59, 'high-bongo': 60, 'low-bongo': 61, 'mute-high-conga': 62, 'open-high-conga': 63, 'low-conga': 64, 'high-timbale': 65, 'low-timbale': 66,
31
+ 'high-agogo': 67, 'low-agogo': 68, cabasa: 69, maracas: 70, 'short-whistle': 71, 'long-whistle': 72, 'short-guiro': 73, 'long-guiro': 74,
32
+ claves: 75, 'high-woodblock': 76, 'low-woodblock': 77, 'mute-cuica': 78, 'open-cuica': 79, 'mute-triangle': 80, 'open-triangle': 81,
33
+ }
34
+
35
+ /** the drum kits of bank 128 by program: standard 0, room 8, power 16, electronic 24, 808 25, jazz 32, brush 40, orchestra 48 */
36
+ export const GM_KITS = { standard: 0, room: 8, power: 16, electronic: 24, tr808: 25, jazz: 32, brush: 40, orchestra: 48 } as const
37
+
38
+ /** a GM program or a drum of a kit */
39
+ export interface GmVoice {
40
+ program: number
41
+ /** a drum: its key on channel 10, `program` its kit */
42
+ drum?: number
43
+ }
44
+
45
+ export interface GmOptions {
46
+ /** 0..1, default 0.8 */
47
+ vol?: number
48
+ pan?: number
49
+ reverb?: number
50
+ /** a drum's kit: a number or a name of GM_KITS */
51
+ kit?: number | keyof typeof GM_KITS
52
+ }
53
+
54
+ /** a program or a drum by name or number: `acoustic-grand`, 48, `kick`; null when no such name */
55
+ export function gmVoice(name: string | number, kit: GmOptions['kit'] = 0): GmVoice | null {
56
+ const k = typeof kit === 'number' ? kit : GM_KITS[kit]
57
+ if (typeof name === 'number' || /^\d+$/.test(name)) {
58
+ const n = Number(name)
59
+ return Number.isInteger(n) && n >= 0 && n < 128 ? { program: n } : null
60
+ }
61
+ const p = (GM_PROGRAMS as readonly string[]).indexOf(name)
62
+ if (p >= 0) return { program: p }
63
+ const d = GM_DRUMS[name]
64
+ return d === undefined || k === undefined ? null : { program: k, drum: d }
65
+ }
66
+
67
+ /** the instrument a track plays through the soundfont: `gm('strings', { vol: 0.7, pan: -0.3 })`, `gm(48)`, `gm('kick', { kit: 'brush' })` */
68
+ export function gm(name: string | number, o: GmOptions = {}): InstrumentDef {
69
+ const v = gmVoice(name, o.kit)
70
+ if (!v) throw new Error(`gm ${name}: no General MIDI program or drum of that name (sk sound gm lists them)`)
71
+ return {
72
+ osc: 'gm',
73
+ gm: v,
74
+ vol: o.vol ?? 0.8,
75
+ env: { a: 0, d: 0, s: 1, r: 0 },
76
+ ...(o.pan !== undefined ? { pan: o.pan } : {}),
77
+ ...(o.reverb !== undefined ? { reverb: o.reverb } : {}),
78
+ }
79
+ }
80
+
81
+ /** the GM name an instrument name `gm:<name>` holds, else null */
82
+ export const gmName = (instrument: string): string | null => (instrument.startsWith('gm:') ? instrument.slice(3) : null)
83
+
84
+ /** the programs and drums as lines: what `sk sound gm` prints */
85
+ export function gmTable(): string[] {
86
+ const rows = GM_PROGRAMS.map((n, i) => `${String(i).padStart(3)} ${n}`)
87
+ const drums = Object.entries(GM_DRUMS).map(([n, k]) => `${String(k).padStart(3)} ${n}`)
88
+ const kits = Object.entries(GM_KITS).map(([n, k]) => `${String(k).padStart(3)} ${n}`)
89
+ return ['programs (track x gm:<name>, gm(<name or number>)):', ...rows, '', 'drums (drums k=gm:<name>, gm(<name>, { kit })):', ...drums, '', 'kits (gm(<drum>, { kit: <name or number> })):', ...kits]
90
+ }
@@ -0,0 +1,143 @@
1
+ // the score as a Standard MIDI File: `scoreMidi` writes the whole score for a DAW (a track per score track, the
2
+ // sections' tempos and meters), `stemMidi` one GM instrument's notes at a fixed clock for FluidSynth to render.
3
+ // Pure: bytes out, no file here
4
+ import type { ScheduledNote, CompiledScore } from './score.ts'
5
+ import type { InstrumentDef } from './pattern.ts'
6
+ import { SCORE_LEAD } from '../grammar.ts'
7
+
8
+ interface Ev {
9
+ tick: number
10
+ /** a sort key inside one tick: note offs first, then meta and program changes, then note ons */
11
+ order: number
12
+ data: number[]
13
+ }
14
+
15
+ const vlq = (n: number): number[] => {
16
+ const out = [n & 0x7f]
17
+ for (let v = n >> 7; v > 0; v >>= 7) out.unshift((v & 0x7f) | 0x80)
18
+ return out
19
+ }
20
+
21
+ const u32 = (n: number): number[] => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]
22
+
23
+ function chunk(type: string, body: number[]): number[] {
24
+ return [...Buffer.from(type, 'latin1'), ...u32(body.length), ...body]
25
+ }
26
+
27
+ function trackChunk(events: Ev[], name: string | null, end: number): number[] {
28
+ const sorted = [...events].sort((a, b) => a.tick - b.tick || a.order - b.order)
29
+ const body: number[] = []
30
+ let at = 0
31
+ if (name !== null) {
32
+ const bytes = [...Buffer.from(name, 'utf8')]
33
+ body.push(0, 0xff, 0x03, ...vlq(bytes.length), ...bytes)
34
+ }
35
+ for (const e of sorted) {
36
+ body.push(...vlq(Math.max(0, e.tick - at)), ...e.data)
37
+ at = Math.max(at, e.tick)
38
+ }
39
+ body.push(...vlq(Math.max(0, end - at)), 0xff, 0x2f, 0x00)
40
+ return chunk('MTrk', body)
41
+ }
42
+
43
+ /** a type 1 file: the conductor track, then each track */
44
+ function smf(ppq: number, conductor: Ev[], tracks: { name: string; events: Ev[] }[], end: number): Uint8Array {
45
+ const head = chunk('MThd', [0, 1, 0, tracks.length + 1, (ppq >> 8) & 0xff, ppq & 0xff])
46
+ return new Uint8Array([...head, ...trackChunk(conductor, null, end), ...tracks.flatMap((t) => trackChunk(t.events, t.name, end))])
47
+ }
48
+
49
+ const velocity = (vol: number): number => Math.max(1, Math.min(127, Math.round(127 * vol)))
50
+
51
+ /** what a synth instrument of the default palette is written as: its nearest GM program, a drum's key */
52
+ const NEAREST: Readonly<Record<string, number>> = {
53
+ lead: 80, lead25: 80, flute: 73, ocarina: 79, reed: 71, keys: 4, glock: 9, cello: 42, bell: 14, pluck: 45, arp: 81, harmony: 81,
54
+ pad: 89, strings: 48, brass: 61, bass: 33, bassPulse: 38, bassSaw: 39, sub: 38,
55
+ }
56
+ const NEAREST_DRUM: Readonly<Record<string, number>> = { kick: 36, snare: 38, clap: 39, hat: 42, openhat: 46, crash: 49, tom: 45, shaker: 70 }
57
+
58
+ /** a note as the file writes it: a drum on channel 10 at its key, else a program */
59
+ function gmOf(name: string, inst: InstrumentDef | undefined, drum: boolean): { drum: true; key: number; kit: number } | { drum: false; program: number } {
60
+ if (inst?.gm) return inst.gm.drum !== undefined ? { drum: true, key: inst.gm.drum, kit: inst.gm.program } : { drum: false, program: inst.gm.program }
61
+ if (drum || inst?.osc === 'noise') return { drum: true, key: inst?.midi ?? NEAREST_DRUM[name] ?? 38, kit: 0 }
62
+ return { drum: false, program: inst?.midi ?? NEAREST[name] ?? 80 }
63
+ }
64
+
65
+ const PPQ = 480
66
+
67
+ /** the whole score as a .mid: each score track a MIDI track, the sections' tempos and meters on the conductor track,
68
+ * the score's time from its first section on; a note past the stop is cut there */
69
+ export function scoreMidi(score: CompiledScore): Uint8Array {
70
+ const secs = score.sections
71
+ // each section's first tick, the score clock starting at the first section
72
+ const starts: { at: number; tick: number; bpm: number }[] = []
73
+ let tick = 0
74
+ for (const s of secs) {
75
+ starts.push({ at: s.at, tick, bpm: s.bpm })
76
+ tick += Math.round((s.seconds * s.bpm * PPQ) / 60)
77
+ }
78
+ const tickOf = (time: number): number => {
79
+ let k = 0
80
+ while (k + 1 < starts.length && starts[k + 1]!.at <= time + 1e-9) k++
81
+ const s = starts[k] ?? { at: SCORE_LEAD, tick: 0, bpm: 120 }
82
+ return Math.max(0, s.tick + Math.round(((time - s.at) * s.bpm * PPQ) / 60))
83
+ }
84
+ const stop = score.stop ?? Number.POSITIVE_INFINITY
85
+ const end = Math.min(tick, tickOf(stop))
86
+ const conductor: Ev[] = []
87
+ let meter = 0
88
+ let bpm = 0
89
+ for (const [i, s] of secs.entries()) {
90
+ const t = starts[i]!.tick
91
+ if (s.bpm !== bpm) conductor.push({ tick: t, order: 1, data: [0xff, 0x51, 0x03, ...u32(Math.round(60_000_000 / s.bpm)).slice(1)] })
92
+ if (s.meter !== meter) conductor.push({ tick: t, order: 1, data: [0xff, 0x58, 0x04, s.meter, 2, 24, 8] })
93
+ bpm = s.bpm
94
+ meter = s.meter
95
+ }
96
+ const byTrack = new Map<string, ScheduledNote[]>()
97
+ for (const n of score.notes) {
98
+ if (n.time >= stop) continue
99
+ byTrack.set(n.track, [...(byTrack.get(n.track) ?? []), n])
100
+ }
101
+ let next = 0
102
+ const tracks = [...byTrack.entries()].map(([name, notes]) => {
103
+ const channel = next === 9 ? ++next : next
104
+ next = Math.min(15, channel + 1)
105
+ const events: Ev[] = []
106
+ const program = new Map<number, number>()
107
+ for (const n of notes) {
108
+ const inst = score.instruments[n.instrument]
109
+ const g = gmOf(n.instrument, inst, n.midi === null)
110
+ const ch = g.drum ? 9 : channel
111
+ const want = g.drum ? g.kit : g.program
112
+ const a = tickOf(n.time)
113
+ if (program.get(ch) !== want && !(g.drum && want === 0 && !program.has(ch))) events.push({ tick: a, order: 1, data: [0xc0 | ch, want] })
114
+ program.set(ch, want)
115
+ const key = g.drum ? g.key : n.midi!
116
+ const b = Math.max(a + 1, Math.min(end, tickOf(Math.min(stop, n.time + n.dur))))
117
+ events.push({ tick: a, order: 2, data: [0x90 | ch, key, velocity(n.vol)] })
118
+ events.push({ tick: b, order: 0, data: [0x80 | ch, key, 0] })
119
+ }
120
+ return { name, events }
121
+ })
122
+ return smf(PPQ, conductor, tracks, end)
123
+ }
124
+
125
+ /** the clock a stem is written at: 120 bpm, so a tick is 1/1920 s */
126
+ const STEM_PPQ = 960
127
+ const STEM_TPS = (120 * STEM_PPQ) / 60
128
+
129
+ /** one GM instrument's notes as a .mid at a fixed clock, its time the render's (lead counted), cut at `end` seconds */
130
+ export function stemMidi(notes: readonly ScheduledNote[], v: { program: number; drum?: number }, end: number): Uint8Array {
131
+ const ch = v.drum !== undefined ? 9 : 0
132
+ const t = (s: number): number => Math.round(Math.min(s, end) * STEM_TPS)
133
+ const events: Ev[] = [{ tick: 0, order: 1, data: [0xc0 | ch, v.program] }]
134
+ for (const n of notes) {
135
+ if (n.time >= end) continue
136
+ const key = v.drum ?? n.midi ?? 60
137
+ const a = t(n.time)
138
+ events.push({ tick: a, order: 2, data: [0x90 | ch, key, velocity(n.vol)] })
139
+ events.push({ tick: Math.max(a + 1, t(n.time + n.dur)), order: 0, data: [0x80 | ch, key, 0] })
140
+ }
141
+ const conductor: Ev[] = [{ tick: 0, order: 1, data: [0xff, 0x51, 0x03, ...u32(500_000).slice(1)] }]
142
+ return smf(STEM_PPQ, conductor, [{ name: 'stem', events }], t(end))
143
+ }