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,383 @@
1
+ // the score rendered offline, in Bun: every note an oscillator (or noise) through its envelope, filter and pan into the
2
+ // music bus, a send into the room (a convolution reverb), then the master chain (gain, compressor, limiter, soft
3
+ // ceiling). The same graph and settings as the browser synth the language comes from, computed sample by sample, so a
4
+ // score sounds alike in either. No browser, no audio device
5
+ import { applyEnvelope, biquad, compress, convolveInto, envPoints, softClip } from './dsp.ts'
6
+ import { type InstrumentDef, midiToHz } from './pattern.ts'
7
+ import type { Stem } from './gm-render.ts'
8
+ import type { CompiledScore, ScheduledNote } from './score.ts'
9
+
10
+ export const RATE = 48000
11
+ /** the master's gain into the compressor */
12
+ export const MASTER_GAIN = 0.45
13
+ /** what a render keeps past its end: the last notes' release and the room */
14
+ export const TAIL_S = 1.5
15
+ /** the fade that closes a render at its end */
16
+ const CLOSE_FADE_S = 1.2
17
+ /** a dead stop: the score and its room cut over this long */
18
+ const STOP_FADE_S = 0.01
19
+
20
+ export interface Rendered {
21
+ rate: number
22
+ left: Float32Array
23
+ right: Float32Array
24
+ }
25
+
26
+ const TABLE = 2048
27
+ const HARMONICS = 64
28
+ /** one softened wave serves every note within a minor third */
29
+ const BAND_SEMIS = 3
30
+ const bandOf = (hz: number): number => Math.round((12 * Math.log2(Math.max(20, hz) / 440)) / BAND_SEMIS)
31
+ const bandHz = (band: number): number => 440 * 2 ** ((band * BAND_SEMIS) / 12)
32
+ /** the frequency above which a pulse, a saw or a wave loses its harmonics, unless the instrument sets its own */
33
+ const TONE_HZ = 3000
34
+ /** the pitch metal noise loops at, and the one drums and noise notes are relative to */
35
+ const NOISE_REF_HZ = midiToHz(60)
36
+
37
+ type Coefficients = { real: Float64Array; imag: Float64Array }
38
+
39
+ function pulseCoefficients(duty: number): Coefficients {
40
+ const d = Math.max(0.05, Math.min(0.95, duty))
41
+ const real = new Float64Array(HARMONICS + 1)
42
+ const imag = new Float64Array(HARMONICS + 1)
43
+ for (let k = 1; k <= HARMONICS; k++) {
44
+ const c = (2 / (k * Math.PI)) * Math.sin(Math.PI * k * d)
45
+ real[k] = c * Math.cos(Math.PI * k * d)
46
+ imag[k] = c * Math.sin(Math.PI * k * d)
47
+ }
48
+ return { real, imag }
49
+ }
50
+
51
+ function waveCoefficients(samples: readonly number[]): Coefficients {
52
+ const n = samples.length
53
+ const h = Math.max(1, Math.min(32, Math.floor(n / 2)))
54
+ const real = new Float64Array(h + 1)
55
+ const imag = new Float64Array(h + 1)
56
+ for (let k = 1; k <= h; k++) {
57
+ let re = 0
58
+ let im = 0
59
+ for (let i = 0; i < n; i++) {
60
+ const ph = (2 * Math.PI * k * i) / n
61
+ re += (samples[i] ?? 0) * Math.cos(ph)
62
+ im += (samples[i] ?? 0) * Math.sin(ph)
63
+ }
64
+ real[k] = (2 * re) / n
65
+ imag[k] = (2 * im) / n
66
+ }
67
+ return { real, imag }
68
+ }
69
+
70
+ function sawCoefficients(): Coefficients {
71
+ const real = new Float64Array(HARMONICS + 1)
72
+ const imag = new Float64Array(HARMONICS + 1)
73
+ for (let k = 1; k <= HARMONICS; k++) imag[k] = (2 / (k * Math.PI)) * (k % 2 === 1 ? 1 : -1)
74
+ return { real, imag }
75
+ }
76
+
77
+ function triangleCoefficients(): Coefficients {
78
+ const real = new Float64Array(HARMONICS + 1)
79
+ const imag = new Float64Array(HARMONICS + 1)
80
+ for (let k = 1; k <= HARMONICS; k += 2) imag[k] = (8 / (Math.PI * Math.PI * k * k)) * (k % 4 === 1 ? 1 : -1)
81
+ return { real, imag }
82
+ }
83
+
84
+ /** one cycle, its harmonics past `top` Hz dropped (no alias), softened past `tone`, its peak brought to 1 as a browser
85
+ * periodic wave is */
86
+ function tableOf(c: Coefficients, hz: number, tone: number | null, rate: number): Float32Array {
87
+ const top = hz * 2 ** (2 / 12)
88
+ const out = new Float32Array(TABLE + 1)
89
+ for (let k = 1; k < c.real.length; k++) {
90
+ if (k * top >= rate / 2) break
91
+ const g = tone === null ? 1 : 1 / (1 + ((k * hz) / tone) ** 2)
92
+ const a = c.real[k]! * g
93
+ const b = c.imag[k]! * g
94
+ if (a === 0 && b === 0) continue
95
+ for (let i = 0; i < TABLE; i++) {
96
+ const ph = (2 * Math.PI * k * i) / TABLE
97
+ out[i] = out[i]! + a * Math.cos(ph) + b * Math.sin(ph)
98
+ }
99
+ }
100
+ let peak = 0
101
+ for (let i = 0; i < TABLE; i++) peak = Math.max(peak, Math.abs(out[i]!))
102
+ if (peak > 0) for (let i = 0; i < TABLE; i++) out[i] = out[i]! / peak
103
+ out[TABLE] = out[0]!
104
+ return out
105
+ }
106
+
107
+ /** white: one second of hiss; metal: a short 7 bit lfsr cycle that rings with a pitch, as the old sound chips' noise */
108
+ function noiseBuffer(rate: number, metal: boolean): Float32Array {
109
+ if (!metal) {
110
+ const d = new Float32Array(rate)
111
+ let s = 0x1234
112
+ for (let i = 0; i < d.length; i++) {
113
+ s = (s * 1103515245 + 12345) & 0x7fffffff
114
+ d[i] = s / 0x3fffffff - 1
115
+ }
116
+ return d
117
+ }
118
+ const period = 127
119
+ const perStep = rate / (NOISE_REF_HZ * period)
120
+ const len = Math.max(1, Math.round(period * perStep))
121
+ const d = new Float32Array(len)
122
+ let reg = 0x7f
123
+ const bits: number[] = []
124
+ for (let i = 0; i < period; i++) {
125
+ const bit = (reg ^ (reg >> 1)) & 1
126
+ reg = (reg >> 1) | (bit << 6)
127
+ bits.push(reg & 1 ? 1 : -1)
128
+ }
129
+ for (let i = 0; i < len; i++) d[i] = bits[Math.floor(i / perStep) % period] ?? 0
130
+ return d
131
+ }
132
+
133
+ /** the room: a stereo burst of noise decaying over `seconds` */
134
+ function impulse(seconds: number, rate: number): [Float32Array, Float32Array] {
135
+ const key = Math.round(seconds * 10) / 10
136
+ const len = Math.max(1, Math.floor(rate * Math.max(0.1, key)))
137
+ const out: [Float32Array, Float32Array] = [new Float32Array(len), new Float32Array(len)]
138
+ out.forEach((d, ch) => {
139
+ let s = 777 + ch * 31
140
+ for (let i = 0; i < len; i++) {
141
+ s = (s * 1103515245 + 12345) & 0x7fffffff
142
+ const decay = (1 - i / len) ** 2.5
143
+ const early = i < rate * 0.012 ? 0.4 : 1
144
+ d[i] = (s / 0x3fffffff - 1) * decay * early
145
+ }
146
+ })
147
+ return out
148
+ }
149
+
150
+ /** a browser convolver's own normalization of its impulse, so the room's level matches it */
151
+ function impulseScale(ir: readonly Float32Array[], rate: number): number {
152
+ let power = 0
153
+ for (const d of ir) for (let i = 0; i < d.length; i++) power += d[i]! * d[i]!
154
+ power = Math.sqrt(power / (ir.length * (ir[0]?.length ?? 1)))
155
+ if (!Number.isFinite(power) || power < 0.000125) power = 0.000125
156
+ return (1 / power) * 0.00125 * (44100 / rate)
157
+ }
158
+
159
+ /** the waves a render reads, made once each */
160
+ class Waves {
161
+ private tables = new Map<string, Float32Array>()
162
+ private waveIds = new Map<readonly number[], number>()
163
+ readonly white: Float32Array
164
+ readonly metal: Float32Array
165
+ constructor(readonly rate: number) {
166
+ this.white = noiseBuffer(rate, false)
167
+ this.metal = noiseBuffer(rate, true)
168
+ }
169
+ table(inst: InstrumentDef, hz: number): Float32Array {
170
+ const band = bandOf(hz)
171
+ const osc = inst.osc === 'wave' && !(inst.wave && inst.wave.length > 1) ? 'triangle' : inst.osc
172
+ const soft = osc === 'pulse' || osc === 'saw' || osc === 'wave'
173
+ const tone = soft ? (inst.tone ?? TONE_HZ) : null
174
+ let id = ''
175
+ if (osc === 'pulse') id = `p${Math.round((inst.duty ?? 0.5) * 100) / 100}`
176
+ else if (osc === 'wave') {
177
+ let w = this.waveIds.get(inst.wave!)
178
+ if (w === undefined) this.waveIds.set(inst.wave!, (w = this.waveIds.size))
179
+ id = `w${w}`
180
+ } else id = osc
181
+ const key = `${id}:${band}:${tone}`
182
+ let t = this.tables.get(key)
183
+ if (!t) {
184
+ const c =
185
+ osc === 'pulse' ? pulseCoefficients(Math.round((inst.duty ?? 0.5) * 100) / 100)
186
+ : osc === 'saw' ? sawCoefficients()
187
+ : osc === 'wave' ? waveCoefficients(inst.wave!)
188
+ : osc === 'triangle' ? triangleCoefficients()
189
+ : { real: new Float64Array(2), imag: Float64Array.of(0, 1) }
190
+ t = tableOf(c, bandHz(band), tone, this.rate)
191
+ this.tables.set(key, t)
192
+ }
193
+ return t
194
+ }
195
+ }
196
+
197
+ /** how a note's pitch moves from its start: held, swept (kicks, toms) or glided from the note before */
198
+ interface Pitch {
199
+ from: number
200
+ to: number
201
+ /** seconds to go from `from` to `to`, exponentially; 0: held at `to` */
202
+ over: number
203
+ }
204
+
205
+ const pitchAt = (p: Pitch, t: number): number => (p.over <= 0 || t >= p.over ? p.to : p.from * (p.to / p.from) ** (Math.max(0, t) / p.over))
206
+
207
+ /** one oscillator voice added into `buf`: a table read at a moving pitch, its detune and vibrato in cents */
208
+ function oscillatorRun(buf: Float32Array, n: number, t0: number, rate: number, table: Float32Array, p: Pitch, cents: number, vib: InstrumentDef['vibrato'] | null, gain: number): void {
209
+ const LN = Math.LN2 / 1200
210
+ const steady = p.over <= 0 && !vib
211
+ const fixed = (p.to * Math.exp(cents * LN)) / rate
212
+ let phase = Math.max(0, t0) * (p.over <= 0 ? p.to : p.from) * Math.exp(cents * LN)
213
+ phase -= Math.floor(phase)
214
+ for (let i = 0; i < n; i++) {
215
+ const x = phase * TABLE
216
+ const j = x | 0
217
+ const v = table[j]!
218
+ buf[i] = buf[i]! + gain * (v + (table[j + 1]! - v) * (x - j))
219
+ let inc = fixed
220
+ if (!steady) {
221
+ const t = t0 + i / rate
222
+ let c = cents
223
+ if (vib) {
224
+ const on = Math.max(0, vib.delay)
225
+ const depth = t <= on ? 0 : vib.depth * 100 * Math.min(1, (t - on) / 0.12)
226
+ c += depth * Math.sin(2 * Math.PI * vib.rate * t)
227
+ }
228
+ inc = (pitchAt(p, t) * Math.exp(c * LN)) / rate
229
+ }
230
+ phase += inc
231
+ if (phase >= 1) phase -= Math.floor(phase)
232
+ }
233
+ }
234
+
235
+ /** a noise buffer looped at a moving playback rate, added into `buf` */
236
+ function noiseRun(buf: Float32Array, n: number, t0: number, rate: number, src: Float32Array, p: Pitch): void {
237
+ let pos = 0
238
+ const len = src.length
239
+ for (let i = 0; i < n; i++) {
240
+ const j = pos | 0
241
+ const v = src[j % len]!
242
+ buf[i] = buf[i]! + v + (src[(j + 1) % len]! - v) * (pos - j)
243
+ pos += pitchAt(p, t0 + i / rate)
244
+ if (pos >= len) pos -= len
245
+ }
246
+ }
247
+
248
+ /** the music bus a render writes: the dry mix and the room's send, both stereo */
249
+ interface Bus {
250
+ l: Float32Array
251
+ r: Float32Array
252
+ sendL: Float32Array | null
253
+ sendR: Float32Array | null
254
+ }
255
+
256
+ const scratch = { buf: new Float32Array(0) }
257
+
258
+ /** one note into the bus: its source (oscillators or noise), its envelope, filter and pan, its send to the room */
259
+ function playNote(bus: Bus, waves: Waves, inst: InstrumentDef, note: ScheduledNote): void {
260
+ const rate = waves.rate
261
+ const midi = note.midi ?? inst.pitch ?? 60
262
+ const hz = midiToHz(midi)
263
+ const peak = inst.vol * note.vol
264
+ if (peak <= 0) return
265
+ const pts = envPoints(inst.env, note.dur, peak)
266
+ const length = (pts[pts.length - 1]?.[0] ?? note.dur) + 0.02
267
+ const i0 = Math.max(0, Math.ceil(note.time * rate))
268
+ const i1 = Math.min(bus.l.length, Math.ceil((note.time + length) * rate))
269
+ const n = i1 - i0
270
+ if (n <= 0) return
271
+ if (scratch.buf.length < n) scratch.buf = new Float32Array(n)
272
+ const buf = scratch.buf
273
+ buf.fill(0, 0, n)
274
+ const t0 = i0 / rate - note.time
275
+ if (inst.osc === 'noise') {
276
+ const r0 = Math.max(0.05, Math.min(8, hz / NOISE_REF_HZ))
277
+ const pitch: Pitch = inst.sweep ? { from: r0, to: Math.max(0.02, r0 * 2 ** (inst.sweep.semis / 12)), over: Math.max(1e-4, inst.sweep.time) } : { from: r0, to: r0, over: 0 }
278
+ noiseRun(buf, n, t0, rate, inst.noise === 'metal' ? waves.metal : waves.white, pitch)
279
+ } else {
280
+ const voices = Math.max(1, Math.min(4, inst.unison ?? (inst.detune ? 2 : 1)))
281
+ const spread = inst.detune ?? 0
282
+ const pitch: Pitch = inst.sweep
283
+ ? { from: hz, to: Math.max(10, hz * 2 ** (inst.sweep.semis / 12)), over: Math.max(0.005, inst.sweep.time) }
284
+ : inst.glide && note.from !== null && note.from !== note.midi
285
+ ? { from: midiToHz(note.from), to: hz, over: inst.glide }
286
+ : { from: hz, to: hz, over: 0 }
287
+ const vib = inst.vibrato && inst.vibrato.depth > 0 ? inst.vibrato : null
288
+ const table = waves.table(inst, hz)
289
+ for (let v = 0; v < voices; v++) {
290
+ const cents = voices === 1 ? spread : spread * ((2 * v) / (voices - 1) - 1)
291
+ oscillatorRun(buf, n, t0, rate, table, pitch, cents, vib, 1 / Math.sqrt(voices))
292
+ }
293
+ }
294
+ applyEnvelope(buf, n, t0, rate, pts)
295
+ if (inst.filter) biquad(buf, n, rate, inst.filter.type, inst.filter.freq, inst.filter.q ?? 0.8)
296
+ const pan = inst.pan !== undefined && inst.pan !== 0 ? (Math.max(-1, Math.min(1, inst.pan)) + 1) / 2 : null
297
+ const gl = pan === null ? 1 : Math.cos((pan * Math.PI) / 2)
298
+ const gr = pan === null ? 1 : Math.sin((pan * Math.PI) / 2)
299
+ const send = bus.sendL && inst.reverb && inst.reverb > 0 ? inst.reverb : 0
300
+ mixRun(buf, n, i0, bus.l, bus.r, gl, gr)
301
+ if (send > 0) mixRun(buf, n, i0, bus.sendL!, bus.sendR!, gl * send, gr * send)
302
+ }
303
+
304
+ function mixRun(buf: Float32Array, n: number, at: number, l: Float32Array, r: Float32Array, gl: number, gr: number): void {
305
+ for (let i = 0; i < n; i++) {
306
+ const x = buf[i]!
307
+ l[at + i] = l[at + i]! + x * gl
308
+ r[at + i] = r[at + i]! + x * gr
309
+ }
310
+ }
311
+
312
+ /** a GM stem into the bus at its instrument's volume and pan, and its share into the room */
313
+ function mixStem(bus: Bus, st: Stem, inst: InstrumentDef | undefined): void {
314
+ if (!inst) return
315
+ const n = Math.min(bus.l.length, st.left.length)
316
+ const pan = inst.pan !== undefined && inst.pan !== 0 ? (Math.max(-1, Math.min(1, inst.pan)) + 1) / 2 : null
317
+ const gl = inst.vol * (pan === null ? 1 : Math.SQRT2 * Math.cos((pan * Math.PI) / 2))
318
+ const gr = inst.vol * (pan === null ? 1 : Math.SQRT2 * Math.sin((pan * Math.PI) / 2))
319
+ const send = bus.sendL && inst.reverb && inst.reverb > 0 ? inst.reverb : 0
320
+ for (let i = 0; i < n; i++) {
321
+ const l = st.left[i]! * gl
322
+ const r = st.right[i]! * gr
323
+ bus.l[i] = bus.l[i]! + l
324
+ bus.r[i] = bus.r[i]! + r
325
+ if (send > 0) {
326
+ bus.sendL![i] = bus.sendL![i]! + l * send
327
+ bus.sendR![i] = bus.sendR![i]! + r * send
328
+ }
329
+ }
330
+ }
331
+
332
+ /** the gain over the render: whole, then a dead stop or the closing fade at the end */
333
+ function closeRun(l: Float32Array, r: Float32Array, rate: number, end: number, fade: number): void {
334
+ const a = Math.max(0, Math.floor(end * rate))
335
+ const b = Math.ceil((end + fade) * rate)
336
+ for (let i = a; i < l.length; i++) {
337
+ const g = i >= b ? 0 : Math.cos(((i - a) / (b - a)) * (Math.PI / 2))
338
+ l[i] = l[i]! * g
339
+ r[i] = r[i]! * g
340
+ }
341
+ }
342
+
343
+ /** `seconds` of the score from its start, rendered with a tail of TAIL_S: the notes starting before its end (and before
344
+ * its stop, if any) played, closed with a fade at the end, or cut dead at its stop */
345
+ export function renderScore(score: CompiledScore, seconds: number, rate = RATE, stems: readonly Stem[] = []): Rendered {
346
+ const n = Math.ceil(rate * (seconds + TAIL_S))
347
+ const room = score.reverb && score.reverb.mix > 0 ? score.reverb : null
348
+ const bus: Bus = { l: new Float32Array(n), r: new Float32Array(n), sendL: room ? new Float32Array(n) : null, sendR: room ? new Float32Array(n) : null }
349
+ const waves = new Waves(rate)
350
+ const end = Math.min(seconds, score.stop ?? Number.POSITIVE_INFINITY)
351
+ for (const note of score.notes) {
352
+ if (note.time >= end) break
353
+ const inst = score.instruments[note.instrument]
354
+ if (inst && inst.osc !== 'gm') playNote(bus, waves, inst, note)
355
+ }
356
+ for (const st of stems) mixStem(bus, st, score.instruments[st.instrument])
357
+ if (room) {
358
+ const ir = impulse(room.seconds, rate)
359
+ convolveInto(bus.sendL!, bus.sendR!, ir[0], ir[1], bus.l, bus.r, impulseScale(ir, rate) * Math.min(1, room.mix * 1.6))
360
+ }
361
+ if (score.stop !== null && score.stop < seconds) closeRun(bus.l, bus.r, rate, score.stop, STOP_FADE_S)
362
+ else closeRun(bus.l, bus.r, rate, seconds, CLOSE_FADE_S)
363
+ master(bus.l, bus.r, rate)
364
+ return { rate, left: bus.l, right: bus.r }
365
+ }
366
+
367
+ /** the master chain: its gain, a gentle compressor, a fast limiter, then the soft ceiling */
368
+ function master(l: Float32Array, r: Float32Array, rate: number): void {
369
+ for (let i = 0; i < l.length; i++) {
370
+ l[i] = l[i]! * MASTER_GAIN
371
+ r[i] = r[i]! * MASTER_GAIN
372
+ }
373
+ compress(l, r, rate, { threshold: -10, knee: 30, ratio: 4, attack: 0.003, release: 0.25 })
374
+ compress(l, r, rate, { threshold: -3, knee: 0, ratio: 20, attack: 0.001, release: 0.05 })
375
+ // a browser wave shaper clamps its input to [-1, 1] before its curve: the ceiling is softClip(1), never CEILING
376
+ for (let i = 0; i < l.length; i++) {
377
+ l[i] = softClip(Math.max(-1, Math.min(1, l[i]!)))
378
+ r[i] = softClip(Math.max(-1, Math.min(1, r[i]!)))
379
+ }
380
+ }
381
+
382
+ /** a sample this close to the ceiling is the soft clip working: distortion when it lasts */
383
+ export const HOT = softClip(1) - 0.01
@@ -0,0 +1,148 @@
1
+ // what a made sound is, as numbers and one picture, since nobody hears a build: its length, peak, loudness, onsets
2
+ // and its waveform png; and the trims a generator applies (the silence before, the tail, the first hit). ffmpeg does
3
+ // the reading and the writing (trailer/ffmpeg.ts)
4
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { ffmpeg, loudness } from '../ffmpeg.ts'
8
+
9
+ export const SOUND_RATE = 48000
10
+ /** the envelope's window */
11
+ const WIN_S = 0.01
12
+
13
+ /** the file's samples, both channels, at SOUND_RATE */
14
+ export async function decode(file: string): Promise<{ left: Float32Array; right: Float32Array }> {
15
+ const dir = mkdtempSync(join(tmpdir(), 'sk-sound-'))
16
+ try {
17
+ const raw = join(dir, 'pcm.f32')
18
+ await ffmpeg(['-y', '-v', 'error', '-i', file, '-ac', '2', '-ar', String(SOUND_RATE), '-f', 'f32le', raw])
19
+ const b = readFileSync(raw)
20
+ const all = new Float32Array(b.buffer, b.byteOffset, Math.floor(b.byteLength / 4))
21
+ const n = all.length >> 1
22
+ const left = new Float32Array(n)
23
+ const right = new Float32Array(n)
24
+ for (let i = 0; i < n; i++) {
25
+ left[i] = all[i * 2]!
26
+ right[i] = all[i * 2 + 1]!
27
+ }
28
+ return { left, right }
29
+ } finally {
30
+ rmSync(dir, { recursive: true, force: true })
31
+ }
32
+ }
33
+
34
+ const db = (x: number): number => (x > 1e-9 ? 20 * Math.log10(x) : -180)
35
+
36
+ /** each window's RMS in dBFS, the louder channel */
37
+ export function envelope(s: { left: Float32Array; right: Float32Array }): number[] {
38
+ const w = Math.round(WIN_S * SOUND_RATE)
39
+ const out: number[] = []
40
+ for (let a = 0; a < s.left.length; a += w) {
41
+ let l = 0
42
+ let r = 0
43
+ const b = Math.min(s.left.length, a + w)
44
+ for (let i = a; i < b; i++) {
45
+ l += s.left[i]! ** 2
46
+ r += s.right[i]! ** 2
47
+ }
48
+ out.push(db(Math.sqrt(Math.max(l, r) / Math.max(1, b - a))))
49
+ }
50
+ return out
51
+ }
52
+
53
+ /** the sample peak in dBFS */
54
+ export function peakOf(s: { left: Float32Array; right: Float32Array }): number {
55
+ let p = 0
56
+ for (let i = 0; i < s.left.length; i++) p = Math.max(p, Math.abs(s.left[i]!), Math.abs(s.right[i]!))
57
+ return db(p)
58
+ }
59
+
60
+ /** the windows a new sound starts on: a rise of 9 dB over the 50 ms before, within 35 dB of the loudest window, 80 ms
61
+ * after the last onset at least */
62
+ export function onsets(env: readonly number[]): number[] {
63
+ const top = Math.max(...env)
64
+ const out: number[] = []
65
+ for (let i = 0; i < env.length; i++) {
66
+ if (env[i]! < top - 35) continue
67
+ const before = i === 0 ? -180 : Math.min(...env.slice(Math.max(0, i - 5), i))
68
+ if (env[i]! - before < 9) continue
69
+ if (out.length > 0 && i - out.at(-1)! < 8) continue
70
+ out.push(i)
71
+ }
72
+ return out
73
+ }
74
+
75
+ export interface Trim {
76
+ start: number
77
+ end: number
78
+ /** seconds of fade at the end */
79
+ fade: number
80
+ }
81
+
82
+ /** where a made sound starts and ends: the silence before cut (40 dB under its loudest window), the tail cut where it
83
+ * stays `tailDb` under it; `hit`: only its first hit, also cut just before the next onset, its fade a share of it */
84
+ export function trimOf(env: readonly number[], o: { tailDb: number; hit?: boolean }): Trim {
85
+ const top = Math.max(...env)
86
+ const first = Math.max(0, env.findIndex((x) => x >= top - 40) - 1)
87
+ let last = env.length - 1
88
+ while (last > first && env[last]! < top - o.tailDb) last--
89
+ if (!o.hit) return { start: first * WIN_S, end: (last + 1) * WIN_S, fade: 0.03 }
90
+ const next = onsets(env).find((i) => i > first + 2)
91
+ if (next !== undefined) last = Math.min(last, next - 2)
92
+ // the quiet floor between the hit and the next one is tail too
93
+ while (last > first && env[last]! < top - o.tailDb) last--
94
+ const len = (last + 1 - first) * WIN_S
95
+ return { start: first * WIN_S, end: (last + 1) * WIN_S, fade: Math.min(0.12, Math.max(0.02, len * 0.15)) }
96
+ }
97
+
98
+ /** the trimmed sound written as a 48 kHz 16 bit stereo wav */
99
+ export async function writeTrimmed(src: string, out: string, t: Trim): Promise<void> {
100
+ const len = Math.max(0.02, t.end - t.start)
101
+ const fade = Math.min(t.fade, len / 2)
102
+ const af = [`atrim=${t.start.toFixed(3)}:${t.end.toFixed(3)}`, 'asetpts=PTS-STARTPTS', `afade=t=out:st=${(len - fade).toFixed(3)}:d=${fade.toFixed(3)}`].join(',')
103
+ await ffmpeg(['-y', '-v', 'error', '-i', src, '-af', af, '-ac', '2', '-ar', String(SOUND_RATE), '-c:a', 'pcm_s16le', out])
104
+ }
105
+
106
+ /** the true peak no made sound passes */
107
+ const CEILING_DBTP = -1.5
108
+
109
+ /** the trimmed sound written at its level: `lufs` its integrated loudness (a sound too short to measure, under 0.4 s,
110
+ * set by its peak at -3 dBFS), `peak` its sample peak; never past -1.5 dBTP. A measured static gain, so a short line
111
+ * lands where a long one does */
112
+ export async function finish(src: string, out: string, t: Trim, level: { lufs: number } | { peak: number }): Promise<void> {
113
+ const trimmed = `${out}.trim.wav`
114
+ try {
115
+ await writeTrimmed(src, trimmed, t)
116
+ const peak = peakOf(await decode(trimmed))
117
+ const m = await loudness(trimmed)
118
+ const gain = 'peak' in level ? Math.min(level.peak - peak, CEILING_DBTP - m.truePeak) : m.lufs > -70 && t.end - t.start >= 0.4 ? Math.min(level.lufs - m.lufs, CEILING_DBTP - m.truePeak) : -3 - peak
119
+ await ffmpeg(['-y', '-v', 'error', '-i', trimmed, '-af', `volume=${gain.toFixed(2)}dB`, '-c:a', 'pcm_s16le', out])
120
+ } finally {
121
+ rmSync(trimmed, { force: true })
122
+ }
123
+ }
124
+
125
+ export interface Measures {
126
+ seconds: number
127
+ peak: number
128
+ lufs: number
129
+ /** seconds of each onset */
130
+ onsets: number[]
131
+ }
132
+
133
+ /** a sound as numbers */
134
+ export async function measure(file: string): Promise<Measures> {
135
+ const s = await decode(file)
136
+ const env = envelope(s)
137
+ const { lufs } = await loudness(file)
138
+ return { seconds: +(s.left.length / SOUND_RATE).toFixed(3), peak: +peakOf(s).toFixed(1), lufs: +lufs.toFixed(1), onsets: onsets(env).map((i) => +(i * WIN_S).toFixed(2)) }
139
+ }
140
+
141
+ /** the measures as one line */
142
+ export const measureLine = (m: Measures): string =>
143
+ `${m.seconds.toFixed(2)} s, peak ${m.peak.toFixed(1)} dBFS, ${Number.isFinite(m.lufs) ? m.lufs.toFixed(1) : '-inf'} LUFS, ${m.onsets.length} onset${m.onsets.length === 1 ? '' : 's'}${m.onsets.length ? ` at ${m.onsets.slice(0, 8).join(', ')}${m.onsets.length > 8 ? ' …' : ''} s` : ''}`
144
+
145
+ /** the waveform as a png, 1200 by 240 */
146
+ export async function waveformPng(file: string, png: string): Promise<void> {
147
+ await ffmpeg(['-y', '-v', 'error', '-i', file, '-filter_complex', 'showwavespic=s=1200x240:split_channels=1:colors=#f0c674|#81a2be', '-frames:v', '1', png])
148
+ }
@@ -0,0 +1,42 @@
1
+ // the voices a brand declares for its trailers (`.shipkit/identity/brand.ts`, its `trailer.voice` and
2
+ // `trailer.voices`): an ElevenLabs voice, a clip comfy-ts clones, or a voice comfy-ts designs from words. Pure
3
+ export type TrailerVoice =
4
+ /** ElevenLabs, paid: a voice id of the account or its library */
5
+ | { eleven: string; model?: string; stability?: number; similarity?: number; speed?: number; language?: string }
6
+ /** comfy-ts clones a clean 5 to 10 s clip (repo relative); its transcript, default the text of `<clip>.txt` */
7
+ | { clone: string; transcript?: string; engine?: string; language?: string }
8
+ /** comfy-ts designs the voice once from its description (the reference reads `line`), then clones it, free */
9
+ | { design: string; line?: string; seed?: number; engine?: string; language?: string }
10
+
11
+ export interface TrailerSoundInput {
12
+ /** the comfy-ts module folder the free generators run (its tts-voice-clone, tts-voice-design, stable-audio,
13
+ * ace-step): `comfy-ts run <comfy>/<module>` */
14
+ comfy?: string
15
+ /** argv printing the ElevenLabs key on stdout, as a secret manager read (ELEVENLABS_API_KEY wins); never the key */
16
+ elevenKey?: string[]
17
+ /** the voice every `voice "<line>"` reads */
18
+ voice?: TrailerVoice
19
+ /** more voices, by name, for `voice "<line>" by <name>` */
20
+ voices?: Record<string, TrailerVoice>
21
+ }
22
+
23
+ /** a repo with no voice: the house narrator, designed by comfy-ts */
24
+ export const HOUSE_VOICE: TrailerVoice = {
25
+ design: 'a warm, clear narrator in their thirties, natural and unhurried, close to the microphone, studio quality',
26
+ line: 'Every story starts somewhere. This one starts right here, with you, and it is only getting better.',
27
+ seed: 7,
28
+ }
29
+
30
+ const kinds = (v: TrailerVoice): string[] => ['eleven', 'clone', 'design'].filter((k) => k in v && typeof (v as Record<string, unknown>)[k] === 'string')
31
+
32
+ /** the voice a line reads with: `by` one of the brand's voices, else its voice, else the house narrator */
33
+ export function voiceOf(brand: TrailerSoundInput | undefined, by?: string): TrailerVoice {
34
+ const v = by === undefined ? (brand?.voice ?? HOUSE_VOICE) : brand?.voices?.[by]
35
+ if (!v) {
36
+ const names = Object.keys(brand?.voices ?? {})
37
+ throw new Error(`no voice ${by} in the brand: ${names.length ? `its voices are ${names.join(', ')}` : 'it names none (trailer.voices in .shipkit/identity/brand.ts)'}`)
38
+ }
39
+ const k = kinds(v)
40
+ if (k.length !== 1) throw new Error(`the brand's voice${by ? ` ${by}` : ''} names ${k.length ? k.join(' and ') : 'nothing'}: one of eleven (a voice id), clone (a clip) or design (a description)`)
41
+ return v
42
+ }
@@ -0,0 +1,38 @@
1
+ // a comfy-ts module run on the GPU box: its vars as flags, the file at `out`, its progress on stderr, a loud failure
2
+ // that names the box when it is down. The modules sit in the folder the brand names (`trailer.comfy`)
3
+ import { killGroup } from './run.ts'
4
+
5
+ /** the module `name` of the folder `comfy`, or a loud error when no folder is named */
6
+ export function comfyModule(comfy: string | undefined, name: string): string {
7
+ if (!comfy) throw new Error(`no comfy-ts module folder for ${name}: name it in the brand (trailer.comfy) or pass --comfy <folder>`)
8
+ return `${comfy}/${name}`
9
+ }
10
+
11
+ /** a module run that has not answered in this long is killed, loud */
12
+ const COMFY_TIMEOUT_S = 15 * 60
13
+
14
+ export async function comfyRun(module: string, vars: Record<string, string | number | boolean | undefined>, out: string): Promise<void> {
15
+ const args = ['comfy-ts', 'run', module]
16
+ for (const [k, v] of Object.entries(vars)) if (v !== undefined) args.push(`--${k}`, String(v))
17
+ args.push('--out', out, '--json')
18
+ const p = Bun.spawn(args, { stdout: 'pipe', stderr: 'inherit', detached: true })
19
+ let timedOut = false
20
+ const timer = setTimeout(() => {
21
+ timedOut = true
22
+ killGroup(p.pid)
23
+ }, COMFY_TIMEOUT_S * 1000)
24
+ const text = await new Response(p.stdout).text()
25
+ const code = await p.exited.finally(() => clearTimeout(timer))
26
+ if (timedOut) throw new Error(`comfy-ts run ${module} did not answer in ${COMFY_TIMEOUT_S} s and was killed. Is the GPU box up?`)
27
+ const last = text.trim().split('\n').at(-1) ?? ''
28
+ let res: { ok?: boolean; error?: string } = {}
29
+ try {
30
+ res = JSON.parse(last) as typeof res
31
+ } catch {
32
+ // not json: the tail below says what happened
33
+ }
34
+ if (code !== 0 || res.ok === false) {
35
+ const why = res.error ?? text.trim().split('\n').slice(-3).join(' ')
36
+ throw new Error(`comfy-ts run ${module} failed (exit ${code}): ${why || 'no output'}. Is the GPU box up?`)
37
+ }
38
+ }