rvlib-shipkit 2.1.1 → 2.3.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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -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 +7 -3
  12. package/src/assets/run-assets.ts +18 -4
  13. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  14. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  15. package/src/cli/cmd_Calls.ts +53 -0
  16. package/src/cli/cmd_Commands.ts +163 -43
  17. package/src/cli/cmd_Review.ts +948 -0
  18. package/src/cli/cmd_Shot.ts +63 -0
  19. package/src/cli/cmd_Start.ts +8 -4
  20. package/src/cli/cmd_Time.ts +112 -0
  21. package/src/cli/cmd_Trailer.ts +361 -0
  22. package/src/cli/kit/builtins.ts +40 -0
  23. package/src/cli/kit/calls.ts +177 -0
  24. package/src/cli/kit/families.ts +67 -0
  25. package/src/cli/kit/index.ts +8 -0
  26. package/src/cli/kit/pages-check.ts +36 -0
  27. package/src/cli/kit/project-cli.ts +73 -0
  28. package/src/cli/kit/verb.ts +167 -0
  29. package/src/cli.ts +15 -2
  30. package/src/collections/collection-types.ts +6 -1
  31. package/src/collections/providers.ts +3 -0
  32. package/src/collections/review.ts +70 -0
  33. package/src/core/layout.ts +12 -2
  34. package/src/core/migrate-layout.ts +31 -8
  35. package/src/core/one-way-in-policy.ts +12 -4
  36. package/src/reflect/prompt-corpus.ts +15 -114
  37. package/src/reflect/transcript.ts +254 -0
  38. package/src/review/anim.ts +228 -0
  39. package/src/review/auto.ts +141 -0
  40. package/src/review/format.ts +150 -0
  41. package/src/review/http.ts +23 -0
  42. package/src/review/marks.ts +43 -0
  43. package/src/review/media.ts +45 -0
  44. package/src/review/model.ts +854 -0
  45. package/src/review/project.ts +110 -0
  46. package/src/review/role.ts +48 -0
  47. package/src/review/routes.ts +228 -0
  48. package/src/review/store.ts +1002 -0
  49. package/src/review/threads.ts +237 -0
  50. package/src/review/trailer-desk.ts +385 -0
  51. package/src/review/trailer-model.ts +183 -0
  52. package/src/review/trailer-routes.ts +136 -0
  53. package/src/review/unit.ts +165 -0
  54. package/src/review/watchers.ts +54 -0
  55. package/src/scaffold/cli-skeleton.ts +149 -0
  56. package/src/scaffold/files/agent-index.md +2 -2
  57. package/src/scaffold/files/tsconfig.json +3 -0
  58. package/src/scaffold/files/verify-skill.md +1 -1
  59. package/src/scaffold/project-scaffold.ts +90 -12
  60. package/src/serve/embed.ts +35 -0
  61. package/src/serve/hub-app/activity-view.tsx +1 -1
  62. package/src/serve/hub-app/activity.css +0 -4
  63. package/src/serve/hub-app/dock-view.tsx +5 -4
  64. package/src/serve/hub-app/hash-route.ts +28 -1
  65. package/src/serve/hub-app/header-bar.tsx +3 -1
  66. package/src/serve/hub-app/home-st.ts +10 -2
  67. package/src/serve/hub-app/home-strip.tsx +3 -2
  68. package/src/serve/hub-app/home.html +1 -0
  69. package/src/serve/hub-app/hub-app.tsx +80 -11
  70. package/src/serve/hub-app/hub.css +45 -13
  71. package/src/serve/hub-app/icons.tsx +9 -0
  72. package/src/serve/hub-app/index.html +1 -0
  73. package/src/serve/hub-app/insights-view.tsx +4 -3
  74. package/src/serve/hub-app/keys.ts +14 -10
  75. package/src/serve/hub-app/layout-st.ts +63 -2
  76. package/src/serve/hub-app/planned-view.tsx +3 -2
  77. package/src/serve/hub-app/planned.css +0 -1
  78. package/src/serve/hub-app/project-list.css +22 -4
  79. package/src/serve/hub-app/project-list.tsx +41 -8
  80. package/src/serve/hub-app/projects-svc.ts +4 -0
  81. package/src/serve/hub-app/repo-sections.ts +15 -3
  82. package/src/serve/hub-app/repo-st.ts +45 -3
  83. package/src/serve/hub-app/review/clip-st.ts +208 -0
  84. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  85. package/src/serve/hub-app/review/kit.tsx +113 -0
  86. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  87. package/src/serve/hub-app/review/review-board.tsx +46 -0
  88. package/src/serve/hub-app/review/review-list.tsx +156 -0
  89. package/src/serve/hub-app/review/review-page.tsx +101 -0
  90. package/src/serve/hub-app/review/review-st.ts +779 -0
  91. package/src/serve/hub-app/review/review-views.tsx +503 -0
  92. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  93. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  94. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  95. package/src/serve/hub-app/review.css +329 -0
  96. package/src/serve/hub-app/theme-st.ts +56 -0
  97. package/src/serve/hub-app/tree-build.ts +4 -0
  98. package/src/serve/hub-app/tree-row.tsx +1 -1
  99. package/src/serve/hub-app/tree-svc.ts +4 -0
  100. package/src/serve/hub-runtime.ts +5 -3
  101. package/src/serve/same-origin.ts +5 -1
  102. package/src/serve/serve-hub.ts +8 -0
  103. package/src/shipkit-files.ts +12 -4
  104. package/src/shot/client.ts +67 -0
  105. package/src/shot/clip-encoder.ts +146 -0
  106. package/src/shot/dom-clip.ts +124 -0
  107. package/src/shot/serve-shot.ts +54 -0
  108. package/src/shot/shooter.ts +343 -0
  109. package/src/shot/shot-ask.ts +189 -0
  110. package/src/shot/shot.shipkit.ts +14 -0
  111. package/src/smoke/run-smoke.ts +2 -2
  112. package/src/start-repo.ts +29 -14
  113. package/src/suite/judge-suites.ts +56 -10
  114. package/src/suite/select.ts +2 -5
  115. package/src/suite/suite-cli.ts +3 -2
  116. package/src/trailer/adapter.ts +78 -0
  117. package/src/trailer/build.ts +603 -0
  118. package/src/trailer/ffmpeg.ts +72 -0
  119. package/src/trailer/footage.ts +173 -0
  120. package/src/trailer/grammar.ts +1290 -0
  121. package/src/trailer/graph.ts +508 -0
  122. package/src/trailer/holes.ts +72 -0
  123. package/src/trailer/load-unit.ts +31 -0
  124. package/src/trailer/look.ts +124 -0
  125. package/src/trailer/passage.ts +129 -0
  126. package/src/trailer/plan.ts +192 -0
  127. package/src/trailer/prune.ts +193 -0
  128. package/src/trailer/score/compose.ts +240 -0
  129. package/src/trailer/score/dsp.ts +272 -0
  130. package/src/trailer/score/palette.ts +188 -0
  131. package/src/trailer/score/pattern.ts +91 -0
  132. package/src/trailer/score/score.ts +214 -0
  133. package/src/trailer/score/sound.ts +112 -0
  134. package/src/trailer/score/synth.ts +361 -0
  135. package/src/trailer/states.ts +76 -0
  136. package/src/trailer/stitch.ts +233 -0
  137. package/src/trailer/titles.ts +379 -0
  138. package/src/trailer/url.ts +108 -0
  139. package/src/trailer/vertical-draw.ts +286 -0
  140. package/src/trailer/vertical.ts +148 -0
  141. package/src/trailer/vocab.ts +59 -0
  142. package/src/trailer/words.ts +254 -0
@@ -0,0 +1,361 @@
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 { CompiledScore, ScheduledNote } from './score.ts'
8
+
9
+ export const RATE = 48000
10
+ /** the master's gain into the compressor */
11
+ export const MASTER_GAIN = 0.45
12
+ /** what a render keeps past its end: the last notes' release and the room */
13
+ export const TAIL_S = 1.5
14
+ /** the fade that closes a render at its end */
15
+ const CLOSE_FADE_S = 1.2
16
+ /** a dead stop: the score and its room cut over this long */
17
+ const STOP_FADE_S = 0.01
18
+
19
+ export interface Rendered {
20
+ rate: number
21
+ left: Float32Array
22
+ right: Float32Array
23
+ }
24
+
25
+ const TABLE = 2048
26
+ const HARMONICS = 64
27
+ /** one softened wave serves every note within a minor third */
28
+ const BAND_SEMIS = 3
29
+ const bandOf = (hz: number): number => Math.round((12 * Math.log2(Math.max(20, hz) / 440)) / BAND_SEMIS)
30
+ const bandHz = (band: number): number => 440 * 2 ** ((band * BAND_SEMIS) / 12)
31
+ /** the frequency above which a pulse, a saw or a wave loses its harmonics, unless the instrument sets its own */
32
+ const TONE_HZ = 3000
33
+ /** the pitch metal noise loops at, and the one drums and noise notes are relative to */
34
+ const NOISE_REF_HZ = midiToHz(60)
35
+
36
+ type Coefficients = { real: Float64Array; imag: Float64Array }
37
+
38
+ function pulseCoefficients(duty: number): Coefficients {
39
+ const d = Math.max(0.05, Math.min(0.95, duty))
40
+ const real = new Float64Array(HARMONICS + 1)
41
+ const imag = new Float64Array(HARMONICS + 1)
42
+ for (let k = 1; k <= HARMONICS; k++) {
43
+ const c = (2 / (k * Math.PI)) * Math.sin(Math.PI * k * d)
44
+ real[k] = c * Math.cos(Math.PI * k * d)
45
+ imag[k] = c * Math.sin(Math.PI * k * d)
46
+ }
47
+ return { real, imag }
48
+ }
49
+
50
+ function waveCoefficients(samples: readonly number[]): Coefficients {
51
+ const n = samples.length
52
+ const h = Math.max(1, Math.min(32, Math.floor(n / 2)))
53
+ const real = new Float64Array(h + 1)
54
+ const imag = new Float64Array(h + 1)
55
+ for (let k = 1; k <= h; k++) {
56
+ let re = 0
57
+ let im = 0
58
+ for (let i = 0; i < n; i++) {
59
+ const ph = (2 * Math.PI * k * i) / n
60
+ re += (samples[i] ?? 0) * Math.cos(ph)
61
+ im += (samples[i] ?? 0) * Math.sin(ph)
62
+ }
63
+ real[k] = (2 * re) / n
64
+ imag[k] = (2 * im) / n
65
+ }
66
+ return { real, imag }
67
+ }
68
+
69
+ function sawCoefficients(): Coefficients {
70
+ const real = new Float64Array(HARMONICS + 1)
71
+ const imag = new Float64Array(HARMONICS + 1)
72
+ for (let k = 1; k <= HARMONICS; k++) imag[k] = (2 / (k * Math.PI)) * (k % 2 === 1 ? 1 : -1)
73
+ return { real, imag }
74
+ }
75
+
76
+ function triangleCoefficients(): Coefficients {
77
+ const real = new Float64Array(HARMONICS + 1)
78
+ const imag = new Float64Array(HARMONICS + 1)
79
+ for (let k = 1; k <= HARMONICS; k += 2) imag[k] = (8 / (Math.PI * Math.PI * k * k)) * (k % 4 === 1 ? 1 : -1)
80
+ return { real, imag }
81
+ }
82
+
83
+ /** one cycle, its harmonics past `top` Hz dropped (no alias), softened past `tone`, its peak brought to 1 as a browser
84
+ * periodic wave is */
85
+ function tableOf(c: Coefficients, hz: number, tone: number | null, rate: number): Float32Array {
86
+ const top = hz * 2 ** (2 / 12)
87
+ const out = new Float32Array(TABLE + 1)
88
+ for (let k = 1; k < c.real.length; k++) {
89
+ if (k * top >= rate / 2) break
90
+ const g = tone === null ? 1 : 1 / (1 + ((k * hz) / tone) ** 2)
91
+ const a = c.real[k]! * g
92
+ const b = c.imag[k]! * g
93
+ if (a === 0 && b === 0) continue
94
+ for (let i = 0; i < TABLE; i++) {
95
+ const ph = (2 * Math.PI * k * i) / TABLE
96
+ out[i] = out[i]! + a * Math.cos(ph) + b * Math.sin(ph)
97
+ }
98
+ }
99
+ let peak = 0
100
+ for (let i = 0; i < TABLE; i++) peak = Math.max(peak, Math.abs(out[i]!))
101
+ if (peak > 0) for (let i = 0; i < TABLE; i++) out[i] = out[i]! / peak
102
+ out[TABLE] = out[0]!
103
+ return out
104
+ }
105
+
106
+ /** white: one second of hiss; metal: a short 7 bit lfsr cycle that rings with a pitch, as the old sound chips' noise */
107
+ function noiseBuffer(rate: number, metal: boolean): Float32Array {
108
+ if (!metal) {
109
+ const d = new Float32Array(rate)
110
+ let s = 0x1234
111
+ for (let i = 0; i < d.length; i++) {
112
+ s = (s * 1103515245 + 12345) & 0x7fffffff
113
+ d[i] = s / 0x3fffffff - 1
114
+ }
115
+ return d
116
+ }
117
+ const period = 127
118
+ const perStep = rate / (NOISE_REF_HZ * period)
119
+ const len = Math.max(1, Math.round(period * perStep))
120
+ const d = new Float32Array(len)
121
+ let reg = 0x7f
122
+ const bits: number[] = []
123
+ for (let i = 0; i < period; i++) {
124
+ const bit = (reg ^ (reg >> 1)) & 1
125
+ reg = (reg >> 1) | (bit << 6)
126
+ bits.push(reg & 1 ? 1 : -1)
127
+ }
128
+ for (let i = 0; i < len; i++) d[i] = bits[Math.floor(i / perStep) % period] ?? 0
129
+ return d
130
+ }
131
+
132
+ /** the room: a stereo burst of noise decaying over `seconds` */
133
+ function impulse(seconds: number, rate: number): [Float32Array, Float32Array] {
134
+ const key = Math.round(seconds * 10) / 10
135
+ const len = Math.max(1, Math.floor(rate * Math.max(0.1, key)))
136
+ const out: [Float32Array, Float32Array] = [new Float32Array(len), new Float32Array(len)]
137
+ out.forEach((d, ch) => {
138
+ let s = 777 + ch * 31
139
+ for (let i = 0; i < len; i++) {
140
+ s = (s * 1103515245 + 12345) & 0x7fffffff
141
+ const decay = (1 - i / len) ** 2.5
142
+ const early = i < rate * 0.012 ? 0.4 : 1
143
+ d[i] = (s / 0x3fffffff - 1) * decay * early
144
+ }
145
+ })
146
+ return out
147
+ }
148
+
149
+ /** a browser convolver's own normalization of its impulse, so the room's level matches it */
150
+ function impulseScale(ir: readonly Float32Array[], rate: number): number {
151
+ let power = 0
152
+ for (const d of ir) for (let i = 0; i < d.length; i++) power += d[i]! * d[i]!
153
+ power = Math.sqrt(power / (ir.length * (ir[0]?.length ?? 1)))
154
+ if (!Number.isFinite(power) || power < 0.000125) power = 0.000125
155
+ return (1 / power) * 0.00125 * (44100 / rate)
156
+ }
157
+
158
+ /** the waves a render reads, made once each */
159
+ class Waves {
160
+ private tables = new Map<string, Float32Array>()
161
+ private waveIds = new Map<readonly number[], number>()
162
+ readonly white: Float32Array
163
+ readonly metal: Float32Array
164
+ constructor(readonly rate: number) {
165
+ this.white = noiseBuffer(rate, false)
166
+ this.metal = noiseBuffer(rate, true)
167
+ }
168
+ table(inst: InstrumentDef, hz: number): Float32Array {
169
+ const band = bandOf(hz)
170
+ const osc = inst.osc === 'wave' && !(inst.wave && inst.wave.length > 1) ? 'triangle' : inst.osc
171
+ const soft = osc === 'pulse' || osc === 'saw' || osc === 'wave'
172
+ const tone = soft ? (inst.tone ?? TONE_HZ) : null
173
+ let id = ''
174
+ if (osc === 'pulse') id = `p${Math.round((inst.duty ?? 0.5) * 100) / 100}`
175
+ else if (osc === 'wave') {
176
+ let w = this.waveIds.get(inst.wave!)
177
+ if (w === undefined) this.waveIds.set(inst.wave!, (w = this.waveIds.size))
178
+ id = `w${w}`
179
+ } else id = osc
180
+ const key = `${id}:${band}:${tone}`
181
+ let t = this.tables.get(key)
182
+ if (!t) {
183
+ const c =
184
+ osc === 'pulse' ? pulseCoefficients(Math.round((inst.duty ?? 0.5) * 100) / 100)
185
+ : osc === 'saw' ? sawCoefficients()
186
+ : osc === 'wave' ? waveCoefficients(inst.wave!)
187
+ : osc === 'triangle' ? triangleCoefficients()
188
+ : { real: new Float64Array(2), imag: Float64Array.of(0, 1) }
189
+ t = tableOf(c, bandHz(band), tone, this.rate)
190
+ this.tables.set(key, t)
191
+ }
192
+ return t
193
+ }
194
+ }
195
+
196
+ /** how a note's pitch moves from its start: held, swept (kicks, toms) or glided from the note before */
197
+ interface Pitch {
198
+ from: number
199
+ to: number
200
+ /** seconds to go from `from` to `to`, exponentially; 0: held at `to` */
201
+ over: number
202
+ }
203
+
204
+ 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))
205
+
206
+ /** one oscillator voice added into `buf`: a table read at a moving pitch, its detune and vibrato in cents */
207
+ function oscillatorRun(buf: Float32Array, n: number, t0: number, rate: number, table: Float32Array, p: Pitch, cents: number, vib: InstrumentDef['vibrato'] | null, gain: number): void {
208
+ const LN = Math.LN2 / 1200
209
+ const steady = p.over <= 0 && !vib
210
+ const fixed = (p.to * Math.exp(cents * LN)) / rate
211
+ let phase = Math.max(0, t0) * (p.over <= 0 ? p.to : p.from) * Math.exp(cents * LN)
212
+ phase -= Math.floor(phase)
213
+ for (let i = 0; i < n; i++) {
214
+ const x = phase * TABLE
215
+ const j = x | 0
216
+ const v = table[j]!
217
+ buf[i] = buf[i]! + gain * (v + (table[j + 1]! - v) * (x - j))
218
+ let inc = fixed
219
+ if (!steady) {
220
+ const t = t0 + i / rate
221
+ let c = cents
222
+ if (vib) {
223
+ const on = Math.max(0, vib.delay)
224
+ const depth = t <= on ? 0 : vib.depth * 100 * Math.min(1, (t - on) / 0.12)
225
+ c += depth * Math.sin(2 * Math.PI * vib.rate * t)
226
+ }
227
+ inc = (pitchAt(p, t) * Math.exp(c * LN)) / rate
228
+ }
229
+ phase += inc
230
+ if (phase >= 1) phase -= Math.floor(phase)
231
+ }
232
+ }
233
+
234
+ /** a noise buffer looped at a moving playback rate, added into `buf` */
235
+ function noiseRun(buf: Float32Array, n: number, t0: number, rate: number, src: Float32Array, p: Pitch): void {
236
+ let pos = 0
237
+ const len = src.length
238
+ for (let i = 0; i < n; i++) {
239
+ const j = pos | 0
240
+ const v = src[j % len]!
241
+ buf[i] = buf[i]! + v + (src[(j + 1) % len]! - v) * (pos - j)
242
+ pos += pitchAt(p, t0 + i / rate)
243
+ if (pos >= len) pos -= len
244
+ }
245
+ }
246
+
247
+ /** the music bus a render writes: the dry mix and the room's send, both stereo */
248
+ interface Bus {
249
+ l: Float32Array
250
+ r: Float32Array
251
+ sendL: Float32Array | null
252
+ sendR: Float32Array | null
253
+ }
254
+
255
+ const scratch = { buf: new Float32Array(0) }
256
+
257
+ /** one note into the bus: its source (oscillators or noise), its envelope, filter and pan, its send to the room */
258
+ function playNote(bus: Bus, waves: Waves, inst: InstrumentDef, note: ScheduledNote): void {
259
+ const rate = waves.rate
260
+ const midi = note.midi ?? inst.pitch ?? 60
261
+ const hz = midiToHz(midi)
262
+ const peak = inst.vol * note.vol
263
+ if (peak <= 0) return
264
+ const pts = envPoints(inst.env, note.dur, peak)
265
+ const length = (pts[pts.length - 1]?.[0] ?? note.dur) + 0.02
266
+ const i0 = Math.max(0, Math.ceil(note.time * rate))
267
+ const i1 = Math.min(bus.l.length, Math.ceil((note.time + length) * rate))
268
+ const n = i1 - i0
269
+ if (n <= 0) return
270
+ if (scratch.buf.length < n) scratch.buf = new Float32Array(n)
271
+ const buf = scratch.buf
272
+ buf.fill(0, 0, n)
273
+ const t0 = i0 / rate - note.time
274
+ if (inst.osc === 'noise') {
275
+ const r0 = Math.max(0.05, Math.min(8, hz / NOISE_REF_HZ))
276
+ 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 }
277
+ noiseRun(buf, n, t0, rate, inst.noise === 'metal' ? waves.metal : waves.white, pitch)
278
+ } else {
279
+ const voices = Math.max(1, Math.min(4, inst.unison ?? (inst.detune ? 2 : 1)))
280
+ const spread = inst.detune ?? 0
281
+ const pitch: Pitch = inst.sweep
282
+ ? { from: hz, to: Math.max(10, hz * 2 ** (inst.sweep.semis / 12)), over: Math.max(0.005, inst.sweep.time) }
283
+ : inst.glide && note.from !== null && note.from !== note.midi
284
+ ? { from: midiToHz(note.from), to: hz, over: inst.glide }
285
+ : { from: hz, to: hz, over: 0 }
286
+ const vib = inst.vibrato && inst.vibrato.depth > 0 ? inst.vibrato : null
287
+ const table = waves.table(inst, hz)
288
+ for (let v = 0; v < voices; v++) {
289
+ const cents = voices === 1 ? spread : spread * ((2 * v) / (voices - 1) - 1)
290
+ oscillatorRun(buf, n, t0, rate, table, pitch, cents, vib, 1 / Math.sqrt(voices))
291
+ }
292
+ }
293
+ applyEnvelope(buf, n, t0, rate, pts)
294
+ if (inst.filter) biquad(buf, n, rate, inst.filter.type, inst.filter.freq, inst.filter.q ?? 0.8)
295
+ const pan = inst.pan !== undefined && inst.pan !== 0 ? (Math.max(-1, Math.min(1, inst.pan)) + 1) / 2 : null
296
+ const gl = pan === null ? 1 : Math.cos((pan * Math.PI) / 2)
297
+ const gr = pan === null ? 1 : Math.sin((pan * Math.PI) / 2)
298
+ const send = bus.sendL && inst.reverb && inst.reverb > 0 ? inst.reverb : 0
299
+ mixRun(buf, n, i0, bus.l, bus.r, gl, gr)
300
+ if (send > 0) mixRun(buf, n, i0, bus.sendL!, bus.sendR!, gl * send, gr * send)
301
+ }
302
+
303
+ function mixRun(buf: Float32Array, n: number, at: number, l: Float32Array, r: Float32Array, gl: number, gr: number): void {
304
+ for (let i = 0; i < n; i++) {
305
+ const x = buf[i]!
306
+ l[at + i] = l[at + i]! + x * gl
307
+ r[at + i] = r[at + i]! + x * gr
308
+ }
309
+ }
310
+
311
+ /** the gain over the render: whole, then a dead stop or the closing fade at the end */
312
+ function closeRun(l: Float32Array, r: Float32Array, rate: number, end: number, fade: number): void {
313
+ const a = Math.max(0, Math.floor(end * rate))
314
+ const b = Math.ceil((end + fade) * rate)
315
+ for (let i = a; i < l.length; i++) {
316
+ const g = i >= b ? 0 : Math.cos(((i - a) / (b - a)) * (Math.PI / 2))
317
+ l[i] = l[i]! * g
318
+ r[i] = r[i]! * g
319
+ }
320
+ }
321
+
322
+ /** `seconds` of the score from its start, rendered with a tail of TAIL_S: the notes starting before its end (and before
323
+ * its stop, if any) played, closed with a fade at the end, or cut dead at its stop */
324
+ export function renderScore(score: CompiledScore, seconds: number, rate = RATE): Rendered {
325
+ const n = Math.ceil(rate * (seconds + TAIL_S))
326
+ const room = score.reverb && score.reverb.mix > 0 ? score.reverb : null
327
+ const bus: Bus = { l: new Float32Array(n), r: new Float32Array(n), sendL: room ? new Float32Array(n) : null, sendR: room ? new Float32Array(n) : null }
328
+ const waves = new Waves(rate)
329
+ const end = Math.min(seconds, score.stop ?? Number.POSITIVE_INFINITY)
330
+ for (const note of score.notes) {
331
+ if (note.time >= end) break
332
+ const inst = score.instruments[note.instrument]
333
+ if (inst) playNote(bus, waves, inst, note)
334
+ }
335
+ if (room) {
336
+ const ir = impulse(room.seconds, rate)
337
+ convolveInto(bus.sendL!, bus.sendR!, ir[0], ir[1], bus.l, bus.r, impulseScale(ir, rate) * Math.min(1, room.mix * 1.6))
338
+ }
339
+ if (score.stop !== null && score.stop < seconds) closeRun(bus.l, bus.r, rate, score.stop, STOP_FADE_S)
340
+ else closeRun(bus.l, bus.r, rate, seconds, CLOSE_FADE_S)
341
+ master(bus.l, bus.r, rate)
342
+ return { rate, left: bus.l, right: bus.r }
343
+ }
344
+
345
+ /** the master chain: its gain, a gentle compressor, a fast limiter, then the soft ceiling */
346
+ function master(l: Float32Array, r: Float32Array, rate: number): void {
347
+ for (let i = 0; i < l.length; i++) {
348
+ l[i] = l[i]! * MASTER_GAIN
349
+ r[i] = r[i]! * MASTER_GAIN
350
+ }
351
+ compress(l, r, rate, { threshold: -10, knee: 30, ratio: 4, attack: 0.003, release: 0.25 })
352
+ compress(l, r, rate, { threshold: -3, knee: 0, ratio: 20, attack: 0.001, release: 0.05 })
353
+ // a browser wave shaper clamps its input to [-1, 1] before its curve: the ceiling is softClip(1), never CEILING
354
+ for (let i = 0; i < l.length; i++) {
355
+ l[i] = softClip(Math.max(-1, Math.min(1, l[i]!)))
356
+ r[i] = softClip(Math.max(-1, Math.min(1, r[i]!)))
357
+ }
358
+ }
359
+
360
+ /** a sample this close to the ceiling is the soft clip working: distortion when it lasts */
361
+ export const HOT = softClip(1) - 0.01
@@ -0,0 +1,76 @@
1
+ // what each frame of a take holds that a cut must not land on (wiki build/trailer.md, Film lines): a film command may
2
+ // print `states <rle>`, each run of one state and its frames (`dialog*12,-*30`, `-` a plain frame, parts joined by `+`).
3
+ // The stamp keeps it; the timeline names a shot whose edges cut into a change. Pure: no page, no file
4
+
5
+ /** a frame with nothing on it a cut must mind */
6
+ export const FREE = '-'
7
+
8
+ /** `dialog*12,-*30`: each run of one state and its length */
9
+ export function encodeStates(states: readonly string[]): string {
10
+ const runs: string[] = []
11
+ let i = 0
12
+ while (i < states.length) {
13
+ let j = i
14
+ while (j < states.length && states[j] === states[i]) j++
15
+ runs.push(`${states[i]}*${j - i}`)
16
+ i = j
17
+ }
18
+ return runs.join(',')
19
+ }
20
+
21
+ export function decodeStates(text: string): string[] {
22
+ const out: string[] = []
23
+ for (const run of text.split(',').filter(Boolean)) {
24
+ const at = run.lastIndexOf('*')
25
+ const n = Number(run.slice(at + 1))
26
+ if (at <= 0 || !Number.isInteger(n) || n < 1) throw new Error(`states: ${run} is no <state>*<frames>`)
27
+ for (let k = 0; k < n; k++) out.push(run.slice(0, at))
28
+ }
29
+ return out
30
+ }
31
+
32
+ /** the states a film's output says, its `states <rle>` word (on its own line or after ` · `); null when it says none */
33
+ export function statesOf(said: string): string | null {
34
+ return /(?:^|\s·\s|\n)states (\S+)/.exec(said)?.[1] ?? null
35
+ }
36
+
37
+ /** frames at each edge of a shot where a change shows: a box rising or a change still playing reads as a flash */
38
+ export const EDGE_FRAMES = 6
39
+
40
+ export interface StatesRule {
41
+ /** parts that open on their own way in, never a change a cut minds */
42
+ ignore?: readonly string[]
43
+ /** parts that are a change under way: a shot never opens or ends on one */
44
+ underWay?: readonly string[]
45
+ }
46
+
47
+ /** what changes within EDGE_FRAMES of the shot's first or last frame (`first`, `last`: frames of the take), and a change
48
+ * under way the shot opens or ends on: one line each, none when the edges are settled */
49
+ export function edgeChanges(states: readonly string[], first: number, last: number, rule: StatesRule = {}): string[] {
50
+ const ignore = new Set(rule.ignore ?? [])
51
+ const parts = (s: string): string[] => (s === FREE ? [] : s.split('+').filter((x) => !ignore.has(x)))
52
+ const change = (from: string, to: string): string => {
53
+ const a = parts(from)
54
+ const b = parts(to)
55
+ return [...b.filter((x) => !a.includes(x)).map((x) => `${x} in`), ...a.filter((x) => !b.includes(x)).map((x) => `${x} out`)].join(', ')
56
+ }
57
+ const end = Math.min(last, states.length - 1)
58
+ if (first > end || first < 0) return []
59
+ const out: string[] = []
60
+ const seen = new Set<number>()
61
+ const at = (i: number): void => {
62
+ if (seen.has(i)) return
63
+ seen.add(i)
64
+ const after = i - first
65
+ const before = end - i + 1
66
+ out.push(`${change(states[i - 1]!, states[i]!)} ${after <= before ? `${after} frame${after === 1 ? '' : 's'} after its start` : `${before} frame${before === 1 ? '' : 's'} before its end`}`)
67
+ }
68
+ const moved = (i: number): boolean => change(states[i - 1]!, states[i]!) !== ''
69
+ for (let i = first + 1; i <= Math.min(end, first + EDGE_FRAMES); i++) if (moved(i)) at(i)
70
+ for (let i = Math.max(first + 1, end - EDGE_FRAMES + 1); i <= end; i++) if (moved(i)) at(i)
71
+ for (const u of rule.underWay ?? []) {
72
+ if (parts(states[first]!).includes(u)) out.unshift(`opens on a ${u}`)
73
+ if (end > first && parts(states[end]!).includes(u)) out.push(`ends on a ${u}`)
74
+ }
75
+ return out
76
+ }
@@ -0,0 +1,233 @@
1
+ // the stitch: the cut rendered piece by piece (a piece = a section), each cached on its ffmpeg arguments and the size
2
+ // and time of every file it reads, so an edit renders only the pieces it touches. The pieces join with no second
3
+ // encode, the sound is mixed once (cached the same way) and muxed beside them, and the strip lays out each piece's row
4
+ import { existsSync, mkdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'
5
+ import { availableParallelism } from 'node:os'
6
+ import { dirname, join } from 'node:path'
7
+ import sharp from 'sharp'
8
+ import { ffmpeg, loudnessKept } from './ffmpeg.ts'
9
+ import { type Encoder, GRID_RADIUS, gridLayout, MIX_TRUE_PEAK, mixArgs, pieceArgs, type SplitMasks, splitPane, type StitchInput, THUMB_W } from './graph.ts'
10
+ import type { TrailerLook } from './look.ts'
11
+ import { type Piece, pieces } from './plan.ts'
12
+
13
+ /** bump when the stitch changes what the same arguments make */
14
+ const STITCH_VERSION = 1
15
+
16
+ /** the files an ffmpeg command reads */
17
+ const inputsOf = (args: string[]): string[] => args.flatMap((x, i) => (args[i - 1] === '-i' && existsSync(x) ? [x] : []))
18
+
19
+ /** a render's key: its arguments (the outputs named alike) and every file it reads as it stands: a refilm rewrites a
20
+ * take in place, so its size and time count */
21
+ function keyOf(args: string[]): string {
22
+ const reads = inputsOf(args).map((f) => {
23
+ const s = statSync(f)
24
+ return [f, s.size, s.mtimeMs]
25
+ })
26
+ return new Bun.CryptoHasher('sha1').update(JSON.stringify([STITCH_VERSION, args, reads])).digest('hex').slice(0, 16)
27
+ }
28
+
29
+ /** a render kept under its key, one file per extension (the first always made, the others when the render has them):
30
+ * made only when missing, written aside then moved in place, the first last */
31
+ async function cached(dir: string, stem: string, exts: string[], make: (outs: string[]) => string[]): Promise<{ files: string[]; made: boolean }> {
32
+ return cachedAs(dir, stem, keyOf(make(exts.map((e) => `out.${e}`))), exts, make)
33
+ }
34
+
35
+ /** a render kept under a key its caller computed: for one whose inputs are rewritten by every build */
36
+ export async function cachedAs(dir: string, stem: string, key: string, exts: string[], make: (outs: string[]) => string[]): Promise<{ files: string[]; made: boolean }> {
37
+ const files = exts.map((e) => join(dir, `${stem}-${key}.${e}`))
38
+ if (existsSync(files[0]!)) {
39
+ const now = new Date()
40
+ for (const f of files) if (existsSync(f)) utimesSync(f, now, now)
41
+ return { files, made: false }
42
+ }
43
+ mkdirSync(dir, { recursive: true })
44
+ const parts = exts.map((e) => join(dir, `${stem}-${key}.part.${e}`))
45
+ try {
46
+ await ffmpeg(make(parts), { nice: 10 })
47
+ for (let i = parts.length - 1; i >= 0; i--) if (existsSync(parts[i]!)) renameSync(parts[i]!, files[i]!)
48
+ } finally {
49
+ for (const f of parts) rmSync(f, { force: true })
50
+ }
51
+ return { files, made: true }
52
+ }
53
+
54
+ const n3 = (x: number): string => (+x.toFixed(4)).toString()
55
+
56
+ /** a drawing's own temporary file beside `file`: two builds drawing one piece at once each rename their own */
57
+ const partOf = (file: string): string => `${file}.${process.pid}-${Math.random().toString(36).slice(2, 8)}.part.png`
58
+
59
+ async function drawSvg(file: string, svg: string): Promise<void> {
60
+ mkdirSync(dirname(file), { recursive: true })
61
+ const part = partOf(file)
62
+ await sharp(Buffer.from(svg)).png().toFile(part)
63
+ renameSync(part, file)
64
+ }
65
+
66
+ const colourKey = (look: Pick<TrailerLook, 'ground' | 'light' | 'edge'>): string => new Bun.CryptoHasher('sha1').update(JSON.stringify([look.ground, look.light, look.edge])).digest('hex').slice(0, 8)
67
+
68
+ /** a grid's frame for `n` cells: the look's ground with a hole per cell, its corners rounded and rimmed in its light;
69
+ * laid over the cells. Drawn once per layout and look and kept, so its file never changes a piece's key */
70
+ export async function gridMask(dir: string, n: number, size: readonly [number, number], look: Pick<TrailerLook, 'ground' | 'light' | 'edge'>): Promise<string> {
71
+ const [W, H] = size
72
+ const file = join(dir, 'grid', `mask-${STITCH_VERSION}-${n}-${W}x${H}-${colourKey(look)}.png`)
73
+ if (existsSync(file)) return file
74
+ const r = H * GRID_RADIUS
75
+ const rim = Math.max(1.5, H * 0.0028)
76
+ const cells = gridLayout(n, W, H)
77
+ const holes = cells.map((c) => `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" rx="${n3(r)}" fill="black"/>`).join('')
78
+ const rims = cells.map((c) => `<rect x="${c.x + rim / 2}" y="${c.y + rim / 2}" width="${c.w - rim}" height="${c.h - rim}" rx="${n3(r - rim / 2)}" fill="none" stroke="${look.light}" stroke-opacity="0.85" stroke-width="${n3(rim)}"/>`).join('')
79
+ await drawSvg(
80
+ file,
81
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
82
+ <defs><radialGradient id="g" cx="50%" cy="45%" r="75%"><stop offset="0" stop-color="${look.ground[0]}"/><stop offset="1" stop-color="${look.ground[1]}"/></radialGradient>
83
+ <mask id="m"><rect width="${W}" height="${H}" fill="white"/>${holes}</mask></defs>
84
+ <rect width="${W}" height="${H}" fill="url(#g)" mask="url(#m)"/>${rims}</svg>`,
85
+ )
86
+ return file
87
+ }
88
+
89
+ /** a split's panes and seams for `n` panes at `angle`: each pane's shape, and the seams, a line of the look's light over
90
+ * a soft shadow of its edge, its width a 150th of the frame's height. Drawn once and kept like the grid's frame */
91
+ export async function splitMasks(dir: string, n: number, angle: number, size: readonly [number, number], look: Pick<TrailerLook, 'ground' | 'light' | 'edge'>): Promise<SplitMasks> {
92
+ const [W, H] = size
93
+ const stem = join(dir, 'split', `${STITCH_VERSION}-${n}-${n3(angle)}-${W}x${H}`)
94
+ const panes = Array.from({ length: n }, (_, j) => `${stem}-pane${j}.png`)
95
+ const seams = `${stem}-seams-${colourKey(look)}.png`
96
+ if ([...panes, seams].every((f) => existsSync(f))) return { panes, seams }
97
+ const poly = (pts: [number, number][]): string => pts.map(([x, y]) => `${n3(x)},${n3(y)}`).join(' ')
98
+ for (let j = 0; j < n; j++)
99
+ if (!existsSync(panes[j]!))
100
+ await drawSvg(panes[j]!, `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><rect width="${W}" height="${H}" fill="black"/><polygon points="${poly(splitPane(n, j, angle, W, H).points)}" fill="white"/></svg>`)
101
+ const t = Math.tan((angle * Math.PI) / 180)
102
+ // each seam drawn from well above the frame to well below it, so it crosses it whole
103
+ const lines = Array.from({ length: n - 1 }, (_, i) => (W * (i + 1)) / n)
104
+ const wLine = Math.max(2, H / 150)
105
+ const seg = (stroke: string, width: number): string =>
106
+ lines.map((x) => `<line x1="${n3(x + 1.5 * H * t)}" y1="${-H}" x2="${n3(x - 1.5 * H * t)}" y2="${2 * H}" stroke="${stroke}" stroke-width="${n3(width)}"/>`).join('')
107
+ await drawSvg(
108
+ seams,
109
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><defs><filter id="s" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="${n3(H / 90)}"/></filter></defs>` +
110
+ `<g filter="url(#s)" opacity="0.55">${seg(look.edge, wLine * 4)}</g>${seg(look.light, wLine)}</svg>`,
111
+ )
112
+ return { panes, seams }
113
+ }
114
+
115
+ /** runs the jobs, at most `n` at once */
116
+ export async function pool<T>(jobs: (() => Promise<T>)[], n: number): Promise<T[]> {
117
+ const out: T[] = []
118
+ let next = 0
119
+ const lane = async (): Promise<void> => {
120
+ while (next < jobs.length) {
121
+ const i = next++
122
+ out[i] = await jobs[i]!()
123
+ }
124
+ }
125
+ await Promise.all(Array.from({ length: Math.min(n, jobs.length) }, lane))
126
+ return out
127
+ }
128
+
129
+ /** how many pieces render at once: a graph runs mostly on one core */
130
+ const LANES = Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2)))
131
+
132
+ export interface Stitched {
133
+ /** the pieces, each with whether this run rendered it, its video and its row of the contact strip */
134
+ pieces: (Piece & { made: boolean; file: string; thumbs: string })[]
135
+ /** the sound was mixed again */
136
+ mixed: boolean
137
+ /** the mix file, null for a silent stitch */
138
+ mix: string | null
139
+ }
140
+
141
+ export interface StitchRun extends StitchInput {
142
+ out: string
143
+ /** where the rendered pieces and the mix are kept */
144
+ cache: string
145
+ encoder?: Encoder
146
+ /** no sound at all: a shot's preview */
147
+ silent?: boolean
148
+ }
149
+
150
+ async function renderPieces(a: StitchRun, ps: Piece[]): Promise<Stitched['pieces']> {
151
+ return pool(
152
+ ps.map((p) => async () => {
153
+ const { files, made } = await cached(a.cache, `${a.t.name}-${p.name}`, ['mp4', 'png'], ([video, row]) => pieceArgs(a, p, video!, a.encoder, row))
154
+ return { ...p, made, file: files[0]!, thumbs: files[1]! }
155
+ }),
156
+ LANES,
157
+ )
158
+ }
159
+
160
+ /** the mix, its true peak held under MIX_TRUE_PEAK after the aac encode: the encoder's band limit rings over a limited
161
+ * harsh sound (a square wave measured +2.1 dB), so a mix that overshoots is made again that much lower */
162
+ export async function renderMix(a: Pick<StitchRun, 't' | 'music' | 'sfx' | 'voices' | 'cache'>): Promise<{ file: string; made: boolean }> {
163
+ let trim = 0
164
+ let made = false
165
+ for (let pass = 0; ; pass++) {
166
+ const r = await cached(join(a.cache, 'mix'), `mix-${a.t.name}`, ['m4a'], ([out]) => mixArgs(a, out!, trim))
167
+ made ||= r.made
168
+ const { truePeak } = await loudnessKept(r.files[0]!)
169
+ if (truePeak <= MIX_TRUE_PEAK || pass === 2) return { file: r.files[0]!, made }
170
+ trim = +(trim - (truePeak - MIX_TRUE_PEAK) - 0.1).toFixed(2)
171
+ }
172
+ }
173
+
174
+ /** the pieces joined as they are, no encode, with the sound beside them: the whole mix copied, or a window of it (a
175
+ * section alone) cut on its samples, so encoded again */
176
+ async function joinPieces(files: string[], sound: { file: string; start?: number; seconds?: number } | null, out: string): Promise<void> {
177
+ mkdirSync(dirname(out), { recursive: true })
178
+ const list = `${out}.list.txt`
179
+ writeFileSync(list, files.map((f) => `file '${f.replaceAll("'", "'\\''")}'`).join('\n'))
180
+ const part = out.replace(/(\.\w+)$/, '.part$1')
181
+ const audio = sound ? [...(sound.start !== undefined ? ['-ss', n3(sound.start), '-t', n3(sound.seconds ?? 0)] : []), '-i', sound.file] : []
182
+ const acodec = sound?.start !== undefined ? ['-c:a', 'aac', '-b:a', '192k'] : ['-c:a', 'copy']
183
+ try {
184
+ await ffmpeg(['-f', 'concat', '-safe', '0', '-i', list, ...audio, '-map', '0:v', ...(sound ? ['-map', '1:a', ...acodec] : []), '-c:v', 'copy', '-movflags', '+faststart', part], { nice: 10 })
185
+ renameSync(part, out)
186
+ } finally {
187
+ rmSync(list, { force: true })
188
+ rmSync(part, { force: true })
189
+ }
190
+ }
191
+
192
+ /** the whole cut: every piece (rendered where the cache misses it), joined, with its sound */
193
+ export async function stitch(a: StitchRun): Promise<Stitched> {
194
+ const [ps, mix] = await Promise.all([renderPieces(a, pieces(a.t)), a.silent ? null : renderMix(a)])
195
+ await joinPieces(ps.map((p) => p.file), mix ? { file: mix.file } : null, a.out)
196
+ return { pieces: ps, mixed: mix?.made ?? false, mix: mix?.file ?? null }
197
+ }
198
+
199
+ /** one piece alone (a section, `start` the shots before the first), with its window of the sound */
200
+ export async function stitchPiece(a: StitchRun, name: string): Promise<Stitched> {
201
+ const p = pieces(a.t).find((x) => x.name === name)
202
+ if (!p) throw new Error(`no section ${name}: ${pieces(a.t).map((x) => x.name).join(', ')}`)
203
+ const [ps, mix] = await Promise.all([renderPieces(a, [p]), a.silent ? null : renderMix(a)])
204
+ await joinPieces(ps.map((x) => x.file), mix && { file: mix.file, start: p.from / a.t.fps, seconds: (p.to - p.from) / a.t.fps }, a.out)
205
+ return { pieces: ps, mixed: mix?.made ?? false, mix: mix?.file ?? null }
206
+ }
207
+
208
+ /** one frame from the middle of each shot, side by side, rows of six: the cut at a glance. Each piece drew its own
209
+ * row as it rendered, so the strip only lays the rows out */
210
+ export async function strip(ps: Pick<Stitched['pieces'][number], 'thumbs'>[], out: string): Promise<void> {
211
+ const rows = await Promise.all(ps.filter((p) => existsSync(p.thumbs)).map((p) => sharp(p.thumbs).removeAlpha().raw().toBuffer({ resolveWithObject: true })))
212
+ const h = rows[0]?.info.height ?? 0
213
+ const cells = rows.flatMap(({ data, info }) => Array.from({ length: Math.round(info.width / THUMB_W) }, (_, k) => ({ data, left: k * THUMB_W, width: info.width })))
214
+ if (cells.length === 0) return
215
+ const PAD = 6
216
+ const cols = Math.min(6, cells.length)
217
+ const W = cols * THUMB_W + (cols - 1) * PAD
218
+ const H = Math.ceil(cells.length / cols) * (h + PAD) - PAD
219
+ const grid = Buffer.alloc(W * H * 3)
220
+ cells.forEach((c, i) => {
221
+ const [x, y] = [(i % cols) * (THUMB_W + PAD), Math.floor(i / cols) * (h + PAD)]
222
+ for (let r = 0; r < h; r++) c.data.copy(grid, ((y + r) * W + x) * 3, (r * c.width + c.left) * 3, (r * c.width + c.left + THUMB_W) * 3)
223
+ })
224
+ await sharp(grid, { raw: { width: W, height: H, channels: 3 } }).png({ compressionLevel: 3 }).toFile(out)
225
+ }
226
+
227
+ /** which pieces a stitch rendered, in one phrase; the others came from the cache */
228
+ export function rendered(d: Stitched): string {
229
+ const made = d.pieces.filter((p) => p.made).map((p) => p.name)
230
+ const all = d.pieces.length
231
+ const sound = d.mixed ? ', the sound mixed' : ''
232
+ return made.length === 0 ? `every section cached${sound}` : made.length === all ? `${all} section(s) rendered${sound}` : `rendered ${made.join(', ')}, ${all - made.length} of ${all} sections cached${sound}`
233
+ }