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,91 @@
1
+ // the score's note grammar and its instruments: one token a grid row, an instrument as plain data the synth plays.
2
+ // Pure: no audio here (synth.ts renders)
3
+
4
+ /** oscillator families: chiptune roots (pulse, wave, noise) plus the smoother modern ones */
5
+ export type Osc = 'pulse' | 'saw' | 'triangle' | 'sine' | 'wave' | 'noise'
6
+
7
+ export interface InstrumentDef {
8
+ osc: Osc
9
+ /** 0..1 */
10
+ vol: number
11
+ /** ADSR in seconds, sustain as a 0..1 level */
12
+ env: { a: number; d: number; s: number; r: number }
13
+ /** pulse only: duty cycle 0.05..0.95 */
14
+ duty?: number
15
+ /** wave only: one cycle of samples in -1..1, any length */
16
+ wave?: number[]
17
+ /** pulse, saw and wave: the frequency in Hz above which the harmonics fall away, default 3000; higher is brighter */
18
+ tone?: number
19
+ /** noise only: 'white' hiss or 'metal' (short period, pitched) */
20
+ noise?: 'white' | 'metal'
21
+ /** detune in cents, and extra unison voices spread by it */
22
+ detune?: number
23
+ unison?: number
24
+ filter?: { type: 'lowpass' | 'highpass' | 'bandpass'; freq: number; q?: number }
25
+ /** depth in semitones, rate in Hz, delay in seconds from the note start */
26
+ vibrato?: { delay: number; depth: number; rate: number }
27
+ /** portamento between consecutive notes, seconds */
28
+ glide?: number
29
+ /** -1 left .. 1 right */
30
+ pan?: number
31
+ /** 0..1 send to the score's reverb */
32
+ reverb?: number
33
+ /** pitch slide from each note start: `semis` over `time` seconds (kicks, toms, zaps) */
34
+ sweep?: { semis: number; time: number }
35
+ /** the midi note a drum letter plays this instrument at, default 60 */
36
+ pitch?: number
37
+ }
38
+
39
+ // one token a row, whitespace separated, '|' ignored:
40
+ // C4 C#4 Db4 note (octave 0..8) . hold the previous note
41
+ // - silence k drum letter (from `drums`)
42
+ // zero length modifiers glued before a row token: `@lead:C4` switches instrument, `v8:C4` sets the volume 0..15
43
+ // (15 = the instrument's vol) from that note on
44
+ type Row = { kind: 'note'; midi: number } | { kind: 'hold' } | { kind: 'off' } | { kind: 'drum'; letter: string }
45
+
46
+ export interface RowEvent {
47
+ row: Row
48
+ instrument?: string
49
+ vol?: number
50
+ }
51
+
52
+ const NOTE_RE = /^([A-G])([#b]?)(\d)$/
53
+ const SEMI: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }
54
+
55
+ export function noteToMidi(token: string): number | null {
56
+ const m = NOTE_RE.exec(token)
57
+ if (!m) return null
58
+ const [, letter, acc, oct] = m
59
+ const base = SEMI[letter ?? '']
60
+ if (base === undefined || oct === undefined) return null
61
+ return 12 * (Number(oct) + 1) + base + (acc === '#' ? 1 : acc === 'b' ? -1 : 0)
62
+ }
63
+
64
+ export function midiToHz(midi: number): number {
65
+ return 440 * 2 ** ((midi - 69) / 12)
66
+ }
67
+
68
+ export function parsePattern(text: string, drums: Record<string, string> = {}): RowEvent[] {
69
+ const out: RowEvent[] = []
70
+ for (const raw of text.split(/\s+/)) {
71
+ if (raw === '' || raw === '|') continue
72
+ const parts = raw.split(':')
73
+ const body = parts.pop() ?? ''
74
+ const ev: RowEvent = { row: { kind: 'off' } }
75
+ for (const mod of parts) {
76
+ if (mod.startsWith('@')) ev.instrument = mod.slice(1)
77
+ else if (/^v\d{1,2}$/.test(mod)) ev.vol = Number(mod.slice(1))
78
+ else throw new Error(`pattern token "${raw}": unknown modifier "${mod}"`)
79
+ }
80
+ if (body === '.') ev.row = { kind: 'hold' }
81
+ else if (body === '-') ev.row = { kind: 'off' }
82
+ else if (drums[body] !== undefined) ev.row = { kind: 'drum', letter: body }
83
+ else {
84
+ const midi = noteToMidi(body)
85
+ if (midi === null) throw new Error(`pattern token "${raw}": not a note, a drum letter, '.' or '-'`)
86
+ ev.row = { kind: 'note', midi }
87
+ }
88
+ out.push(ev)
89
+ }
90
+ return out
91
+ }
@@ -0,0 +1,214 @@
1
+ // a trailer's score: its `code` block and `play` lines made one module, each section's tracks read in the pattern
2
+ // grammar and timed at the section's own tempo, into the notes the synth plays. A track's instrument, volume and held
3
+ // note carry across sections. The module is loaded synchronously (require), so a check reads every error at its line
4
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import { type Score, SCORE_LEAD, type Section, type TrailerError } from '../grammar.ts'
7
+ import { INST } from './palette.ts'
8
+ import { type InstrumentDef, parsePattern, type RowEvent } from './pattern.ts'
9
+
10
+ export interface ScheduledNote {
11
+ track: string
12
+ /** the grid row it starts on, counted over the whole score */
13
+ row: number
14
+ rows: number
15
+ /** null for a drum hit, which plays its instrument's `pitch` */
16
+ midi: number | null
17
+ instrument: string
18
+ /** 0..1, the v modifier times the track volume */
19
+ vol: number
20
+ /** the track's note before, for glide */
21
+ from: number | null
22
+ /** seconds from the render's start, its lead counted */
23
+ time: number
24
+ dur: number
25
+ }
26
+
27
+ export interface CompiledScore {
28
+ instruments: Record<string, InstrumentDef>
29
+ reverb?: { seconds: number; mix: number }
30
+ /** every note sorted by time */
31
+ notes: ScheduledNote[]
32
+ /** the score's length, its lead counted */
33
+ seconds: number
34
+ /** where it cuts dead, its lead counted; null: it rings out */
35
+ stop: number | null
36
+ /** each section's span in the render, its lead counted */
37
+ sections: { name: string; at: number; seconds: number; notes: number }[]
38
+ }
39
+
40
+ const here = import.meta.dir
41
+ // the helpers every `code` block and `play` line reads without importing them
42
+ const PRELUDE = [
43
+ `import { arp, bass, chart, mel, midiToNote, parseChord, rep, seq, transpose, voice } from ${JSON.stringify(join(here, 'compose.ts'))}`,
44
+ `import { additive, INST, KIT } from ${JSON.stringify(join(here, 'palette.ts'))}`,
45
+ "const rest = (n: number): string => Array.from({ length: n }, () => '-').join(' ')",
46
+ 'export const __plays: (() => unknown)[] = []',
47
+ 'const __play = (i: number, f: () => unknown): void => void (__plays[i] = f)',
48
+ ]
49
+
50
+ type Play = Section['plays'][number]
51
+
52
+ /** the score as one module: the prelude, the code block, each play as a function; `lineOf` maps its lines to the file's */
53
+ export function scoreModule(s: Score, sections: readonly Section[]): { text: string; lineOf: number[]; plays: Play[] } {
54
+ const out: string[] = []
55
+ const lineOf: number[] = []
56
+ const push = (text: string, line: number): void => {
57
+ out.push(text)
58
+ lineOf.push(line)
59
+ }
60
+ for (const l of PRELUDE) push(l, s.line)
61
+ // the code's lines from its own line on, the blank ones kept: a template string may hold one
62
+ for (const [i, l] of (s.code?.text.split('\n') ?? []).entries()) if (i >= (s.code?.line ?? 0)) push(l, i + 1)
63
+ const plays = sections.flatMap((sec) => sec.plays)
64
+ plays.forEach((pl, i) => {
65
+ // a plain quoted string is a melody
66
+ const expr = /^"[^"\\]*"$/.test(pl.expr) ? `mel(${pl.expr})` : pl.expr
67
+ const [first, ...more] = expr.split('\n')
68
+ push(`__play(${i}, () => (${first ?? ''}`, pl.line)
69
+ for (const [k, l] of more.entries()) push(l, pl.line + 1 + k)
70
+ push('))', pl.line)
71
+ })
72
+ return { text: `${out.join('\n')}\n`, lineOf, plays }
73
+ }
74
+
75
+ /** the module's line a load error names: a syntax error carries its place, a throw at the top level its stack */
76
+ function errorLine(e: unknown, file: string): number | null {
77
+ const errs = (e as { errors?: { position?: { line?: number } }[] }).errors
78
+ const pos = errs?.[0]?.position?.line ?? (e as { position?: { line?: number } }).position?.line
79
+ if (pos !== undefined) return pos
80
+ const m = new RegExp(`${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:(\\d+)`).exec((e as Error).stack ?? '')
81
+ return m ? Number(m[1]) : null
82
+ }
83
+
84
+ const firstLine = (e: unknown): string => {
85
+ const errs = (e as { errors?: unknown[] }).errors
86
+ return String(errs?.[0] ?? e).split('\n')[0]!.replace(/^(BuildMessage|Error): /, '')
87
+ }
88
+
89
+ /** the file a score's module text is kept in */
90
+ export const scoreModuleFile = (text: string, dir: string): string => join(dir, `${Bun.hash(text).toString(36)}.ts`)
91
+
92
+ /** the score's notes, or every error on its line; `dir` keeps the module files, each named by its text */
93
+ export function compileScore(s: Score, sections: readonly Section[], dir: string): { score: CompiledScore | null; errors: TrailerError[] } {
94
+ const errors: TrailerError[] = []
95
+ const { text, lineOf, plays } = scoreModule(s, sections)
96
+ mkdirSync(dir, { recursive: true })
97
+ const file = scoreModuleFile(text, dir)
98
+ if (!existsSync(file)) writeFileSync(file, text)
99
+ let mod: { instruments?: unknown; __plays: (() => unknown)[] }
100
+ try {
101
+ mod = require(file)
102
+ } catch (e) {
103
+ const at = errorLine(e, file)
104
+ const line = at !== null ? (lineOf[at - 1] ?? s.line) : (s.code?.line ?? s.line)
105
+ return { score: null, errors: [{ line, message: `the score's TypeScript fails: ${firstLine(e)}` }] }
106
+ }
107
+ const instruments = mod.instruments ?? INST
108
+ if (typeof instruments !== 'object' || instruments === null)
109
+ return { score: null, errors: [{ line: s.code?.line ?? s.line, message: 'the code block\'s instruments is no record: export const instruments = { … }' }] }
110
+ const inst = instruments as Record<string, InstrumentDef>
111
+ const names = (): string => Object.keys(inst).join(', ')
112
+ const values = new Map(plays.map((pl, i) => [pl, mod.__plays[i]]))
113
+
114
+ const tracks = s.tracks
115
+ for (const tr of tracks) if (!inst[tr.instrument]) errors.push({ line: tr.line, message: `track ${tr.name}: no instrument ${tr.instrument} (${names()})` })
116
+ for (const [letter, name] of Object.entries(s.drums)) if (!inst[name]) errors.push({ line: s.line, message: `drums ${letter}=${name}: no instrument ${name} (${names()})` })
117
+
118
+ const state = new Map(tracks.map((tr) => [tr.name, { instrument: tr.instrument, vol: 15, open: null as ScheduledNote | null, prev: null as number | null }]))
119
+ const notes: ScheduledNote[] = []
120
+ const spans: CompiledScore['sections'] = []
121
+ let row0 = 0
122
+ const close = (st: { open: ScheduledNote | null }, time: number, row: number): void => {
123
+ if (!st.open) return
124
+ st.open.dur = time - st.open.time
125
+ st.open.rows = row - st.open.row
126
+ st.open = null
127
+ }
128
+ for (const sec of sections) {
129
+ const { bpm, grid, meter } = sec.clock ?? s.clock
130
+ const rowsExact = (sec.seconds * bpm * grid) / 60
131
+ const rows = Math.round(rowsExact)
132
+ const spr = 60 / bpm / grid
133
+ const bars = (n: number): string => `${+(n / grid / meter).toFixed(3)} bars`
134
+ const before = notes.length
135
+ spans.push({ name: sec.name, at: SCORE_LEAD + sec.at, seconds: sec.seconds, notes: 0 })
136
+ if (Math.abs(rowsExact - rows) > 1e-6) {
137
+ errors.push({ line: sec.line, message: `section ${sec.name}: ${+sec.seconds.toFixed(4)} s is ${+rowsExact.toFixed(3)} rows of its grid (${grid} a beat), never a whole number` })
138
+ continue
139
+ }
140
+ if (s.stop && sec.at >= s.stop.at - 1e-9 && sec.plays.length > 0) {
141
+ for (const pl of sec.plays) errors.push({ line: pl.line, message: `play ${pl.track}: section ${sec.name} starts after the score stops (line ${s.stop.line}), so it is never heard` })
142
+ continue
143
+ }
144
+ const drums = { ...s.drums, ...sec.drums }
145
+ for (const [letter, name] of Object.entries(sec.drums)) if (!inst[name]) errors.push({ line: sec.line, message: `drums ${letter}=${name}: no instrument ${name} (${names()})` })
146
+ const byTrack = new Map<string, { line: number; text: string }>()
147
+ for (const pl of sec.plays) {
148
+ let v: unknown
149
+ try {
150
+ v = values.get(pl)?.()
151
+ } catch (e) {
152
+ errors.push({ line: pl.line, message: `play ${pl.track}: ${firstLine(e)}` })
153
+ continue
154
+ }
155
+ if (pl.track === '*') {
156
+ if (typeof v !== 'object' || v === null || Object.values(v).some((x) => typeof x !== 'string')) {
157
+ errors.push({ line: pl.line, message: 'play * wants a record of tracks, each its rows as text' })
158
+ continue
159
+ }
160
+ for (const [k, x] of Object.entries(v as Record<string, string>)) {
161
+ if (!state.has(k)) errors.push({ line: pl.line, message: `play *: no track ${k} (${tracks.map((x) => x.name).join(', ')})` })
162
+ else if (byTrack.has(k)) errors.push({ line: pl.line, message: `play *: section ${sec.name} already plays ${k}` })
163
+ else byTrack.set(k, { line: pl.line, text: x })
164
+ }
165
+ } else if (typeof v !== 'string') errors.push({ line: pl.line, message: `play ${pl.track}: wants its rows as text, got ${typeof v}` })
166
+ else if (byTrack.has(pl.track)) errors.push({ line: pl.line, message: `play ${pl.track}: section ${sec.name} already plays it` })
167
+ else byTrack.set(pl.track, { line: pl.line, text: v })
168
+ }
169
+ for (const tr of tracks) {
170
+ const st = state.get(tr.name)!
171
+ const src = byTrack.get(tr.name)
172
+ let events: RowEvent[]
173
+ try {
174
+ events = src ? parsePattern(src.text, drums) : Array.from({ length: rows }, (): RowEvent => ({ row: { kind: 'off' } }))
175
+ } catch (e) {
176
+ errors.push({ line: src!.line, message: `play ${tr.name}: ${firstLine(e)}` })
177
+ continue
178
+ }
179
+ if (events.length !== rows) {
180
+ errors.push({ line: src!.line, message: `play ${tr.name}: ${events.length} rows, ${bars(events.length)}; section ${sec.name} is ${rows} rows, ${bars(rows)}` })
181
+ continue
182
+ }
183
+ events.forEach((ev, r) => {
184
+ const time = SCORE_LEAD + sec.at + r * spr
185
+ if (ev.instrument !== undefined) {
186
+ if (!inst[ev.instrument]) errors.push({ line: src!.line, message: `play ${tr.name}: no instrument ${ev.instrument} (${names()})` })
187
+ st.instrument = ev.instrument
188
+ }
189
+ if (ev.vol !== undefined) st.vol = Math.max(0, Math.min(15, ev.vol))
190
+ const k = ev.row
191
+ if (k.kind === 'hold') return
192
+ close(st, time, row0 + r)
193
+ if (k.kind === 'off') return
194
+ const vol = (st.vol / 15) * tr.volume
195
+ if (k.kind === 'drum') st.open = { track: tr.name, row: row0 + r, rows: 1, midi: null, instrument: drums[k.letter] ?? st.instrument, vol, from: null, time, dur: spr }
196
+ else {
197
+ st.open = { track: tr.name, row: row0 + r, rows: 1, midi: k.midi, instrument: st.instrument, vol, from: st.prev, time, dur: spr }
198
+ st.prev = k.midi
199
+ }
200
+ notes.push(st.open)
201
+ })
202
+ }
203
+ spans.at(-1)!.notes = notes.length - before
204
+ row0 += rows
205
+ }
206
+ const last = sections.at(-1)
207
+ const seconds = SCORE_LEAD + (last ? last.at + last.seconds : 0)
208
+ for (const st of state.values()) close(st, seconds, row0)
209
+ if (errors.length > 0) return { score: null, errors: dedupe(errors) }
210
+ notes.sort((a, b) => a.time - b.time)
211
+ return { score: { instruments: inst, ...(s.reverb ? { reverb: s.reverb } : {}), notes, seconds, stop: s.stop ? SCORE_LEAD + s.stop.at : null, sections: spans }, errors: [] }
212
+ }
213
+
214
+ const dedupe = (es: TrailerError[]): TrailerError[] => [...new Map(es.map((e) => [`${e.line} ${e.message}`, e])).values()]
@@ -0,0 +1,112 @@
1
+ // a score as a wav in the cache, keyed on its notes, instruments, room and the synth's own code, and what it sounds
2
+ // like as numbers: each section's loudness and peak, since nobody hears a build. Silence under notes refuses
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import type { CompiledScore } from './score.ts'
6
+ import { HOT, RATE, type Rendered, renderScore } from './synth.ts'
7
+
8
+ /** the synth's sources: an edit to any renders every score again */
9
+ const SYNTH = ['synth.ts', 'dsp.ts', 'pattern.ts'].map((f) => join(import.meta.dir, f))
10
+
11
+ export interface Level {
12
+ name: string
13
+ /** seconds in the render */
14
+ at: number
15
+ seconds: number
16
+ notes: number
17
+ /** dBFS, the louder channel */
18
+ rms: number
19
+ peak: number
20
+ /** the share of its samples at the soft ceiling */
21
+ hot: number
22
+ }
23
+
24
+ /** below this RMS a section is silence */
25
+ export const SILENT_DB = -60
26
+ /** past this share of samples at the ceiling, a section distorts */
27
+ const HOT_SHARE = 0.002
28
+
29
+ const db = (x: number): number => (x > 0 ? 20 * Math.log10(x) : -120)
30
+
31
+ /** each span's RMS, peak and share of samples at the ceiling */
32
+ export function levels(r: Rendered, spans: readonly { name: string; at: number; seconds: number; notes: number }[]): Level[] {
33
+ return spans.map((s) => {
34
+ const a = Math.max(0, Math.floor(s.at * r.rate))
35
+ const b = Math.min(r.left.length, Math.ceil((s.at + s.seconds) * r.rate))
36
+ const m = spanStats(r.left, r.right, a, b)
37
+ return { ...s, rms: +db(m.rms).toFixed(1), peak: +db(m.peak).toFixed(1), hot: +m.hot.toFixed(4) }
38
+ })
39
+ }
40
+
41
+ function spanStats(l: Float32Array, r: Float32Array, a: number, b: number): { rms: number; peak: number; hot: number } {
42
+ let sl = 0
43
+ let sr = 0
44
+ let peak = 0
45
+ let hot = 0
46
+ for (let i = a; i < b; i++) {
47
+ const x = l[i]!
48
+ const y = r[i]!
49
+ sl += x * x
50
+ sr += y * y
51
+ const p = Math.max(Math.abs(x), Math.abs(y))
52
+ if (p > peak) peak = p
53
+ if (p >= HOT) hot++
54
+ }
55
+ const n = Math.max(1, b - a)
56
+ return { rms: Math.sqrt(Math.max(sl, sr) / n), peak, hot: hot / n }
57
+ }
58
+
59
+ /** a level as one line; `bad` names what is wrong with it */
60
+ export function levelLine(x: Level): { line: string; bad: 'silent' | 'hot' | null } {
61
+ const bad = x.notes > 0 && x.rms < SILENT_DB ? 'silent' : x.hot > HOT_SHARE ? 'hot' : null
62
+ const why = bad === 'silent' ? `: SILENT, ${x.notes} notes nobody hears` : bad === 'hot' ? `: HOT, ${(x.hot * 100).toFixed(1)}% of it at the ceiling, lower the score's volume or its loudest tracks` : ''
63
+ return { line: `score ${x.name.padEnd(14)} ${x.at.toFixed(2).padStart(6)} s ${x.seconds.toFixed(2).padStart(6)} s ${String(x.notes).padStart(4)} notes rms ${x.rms.toFixed(1).padStart(6)} dBFS peak ${x.peak.toFixed(1).padStart(6)} dBFS${why}`, bad }
64
+ }
65
+
66
+ /** 16 bit stereo PCM */
67
+ export function wavOf(r: Rendered): Buffer {
68
+ const n = r.left.length
69
+ const out = new Int16Array(n * 2)
70
+ for (let i = 0; i < n; i++) {
71
+ out[i * 2] = Math.round(Math.max(-1, Math.min(1, r.left[i]!)) * 32767)
72
+ out[i * 2 + 1] = Math.round(Math.max(-1, Math.min(1, r.right[i]!)) * 32767)
73
+ }
74
+ const pcm = Buffer.from(out.buffer)
75
+ const h = Buffer.alloc(44)
76
+ h.write('RIFF', 0)
77
+ h.writeUInt32LE(36 + pcm.length, 4)
78
+ h.write('WAVEfmt ', 8)
79
+ h.writeUInt32LE(16, 16)
80
+ h.writeUInt16LE(1, 20)
81
+ h.writeUInt16LE(2, 22)
82
+ h.writeUInt32LE(r.rate, 24)
83
+ h.writeUInt32LE(r.rate * 4, 28)
84
+ h.writeUInt16LE(4, 32)
85
+ h.writeUInt16LE(16, 34)
86
+ h.write('data', 36)
87
+ h.writeUInt32LE(pcm.length, 40)
88
+ return Buffer.concat([h, pcm])
89
+ }
90
+
91
+ /** the file the score's wav of `seconds` (rounded up) is kept in: its notes, instruments, room and the synth's code */
92
+ export function scoreWavFile(score: CompiledScore, seconds: number, dir: string): string {
93
+ const h = new Bun.CryptoHasher('sha1').update(JSON.stringify([score.instruments, score.reverb ?? null, score.notes, score.stop, score.sections, Math.ceil(seconds), RATE]))
94
+ for (const f of SYNTH) h.update(readFileSync(f))
95
+ return join(dir, `score-${h.digest('hex').slice(0, 16)}.wav`)
96
+ }
97
+
98
+ /** the score's wav of `seconds` (rounded up) in `dir`, rendered when missing; its levels kept beside it */
99
+ export function scoreWav(score: CompiledScore, seconds: number, dir: string): { file: string; made: boolean; levels: Level[]; ms: number } {
100
+ const s = Math.ceil(seconds)
101
+ const file = scoreWavFile(score, seconds, dir)
102
+ const meta = `${file}.levels.json`
103
+ if (existsSync(file) && existsSync(meta)) return { file, made: false, levels: JSON.parse(readFileSync(meta, 'utf8')) as Level[], ms: 0 }
104
+ const t0 = performance.now()
105
+ const r = renderScore(score, s)
106
+ const lv = levels(r, score.sections)
107
+ mkdirSync(dir, { recursive: true })
108
+ writeFileSync(`${file}.part`, wavOf(r))
109
+ writeFileSync(meta, JSON.stringify(lv))
110
+ renameSync(`${file}.part`, file)
111
+ return { file, made: true, levels: lv, ms: performance.now() - t0 }
112
+ }