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.
- package/CHANGELOG.md +47 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +9 -0
- package/src/cli/cmd_Review.ts +974 -0
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +264 -6
- package/src/cli/kit/verb.ts +22 -4
- package/src/cli.ts +8 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/app-icon.ts +10 -6
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/repo-config.ts +3 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +158 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +896 -0
- package/src/review/project.ts +126 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +239 -0
- package/src/review/store.ts +1011 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +183 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +36 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +17 -3
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +83 -14
- package/src/serve/hub-app/icons.tsx +15 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +26 -6
- package/src/serve/hub-app/project-list.tsx +46 -10
- package/src/serve/hub-app/projects-svc.ts +7 -2
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +61 -4
- package/src/serve/hub-app/review/clip-st.ts +258 -0
- package/src/serve/hub-app/review/kind-view.tsx +240 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +269 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +96 -0
- package/src/serve/hub-app/review/review-st.ts +902 -0
- package/src/serve/hub-app/review/review-views.tsx +648 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +389 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +9 -7
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +15 -0
- package/src/shipkit-files.ts +9 -5
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +90 -6
- package/src/trailer/build.ts +459 -59
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +112 -43
- package/src/trailer/grammar.ts +864 -127
- package/src/trailer/graph.ts +308 -49
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +20 -3
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +82 -23
- package/src/trailer/prune.ts +204 -0
- package/src/trailer/score/compose.ts +256 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +98 -0
- package/src/trailer/score/score.ts +228 -0
- package/src/trailer/score/sound.ts +123 -0
- package/src/trailer/score/synth.ts +383 -0
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +471 -150
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +34 -4
- package/src/trailer/words.ts +275 -0
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- 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
|
+
}
|