@homie-rocks/studio 0.48.4 → 0.50.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 +46 -0
- package/app-starters/gathering/README.md +4 -0
- package/app-starters/gathering/app.json +162 -0
- package/app-starters/gathering/index.html +3 -0
- package/app-starters/gathering/map/main.json +1 -0
- package/app-starters/gathering/src/move.ts +3 -0
- package/app-starters/gathering/src/rules.ts +21 -0
- package/app-starters/gathering/src/view.ts +74 -0
- package/app-starters/giving/README.md +5 -0
- package/app-starters/giving/app.json +162 -0
- package/app-starters/giving/index.html +3 -0
- package/app-starters/giving/map/main.json +1 -0
- package/app-starters/giving/src/move.ts +3 -0
- package/app-starters/giving/src/rules.ts +21 -0
- package/app-starters/giving/src/view.ts +74 -0
- package/app-starters/jobs/README.md +4 -0
- package/app-starters/jobs/app.json +162 -0
- package/app-starters/jobs/index.html +3 -0
- package/app-starters/jobs/map/main.json +1 -0
- package/app-starters/jobs/src/move.ts +3 -0
- package/app-starters/jobs/src/rules.ts +21 -0
- package/app-starters/jobs/src/view.ts +74 -0
- package/app-starters/quiz/README.md +4 -0
- package/app-starters/quiz/app.json +162 -0
- package/app-starters/quiz/index.html +3 -0
- package/app-starters/quiz/map/main.json +1 -0
- package/app-starters/quiz/src/move.ts +3 -0
- package/app-starters/quiz/src/rules.ts +21 -0
- package/app-starters/quiz/src/view.ts +74 -0
- package/app-starters/welcome/app.json +25 -2
- package/app-starters/welcome/index.html +1 -1
- package/app-starters/welcome/src/view.ts +20 -8
- package/bin/homie-studio.mjs +20 -16
- package/functions/FUNCTIONS.md +1 -1
- package/guides/app/SKILL.md +178 -0
- package/guides/art/SKILL.md +154 -0
- package/guides/art/references/RIGHTS.md +20 -0
- package/guides/art/scripts/art.mjs +308 -0
- package/guides/models/SKILL.md +198 -0
- package/guides/models/references/RIGHTS.md +46 -0
- package/guides/models/references/models.json +175 -0
- package/guides/models/scripts/models.mjs +483 -0
- package/guides/music/SKILL.md +197 -0
- package/guides/music/references/COMPOSITION.md +59 -0
- package/guides/music/references/GAME-AUDIO.md +50 -0
- package/guides/music/references/RIGHTS.md +43 -0
- package/guides/music/scripts/lib/audio.mjs +258 -0
- package/guides/music/scripts/lib/eleven.mjs +260 -0
- package/guides/music/scripts/lib/studio.mjs +147 -0
- package/guides/music/scripts/music.mjs +644 -0
- package/guides/parts/SKILL.md +158 -0
- package/guides/plan/SKILL.md +126 -0
- package/guides/plan/references/CODEX.md +105 -0
- package/guides/plan/references/INTERVIEW.md +86 -0
- package/guides/playtest/SKILL.md +205 -0
- package/guides/playtest/references/METHOD.md +124 -0
- package/guides/playtest/references/REVIEWER.md +78 -0
- package/guides/playtest/scripts/lib/exit.mjs +31 -0
- package/guides/playtest/scripts/lib/judge.mjs +502 -0
- package/guides/playtest/scripts/lib/pixels.mjs +128 -0
- package/guides/playtest/scripts/lib/preflight.mjs +69 -0
- package/guides/playtest/scripts/playtest.mjs +870 -0
- package/guides/shop/SKILL.md +153 -0
- package/guides/sound/SKILL.md +156 -0
- package/guides/sound/references/GAME-SOUND.md +100 -0
- package/guides/sound/references/SCORE.md +131 -0
- package/guides/sound/references/TRAPS.md +93 -0
- package/guides/sound/scripts/lib/measure.mjs +252 -0
- package/guides/sound/scripts/lib/score.mjs +415 -0
- package/guides/sound/scripts/lib/sfx.mjs +222 -0
- package/guides/sound/scripts/lib/synth.mjs +509 -0
- package/guides/sound/scripts/sound.mjs +520 -0
- package/guides/studio-setup/SKILL.md +419 -0
- package/guides/style/SKILL.md +105 -0
- package/lib/app-needs.mjs +63 -0
- package/lib/authoring-guides.mjs +5 -4
- package/lib/build.mjs +5 -4
- package/lib/check-site.mjs +6 -6
- package/lib/check.mjs +18 -5
- package/lib/codex.mjs +95 -18
- package/lib/decisions.mjs +9 -7
- package/lib/dev.mjs +4 -2
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +13 -8
- package/lib/mcp-tools.mjs +19 -17
- package/lib/mcp.mjs +1 -1
- package/lib/parts.mjs +6 -6
- package/lib/scaffold.mjs +45 -11
- package/lib/shop-links.mjs +2 -2
- package/lib/shop.mjs +5 -3
- package/lib/studio.mjs +7 -6
- package/lib/template-history.json +84 -0
- package/package.json +4 -4
- package/shop/SHOP.md +4 -0
- package/shop/shop.ts +4 -4
- package/worker/app-format.mjs +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/functions-schema.mjs +15 -0
- package/worker/index.mjs +2 -1
- package/worker/parts.mjs +4 -4
- package/worker/shop-page.mjs +22 -16
- package/worker/shop-rules.mjs +4 -3
- package/worker/shop.mjs +6 -2
- package/worker/site.mjs +4 -2
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A score, written as JSON, rendered to audio on this computer: tempo, key, chords per section,
|
|
3
|
+
* a pattern per instrument, an arrangement of sections. Every section is a whole number of bars,
|
|
4
|
+
* so section starts are bar lines a game can switch music on and a trailer can cut on.
|
|
5
|
+
*
|
|
6
|
+
* What comes out (render()):
|
|
7
|
+
* mix the stereo master (limited, before loudness targeting)
|
|
8
|
+
* stems one stereo buffer per instrument, plus 'reverb' (the shared room); they sum to the mix
|
|
9
|
+
* grid { bpm, beatsPerBar, barSeconds, sections: [{ name, bar, at, bars }] }
|
|
10
|
+
* loop(): one section rendered three times over and the middle copy kept, so the tails of the
|
|
11
|
+
* copy before it (reverb, echoes, a held note) are already in its head: it loops without a seam.
|
|
12
|
+
*
|
|
13
|
+
* The pattern language (references/SCORE.md has all of it):
|
|
14
|
+
* notes "A4 . C5 - | E5*2 D5 C5 ." one token per step; '-' holds, '.' rests, '*n' lasts n steps,
|
|
15
|
+
* "[A3 C4 E4]" a chord, '!' after a note accents it, '|' marks a bar (checked)
|
|
16
|
+
* from the chords root · root8 · pulse · octaves · chords · stabs · arp · arp-down · arp-updown (':8' sets the rate)
|
|
17
|
+
* drums four · backbeat · half · break · shuffle · hats · none, '+fill' on the last bar,
|
|
18
|
+
* or { "kick": "x...x...", "snare": "....x...", "hat": "x.x.x.x." } (a bar, repeated)
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
RATE, addInto, dbToGain, dcBlock, drum, echo, hzOfMidi, limit, midiOf, mixMono, peakOf, pluck, reverb, stereo, voice,
|
|
22
|
+
} from './synth.mjs';
|
|
23
|
+
|
|
24
|
+
/* ---------------------------------------------------------------- harmony */
|
|
25
|
+
|
|
26
|
+
const QUALITIES = {
|
|
27
|
+
'': [0, 4, 7], maj: [0, 4, 7], m: [0, 3, 7], min: [0, 3, 7], '5': [0, 7], '6': [0, 4, 7, 9], m6: [0, 3, 7, 9],
|
|
28
|
+
'7': [0, 4, 7, 10], maj7: [0, 4, 7, 11], m7: [0, 3, 7, 10], m9: [0, 3, 7, 10, 14], '9': [0, 4, 7, 10, 14], add9: [0, 4, 7, 14],
|
|
29
|
+
dim: [0, 3, 6], dim7: [0, 3, 6, 9], m7b5: [0, 3, 6, 10], aug: [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], '7sus4': [0, 5, 7, 10],
|
|
30
|
+
};
|
|
31
|
+
const PC = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
|
|
32
|
+
|
|
33
|
+
/** "Am7" → { root: 9, tones: [0,3,7,10], bass: 9 }; "C/E" puts E in the bass. */
|
|
34
|
+
export function parseChord(sym) {
|
|
35
|
+
const m = /^([A-G])([#b]?)([a-z0-9#]*)(?:\/([A-G])([#b]?))?$/.exec(String(sym).trim());
|
|
36
|
+
if (!m) throw new Error(`not a chord: "${sym}" (write it like Am, F, G7, Cmaj7, Dsus4, C/E)`);
|
|
37
|
+
const acc = (a) => (a === '#' ? 1 : a === 'b' ? -1 : 0);
|
|
38
|
+
const root = (PC[m[1]] + acc(m[2]) + 12) % 12;
|
|
39
|
+
const tones = QUALITIES[m[3]];
|
|
40
|
+
if (!tones) throw new Error(`unknown chord quality "${m[3]}" in "${sym}" (${Object.keys(QUALITIES).filter(Boolean).join(', ')})`);
|
|
41
|
+
const bass = m[4] ? (PC[m[4]] + acc(m[5]) + 12) % 12 : root;
|
|
42
|
+
return { sym, root, tones, bass };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The chords of a section, one entry per bar: [[{ chord, from, to } in beats within the bar]]. */
|
|
46
|
+
export function chordBars(text, bars, beatsPerBar) {
|
|
47
|
+
if (!text) return Array.from({ length: bars }, () => []);
|
|
48
|
+
const src = String(text).trim();
|
|
49
|
+
const groups = src.includes('|') ? src.split('|').map((g) => g.trim()).filter(Boolean) : src.split(/\s+/).filter(Boolean);
|
|
50
|
+
const out = [];
|
|
51
|
+
for (let b = 0; b < bars; b++) {
|
|
52
|
+
const g = groups[b % groups.length];
|
|
53
|
+
const syms = src.includes('|') ? g.split(/\s+/).filter(Boolean) : [g];
|
|
54
|
+
const each = beatsPerBar / syms.length;
|
|
55
|
+
out.push(syms.map((s, i) => ({ chord: parseChord(s), from: i * each, to: (i + 1) * each })));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Chord tones as MIDI notes, voiced close around `center` (a pad in the middle of the keyboard). */
|
|
61
|
+
function voiceChord(ch, center = 60) {
|
|
62
|
+
const notes = ch.tones.map((t) => {
|
|
63
|
+
let m = 48 + ((ch.root + t) % 12);
|
|
64
|
+
while (m < center - 7) m += 12;
|
|
65
|
+
while (m > center + 7) m -= 12;
|
|
66
|
+
return m;
|
|
67
|
+
});
|
|
68
|
+
return [...new Set(notes)].sort((a, b) => a - b);
|
|
69
|
+
}
|
|
70
|
+
const bassNote = (ch, octave = 2) => 12 * (octave + 1) + ch.bass;
|
|
71
|
+
|
|
72
|
+
/* ---------------------------------------------------------------- instruments */
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A note on an instrument: a mono (or stereo) buffer. `gate` is how long the key is held, in seconds.
|
|
76
|
+
* `m` is a MIDI note number and is handed to the synthesizer AS one (`midi:`), never as a bare number in `freq`:
|
|
77
|
+
* a bare number there is hertz. Only the organ converts, because a harmonic is a multiple of hertz, not of a note.
|
|
78
|
+
*/
|
|
79
|
+
export const INSTRUMENTS = {
|
|
80
|
+
'pulse-lead': { about: 'a narrow pulse lead with a late vibrato: the classic game melody', role: 'lead', play: (m, gate, v) => voice({ wave: 'pulse', duty: 0.25, midi: m, gate, env: { a: 0.004, d: 0.15, s: 0.62, r: 0.07 }, vibrato: { rate: 5.5, depth: 0.12, delay: 0.18 }, filter: { type: 'lowpass', freq: 5200 }, gain: v }) },
|
|
81
|
+
'square-lead': { about: 'a hollow square lead: brighter, more 8-bit', role: 'lead', play: (m, gate, v) => voice({ wave: 'square', midi: m, gate, env: { a: 0.003, d: 0.12, s: 0.6, r: 0.06 }, filter: { type: 'lowpass', freq: 4200 }, gain: v * 0.8 }) },
|
|
82
|
+
'saw-lead': { about: 'two detuned saws, filtered: a fat synth lead', role: 'lead', play: (m, gate, v) => mixMono([voice({ wave: 'saw', midi: m + 0.07, gate, env: { a: 0.008, d: 0.2, s: 0.7, r: 0.1 }, filter: { type: 'lowpass', freq: 3400, q: 0.9 }, gain: v * 0.55 }), voice({ wave: 'saw', midi: m - 0.07, phase: 0.37, gate, env: { a: 0.008, d: 0.2, s: 0.7, r: 0.1 }, filter: { type: 'lowpass', freq: 3400, q: 0.9 }, gain: v * 0.55 })]) },
|
|
83
|
+
'soft-lead': { about: 'a triangle with vibrato: a gentle flute-like line', role: 'lead', play: (m, gate, v) => voice({ wave: 'triangle', midi: m, gate, env: { a: 0.03, d: 0.2, s: 0.8, r: 0.15 }, vibrato: { rate: 5, depth: 0.15, delay: 0.2 }, gain: v }) },
|
|
84
|
+
bell: { about: 'an FM bell: sparkle, a music box, a menu theme', role: 'lead', play: (m, gate, v) => voice({ fm: { ratio: 3.5, index: 2.4, decay: 0.3 }, midi: m, gate, env: { a: 0.001, d: 1.4, s: 0, r: 0.6, curve: 2.4 }, gain: v * 0.8, length: Math.max(gate, 1.4) }) },
|
|
85
|
+
epiano: { about: 'an FM electric piano: warm chords and lines', role: 'keys', play: (m, gate, v) => voice({ fm: { ratio: 1, index: 1.6, decay: 0.45 }, midi: m, gate, env: { a: 0.002, d: 1.2, s: 0.25, r: 0.25 }, gain: v * 0.8 }) },
|
|
86
|
+
pluck: { about: 'a plucked string (Karplus-Strong): arpeggios, a harp, a koto', role: 'keys', play: (m, gate, v) => pluck({ midi: m, gate, bright: 0.55, gain: v * 0.9 }) },
|
|
87
|
+
organ: { about: 'three sine harmonics: an organ, a calm pad', role: 'keys', play: (m, gate, v) => mixMono([1, 2, 3].map((h, i) => voice({ wave: 'sine', freq: hzOfMidi(m) * h, gate, env: { a: 0.01, d: 0.1, s: 0.9, r: 0.08 }, gain: v * [0.6, 0.3, 0.15][i] }))) },
|
|
88
|
+
pad: { about: 'three detuned saws, slow and filtered: a wide, soft bed', role: 'pad', stereo: true, play: (m, gate, v) => {
|
|
89
|
+
const len = gate + 0.9;
|
|
90
|
+
const out = stereo(len);
|
|
91
|
+
[[-0.1, -0.6], [0, 0], [0.1, 0.6]].forEach(([det, pan], i) => addInto(out, voice({ wave: 'saw', midi: m + det, phase: i * 0.31, gate, env: { a: 0.35, d: 0.5, s: 0.8, r: 0.8 }, filter: { type: 'lowpass', freq: 1500, q: 0.7 }, gain: v * 0.4 }), { pan }));
|
|
92
|
+
return out;
|
|
93
|
+
} },
|
|
94
|
+
'warm-pad': { about: 'soft triangles and a sine: a darker, calmer bed', role: 'pad', play: (m, gate, v) => mixMono([voice({ wave: 'triangle', midi: m, gate, env: { a: 0.5, d: 0.5, s: 0.85, r: 1.0 }, gain: v * 0.6 }), voice({ wave: 'sine', midi: m + 12, gate, env: { a: 0.6, d: 0.5, s: 0.7, r: 1.0 }, gain: v * 0.2 })]) },
|
|
95
|
+
'saw-bass': { about: 'a filtered saw bass with a pluck on the filter', role: 'bass', play: (m, gate, v) => voice({ wave: 'saw', midi: m, gate, env: { a: 0.003, d: 0.25, s: 0.7, r: 0.05 }, filter: { type: 'lowpass', freq: 1800, to: 420, time: 0.18, q: 1.1 }, gain: v }) },
|
|
96
|
+
'square-bass': { about: 'a square bass: chiptune', role: 'bass', play: (m, gate, v) => voice({ wave: 'square', midi: m, gate, env: { a: 0.002, d: 0.1, s: 0.8, r: 0.04 }, filter: { type: 'lowpass', freq: 1400 }, gain: v * 0.8 }) },
|
|
97
|
+
'triangle-bass': { about: 'a triangle bass: the 8-bit console bass', role: 'bass', play: (m, gate, v) => voice({ wave: 'triangle', midi: m, gate, env: { a: 0.002, d: 0.05, s: 0.95, r: 0.03 }, gain: v }) },
|
|
98
|
+
'sub-bass': { about: 'a sine sub with a little edge (phones need the edge to hear it)', role: 'bass', play: (m, gate, v) => mixMono([voice({ wave: 'sine', midi: m, gate, env: { a: 0.004, d: 0.1, s: 0.9, r: 0.06 }, gain: v }), voice({ wave: 'saw', midi: m + 12, gate, env: { a: 0.004, d: 0.1, s: 0.9, r: 0.06 }, filter: { type: 'lowpass', freq: 900 }, gain: v * 0.18 })]) },
|
|
99
|
+
kit: { about: 'synthesized drums (kick, snare, clap, hats, toms)', role: 'drums' },
|
|
100
|
+
'chip-kit': { about: 'crunchy 8-bit drums', role: 'drums' },
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/* ---------------------------------------------------------------- patterns */
|
|
104
|
+
|
|
105
|
+
const GROOVES = {
|
|
106
|
+
// One bar at 4 steps a beat (16 steps in 4/4); stretched or cut to other bar lengths.
|
|
107
|
+
four: { kick: 'x...x...x...x...', clap: '....x.......x...', hat: '..x...x...x...x.', openhat: '' },
|
|
108
|
+
backbeat: { kick: 'x.......x.x.....', snare: '....x.......x...', hat: 'x.x.x.x.x.x.x.x.' },
|
|
109
|
+
half: { kick: 'x...........x...', snare: '........x.......', hat: 'x.x.x.x.x.x.x.x.' },
|
|
110
|
+
break: { kick: 'x.........x..x..', snare: '....x..x.x..x...', hat: 'x.xxx.x.x.xxx.x.' },
|
|
111
|
+
shuffle: { kick: 'x.....x...x.....', snare: '....x.......x..x', hat: 'x..x..x..x..x..x' },
|
|
112
|
+
hats: { hat: 'x.x.x.x.x.x.x.x.' },
|
|
113
|
+
pulse: { kick: 'x...x...x...x...' },
|
|
114
|
+
none: {},
|
|
115
|
+
};
|
|
116
|
+
const FILL = { snare: '........x.x.xxxx', tom: '....x.x.........' };
|
|
117
|
+
|
|
118
|
+
function tokens(text) {
|
|
119
|
+
const t = [];
|
|
120
|
+
const re = /\[[^\]]*\]!?(\*\d+)?|\S+/g;
|
|
121
|
+
let m;
|
|
122
|
+
while ((m = re.exec(text))) t.push(m[0]);
|
|
123
|
+
return t;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Parse a notes pattern for one section into events { step, steps, notes: [midi], accent }. */
|
|
127
|
+
export function parseNotes(text, { stepsPerBar, bars, octave = 0, where = 'a pattern' }) {
|
|
128
|
+
const events = [];
|
|
129
|
+
let step = 0;
|
|
130
|
+
const barMarks = [];
|
|
131
|
+
let last = null;
|
|
132
|
+
for (const tok of tokens(String(text))) {
|
|
133
|
+
if (tok === '|') { barMarks.push(step); continue; }
|
|
134
|
+
const mult = /\*(\d+)$/.exec(tok);
|
|
135
|
+
const n = mult ? Number(mult[1]) : 1;
|
|
136
|
+
const body = mult ? tok.slice(0, -mult[0].length) : tok;
|
|
137
|
+
if (body === '.') { step += n; last = null; continue; }
|
|
138
|
+
if (body === '-') { if (last) last.steps += n; step += n; continue; }
|
|
139
|
+
const accent = body.endsWith('!');
|
|
140
|
+
const core = accent ? body.slice(0, -1) : body;
|
|
141
|
+
const names = core.startsWith('[') ? core.slice(1, -1).trim().split(/[\s,]+/) : [core];
|
|
142
|
+
let notes;
|
|
143
|
+
try { notes = names.map((x) => midiOf(x) + 12 * octave); } catch (e) { throw new Error(`${where}: ${e.message}`); }
|
|
144
|
+
last = { step, steps: n, notes, accent };
|
|
145
|
+
events.push(last);
|
|
146
|
+
step += n;
|
|
147
|
+
}
|
|
148
|
+
for (const [i, s] of barMarks.entries()) {
|
|
149
|
+
if (s !== (i + 1) * stepsPerBar) throw new Error(`${where}: bar ${i + 1} has ${s - (barMarks[i - 1] ?? 0)} steps, a bar here is ${stepsPerBar} (steps a beat × beats a bar). A token is one step: for eighth notes give the instrument "stepsPerBeat": 2`);
|
|
150
|
+
}
|
|
151
|
+
if (barMarks.length && step % stepsPerBar !== 0) throw new Error(`${where}: the last bar has ${step - barMarks.length * stepsPerBar} steps, a bar here is ${stepsPerBar}`);
|
|
152
|
+
if (step > stepsPerBar * bars) throw new Error(`${where}: ${step} steps is longer than the section (${bars} bars × ${stepsPerBar})`);
|
|
153
|
+
// A pattern shorter than the section repeats to fill it (a one-bar riff over eight bars).
|
|
154
|
+
const len = barMarks.length ? step : Math.ceil(step / stepsPerBar) * stepsPerBar;
|
|
155
|
+
if (len > 0 && len < stepsPerBar * bars) {
|
|
156
|
+
const base = events.slice();
|
|
157
|
+
for (let off = len; off < stepsPerBar * bars; off += len) for (const e of base) if (e.step + off < stepsPerBar * bars) events.push({ ...e, step: e.step + off });
|
|
158
|
+
}
|
|
159
|
+
return events;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Events generated from the chords: a bass line, a pad, stabs or an arpeggio. */
|
|
163
|
+
export function fromChords(kind, cbars, { stepsPerBeat, beatsPerBar, octave = 0 }) {
|
|
164
|
+
const [name, rateArg] = kind.split(':');
|
|
165
|
+
const spb = stepsPerBeat * beatsPerBar;
|
|
166
|
+
const events = [];
|
|
167
|
+
const every = (div) => Math.max(1, Math.round(spb / div));
|
|
168
|
+
cbars.forEach((chords, b) => {
|
|
169
|
+
for (const c of chords) {
|
|
170
|
+
const from = b * spb + Math.round(c.from * stepsPerBeat);
|
|
171
|
+
const to = b * spb + Math.round(c.to * stepsPerBeat);
|
|
172
|
+
const bass = bassNote(c.chord) + 12 * octave;
|
|
173
|
+
const tones = voiceChord(c.chord, 60 + 12 * octave);
|
|
174
|
+
if (name === 'root') events.push({ step: from, steps: to - from, notes: [bass] });
|
|
175
|
+
else if (name === 'root8' || name === 'pulse' || name === 'octaves') {
|
|
176
|
+
const div = name === 'pulse' ? 16 : Number(rateArg) || 8;
|
|
177
|
+
const st = every(div * (beatsPerBar / 4));
|
|
178
|
+
for (let s = from, i = 0; s < to; s += st, i++) events.push({ step: s, steps: Math.max(1, st - (name === 'pulse' ? 0 : 0)), notes: [name === 'octaves' && i % 2 ? bass + 12 : bass], accent: s === from });
|
|
179
|
+
} else if (name === 'chords') events.push({ step: from, steps: to - from, notes: tones });
|
|
180
|
+
else if (name === 'stabs') {
|
|
181
|
+
const st = every(8 * (beatsPerBar / 4));
|
|
182
|
+
for (let s = from + st; s < to; s += st * 2) events.push({ step: s, steps: Math.max(1, Math.round(st / 2)), notes: tones });
|
|
183
|
+
} else if (name.startsWith('arp')) {
|
|
184
|
+
const div = Number(rateArg) || 16;
|
|
185
|
+
const st = every(div * (beatsPerBar / 4));
|
|
186
|
+
const up = [...tones, tones[0] + 12];
|
|
187
|
+
const seq = name === 'arp-down' ? [...up].reverse() : name === 'arp-updown' ? [...up, ...up.slice(1, -1).reverse()] : up;
|
|
188
|
+
for (let s = from, i = 0; s < to; s += st, i++) events.push({ step: s, steps: st, notes: [seq[i % seq.length] + 12] });
|
|
189
|
+
} else throw new Error(`unknown pattern "${kind}" (root, root8, pulse, octaves, chords, stabs, arp, arp-down, arp-updown)`);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
return events;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Drum hits for a section: [{ step, drum, accent }]. */
|
|
196
|
+
export function drumHits(pattern, { stepsPerBeat, beatsPerBar, bars }) {
|
|
197
|
+
let spec = pattern;
|
|
198
|
+
let fill = false;
|
|
199
|
+
if (typeof spec === 'string') {
|
|
200
|
+
fill = spec.includes('+fill');
|
|
201
|
+
const name = spec.replace('+fill', '').replace(/^drums:/, '').trim();
|
|
202
|
+
spec = GROOVES[name];
|
|
203
|
+
if (!spec) throw new Error(`unknown groove "${name}" (${Object.keys(GROOVES).join(', ')}), or write the steps yourself`);
|
|
204
|
+
} else if (spec && typeof spec === 'object') fill = Boolean(spec.fill);
|
|
205
|
+
const spb = stepsPerBeat * beatsPerBar;
|
|
206
|
+
const hits = [];
|
|
207
|
+
for (let b = 0; b < bars; b++) {
|
|
208
|
+
const src = fill && b === bars - 1 ? { ...spec, ...FILL } : spec;
|
|
209
|
+
for (const [kind, line] of Object.entries(src)) {
|
|
210
|
+
if (kind === 'fill' || !line) continue;
|
|
211
|
+
const cells = String(line).replace(/\s+/g, '');
|
|
212
|
+
// A line written at 4 steps a beat is resampled to this score's steps.
|
|
213
|
+
const perBeat = cells.length / beatsPerBar >= 1 ? cells.length / beatsPerBar : 4;
|
|
214
|
+
for (let i = 0; i < cells.length; i++) {
|
|
215
|
+
const c = cells[i];
|
|
216
|
+
if (c !== 'x' && c !== 'X') continue;
|
|
217
|
+
const s = Math.round((i / perBeat) * stepsPerBeat);
|
|
218
|
+
if (s >= spb) continue;
|
|
219
|
+
hits.push({ step: b * spb + s, drum: kind, accent: c === 'X' });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return hits;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* ---------------------------------------------------------------- render */
|
|
227
|
+
|
|
228
|
+
const clampPan = (p) => Math.max(-1, Math.min(1, Number(p) || 0));
|
|
229
|
+
|
|
230
|
+
/** Check a score and fill its defaults. Throws a sentence a person can act on. */
|
|
231
|
+
export function normalise(score) {
|
|
232
|
+
const s = { beatsPerBar: 4, stepsPerBeat: 4, swing: 0, seed: 1, ...score };
|
|
233
|
+
if (!(s.bpm >= 40 && s.bpm <= 240)) throw new Error('bpm must be 40..240');
|
|
234
|
+
if (!Array.isArray(s.sections) || !s.sections.length) throw new Error('a score needs sections: [{ "name", "bars", "chords", "play": { … } }]');
|
|
235
|
+
if (!s.instruments || typeof s.instruments !== 'object') throw new Error('a score needs instruments: { "lead": { "preset": "pulse-lead" }, … }');
|
|
236
|
+
for (const [name, ins] of Object.entries(s.instruments)) {
|
|
237
|
+
if (!INSTRUMENTS[ins.preset]) throw new Error(`instrument "${name}": no preset "${ins.preset}" (${Object.keys(INSTRUMENTS).join(', ')})`);
|
|
238
|
+
}
|
|
239
|
+
const names = new Set();
|
|
240
|
+
for (const sec of s.sections) {
|
|
241
|
+
if (!sec.name || names.has(sec.name)) throw new Error(`every section needs its own name (found "${sec.name}")`);
|
|
242
|
+
names.add(sec.name);
|
|
243
|
+
if (!(Number.isInteger(sec.bars) && sec.bars >= 1 && sec.bars <= 64)) throw new Error(`section "${sec.name}": bars must be a whole number 1..64`);
|
|
244
|
+
for (const inst of Object.keys(sec.play ?? {})) if (!s.instruments[inst]) throw new Error(`section "${sec.name}" plays "${inst}", which is not in instruments`);
|
|
245
|
+
}
|
|
246
|
+
s.arrangement = s.arrangement ?? s.sections.map((x) => x.name);
|
|
247
|
+
for (const a of s.arrangement) if (!names.has(a)) throw new Error(`arrangement names "${a}", which is not a section`);
|
|
248
|
+
return s;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Render a list of section names (an arrangement) to stems and a mix.
|
|
253
|
+
* Returns { stems: { name: stereo }, mix: stereo, grid }.
|
|
254
|
+
* `sameSeeds` gives every section in the list the same drum noise (by default each place in the arrangement
|
|
255
|
+
* has its own, so a repeat is not a photocopy): a loop is cut from three copies that must be the same copy.
|
|
256
|
+
* `period` (seconds) starts each section in the list exactly that far after the one before, instead of on its
|
|
257
|
+
* bar line: a loop is a whole number of samples, and at a tempo whose bar is not (140 bpm: 82285.71 samples) the
|
|
258
|
+
* third copy would otherwise start a fraction of a sample away from where the cut expects it.
|
|
259
|
+
*/
|
|
260
|
+
export function render(scoreIn, { sections: order = null, tail = 2.5, sameSeeds = false, period = null } = {}) {
|
|
261
|
+
const s = normalise(scoreIn);
|
|
262
|
+
const list = order ?? s.arrangement;
|
|
263
|
+
const beat = 60 / s.bpm;
|
|
264
|
+
const step = beat / s.stepsPerBeat;
|
|
265
|
+
const spb = s.stepsPerBeat * s.beatsPerBar;
|
|
266
|
+
const barSeconds = beat * s.beatsPerBar;
|
|
267
|
+
const byName = Object.fromEntries(s.sections.map((x) => [x.name, x]));
|
|
268
|
+
const totalBars = list.reduce((a, n) => a + byName[n].bars, 0);
|
|
269
|
+
const length = totalBars * barSeconds + tail;
|
|
270
|
+
const stems = Object.fromEntries(Object.keys(s.instruments).map((k) => [k, stereo(length)]));
|
|
271
|
+
const kicks = [];
|
|
272
|
+
const grid = { bpm: s.bpm, beatsPerBar: s.beatsPerBar, barSeconds, bars: totalBars, seconds: totalBars * barSeconds, sections: [] };
|
|
273
|
+
const swingAt = (st) => (st % 2 === 1 ? s.swing * step : 0);
|
|
274
|
+
let bar0 = 0;
|
|
275
|
+
for (const [si, name] of list.entries()) {
|
|
276
|
+
const sec = byName[name];
|
|
277
|
+
const t0 = period !== null ? si * period : bar0 * barSeconds;
|
|
278
|
+
grid.sections.push({ name, bar: bar0, at: +t0.toFixed(6), bars: sec.bars });
|
|
279
|
+
const cbars = chordBars(sec.chords, sec.bars, s.beatsPerBar);
|
|
280
|
+
for (const [inst, pat] of Object.entries(sec.play ?? {})) {
|
|
281
|
+
const ins = s.instruments[inst];
|
|
282
|
+
const def = INSTRUMENTS[ins.preset];
|
|
283
|
+
const vel = (a) => (a ? 1 : 0.82);
|
|
284
|
+
if (def.role === 'drums') {
|
|
285
|
+
const hits = drumHits(pat, { stepsPerBeat: s.stepsPerBeat, beatsPerBar: s.beatsPerBar, bars: sec.bars });
|
|
286
|
+
for (const h of hits) {
|
|
287
|
+
const at = t0 + h.step * step + swingAt(h.step);
|
|
288
|
+
const kind = h.drum === 'tom' ? 'tom' : h.drum;
|
|
289
|
+
let buf = drum(kind, { gain: vel(h.accent), seed: 7 + (sameSeeds ? 0 : si) * 131 + h.step, tone: ins.tone ?? 0 });
|
|
290
|
+
if (ins.preset === 'chip-kit') { for (let i = 0, held = 0; i < buf.length; i++) { if (i % 3 === 0) held = Math.round(buf[i] * 16) / 16; buf[i] = held; } }
|
|
291
|
+
const pan = { hat: 0.25, openhat: 0.25, shaker: -0.3, tom: -0.2, rim: 0.2 }[kind] ?? 0;
|
|
292
|
+
addInto(stems[inst], buf, { at, pan });
|
|
293
|
+
if (kind === 'kick') kicks.push(at);
|
|
294
|
+
}
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
// A pattern's own grid: "stepsPerBeat" on the pattern or the instrument (2 = eighth notes), else the score's.
|
|
298
|
+
const ps = Number((typeof pat === 'object' && pat !== null && pat.stepsPerBeat) || ins.stepsPerBeat || s.stepsPerBeat);
|
|
299
|
+
const pstep = beat / ps;
|
|
300
|
+
const opts = { stepsPerBeat: ps, beatsPerBar: s.beatsPerBar, octave: ins.octave ?? 0 };
|
|
301
|
+
const text = typeof pat === 'object' && pat !== null ? pat.notes : pat;
|
|
302
|
+
const isGen = /^(root|root8|pulse|octaves|chords|stabs|arp)/.test(String(text));
|
|
303
|
+
const events = isGen ? fromChords(String(text), cbars, opts) : parseNotes(text, { stepsPerBar: ps * s.beatsPerBar, bars: sec.bars, octave: ins.octave ?? 0, where: `section "${name}", ${inst}` });
|
|
304
|
+
for (const ev of events) {
|
|
305
|
+
const at = t0 + ev.step * pstep + (ev.step % 2 === 1 ? s.swing * pstep : 0);
|
|
306
|
+
const gate = Math.max(0.03, ev.steps * pstep * (ins.legato ?? 0.92));
|
|
307
|
+
for (const note of ev.notes) {
|
|
308
|
+
const buf = def.play(note, gate, vel(ev.accent) / Math.sqrt(ev.notes.length));
|
|
309
|
+
addInto(stems[inst], buf, { at, pan: clampPan(ins.pan) });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
bar0 += sec.bars;
|
|
314
|
+
}
|
|
315
|
+
// Per-instrument effects: an echo in beats, a duck under the kick (the pump), a level in dB.
|
|
316
|
+
const room = stereo(length);
|
|
317
|
+
for (const [inst, ins] of Object.entries(s.instruments)) {
|
|
318
|
+
let st = stems[inst];
|
|
319
|
+
if (ins.echo) {
|
|
320
|
+
const e = echo(st, { time: (ins.echo.beats ?? 0.75) * beat, feedback: ins.echo.feedback ?? 0.3, mix: ins.echo.mix ?? 0.22, pingpong: ins.echo.pingpong ?? true, tail: 0 });
|
|
321
|
+
st = { L: e.L.subarray(0, st.L.length), R: e.R.subarray(0, st.R.length) };
|
|
322
|
+
}
|
|
323
|
+
if (ins.duck && kicks.length) {
|
|
324
|
+
const depth = Math.min(0.95, Number(ins.duck));
|
|
325
|
+
const rel = 0.6 * beat;
|
|
326
|
+
const ks = kicks.slice().sort((a, b) => a - b);
|
|
327
|
+
let k = 0;
|
|
328
|
+
for (let i = 0; i < st.L.length; i++) {
|
|
329
|
+
const t = i / RATE;
|
|
330
|
+
while (k + 1 < ks.length && ks[k + 1] <= t) k++;
|
|
331
|
+
const d = t >= ks[k] ? t - ks[k] : Infinity;
|
|
332
|
+
const g = d === Infinity ? 1 : 1 - depth * Math.exp(-d / (rel / 3));
|
|
333
|
+
st.L[i] *= g; st.R[i] *= g;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const g = dbToGain(ins.db ?? -10);
|
|
337
|
+
for (let i = 0; i < st.L.length; i++) { st.L[i] *= g; st.R[i] *= g; }
|
|
338
|
+
stems[inst] = st;
|
|
339
|
+
const send = ins.reverb ?? 0.12;
|
|
340
|
+
if (send > 0) addInto(room, st, { gain: send });
|
|
341
|
+
}
|
|
342
|
+
const wet = reverb(room, { room: s.room ?? 0.55, damp: 0.45, mix: 1, tail: 0 });
|
|
343
|
+
// reverb() returns dry + wet: keep only the wet part for the room stem.
|
|
344
|
+
const roomStem = stereo(length);
|
|
345
|
+
for (let i = 0; i < roomStem.L.length; i++) { roomStem.L[i] = wet.L[i] - room.L[i]; roomStem.R[i] = wet.R[i] - room.R[i]; }
|
|
346
|
+
stems.reverb = roomStem;
|
|
347
|
+
const mix = stereo(length);
|
|
348
|
+
for (const st of Object.values(stems)) addInto(mix, st);
|
|
349
|
+
dcBlock(mix);
|
|
350
|
+
return { stems, mix, grid, score: s };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* One section as a seamless loop: rendered three times in a row, the middle copy kept, so whatever rings over
|
|
355
|
+
* from the copy before it is already in its head. Returns { mix, stems, seconds, bars, seam }.
|
|
356
|
+
*
|
|
357
|
+
* `gain` and `ceilingDb` are the master's: the level and the limiter are applied HERE, across all three copies,
|
|
358
|
+
* and then the middle is cut. A limiter run over the cut loop alone starts at full gain on its first sample and
|
|
359
|
+
* ends wherever the last bar left it, and that step of gain at the wrap is a click the unlimited loop never had
|
|
360
|
+
* (measured: loops with a seam of 0.1 came out at 2 to 15 once limited that way).
|
|
361
|
+
*
|
|
362
|
+
* `seam` is measured against the truth this render has and a finished file does not: the sample the music
|
|
363
|
+
* really played next (the first sample of the third copy). See seamRatio.
|
|
364
|
+
*/
|
|
365
|
+
export function loop(scoreIn, name, { gain = 1, ceilingDb = null } = {}) {
|
|
366
|
+
const s = normalise(scoreIn);
|
|
367
|
+
const sec = s.sections.find((x) => x.name === name);
|
|
368
|
+
if (!sec) throw new Error(`no section "${name}" to loop`);
|
|
369
|
+
const barSeconds = (60 / s.bpm) * s.beatsPerBar;
|
|
370
|
+
const n = Math.round(sec.bars * barSeconds * RATE);
|
|
371
|
+
const r = render(s, { sections: [name, name, name], tail: 0, sameSeeds: true, period: n / RATE });
|
|
372
|
+
const from = n;
|
|
373
|
+
if (gain !== 1) for (const ch of [r.mix.L, r.mix.R]) for (let i = 0; i < ch.length; i++) ch[i] *= gain;
|
|
374
|
+
if (ceilingDb !== null) limit(r.mix, { ceiling: dbToGain(ceilingDb), releaseMs: 150 });
|
|
375
|
+
const cut = (b) => ({ L: b.L.slice(from, from + n), R: b.R.slice(from, from + n) });
|
|
376
|
+
const stems = Object.fromEntries(Object.entries(r.stems).map(([k, v]) => [k, cut(v)]));
|
|
377
|
+
const mix = cut(r.mix);
|
|
378
|
+
const next = from + n < r.mix.L.length ? { L: r.mix.L[from + n], R: r.mix.R[from + n] } : null;
|
|
379
|
+
return { mix, stems, seconds: n / RATE, bars: sec.bars, seam: seamRatio(mix, next) };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* How wrong the wrap of a loop is, against the music's own sample-to-sample steps (1 or less is seamless).
|
|
384
|
+
*
|
|
385
|
+
* With `next` (the sample the music played right after the loop's last one, which loop() has), the number is how
|
|
386
|
+
* far the loop's FIRST sample is from that: 0 means the wrap is exactly what the music did. That is the honest
|
|
387
|
+
* measure, because a jump at the wrap is often the music's own (a plucked string or a drum starting on the bar
|
|
388
|
+
* line, the edge of a saw or a square falling on the last sample), and calling that a seam fails good loops.
|
|
389
|
+
* Without `next` (a loop cut from a finished file) the whole jump across the wrap is all there is to measure.
|
|
390
|
+
*/
|
|
391
|
+
export function seamRatio(buf, next = null) {
|
|
392
|
+
const n = buf.L.length;
|
|
393
|
+
const steps = [];
|
|
394
|
+
for (let i = 1; i < n; i += 97) steps.push(Math.abs(buf.L[i] - buf.L[i - 1]) + Math.abs(buf.R[i] - buf.R[i - 1]));
|
|
395
|
+
steps.sort((a, b) => a - b);
|
|
396
|
+
const typical = steps[Math.floor(steps.length * 0.95)] || 1e-6;
|
|
397
|
+
const to = next ?? { L: buf.L[n - 1], R: buf.R[n - 1] };
|
|
398
|
+
const jump = Math.abs(buf.L[0] - to.L) + Math.abs(buf.R[0] - to.R);
|
|
399
|
+
return +(jump / typical).toFixed(3);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The seam a loop may have and still be called seamless, and the sentence for one that is over it. */
|
|
403
|
+
export const SEAM_LIMIT = 1;
|
|
404
|
+
export function seamWarning(section, seam) {
|
|
405
|
+
if (!(seam > SEAM_LIMIT)) return null;
|
|
406
|
+
return `loop "${section}": seam ${seam} is over ${SEAM_LIMIT}: the wrap will click or bump every time it comes round. Do not ship this loop as it is: something in the section does not repeat (a note or an echo longer than the section, a pattern that does not fill its bars); shorten it or make the section longer`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Master: limit the peaks of a mix (the loudness target is applied by the caller with ffmpeg's measure). */
|
|
410
|
+
export function master(mix, { ceilingDb = -1.5 } = {}) {
|
|
411
|
+
const p = peakOf(mix);
|
|
412
|
+
if (!(p > 0)) throw new Error('the score rendered silence: check the sections play something');
|
|
413
|
+
limit(mix, { ceiling: dbToGain(ceilingDb), releaseMs: 120, lookaheadMs: 4 });
|
|
414
|
+
return mix;
|
|
415
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sound effects for games, synthesized: each preset is a small recipe of voices and effects with
|
|
3
|
+
* a few knobs (pitch, length, seed, style), so a set of effects shares one character and every
|
|
4
|
+
* effect can be re-rendered from its JSON line.
|
|
5
|
+
*
|
|
6
|
+
* Levels: every effect is set to a loudness class by its loudest 50 ms (see `levels` in synth.mjs),
|
|
7
|
+
* then held under a -1 dBFS peak, so a set sits together in a mix without hand-balancing:
|
|
8
|
+
* ui -20 dB · small -16 dB · normal -12 dB · big -9 dB (RMS of the loudest 50 ms).
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
RATE, addInto, crush, dbToGain, dcBlock, drive, drum, echo, levels, limit, mixMono, mulberry32, peakOf, reverb, scale, semis, stereo, trimTail, voice,
|
|
12
|
+
} from './synth.mjs';
|
|
13
|
+
|
|
14
|
+
export const LEVELS = { ui: -20, small: -16, normal: -12, big: -9 };
|
|
15
|
+
|
|
16
|
+
const P = (p, hz) => hz * semis(p.pitch ?? 0);
|
|
17
|
+
const L = (p, s) => s * (p.length ?? 1);
|
|
18
|
+
|
|
19
|
+
/** Each preset: what it is for, its default loudness class, and the recipe. */
|
|
20
|
+
export const PRESETS = {
|
|
21
|
+
jump: {
|
|
22
|
+
about: 'a rising blip: a jump, a hop, a small launch', level: 'normal',
|
|
23
|
+
make: (p) => voice({ wave: 'square', duty: 0.3, freq: P(p, 270), slide: { to: P(p, 640), time: L(p, 0.16) }, env: { a: 0.002, d: L(p, 0.2), curve: 1.6 }, filter: { type: 'lowpass', freq: 5200 } }),
|
|
24
|
+
},
|
|
25
|
+
doublejump: {
|
|
26
|
+
about: 'a second, higher jump in the air', level: 'normal',
|
|
27
|
+
make: (p) => mixMono([
|
|
28
|
+
voice({ wave: 'square', duty: 0.25, freq: P(p, 420), slide: { to: P(p, 880), time: L(p, 0.1) }, env: { a: 0.002, d: L(p, 0.13), curve: 1.6 } }),
|
|
29
|
+
{ at: L(p, 0.06), buf: voice({ wave: 'triangle', freq: P(p, 880), slide: { to: P(p, 1320), time: L(p, 0.1) }, env: { a: 0.002, d: L(p, 0.16) } }), gain: 0.7 },
|
|
30
|
+
]),
|
|
31
|
+
},
|
|
32
|
+
land: {
|
|
33
|
+
about: 'a soft thud with a knock on top: landing, a heavy footfall (the knock is what a phone speaker plays)', level: 'small',
|
|
34
|
+
make: (p) => mixMono([
|
|
35
|
+
voice({ wave: 'sine', freq: P(p, 160), slide: { to: P(p, 60), time: L(p, 0.08) }, env: { a: 0.001, d: L(p, 0.14) }, gain: 0.45 }),
|
|
36
|
+
{ at: 0, buf: voice({ wave: 'triangle', freq: P(p, 520), slide: { to: P(p, 300), time: L(p, 0.05) }, env: { a: 0.001, d: L(p, 0.08) } }), gain: 1.0 },
|
|
37
|
+
{ at: 0, buf: voice({ wave: 'noise', seed: p.seed, filter: { type: 'bandpass', freq: 1500, q: 1.1 }, env: { a: 0.001, d: L(p, 0.05) } }), gain: 1.6 },
|
|
38
|
+
]),
|
|
39
|
+
},
|
|
40
|
+
step: {
|
|
41
|
+
about: 'a footstep tick', level: 'ui',
|
|
42
|
+
make: (p) => voice({ wave: 'noise', seed: p.seed, filter: { type: 'bandpass', freq: P(p, 1800), q: 1.5 }, env: { a: 0.001, d: L(p, 0.035), curve: 2.5 } }),
|
|
43
|
+
},
|
|
44
|
+
coin: {
|
|
45
|
+
about: 'a two-note pickup chime: a coin, a gem, a point', level: 'normal',
|
|
46
|
+
make: (p) => voice({ wave: 'square', duty: 0.5, freq: P(p, 988), arp: { steps: [0, 5], every: L(p, 0.065) }, env: { a: 0.001, d: L(p, 0.32), curve: 2 }, filter: { type: 'lowpass', freq: 7000 } }),
|
|
47
|
+
},
|
|
48
|
+
pickup: {
|
|
49
|
+
about: 'a bright rising sparkle: an item, a key, a heart', level: 'normal',
|
|
50
|
+
make: (p) => voice({ wave: 'triangle', freq: P(p, 784), arp: { steps: [0, 4, 7, 12], every: L(p, 0.045) }, vibrato: { rate: 14, depth: 0.15 }, env: { a: 0.002, d: L(p, 0.3) } }),
|
|
51
|
+
},
|
|
52
|
+
powerup: {
|
|
53
|
+
about: 'a four-step rising arpeggio with shimmer: a power-up, a level up', level: 'normal',
|
|
54
|
+
make: (p) => {
|
|
55
|
+
const a = voice({ wave: 'square', duty: 0.25, freq: P(p, 392), arp: { steps: [0, 4, 7, 12, 16, 19, 24], every: L(p, 0.06) }, vibrato: { rate: 9, depth: 0.2, delay: 0.3 }, env: { a: 0.002, d: L(p, 0.62), curve: 1.4 }, filter: { type: 'lowpass', freq: 6000 } });
|
|
56
|
+
return echo(a, { time: 0.09, feedback: 0.3, mix: 0.25 });
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
hit: {
|
|
60
|
+
about: 'a punchy impact: a punch, a bonk, a knock-back', level: 'big',
|
|
61
|
+
make: (p) => drive(mixMono([
|
|
62
|
+
voice({ wave: 'noise', seed: p.seed, filter: { type: 'lowpass', freq: 4200, to: 600, time: L(p, 0.12) }, env: { a: 0.0005, d: L(p, 0.14), curve: 2.2 } }),
|
|
63
|
+
voice({ wave: 'square', duty: 0.5, freq: P(p, 220), slide: { to: P(p, 70), time: L(p, 0.1) }, env: { a: 0.001, d: L(p, 0.16) }, gain: 0.6 }),
|
|
64
|
+
voice({ wave: 'sine', freq: P(p, 110), slide: { to: P(p, 45), time: L(p, 0.08) }, env: { a: 0.001, d: L(p, 0.18) }, gain: 0.8 }),
|
|
65
|
+
]), 2.2),
|
|
66
|
+
},
|
|
67
|
+
hurt: {
|
|
68
|
+
about: 'the player took damage: a falling buzz', level: 'normal',
|
|
69
|
+
make: (p) => drive(voice({ wave: 'saw', freq: P(p, 520), slide: { to: P(p, 140), time: L(p, 0.25) }, vibrato: { rate: 30, depth: 0.6 }, env: { a: 0.001, d: L(p, 0.3) }, filter: { type: 'lowpass', freq: 3000 } }), 1.8),
|
|
70
|
+
},
|
|
71
|
+
explosion: {
|
|
72
|
+
about: 'a big noisy boom with a low thump and a room: an explosion, a crash', level: 'big',
|
|
73
|
+
make: (p) => {
|
|
74
|
+
const body = mixMono([
|
|
75
|
+
voice({ wave: 'crunch', seed: p.seed, freq: P(p, 900), slide: { to: P(p, 60), time: L(p, 0.9) }, env: { a: 0.001, d: L(p, 1.1), curve: 2.4 } }),
|
|
76
|
+
voice({ wave: 'noise', seed: p.seed + 1, filter: { type: 'lowpass', freq: 2400, to: 140, time: L(p, 0.8) }, env: { a: 0.001, d: L(p, 1.2), curve: 2 }, gain: 0.8 }),
|
|
77
|
+
voice({ wave: 'sine', freq: P(p, 90), slide: { to: P(p, 32), time: L(p, 0.4) }, env: { a: 0.001, d: L(p, 0.6) }, gain: 1.1 }),
|
|
78
|
+
]);
|
|
79
|
+
return reverb(drive(body, 2.5), { room: 0.5, damp: 0.6, mix: 0.18, tail: 0.8 });
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
shoot: {
|
|
83
|
+
about: 'a falling zap: a laser, a blaster, a shot', level: 'normal',
|
|
84
|
+
make: (p) => mixMono([
|
|
85
|
+
voice({ wave: 'saw', freq: P(p, 1500), slide: { to: P(p, 260), time: L(p, 0.16) }, env: { a: 0.001, d: L(p, 0.18), curve: 1.5 }, filter: { type: 'lowpass', freq: 6500 } }),
|
|
86
|
+
{ at: 0, buf: voice({ wave: 'noise', seed: p.seed, filter: { type: 'highpass', freq: 3000 }, env: { a: 0.0005, d: 0.03 } }), gain: 0.35 },
|
|
87
|
+
]),
|
|
88
|
+
},
|
|
89
|
+
dash: {
|
|
90
|
+
about: 'a quick filtered swoosh: a dash, a dodge, a swing', level: 'small',
|
|
91
|
+
make: (p) => voice({ wave: 'noise', seed: p.seed, filter: { type: 'bandpass', freq: P(p, 500), to: P(p, 4200), time: L(p, 0.22), q: 2.2 }, env: { a: L(p, 0.06), d: L(p, 0.2), curve: 1.5 }, gain: 1.4 }),
|
|
92
|
+
},
|
|
93
|
+
whoosh: {
|
|
94
|
+
about: 'a slow air swell and fall: a pass-by, a transition', level: 'small',
|
|
95
|
+
make: (p) => {
|
|
96
|
+
const n = L(p, 0.7);
|
|
97
|
+
const up = voice({ wave: 'noise', seed: p.seed, length: n, filter: { type: 'bandpass', freq: P(p, 300), to: P(p, 2600), time: n * 0.55, q: 1.6 }, env: { a: n * 0.5, d: n * 0.5, curve: 1.2 }, gain: 1.4 });
|
|
98
|
+
return up;
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
bounce: {
|
|
102
|
+
about: 'a springy boing: a bounce pad, a trampoline', level: 'normal',
|
|
103
|
+
make: (p) => voice({ wave: 'sine', freq: P(p, 180), slide: { to: P(p, 520), time: L(p, 0.08) }, vibrato: { rate: 18, depth: 1.2, delay: 0.06 }, env: { a: 0.002, d: L(p, 0.45), curve: 1.6 } }),
|
|
104
|
+
},
|
|
105
|
+
click: {
|
|
106
|
+
about: 'a UI tick: a button, a hover, a menu move', level: 'ui',
|
|
107
|
+
make: (p) => voice({ wave: 'sine', freq: P(p, 1900), env: { a: 0.0005, d: L(p, 0.025), curve: 2 } }),
|
|
108
|
+
},
|
|
109
|
+
confirm: {
|
|
110
|
+
about: 'a UI yes: two notes up', level: 'ui',
|
|
111
|
+
make: (p) => voice({ wave: 'triangle', freq: P(p, 880), arp: { steps: [0, 7], every: L(p, 0.07) }, env: { a: 0.001, d: L(p, 0.2) } }),
|
|
112
|
+
},
|
|
113
|
+
back: {
|
|
114
|
+
about: 'a UI no or back: two notes down', level: 'ui',
|
|
115
|
+
make: (p) => voice({ wave: 'triangle', freq: P(p, 880), arp: { steps: [0, -5], every: L(p, 0.07) }, env: { a: 0.001, d: L(p, 0.2) } }),
|
|
116
|
+
},
|
|
117
|
+
deny: {
|
|
118
|
+
about: 'a low double buzz: not allowed, locked, not enough', level: 'small',
|
|
119
|
+
make: (p) => voice({ wave: 'square', duty: 0.5, freq: P(p, 150), arp: { steps: [0, 0, -1], every: L(p, 0.08) }, env: { a: 0.001, d: L(p, 0.26), curve: 1 }, filter: { type: 'lowpass', freq: 1800 } }),
|
|
120
|
+
},
|
|
121
|
+
countdown: {
|
|
122
|
+
about: 'a round-start beep (3, 2, 1)', level: 'normal',
|
|
123
|
+
make: (p) => voice({ wave: 'square', duty: 0.5, freq: P(p, 880), env: { a: 0.002, d: L(p, 0.16), s: 0, curve: 1 }, filter: { type: 'lowpass', freq: 5000 } }),
|
|
124
|
+
},
|
|
125
|
+
go: {
|
|
126
|
+
about: 'the start signal after the countdown: higher and longer', level: 'normal',
|
|
127
|
+
make: (p) => voice({ wave: 'square', duty: 0.5, freq: P(p, 1760), env: { a: 0.002, d: L(p, 0.5), curve: 1.2 }, vibrato: { rate: 7, depth: 0.1, delay: 0.1 }, filter: { type: 'lowpass', freq: 6000 } }),
|
|
128
|
+
},
|
|
129
|
+
tick: {
|
|
130
|
+
about: 'a clock tick for the last seconds of a round', level: 'small',
|
|
131
|
+
make: (p) => drum('rim', { tone: p.pitch ?? 0 }),
|
|
132
|
+
},
|
|
133
|
+
alarm: {
|
|
134
|
+
about: 'a two-tone warning: time nearly up, danger near', level: 'normal',
|
|
135
|
+
make: (p) => voice({ wave: 'square', duty: 0.5, freq: P(p, 660), arp: { steps: [0, 5, 0, 5], every: L(p, 0.12) }, env: { a: 0.002, d: L(p, 0.5), curve: 0.6 }, filter: { type: 'lowpass', freq: 3500 } }),
|
|
136
|
+
},
|
|
137
|
+
teleport: {
|
|
138
|
+
about: 'a rising shimmer with echo: a portal, a respawn, a warp', level: 'normal',
|
|
139
|
+
make: (p) => echo(voice({ wave: 'sine', freq: P(p, 300), slide: { to: P(p, 2400), time: L(p, 0.35) }, vibrato: { rate: 22, depth: 0.8 }, env: { a: 0.01, d: L(p, 0.4) } }), { time: 0.07, feedback: 0.45, mix: 0.4 }),
|
|
140
|
+
},
|
|
141
|
+
shatter: {
|
|
142
|
+
about: 'a glassy crackle: breaking a crate, ice, a shield', level: 'normal',
|
|
143
|
+
make: (p) => {
|
|
144
|
+
const r = mulberry32(p.seed ?? 5);
|
|
145
|
+
const parts = [];
|
|
146
|
+
for (let i = 0; i < 9; i++) parts.push({ at: r() * L(p, 0.12), buf: voice({ wave: 'sine', freq: P(p, 2200 + r() * 3800), env: { a: 0.0005, d: 0.05 + r() * 0.12 } }), gain: 0.5 });
|
|
147
|
+
parts.push({ at: 0, buf: voice({ wave: 'noise', seed: p.seed, filter: { type: 'highpass', freq: 2500 }, env: { a: 0.0005, d: L(p, 0.18) } }), gain: 0.8 });
|
|
148
|
+
return mixMono(parts);
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
score: {
|
|
152
|
+
about: 'a clean ding for a point or a combo (raise --pitch per combo step)', level: 'normal',
|
|
153
|
+
make: (p) => voice({ fm: { ratio: 3.5, index: 2.2, decay: 0.12 }, freq: P(p, 1047), env: { a: 0.001, d: L(p, 0.5), curve: 2.2 } }),
|
|
154
|
+
},
|
|
155
|
+
win: {
|
|
156
|
+
about: 'a short major fanfare: a round won', level: 'normal',
|
|
157
|
+
make: (p) => {
|
|
158
|
+
const notes = [[0, 0], [0.11, 4], [0.22, 7], [0.36, 12]];
|
|
159
|
+
const lead = mixMono(notes.map(([at, s], i) => ({ at: L(p, at), buf: voice({ wave: 'square', duty: 0.25, freq: P(p, 523.25) * semis(s), gate: i === 3 ? L(p, 0.5) : L(p, 0.09), env: { a: 0.002, d: 0.08, s: 0.7, r: 0.12 }, vibrato: i === 3 ? { rate: 6, depth: 0.15, delay: 0.1 } : undefined, filter: { type: 'lowpass', freq: 5000 } }) })));
|
|
160
|
+
const harm = mixMono(notes.map(([at, s], i) => ({ at: L(p, at), buf: voice({ wave: 'triangle', freq: P(p, 261.63) * semis(s), gate: i === 3 ? L(p, 0.5) : L(p, 0.09), env: { a: 0.002, d: 0.08, s: 0.8, r: 0.15 } }), gain: 0.7 })));
|
|
161
|
+
return reverb(mixMono([lead, { at: 0, buf: harm, gain: 0.8 }]), { room: 0.4, mix: 0.15, tail: 0.6 });
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
lose: {
|
|
165
|
+
about: 'a short falling minor phrase: a round lost, knocked out', level: 'normal',
|
|
166
|
+
make: (p) => {
|
|
167
|
+
const notes = [[0, 7], [0.18, 6], [0.36, 5], [0.54, 0]];
|
|
168
|
+
return reverb(mixMono(notes.map(([at, s], i) => ({ at: L(p, at), buf: voice({ wave: 'pulse', duty: 0.4, freq: P(p, 392) * semis(s - 5), gate: i === 3 ? L(p, 0.55) : L(p, 0.15), env: { a: 0.004, d: 0.1, s: 0.7, r: 0.2 }, vibrato: i === 3 ? { rate: 5, depth: 0.3, delay: 0.15 } : undefined, filter: { type: 'lowpass', freq: 2600 } }) }))), { room: 0.5, mix: 0.18, tail: 0.8 });
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
thud: {
|
|
172
|
+
about: 'a heavy wooden knock: a door, a crate landing', level: 'normal',
|
|
173
|
+
// The tom now really falls to a low note (its slide target is hertz, and used to be misread as a MIDI note four
|
|
174
|
+
// octaves up, which is what a phone was playing). A body under 150 Hz is silent on a phone, so the knock is its
|
|
175
|
+
// own voice here, as in `land`: a short triangle and a band of noise between 300 Hz and 1 kHz.
|
|
176
|
+
make: (p) => mixMono([
|
|
177
|
+
drum('tom', { tone: (p.pitch ?? 0) - 7, decay: 0.6, gain: 0.6 }),
|
|
178
|
+
{ at: 0, buf: voice({ wave: 'triangle', freq: P(p, 430), slide: { to: P(p, 250), time: 0.05 }, env: { a: 0.001, d: 0.1 } }), gain: 1.0 },
|
|
179
|
+
{ at: 0, buf: voice({ wave: 'noise', seed: p.seed, filter: { type: 'bandpass', freq: 700, q: 1.2 }, env: { a: 0.001, d: 0.06 } }), gain: 1.4 },
|
|
180
|
+
]),
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** The set a game usually needs first, by kind of game. */
|
|
185
|
+
export const KITS = {
|
|
186
|
+
arcade: ['jump', 'land', 'coin', 'powerup', 'hit', 'hurt', 'explosion', 'countdown', 'go', 'win', 'lose', 'click'],
|
|
187
|
+
platformer: ['jump', 'doublejump', 'land', 'step', 'coin', 'pickup', 'hurt', 'bounce', 'teleport', 'win', 'lose'],
|
|
188
|
+
shooter: ['shoot', 'hit', 'hurt', 'explosion', 'dash', 'pickup', 'alarm', 'countdown', 'go', 'win', 'lose'],
|
|
189
|
+
party: ['countdown', 'go', 'score', 'coin', 'hit', 'dash', 'bounce', 'tick', 'alarm', 'win', 'lose', 'confirm', 'back'],
|
|
190
|
+
ui: ['click', 'confirm', 'back', 'deny', 'tick'],
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Render one effect. spec: { name, preset, pitch, length, seed, style: clean|retro|soft, level: ui|small|normal|big|<dB> }.
|
|
195
|
+
* Returns { buf (stereo), facts }.
|
|
196
|
+
*/
|
|
197
|
+
export function renderEffect(spec) {
|
|
198
|
+
const preset = PRESETS[spec.preset ?? spec.name];
|
|
199
|
+
if (!preset) throw new Error(`no preset "${spec.preset ?? spec.name}" (presets: ${Object.keys(PRESETS).join(', ')})`);
|
|
200
|
+
const p = { pitch: Number(spec.pitch ?? 0), length: Number(spec.length ?? 1), seed: Number(spec.seed ?? 1) };
|
|
201
|
+
let buf = preset.make(p);
|
|
202
|
+
if (spec.style === 'retro') buf = crush(buf, { bits: 6, rate: 16000 });
|
|
203
|
+
if (spec.style === 'soft') {
|
|
204
|
+
const s = buf.L ? buf : { L: buf, R: buf };
|
|
205
|
+
const out = stereo(s.L.length / RATE);
|
|
206
|
+
addInto(out, s);
|
|
207
|
+
for (const ch of [out.L, out.R]) { let y = 0; for (let i = 0; i < ch.length; i++) { y += (ch[i] - y) * 0.35; ch[i] = y; } }
|
|
208
|
+
buf = out;
|
|
209
|
+
}
|
|
210
|
+
if (!buf.L) { const s = stereo(buf.length / RATE); addInto(s, buf); buf = s; }
|
|
211
|
+
dcBlock(buf);
|
|
212
|
+
// Loudness class, then the peak ceiling.
|
|
213
|
+
const cls = spec.level ?? preset.level;
|
|
214
|
+
const target = typeof cls === 'number' ? cls : LEVELS[cls] ?? LEVELS.normal;
|
|
215
|
+
const before = levels(buf);
|
|
216
|
+
if (!(peakOf(buf) > 0)) throw new Error(`"${spec.name}" rendered silence`);
|
|
217
|
+
scale(buf, dbToGain(target - before.loudestDb));
|
|
218
|
+
limit(buf, { ceiling: dbToGain(-1), releaseMs: 40 });
|
|
219
|
+
buf = trimTail(buf, { floorDb: -66 });
|
|
220
|
+
const after = levels(buf);
|
|
221
|
+
return { buf, facts: { ...after, level: cls, targetDb: target, preset: spec.preset ?? spec.name } };
|
|
222
|
+
}
|