rvlib-shipkit 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +11 -2
  2. package/package.json +1 -1
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +4 -0
  5. package/src/cli/cmd_Review.ts +36 -10
  6. package/src/cli/cmd_Sound.ts +209 -0
  7. package/src/cli/cmd_Trailer.ts +7 -2
  8. package/src/cli/kit/verb.ts +11 -3
  9. package/src/cli.ts +2 -0
  10. package/src/core/app-icon.ts +10 -6
  11. package/src/repo-config.ts +3 -0
  12. package/src/review/format.ts +11 -3
  13. package/src/review/model.ts +47 -5
  14. package/src/review/project.ts +20 -4
  15. package/src/review/routes.ts +12 -1
  16. package/src/review/store.ts +15 -6
  17. package/src/review/unit.ts +19 -1
  18. package/src/serve/hub-app/admin-svc.ts +1 -1
  19. package/src/serve/hub-app/admin-view.tsx +1 -1
  20. package/src/serve/hub-app/hash-route.ts +12 -4
  21. package/src/serve/hub-app/home-st.ts +7 -1
  22. package/src/serve/hub-app/hub.css +38 -1
  23. package/src/serve/hub-app/icons.tsx +6 -0
  24. package/src/serve/hub-app/layout-st.ts +2 -2
  25. package/src/serve/hub-app/md.ts +32 -0
  26. package/src/serve/hub-app/project-list.css +9 -7
  27. package/src/serve/hub-app/project-list.tsx +5 -2
  28. package/src/serve/hub-app/projects-svc.ts +3 -2
  29. package/src/serve/hub-app/repo-st.ts +22 -7
  30. package/src/serve/hub-app/review/clip-st.ts +57 -7
  31. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  32. package/src/serve/hub-app/review/review-answer.tsx +83 -53
  33. package/src/serve/hub-app/review/review-page.tsx +9 -14
  34. package/src/serve/hub-app/review/review-st.ts +141 -18
  35. package/src/serve/hub-app/review/review-views.tsx +154 -9
  36. package/src/serve/hub-app/review.css +70 -10
  37. package/src/serve/hub-app/wiki-st.ts +190 -0
  38. package/src/serve/hub-app/wikis-view.tsx +134 -5
  39. package/src/serve/hub-runtime.ts +4 -4
  40. package/src/serve/project-gutter-route.ts +25 -1
  41. package/src/serve/project-gutter.ts +1 -1
  42. package/src/serve/serve-hub.ts +7 -0
  43. package/src/shot/client.ts +17 -5
  44. package/src/shot/shooter.ts +19 -3
  45. package/src/shot/shot-ask.ts +9 -1
  46. package/src/shot/stepped-clip.ts +53 -0
  47. package/src/trailer/adapter.ts +33 -4
  48. package/src/trailer/build.ts +170 -29
  49. package/src/trailer/fonts.ts +55 -0
  50. package/src/trailer/footage.ts +4 -4
  51. package/src/trailer/grammar.ts +429 -67
  52. package/src/trailer/graph.ts +130 -56
  53. package/src/trailer/look.ts +3 -0
  54. package/src/trailer/motion.ts +207 -0
  55. package/src/trailer/plan.ts +2 -2
  56. package/src/trailer/prune.ts +13 -2
  57. package/src/trailer/score/compose.ts +18 -2
  58. package/src/trailer/score/gm-render.ts +43 -0
  59. package/src/trailer/score/gm.ts +90 -0
  60. package/src/trailer/score/midi.ts +143 -0
  61. package/src/trailer/score/pattern.ts +13 -6
  62. package/src/trailer/score/score.ts +28 -14
  63. package/src/trailer/score/sound.ts +12 -1
  64. package/src/trailer/score/synth.ts +24 -2
  65. package/src/trailer/sound/audio.ts +148 -0
  66. package/src/trailer/sound/brand.ts +42 -0
  67. package/src/trailer/sound/comfy.ts +38 -0
  68. package/src/trailer/sound/eleven.ts +72 -0
  69. package/src/trailer/sound/fluid.ts +71 -0
  70. package/src/trailer/sound/gen.ts +139 -0
  71. package/src/trailer/sound/index.ts +60 -0
  72. package/src/trailer/sound/presets.ts +69 -0
  73. package/src/trailer/sound/run.ts +189 -0
  74. package/src/trailer/titles.ts +451 -235
  75. package/src/trailer/url.ts +18 -3
  76. package/src/trailer/vocab.ts +12 -1
  77. package/src/trailer/words.ts +29 -8
  78. package/src/wiki/graph-mount.ts +3 -0
  79. package/src/wiki/graph-page.ts +45 -9
  80. package/src/wiki/graph-pages.ts +128 -0
@@ -0,0 +1,90 @@
1
+ // the General MIDI instruments a score track plays through the soundfont: the 128 programs and the drum keys by name,
2
+ // and `gm()`, the instrument data a `gm:<name>` track or a code block names. Pure: FluidSynth renders them (gm-render.ts)
3
+ import type { InstrumentDef } from './pattern.ts'
4
+
5
+ /** the 128 programs in order, each a short kebab name */
6
+ export const GM_PROGRAMS = [
7
+ 'acoustic-grand', 'bright-acoustic', 'electric-grand', 'honky-tonk', 'electric-piano-1', 'electric-piano-2', 'harpsichord', 'clavinet',
8
+ 'celesta', 'glockenspiel', 'music-box', 'vibraphone', 'marimba', 'xylophone', 'tubular-bells', 'dulcimer',
9
+ 'drawbar-organ', 'percussive-organ', 'rock-organ', 'church-organ', 'reed-organ', 'accordion', 'harmonica', 'tango-accordion',
10
+ 'nylon-guitar', 'steel-guitar', 'jazz-guitar', 'clean-guitar', 'muted-guitar', 'overdrive-guitar', 'distortion-guitar', 'guitar-harmonics',
11
+ 'acoustic-bass', 'finger-bass', 'pick-bass', 'fretless-bass', 'slap-bass-1', 'slap-bass-2', 'synth-bass-1', 'synth-bass-2',
12
+ 'violin', 'viola', 'cello', 'contrabass', 'tremolo-strings', 'pizzicato-strings', 'harp', 'timpani',
13
+ 'strings', 'slow-strings', 'synth-strings-1', 'synth-strings-2', 'choir-aahs', 'voice-oohs', 'synth-choir', 'orchestra-hit',
14
+ 'trumpet', 'trombone', 'tuba', 'muted-trumpet', 'french-horn', 'brass-section', 'synth-brass-1', 'synth-brass-2',
15
+ 'soprano-sax', 'alto-sax', 'tenor-sax', 'baritone-sax', 'oboe', 'english-horn', 'bassoon', 'clarinet',
16
+ 'piccolo', 'flute', 'recorder', 'pan-flute', 'blown-bottle', 'shakuhachi', 'whistle', 'ocarina',
17
+ 'square-lead', 'saw-lead', 'calliope-lead', 'chiff-lead', 'charang-lead', 'voice-lead', 'fifths-lead', 'bass-lead',
18
+ 'new-age-pad', 'warm-pad', 'polysynth-pad', 'choir-pad', 'bowed-pad', 'metallic-pad', 'halo-pad', 'sweep-pad',
19
+ 'rain-fx', 'soundtrack-fx', 'crystal-fx', 'atmosphere-fx', 'brightness-fx', 'goblins-fx', 'echoes-fx', 'sci-fi-fx',
20
+ 'sitar', 'banjo', 'shamisen', 'koto', 'kalimba', 'bagpipe', 'fiddle', 'shanai',
21
+ 'tinkle-bell', 'agogo', 'steel-drums', 'woodblock', 'taiko-drum', 'melodic-tom', 'synth-drum', 'reverse-cymbal',
22
+ 'guitar-fret-noise', 'breath-noise', 'seashore', 'bird-tweet', 'telephone-ring', 'helicopter', 'applause', 'gunshot',
23
+ ] as const
24
+
25
+ /** the drum keys of channel 10 (35 to 81), by name */
26
+ export const GM_DRUMS: Readonly<Record<string, number>> = {
27
+ 'kick-2': 35, kick: 36, 'side-stick': 37, snare: 38, clap: 39, 'electric-snare': 40, 'low-floor-tom': 41, 'closed-hat': 42,
28
+ 'high-floor-tom': 43, 'pedal-hat': 44, 'low-tom': 45, 'open-hat': 46, 'low-mid-tom': 47, 'high-mid-tom': 48, crash: 49, 'high-tom': 50,
29
+ ride: 51, china: 52, 'ride-bell': 53, tambourine: 54, splash: 55, cowbell: 56, 'crash-2': 57, vibraslap: 58,
30
+ 'ride-2': 59, 'high-bongo': 60, 'low-bongo': 61, 'mute-high-conga': 62, 'open-high-conga': 63, 'low-conga': 64, 'high-timbale': 65, 'low-timbale': 66,
31
+ 'high-agogo': 67, 'low-agogo': 68, cabasa: 69, maracas: 70, 'short-whistle': 71, 'long-whistle': 72, 'short-guiro': 73, 'long-guiro': 74,
32
+ claves: 75, 'high-woodblock': 76, 'low-woodblock': 77, 'mute-cuica': 78, 'open-cuica': 79, 'mute-triangle': 80, 'open-triangle': 81,
33
+ }
34
+
35
+ /** the drum kits of bank 128 by program: standard 0, room 8, power 16, electronic 24, 808 25, jazz 32, brush 40, orchestra 48 */
36
+ export const GM_KITS = { standard: 0, room: 8, power: 16, electronic: 24, tr808: 25, jazz: 32, brush: 40, orchestra: 48 } as const
37
+
38
+ /** a GM program or a drum of a kit */
39
+ export interface GmVoice {
40
+ program: number
41
+ /** a drum: its key on channel 10, `program` its kit */
42
+ drum?: number
43
+ }
44
+
45
+ export interface GmOptions {
46
+ /** 0..1, default 0.8 */
47
+ vol?: number
48
+ pan?: number
49
+ reverb?: number
50
+ /** a drum's kit: a number or a name of GM_KITS */
51
+ kit?: number | keyof typeof GM_KITS
52
+ }
53
+
54
+ /** a program or a drum by name or number: `acoustic-grand`, 48, `kick`; null when no such name */
55
+ export function gmVoice(name: string | number, kit: GmOptions['kit'] = 0): GmVoice | null {
56
+ const k = typeof kit === 'number' ? kit : GM_KITS[kit]
57
+ if (typeof name === 'number' || /^\d+$/.test(name)) {
58
+ const n = Number(name)
59
+ return Number.isInteger(n) && n >= 0 && n < 128 ? { program: n } : null
60
+ }
61
+ const p = (GM_PROGRAMS as readonly string[]).indexOf(name)
62
+ if (p >= 0) return { program: p }
63
+ const d = GM_DRUMS[name]
64
+ return d === undefined || k === undefined ? null : { program: k, drum: d }
65
+ }
66
+
67
+ /** the instrument a track plays through the soundfont: `gm('strings', { vol: 0.7, pan: -0.3 })`, `gm(48)`, `gm('kick', { kit: 'brush' })` */
68
+ export function gm(name: string | number, o: GmOptions = {}): InstrumentDef {
69
+ const v = gmVoice(name, o.kit)
70
+ if (!v) throw new Error(`gm ${name}: no General MIDI program or drum of that name (sk sound gm lists them)`)
71
+ return {
72
+ osc: 'gm',
73
+ gm: v,
74
+ vol: o.vol ?? 0.8,
75
+ env: { a: 0, d: 0, s: 1, r: 0 },
76
+ ...(o.pan !== undefined ? { pan: o.pan } : {}),
77
+ ...(o.reverb !== undefined ? { reverb: o.reverb } : {}),
78
+ }
79
+ }
80
+
81
+ /** the GM name an instrument name `gm:<name>` holds, else null */
82
+ export const gmName = (instrument: string): string | null => (instrument.startsWith('gm:') ? instrument.slice(3) : null)
83
+
84
+ /** the programs and drums as lines: what `sk sound gm` prints */
85
+ export function gmTable(): string[] {
86
+ const rows = GM_PROGRAMS.map((n, i) => `${String(i).padStart(3)} ${n}`)
87
+ const drums = Object.entries(GM_DRUMS).map(([n, k]) => `${String(k).padStart(3)} ${n}`)
88
+ const kits = Object.entries(GM_KITS).map(([n, k]) => `${String(k).padStart(3)} ${n}`)
89
+ return ['programs (track x gm:<name>, gm(<name or number>)):', ...rows, '', 'drums (drums k=gm:<name>, gm(<name>, { kit })):', ...drums, '', 'kits (gm(<drum>, { kit: <name or number> })):', ...kits]
90
+ }
@@ -0,0 +1,143 @@
1
+ // the score as a Standard MIDI File: `scoreMidi` writes the whole score for a DAW (a track per score track, the
2
+ // sections' tempos and meters), `stemMidi` one GM instrument's notes at a fixed clock for FluidSynth to render.
3
+ // Pure: bytes out, no file here
4
+ import type { ScheduledNote, CompiledScore } from './score.ts'
5
+ import type { InstrumentDef } from './pattern.ts'
6
+ import { SCORE_LEAD } from '../grammar.ts'
7
+
8
+ interface Ev {
9
+ tick: number
10
+ /** a sort key inside one tick: note offs first, then meta and program changes, then note ons */
11
+ order: number
12
+ data: number[]
13
+ }
14
+
15
+ const vlq = (n: number): number[] => {
16
+ const out = [n & 0x7f]
17
+ for (let v = n >> 7; v > 0; v >>= 7) out.unshift((v & 0x7f) | 0x80)
18
+ return out
19
+ }
20
+
21
+ const u32 = (n: number): number[] => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]
22
+
23
+ function chunk(type: string, body: number[]): number[] {
24
+ return [...Buffer.from(type, 'latin1'), ...u32(body.length), ...body]
25
+ }
26
+
27
+ function trackChunk(events: Ev[], name: string | null, end: number): number[] {
28
+ const sorted = [...events].sort((a, b) => a.tick - b.tick || a.order - b.order)
29
+ const body: number[] = []
30
+ let at = 0
31
+ if (name !== null) {
32
+ const bytes = [...Buffer.from(name, 'utf8')]
33
+ body.push(0, 0xff, 0x03, ...vlq(bytes.length), ...bytes)
34
+ }
35
+ for (const e of sorted) {
36
+ body.push(...vlq(Math.max(0, e.tick - at)), ...e.data)
37
+ at = Math.max(at, e.tick)
38
+ }
39
+ body.push(...vlq(Math.max(0, end - at)), 0xff, 0x2f, 0x00)
40
+ return chunk('MTrk', body)
41
+ }
42
+
43
+ /** a type 1 file: the conductor track, then each track */
44
+ function smf(ppq: number, conductor: Ev[], tracks: { name: string; events: Ev[] }[], end: number): Uint8Array {
45
+ const head = chunk('MThd', [0, 1, 0, tracks.length + 1, (ppq >> 8) & 0xff, ppq & 0xff])
46
+ return new Uint8Array([...head, ...trackChunk(conductor, null, end), ...tracks.flatMap((t) => trackChunk(t.events, t.name, end))])
47
+ }
48
+
49
+ const velocity = (vol: number): number => Math.max(1, Math.min(127, Math.round(127 * vol)))
50
+
51
+ /** what a synth instrument of the default palette is written as: its nearest GM program, a drum's key */
52
+ const NEAREST: Readonly<Record<string, number>> = {
53
+ lead: 80, lead25: 80, flute: 73, ocarina: 79, reed: 71, keys: 4, glock: 9, cello: 42, bell: 14, pluck: 45, arp: 81, harmony: 81,
54
+ pad: 89, strings: 48, brass: 61, bass: 33, bassPulse: 38, bassSaw: 39, sub: 38,
55
+ }
56
+ const NEAREST_DRUM: Readonly<Record<string, number>> = { kick: 36, snare: 38, clap: 39, hat: 42, openhat: 46, crash: 49, tom: 45, shaker: 70 }
57
+
58
+ /** a note as the file writes it: a drum on channel 10 at its key, else a program */
59
+ function gmOf(name: string, inst: InstrumentDef | undefined, drum: boolean): { drum: true; key: number; kit: number } | { drum: false; program: number } {
60
+ if (inst?.gm) return inst.gm.drum !== undefined ? { drum: true, key: inst.gm.drum, kit: inst.gm.program } : { drum: false, program: inst.gm.program }
61
+ if (drum || inst?.osc === 'noise') return { drum: true, key: inst?.midi ?? NEAREST_DRUM[name] ?? 38, kit: 0 }
62
+ return { drum: false, program: inst?.midi ?? NEAREST[name] ?? 80 }
63
+ }
64
+
65
+ const PPQ = 480
66
+
67
+ /** the whole score as a .mid: each score track a MIDI track, the sections' tempos and meters on the conductor track,
68
+ * the score's time from its first section on; a note past the stop is cut there */
69
+ export function scoreMidi(score: CompiledScore): Uint8Array {
70
+ const secs = score.sections
71
+ // each section's first tick, the score clock starting at the first section
72
+ const starts: { at: number; tick: number; bpm: number }[] = []
73
+ let tick = 0
74
+ for (const s of secs) {
75
+ starts.push({ at: s.at, tick, bpm: s.bpm })
76
+ tick += Math.round((s.seconds * s.bpm * PPQ) / 60)
77
+ }
78
+ const tickOf = (time: number): number => {
79
+ let k = 0
80
+ while (k + 1 < starts.length && starts[k + 1]!.at <= time + 1e-9) k++
81
+ const s = starts[k] ?? { at: SCORE_LEAD, tick: 0, bpm: 120 }
82
+ return Math.max(0, s.tick + Math.round(((time - s.at) * s.bpm * PPQ) / 60))
83
+ }
84
+ const stop = score.stop ?? Number.POSITIVE_INFINITY
85
+ const end = Math.min(tick, tickOf(stop))
86
+ const conductor: Ev[] = []
87
+ let meter = 0
88
+ let bpm = 0
89
+ for (const [i, s] of secs.entries()) {
90
+ const t = starts[i]!.tick
91
+ if (s.bpm !== bpm) conductor.push({ tick: t, order: 1, data: [0xff, 0x51, 0x03, ...u32(Math.round(60_000_000 / s.bpm)).slice(1)] })
92
+ if (s.meter !== meter) conductor.push({ tick: t, order: 1, data: [0xff, 0x58, 0x04, s.meter, 2, 24, 8] })
93
+ bpm = s.bpm
94
+ meter = s.meter
95
+ }
96
+ const byTrack = new Map<string, ScheduledNote[]>()
97
+ for (const n of score.notes) {
98
+ if (n.time >= stop) continue
99
+ byTrack.set(n.track, [...(byTrack.get(n.track) ?? []), n])
100
+ }
101
+ let next = 0
102
+ const tracks = [...byTrack.entries()].map(([name, notes]) => {
103
+ const channel = next === 9 ? ++next : next
104
+ next = Math.min(15, channel + 1)
105
+ const events: Ev[] = []
106
+ const program = new Map<number, number>()
107
+ for (const n of notes) {
108
+ const inst = score.instruments[n.instrument]
109
+ const g = gmOf(n.instrument, inst, n.midi === null)
110
+ const ch = g.drum ? 9 : channel
111
+ const want = g.drum ? g.kit : g.program
112
+ const a = tickOf(n.time)
113
+ if (program.get(ch) !== want && !(g.drum && want === 0 && !program.has(ch))) events.push({ tick: a, order: 1, data: [0xc0 | ch, want] })
114
+ program.set(ch, want)
115
+ const key = g.drum ? g.key : n.midi!
116
+ const b = Math.max(a + 1, Math.min(end, tickOf(Math.min(stop, n.time + n.dur))))
117
+ events.push({ tick: a, order: 2, data: [0x90 | ch, key, velocity(n.vol)] })
118
+ events.push({ tick: b, order: 0, data: [0x80 | ch, key, 0] })
119
+ }
120
+ return { name, events }
121
+ })
122
+ return smf(PPQ, conductor, tracks, end)
123
+ }
124
+
125
+ /** the clock a stem is written at: 120 bpm, so a tick is 1/1920 s */
126
+ const STEM_PPQ = 960
127
+ const STEM_TPS = (120 * STEM_PPQ) / 60
128
+
129
+ /** one GM instrument's notes as a .mid at a fixed clock, its time the render's (lead counted), cut at `end` seconds */
130
+ export function stemMidi(notes: readonly ScheduledNote[], v: { program: number; drum?: number }, end: number): Uint8Array {
131
+ const ch = v.drum !== undefined ? 9 : 0
132
+ const t = (s: number): number => Math.round(Math.min(s, end) * STEM_TPS)
133
+ const events: Ev[] = [{ tick: 0, order: 1, data: [0xc0 | ch, v.program] }]
134
+ for (const n of notes) {
135
+ if (n.time >= end) continue
136
+ const key = v.drum ?? n.midi ?? 60
137
+ const a = t(n.time)
138
+ events.push({ tick: a, order: 2, data: [0x90 | ch, key, velocity(n.vol)] })
139
+ events.push({ tick: Math.max(a + 1, t(n.time + n.dur)), order: 0, data: [0x80 | ch, key, 0] })
140
+ }
141
+ const conductor: Ev[] = [{ tick: 0, order: 1, data: [0xff, 0x51, 0x03, ...u32(500_000).slice(1)] }]
142
+ return smf(STEM_PPQ, conductor, [{ name: 'stem', events }], t(end))
143
+ }
@@ -1,8 +1,9 @@
1
1
  // the score's note grammar and its instruments: one token a grid row, an instrument as plain data the synth plays.
2
2
  // Pure: no audio here (synth.ts renders)
3
3
 
4
- /** oscillator families: chiptune roots (pulse, wave, noise) plus the smoother modern ones */
5
- export type Osc = 'pulse' | 'saw' | 'triangle' | 'sine' | 'wave' | 'noise'
4
+ /** oscillator families: chiptune roots (pulse, wave, noise) plus the smoother modern ones; `gm` a General MIDI
5
+ * program the soundfont plays (gm.ts) */
6
+ export type Osc = 'pulse' | 'saw' | 'triangle' | 'sine' | 'wave' | 'noise' | 'gm'
6
7
 
7
8
  export interface InstrumentDef {
8
9
  osc: Osc
@@ -34,14 +35,19 @@ export interface InstrumentDef {
34
35
  sweep?: { semis: number; time: number }
35
36
  /** the midi note a drum letter plays this instrument at, default 60 */
36
37
  pitch?: number
38
+ /** gm only: the program, or a drum key of a kit, the soundfont plays */
39
+ gm?: { program: number; drum?: number }
40
+ /** what the score's .mid writes it as: a GM program, a drum's key for a drum instrument */
41
+ midi?: number
37
42
  }
38
43
 
39
44
  // one token a row, whitespace separated, '|' ignored:
40
45
  // C4 C#4 Db4 note (octave 0..8) . hold the previous note
41
46
  // - silence k drum letter (from `drums`)
47
+ // D4+F4+A4 a chord: its notes start and hold together
42
48
  // zero length modifiers glued before a row token: `@lead:C4` switches instrument, `v8:C4` sets the volume 0..15
43
49
  // (15 = the instrument's vol) from that note on
44
- type Row = { kind: 'note'; midi: number } | { kind: 'hold' } | { kind: 'off' } | { kind: 'drum'; letter: string }
50
+ type Row = { kind: 'note'; midi: number; chord?: number[] } | { kind: 'hold' } | { kind: 'off' } | { kind: 'drum'; letter: string }
45
51
 
46
52
  export interface RowEvent {
47
53
  row: Row
@@ -81,9 +87,10 @@ export function parsePattern(text: string, drums: Record<string, string> = {}):
81
87
  else if (body === '-') ev.row = { kind: 'off' }
82
88
  else if (drums[body] !== undefined) ev.row = { kind: 'drum', letter: body }
83
89
  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 }
90
+ const ms = body.split('+').map(noteToMidi)
91
+ if (ms.some((m) => m === null)) throw new Error(`pattern token "${raw}": not a note, a chord (D4+F4+A4), a drum letter, '.' or '-'`)
92
+ const [midi, ...more] = ms as number[]
93
+ ev.row = more.length > 0 ? { kind: 'note', midi: midi!, chord: more } : { kind: 'note', midi: midi! }
87
94
  }
88
95
  out.push(ev)
89
96
  }
@@ -4,6 +4,7 @@
4
4
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
5
5
  import { join } from 'node:path'
6
6
  import { type Score, SCORE_LEAD, type Section, type TrailerError } from '../grammar.ts'
7
+ import { gm, gmName } from './gm.ts'
7
8
  import { INST } from './palette.ts'
8
9
  import { type InstrumentDef, parsePattern, type RowEvent } from './pattern.ts'
9
10
 
@@ -34,14 +35,15 @@ export interface CompiledScore {
34
35
  /** where it cuts dead, its lead counted; null: it rings out */
35
36
  stop: number | null
36
37
  /** each section's span in the render, its lead counted */
37
- sections: { name: string; at: number; seconds: number; notes: number }[]
38
+ sections: { name: string; at: number; seconds: number; notes: number; bpm: number; meter: number }[]
38
39
  }
39
40
 
40
41
  const here = import.meta.dir
41
42
  // the helpers every `code` block and `play` line reads without importing them
42
43
  const PRELUDE = [
43
- `import { arp, bass, chart, mel, midiToNote, parseChord, rep, seq, transpose, voice } from ${JSON.stringify(join(here, 'compose.ts'))}`,
44
+ `import { arp, bass, chart, chords, mel, midiToNote, parseChord, rep, seq, transpose, voice } from ${JSON.stringify(join(here, 'compose.ts'))}`,
44
45
  `import { additive, INST, KIT } from ${JSON.stringify(join(here, 'palette.ts'))}`,
46
+ `import { gm } from ${JSON.stringify(join(here, 'gm.ts'))}`,
45
47
  "const rest = (n: number): string => Array.from({ length: n }, () => '-').join(' ')",
46
48
  'export const __plays: (() => unknown)[] = []',
47
49
  'const __play = (i: number, f: () => unknown): void => void (__plays[i] = f)',
@@ -107,23 +109,34 @@ export function compileScore(s: Score, sections: readonly Section[], dir: string
107
109
  const instruments = mod.instruments ?? INST
108
110
  if (typeof instruments !== 'object' || instruments === null)
109
111
  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 inst: Record<string, InstrumentDef> = { ...(instruments as Record<string, InstrumentDef>) }
113
+ const names = (): string => `${Object.keys(inst).filter((k) => !gmName(k)).join(', ')}, or gm:<name> (sk sound gm)`
114
+ // a `gm:<name>` track or drum is the General MIDI instrument of that name
115
+ for (const name of [...s.tracks.map((tr) => tr.instrument), ...Object.values(s.drums), ...sections.flatMap((sec) => Object.values(sec.drums))]) {
116
+ const g = gmName(name)
117
+ if (g === null || inst[name]) continue
118
+ try {
119
+ inst[name] = gm(g)
120
+ } catch {
121
+ // an unknown name stays missing: the checks below name it at its line
122
+ }
123
+ }
112
124
  const values = new Map(plays.map((pl, i) => [pl, mod.__plays[i]]))
113
125
 
114
126
  const tracks = s.tracks
115
127
  for (const tr of tracks) if (!inst[tr.instrument]) errors.push({ line: tr.line, message: `track ${tr.name}: no instrument ${tr.instrument} (${names()})` })
116
128
  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
129
 
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 }]))
130
+ const state = new Map(tracks.map((tr) => [tr.name, { instrument: tr.instrument, vol: 15, open: [] as ScheduledNote[], prev: null as number | null }]))
119
131
  const notes: ScheduledNote[] = []
120
132
  const spans: CompiledScore['sections'] = []
121
133
  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
134
+ const close = (st: { open: ScheduledNote[] }, time: number, row: number): void => {
135
+ for (const n of st.open) {
136
+ n.dur = time - n.time
137
+ n.rows = row - n.row
138
+ }
139
+ st.open = []
127
140
  }
128
141
  for (const sec of sections) {
129
142
  const { bpm, grid, meter } = sec.clock ?? s.clock
@@ -132,7 +145,7 @@ export function compileScore(s: Score, sections: readonly Section[], dir: string
132
145
  const spr = 60 / bpm / grid
133
146
  const bars = (n: number): string => `${+(n / grid / meter).toFixed(3)} bars`
134
147
  const before = notes.length
135
- spans.push({ name: sec.name, at: SCORE_LEAD + sec.at, seconds: sec.seconds, notes: 0 })
148
+ spans.push({ name: sec.name, at: SCORE_LEAD + sec.at, seconds: sec.seconds, notes: 0, bpm, meter })
136
149
  if (Math.abs(rowsExact - rows) > 1e-6) {
137
150
  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
151
  continue
@@ -192,12 +205,13 @@ export function compileScore(s: Score, sections: readonly Section[], dir: string
192
205
  close(st, time, row0 + r)
193
206
  if (k.kind === 'off') return
194
207
  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 }
208
+ 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
209
  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 }
210
+ const one = (midi: number, from: number | null): ScheduledNote => ({ track: tr.name, row: row0 + r, rows: 1, midi, instrument: st.instrument, vol, from, time, dur: spr })
211
+ st.open = [one(k.midi, st.prev), ...(k.chord ?? []).map((m) => one(m, null))]
198
212
  st.prev = k.midi
199
213
  }
200
- notes.push(st.open)
214
+ notes.push(...st.open)
201
215
  })
202
216
  }
203
217
  spans.at(-1)!.notes = notes.length - before
@@ -2,11 +2,18 @@
2
2
  // like as numbers: each section's loudness and peak, since nobody hears a build. Silence under notes refuses
3
3
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
+ import { fluidIdentity } from '../sound/fluid.ts'
6
+ import { gmInstruments, gmStems } from './gm-render.ts'
5
7
  import type { CompiledScore } from './score.ts'
6
8
  import { HOT, RATE, type Rendered, renderScore } from './synth.ts'
7
9
 
8
10
  /** the synth's sources: an edit to any renders every score again */
9
11
  const SYNTH = ['synth.ts', 'dsp.ts', 'pattern.ts'].map((f) => join(import.meta.dir, f))
12
+ /** what renders a GM track besides: read only by a score that has one */
13
+ const GM = ['gm-render.ts', 'midi.ts', '../sound/fluid.ts'].map((f) => join(import.meta.dir, f))
14
+
15
+ /** where the render stops playing notes: its length, or the stop before it */
16
+ const endOf = (score: CompiledScore, seconds: number): number => Math.min(Math.ceil(seconds), score.stop ?? Number.POSITIVE_INFINITY)
10
17
 
11
18
  export interface Level {
12
19
  name: string
@@ -92,6 +99,10 @@ export function wavOf(r: Rendered): Buffer {
92
99
  export function scoreWavFile(score: CompiledScore, seconds: number, dir: string): string {
93
100
  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
101
  for (const f of SYNTH) h.update(readFileSync(f))
102
+ if (gmInstruments(score, endOf(score, seconds)).length > 0) {
103
+ h.update(fluidIdentity())
104
+ for (const f of GM) h.update(readFileSync(f))
105
+ }
95
106
  return join(dir, `score-${h.digest('hex').slice(0, 16)}.wav`)
96
107
  }
97
108
 
@@ -102,7 +113,7 @@ export function scoreWav(score: CompiledScore, seconds: number, dir: string): {
102
113
  const meta = `${file}.levels.json`
103
114
  if (existsSync(file) && existsSync(meta)) return { file, made: false, levels: JSON.parse(readFileSync(meta, 'utf8')) as Level[], ms: 0 }
104
115
  const t0 = performance.now()
105
- const r = renderScore(score, s)
116
+ const r = renderScore(score, s, RATE, gmStems(score, endOf(score, s), RATE))
106
117
  const lv = levels(r, score.sections)
107
118
  mkdirSync(dir, { recursive: true })
108
119
  writeFileSync(`${file}.part`, wavOf(r))
@@ -4,6 +4,7 @@
4
4
  // score sounds alike in either. No browser, no audio device
5
5
  import { applyEnvelope, biquad, compress, convolveInto, envPoints, softClip } from './dsp.ts'
6
6
  import { type InstrumentDef, midiToHz } from './pattern.ts'
7
+ import type { Stem } from './gm-render.ts'
7
8
  import type { CompiledScore, ScheduledNote } from './score.ts'
8
9
 
9
10
  export const RATE = 48000
@@ -308,6 +309,26 @@ function mixRun(buf: Float32Array, n: number, at: number, l: Float32Array, r: Fl
308
309
  }
309
310
  }
310
311
 
312
+ /** a GM stem into the bus at its instrument's volume and pan, and its share into the room */
313
+ function mixStem(bus: Bus, st: Stem, inst: InstrumentDef | undefined): void {
314
+ if (!inst) return
315
+ const n = Math.min(bus.l.length, st.left.length)
316
+ const pan = inst.pan !== undefined && inst.pan !== 0 ? (Math.max(-1, Math.min(1, inst.pan)) + 1) / 2 : null
317
+ const gl = inst.vol * (pan === null ? 1 : Math.SQRT2 * Math.cos((pan * Math.PI) / 2))
318
+ const gr = inst.vol * (pan === null ? 1 : Math.SQRT2 * Math.sin((pan * Math.PI) / 2))
319
+ const send = bus.sendL && inst.reverb && inst.reverb > 0 ? inst.reverb : 0
320
+ for (let i = 0; i < n; i++) {
321
+ const l = st.left[i]! * gl
322
+ const r = st.right[i]! * gr
323
+ bus.l[i] = bus.l[i]! + l
324
+ bus.r[i] = bus.r[i]! + r
325
+ if (send > 0) {
326
+ bus.sendL![i] = bus.sendL![i]! + l * send
327
+ bus.sendR![i] = bus.sendR![i]! + r * send
328
+ }
329
+ }
330
+ }
331
+
311
332
  /** the gain over the render: whole, then a dead stop or the closing fade at the end */
312
333
  function closeRun(l: Float32Array, r: Float32Array, rate: number, end: number, fade: number): void {
313
334
  const a = Math.max(0, Math.floor(end * rate))
@@ -321,7 +342,7 @@ function closeRun(l: Float32Array, r: Float32Array, rate: number, end: number, f
321
342
 
322
343
  /** `seconds` of the score from its start, rendered with a tail of TAIL_S: the notes starting before its end (and before
323
344
  * 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 {
345
+ export function renderScore(score: CompiledScore, seconds: number, rate = RATE, stems: readonly Stem[] = []): Rendered {
325
346
  const n = Math.ceil(rate * (seconds + TAIL_S))
326
347
  const room = score.reverb && score.reverb.mix > 0 ? score.reverb : null
327
348
  const bus: Bus = { l: new Float32Array(n), r: new Float32Array(n), sendL: room ? new Float32Array(n) : null, sendR: room ? new Float32Array(n) : null }
@@ -330,8 +351,9 @@ export function renderScore(score: CompiledScore, seconds: number, rate = RATE):
330
351
  for (const note of score.notes) {
331
352
  if (note.time >= end) break
332
353
  const inst = score.instruments[note.instrument]
333
- if (inst) playNote(bus, waves, inst, note)
354
+ if (inst && inst.osc !== 'gm') playNote(bus, waves, inst, note)
334
355
  }
356
+ for (const st of stems) mixStem(bus, st, score.instruments[st.instrument])
335
357
  if (room) {
336
358
  const ir = impulse(room.seconds, rate)
337
359
  convolveInto(bus.sendL!, bus.sendR!, ir[0], ir[1], bus.l, bus.r, impulseScale(ir, rate) * Math.min(1, room.mix * 1.6))
@@ -0,0 +1,148 @@
1
+ // what a made sound is, as numbers and one picture, since nobody hears a build: its length, peak, loudness, onsets
2
+ // and its waveform png; and the trims a generator applies (the silence before, the tail, the first hit). ffmpeg does
3
+ // the reading and the writing (trailer/ffmpeg.ts)
4
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { ffmpeg, loudness } from '../ffmpeg.ts'
8
+
9
+ export const SOUND_RATE = 48000
10
+ /** the envelope's window */
11
+ const WIN_S = 0.01
12
+
13
+ /** the file's samples, both channels, at SOUND_RATE */
14
+ export async function decode(file: string): Promise<{ left: Float32Array; right: Float32Array }> {
15
+ const dir = mkdtempSync(join(tmpdir(), 'sk-sound-'))
16
+ try {
17
+ const raw = join(dir, 'pcm.f32')
18
+ await ffmpeg(['-y', '-v', 'error', '-i', file, '-ac', '2', '-ar', String(SOUND_RATE), '-f', 'f32le', raw])
19
+ const b = readFileSync(raw)
20
+ const all = new Float32Array(b.buffer, b.byteOffset, Math.floor(b.byteLength / 4))
21
+ const n = all.length >> 1
22
+ const left = new Float32Array(n)
23
+ const right = new Float32Array(n)
24
+ for (let i = 0; i < n; i++) {
25
+ left[i] = all[i * 2]!
26
+ right[i] = all[i * 2 + 1]!
27
+ }
28
+ return { left, right }
29
+ } finally {
30
+ rmSync(dir, { recursive: true, force: true })
31
+ }
32
+ }
33
+
34
+ const db = (x: number): number => (x > 1e-9 ? 20 * Math.log10(x) : -180)
35
+
36
+ /** each window's RMS in dBFS, the louder channel */
37
+ export function envelope(s: { left: Float32Array; right: Float32Array }): number[] {
38
+ const w = Math.round(WIN_S * SOUND_RATE)
39
+ const out: number[] = []
40
+ for (let a = 0; a < s.left.length; a += w) {
41
+ let l = 0
42
+ let r = 0
43
+ const b = Math.min(s.left.length, a + w)
44
+ for (let i = a; i < b; i++) {
45
+ l += s.left[i]! ** 2
46
+ r += s.right[i]! ** 2
47
+ }
48
+ out.push(db(Math.sqrt(Math.max(l, r) / Math.max(1, b - a))))
49
+ }
50
+ return out
51
+ }
52
+
53
+ /** the sample peak in dBFS */
54
+ export function peakOf(s: { left: Float32Array; right: Float32Array }): number {
55
+ let p = 0
56
+ for (let i = 0; i < s.left.length; i++) p = Math.max(p, Math.abs(s.left[i]!), Math.abs(s.right[i]!))
57
+ return db(p)
58
+ }
59
+
60
+ /** the windows a new sound starts on: a rise of 9 dB over the 50 ms before, within 35 dB of the loudest window, 80 ms
61
+ * after the last onset at least */
62
+ export function onsets(env: readonly number[]): number[] {
63
+ const top = Math.max(...env)
64
+ const out: number[] = []
65
+ for (let i = 0; i < env.length; i++) {
66
+ if (env[i]! < top - 35) continue
67
+ const before = i === 0 ? -180 : Math.min(...env.slice(Math.max(0, i - 5), i))
68
+ if (env[i]! - before < 9) continue
69
+ if (out.length > 0 && i - out.at(-1)! < 8) continue
70
+ out.push(i)
71
+ }
72
+ return out
73
+ }
74
+
75
+ export interface Trim {
76
+ start: number
77
+ end: number
78
+ /** seconds of fade at the end */
79
+ fade: number
80
+ }
81
+
82
+ /** where a made sound starts and ends: the silence before cut (40 dB under its loudest window), the tail cut where it
83
+ * stays `tailDb` under it; `hit`: only its first hit, also cut just before the next onset, its fade a share of it */
84
+ export function trimOf(env: readonly number[], o: { tailDb: number; hit?: boolean }): Trim {
85
+ const top = Math.max(...env)
86
+ const first = Math.max(0, env.findIndex((x) => x >= top - 40) - 1)
87
+ let last = env.length - 1
88
+ while (last > first && env[last]! < top - o.tailDb) last--
89
+ if (!o.hit) return { start: first * WIN_S, end: (last + 1) * WIN_S, fade: 0.03 }
90
+ const next = onsets(env).find((i) => i > first + 2)
91
+ if (next !== undefined) last = Math.min(last, next - 2)
92
+ // the quiet floor between the hit and the next one is tail too
93
+ while (last > first && env[last]! < top - o.tailDb) last--
94
+ const len = (last + 1 - first) * WIN_S
95
+ return { start: first * WIN_S, end: (last + 1) * WIN_S, fade: Math.min(0.12, Math.max(0.02, len * 0.15)) }
96
+ }
97
+
98
+ /** the trimmed sound written as a 48 kHz 16 bit stereo wav */
99
+ export async function writeTrimmed(src: string, out: string, t: Trim): Promise<void> {
100
+ const len = Math.max(0.02, t.end - t.start)
101
+ const fade = Math.min(t.fade, len / 2)
102
+ const af = [`atrim=${t.start.toFixed(3)}:${t.end.toFixed(3)}`, 'asetpts=PTS-STARTPTS', `afade=t=out:st=${(len - fade).toFixed(3)}:d=${fade.toFixed(3)}`].join(',')
103
+ await ffmpeg(['-y', '-v', 'error', '-i', src, '-af', af, '-ac', '2', '-ar', String(SOUND_RATE), '-c:a', 'pcm_s16le', out])
104
+ }
105
+
106
+ /** the true peak no made sound passes */
107
+ const CEILING_DBTP = -1.5
108
+
109
+ /** the trimmed sound written at its level: `lufs` its integrated loudness (a sound too short to measure, under 0.4 s,
110
+ * set by its peak at -3 dBFS), `peak` its sample peak; never past -1.5 dBTP. A measured static gain, so a short line
111
+ * lands where a long one does */
112
+ export async function finish(src: string, out: string, t: Trim, level: { lufs: number } | { peak: number }): Promise<void> {
113
+ const trimmed = `${out}.trim.wav`
114
+ try {
115
+ await writeTrimmed(src, trimmed, t)
116
+ const peak = peakOf(await decode(trimmed))
117
+ const m = await loudness(trimmed)
118
+ const gain = 'peak' in level ? Math.min(level.peak - peak, CEILING_DBTP - m.truePeak) : m.lufs > -70 && t.end - t.start >= 0.4 ? Math.min(level.lufs - m.lufs, CEILING_DBTP - m.truePeak) : -3 - peak
119
+ await ffmpeg(['-y', '-v', 'error', '-i', trimmed, '-af', `volume=${gain.toFixed(2)}dB`, '-c:a', 'pcm_s16le', out])
120
+ } finally {
121
+ rmSync(trimmed, { force: true })
122
+ }
123
+ }
124
+
125
+ export interface Measures {
126
+ seconds: number
127
+ peak: number
128
+ lufs: number
129
+ /** seconds of each onset */
130
+ onsets: number[]
131
+ }
132
+
133
+ /** a sound as numbers */
134
+ export async function measure(file: string): Promise<Measures> {
135
+ const s = await decode(file)
136
+ const env = envelope(s)
137
+ const { lufs } = await loudness(file)
138
+ return { seconds: +(s.left.length / SOUND_RATE).toFixed(3), peak: +peakOf(s).toFixed(1), lufs: +lufs.toFixed(1), onsets: onsets(env).map((i) => +(i * WIN_S).toFixed(2)) }
139
+ }
140
+
141
+ /** the measures as one line */
142
+ export const measureLine = (m: Measures): string =>
143
+ `${m.seconds.toFixed(2)} s, peak ${m.peak.toFixed(1)} dBFS, ${Number.isFinite(m.lufs) ? m.lufs.toFixed(1) : '-inf'} LUFS, ${m.onsets.length} onset${m.onsets.length === 1 ? '' : 's'}${m.onsets.length ? ` at ${m.onsets.slice(0, 8).join(', ')}${m.onsets.length > 8 ? ' …' : ''} s` : ''}`
144
+
145
+ /** the waveform as a png, 1200 by 240 */
146
+ export async function waveformPng(file: string, png: string): Promise<void> {
147
+ await ffmpeg(['-y', '-v', 'error', '-i', file, '-filter_complex', 'showwavespic=s=1200x240:split_channels=1:colors=#f0c674|#81a2be', '-frames:v', '1', png])
148
+ }