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.
- package/CHANGELOG.md +50 -0
- package/package.json +2 -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 +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Commands.ts +163 -43
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +361 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +167 -0
- package/src/cli.ts +15 -2
- 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/layout.ts +12 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/reflect/prompt-corpus.ts +15 -114
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -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 +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -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 +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- 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/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- 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 +45 -13
- package/src/serve/hub-app/icons.tsx +9 -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/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -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 +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -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 +329 -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-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +12 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +78 -0
- package/src/trailer/build.ts +603 -0
- package/src/trailer/ffmpeg.ts +72 -0
- package/src/trailer/footage.ts +173 -0
- package/src/trailer/grammar.ts +1290 -0
- package/src/trailer/graph.ts +508 -0
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +31 -0
- package/src/trailer/look.ts +124 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +192 -0
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +233 -0
- package/src/trailer/titles.ts +379 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +59 -0
- 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
|
+
}
|