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.
- package/CHANGELOG.md +11 -2
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +4 -0
- package/src/cli/cmd_Review.ts +36 -10
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +7 -2
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli.ts +2 -0
- package/src/core/app-icon.ts +10 -6
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +47 -5
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +12 -1
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +83 -53
- package/src/serve/hub-app/review/review-page.tsx +9 -14
- package/src/serve/hub-app/review/review-st.ts +141 -18
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +70 -10
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/serve-hub.ts +7 -0
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +33 -4
- package/src/trailer/build.ts +170 -29
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
|
@@ -0,0 +1,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
|
-
|
|
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
|
|
85
|
-
if (
|
|
86
|
-
|
|
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:
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
+
}
|