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,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
|
+
}
|