@litfamily/lithermes 1.0.28 → 1.0.30
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/README.md +12 -9
- package/README_Ko-KR.md +12 -9
- package/assets/lithermes-plugin/README.md +15 -2
- package/assets/lithermes-plugin/__init__.py +49 -10
- package/assets/lithermes-plugin/adhd_stack.py +228 -0
- package/assets/lithermes-plugin/core.py +30 -4
- package/assets/lithermes-plugin/core_commands.py +99 -0
- package/assets/lithermes-plugin/core_contexts.py +54 -4
- package/assets/lithermes-plugin/core_contract.py +2 -2
- package/assets/lithermes-plugin/core_routing.py +126 -0
- package/assets/lithermes-plugin/diagnostics.py +3 -2
- package/assets/lithermes-plugin/lit_mark.py +75 -0
- package/assets/lithermes-plugin/payload-version.json +472 -60
- package/assets/lithermes-plugin/plugin.yaml +1 -1
- package/assets/lithermes-plugin/scientific_visualization.py +23 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/SKILL.md +7 -7
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/components.css +414 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/fonts.json +42 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/README.md +2 -4
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +4 -4
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/craft-floor.md +10 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe-rules.json +6 -1
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +76 -4
- package/assets/lithermes-plugin/skills/lit-adhd/SKILL.md +194 -0
- package/assets/lithermes-plugin/skills/lit-adhd/references/brief-contract.md +55 -0
- package/assets/lithermes-plugin/skills/lit-adhd/references/card-examples.md +86 -0
- package/assets/lithermes-plugin/skills/lit-adhd/references/status-taxonomy.md +40 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/adhd.mjs +11 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/collect.mjs +554 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/gauge.mjs +89 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/hermes-sessions.mjs +219 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/next-step.mjs +101 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/plan-task-rows.mjs +32 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/redact.mjs +42 -0
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/secret-shapes.mjs +59 -0
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/SKILL.md +7 -4
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/style-guide.md +20 -11
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-tree.md +3 -3
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/verifier-guide.md +5 -0
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/diagram_quality.py +4 -4
- package/assets/lithermes-plugin/skills/lit-docx/SKILL.md +1 -1
- package/assets/lithermes-plugin/skills/lit-hijack/SKILL.md +239 -0
- package/assets/lithermes-plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
- package/assets/lithermes-plugin/skills/lit-hijack/references/clean-room-build.md +128 -0
- package/assets/lithermes-plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
- package/assets/lithermes-plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
- package/assets/lithermes-plugin/skills/lit-hijack/references/parity-and-report.md +131 -0
- package/assets/lithermes-plugin/skills/lit-hijack/scripts/hijack.mjs +995 -0
- package/assets/lithermes-plugin/skills/lit-plan/SKILL.md +1 -0
- package/assets/lithermes-plugin/skills/lit-pptx/SKILL.md +11 -6
- package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/assets/lithermes-plugin/skills/lit-pptx/fonts/provenance.json +1 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/anti-slop-checklist.md +3 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/authoring-guide.md +1 -1
- package/assets/lithermes-plugin/skills/lit-pptx/references/consensus-protocol.md +1 -1
- package/assets/lithermes-plugin/skills/lit-pptx/references/direction-step.md +8 -2
- package/assets/lithermes-plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +175 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +156 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +141 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +160 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +183 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/layout-families.md +1 -1
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/bauhaus.md +105 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +111 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +114 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/newsroom.md +106 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/riso.md +101 -0
- package/assets/lithermes-plugin/skills/lit-pptx/scripts/craft_extras.py +49 -0
- package/assets/lithermes-plugin/skills/lit-pptx/scripts/deck_output.py +4 -2
- package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/render-pack.js +96 -10
- package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/template-registry.js +50 -6
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +103 -0
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/assets/lithermes-plugin/skills/lit-recap/SKILL.md +1 -1
- package/assets/lithermes-plugin/skills/lit-schematic/SKILL.md +144 -0
- package/assets/lithermes-plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
- package/assets/lithermes-plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
- package/assets/lithermes-plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
- package/assets/lithermes-plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/cutaway-outputs.md +70 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/isometric-craft.md +101 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/journal-figures.md +72 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/kit-api.md +134 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/pfd-and-layout.md +53 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/pid-drafting.md +103 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/pid-symbols.md +163 -0
- package/assets/lithermes-plugin/skills/lit-schematic/references/verification.md +100 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/doctor.mjs +57 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/fonts.mjs +27 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/png.mjs +132 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/route.mjs +218 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/motion.mjs +129 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/qa.mjs +92 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/render.mjs +233 -0
- package/assets/lithermes-plugin/skills/lit-schematic/scripts/scaffold.mjs +60 -0
- package/assets/lithermes-plugin/skills/lit-scientific-visualization/SKILL.md +19 -1
- package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +12 -11
- package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +56 -2
- package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +129 -4
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +9 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/blender.mjs +154 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/board.mjs +132 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/completion.mjs +48 -5
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/gate.mjs +13 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/house.mjs +44 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/scaffold.mjs +238 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/score.mjs +643 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound-dsp.mjs +21 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +171 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage-qa.mjs +23 -3
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +124 -17
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/styles.mjs +240 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +152 -10
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +67 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/depth.md +74 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/studio.md +87 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/style-bibles.md +9 -2
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +24 -8
- package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
- package/assets/lithermes-plugin/skills/litresearch/SKILL.md +4 -0
- package/assets/lithermes-plugin/skills/readme-studio/SKILL.md +1 -1
- package/assets/lithermes-plugin/skills/readme-studio/templates/hyperframes/package-lock.json +3 -3
- package/assets/lithermes-plugin/skills/readme-studio/templates/remotion/package-lock.json +3 -3
- package/assets/lithermes-plugin/skills/review-work/SKILL.md +2 -0
- package/assets/lithermes-plugin/skills/start-work/SKILL.md +2 -1
- package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
- package/package.json +1 -1
- package/readme-assets/badge-version.svg +1 -1
- package/src/lib/skillPayload.js +1 -1
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
// The per-film score (sound mode `score`, the default for rich films): a layered house-family track
|
|
2
|
+
// built in product code from the film itself, no samples and no borrowed melodies. The film's own
|
|
3
|
+
// words seed its key, mode, tempo, progression, motif and percussion; its beats give the arrangement
|
|
4
|
+
// (intro, build, the drop on the turn, break, outro) and the drop lands on a downbeat. Each stem is its
|
|
5
|
+
// own buffer with its own pattern: kick, clap, hats, perc, bass (sub and line), chords (stab and pad),
|
|
6
|
+
// lead, arrangement fx and the on-screen events. The mix high-passes every non-bass stem, pans, ducks
|
|
7
|
+
// bass, chords and lead under the kick, sends to a reverb and a tempo delay, compresses the bus and
|
|
8
|
+
// ends in the shared master stage (-16 LUFS BS.1770-4, peak under -2 dBFS). The stems are kept.
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fnv1a32, mulberry32 } from "./util.mjs";
|
|
13
|
+
import { encodeWav, masterTo, planCues, samplePeakDbfs } from "./sound.mjs";
|
|
14
|
+
import { wavetable, WAVETABLE_SIZE } from "./sound-dsp.mjs";
|
|
15
|
+
import { readStyle } from "./styles.mjs";
|
|
16
|
+
|
|
17
|
+
/** Each genre's kit: tempo band, kick pattern, bass and chord idiom, swing (share of a 16th). */
|
|
18
|
+
const KITS = Object.freeze({
|
|
19
|
+
house: { bpm: [120, 126], kick: [0, 4, 8, 12], bass: [2, 6, 10, 14], stab: [3, 6, 11, 14], openHat: true, pad: false, swing: 0 },
|
|
20
|
+
"tech-house": { bpm: [124, 128], kick: [0, 4, 8, 12], bass: [1, 2, 3, 5, 6, 7, 9, 10, 11, 13, 14, 15], stab: [6, 14], openHat: true, pad: false, swing: 0.04 },
|
|
21
|
+
"deep-house": { bpm: [118, 122], kick: [0, 4, 8, 12], bass: [3, 6, 10, 14], stab: [2, 10], openHat: true, pad: true, swing: 0.08 },
|
|
22
|
+
garage: { bpm: [130, 134], kick: [0, 10], bass: [0, 3, 7, 10], stab: [3, 7, 11, 15], openHat: false, pad: true, swing: 0.16 },
|
|
23
|
+
});
|
|
24
|
+
export const SCORE_GENRES = Object.freeze(Object.keys(KITS));
|
|
25
|
+
|
|
26
|
+
const KEY_NAMES = ["C", "Db", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"];
|
|
27
|
+
const QUALITY = { maj: [0, 4, 7], min: [0, 3, 7], maj7: [0, 4, 7, 11], min7: [0, 3, 7, 10], dom7: [0, 4, 7, 10], maj9: [0, 4, 7, 11, 14], min9: [0, 3, 7, 10, 14] };
|
|
28
|
+
const ROMAN = ["I", "bII", "II", "bIII", "III", "IV", "#IV", "V", "bVI", "VI", "bVII", "VII"];
|
|
29
|
+
const PROGRESSIONS = {
|
|
30
|
+
major: [
|
|
31
|
+
[[0, "maj"], [7, "maj"], [9, "min"], [5, "maj"]],
|
|
32
|
+
[[9, "min7"], [5, "maj7"], [0, "maj"], [7, "maj"]],
|
|
33
|
+
[[0, "maj7"], [4, "min7"], [5, "maj7"], [7, "dom7"]],
|
|
34
|
+
[[5, "maj7"], [7, "maj"], [4, "min7"], [9, "min7"]],
|
|
35
|
+
[[0, "maj7"], [9, "min7"], [2, "min7"], [7, "dom7"]],
|
|
36
|
+
[[2, "min9"], [7, "dom7"], [0, "maj9"], [0, "maj9"]],
|
|
37
|
+
],
|
|
38
|
+
minor: [
|
|
39
|
+
[[0, "min"], [8, "maj"], [3, "maj"], [10, "maj"]],
|
|
40
|
+
[[0, "min7"], [5, "min7"], [10, "maj"], [3, "maj7"]],
|
|
41
|
+
[[0, "min"], [10, "maj"], [8, "maj"], [10, "maj"]],
|
|
42
|
+
[[0, "min7"], [7, "min7"], [8, "maj7"], [5, "min7"]],
|
|
43
|
+
[[8, "maj7"], [10, "maj"], [0, "min9"], [0, "min9"]],
|
|
44
|
+
[[0, "min9"], [5, "min7"], [0, "min9"], [7, "min7"]],
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
const PENTA = { major: [0, 2, 4, 7, 9], minor: [0, 3, 5, 7, 10] };
|
|
48
|
+
const chordName = ([offset, quality]) => {
|
|
49
|
+
const minorish = quality.startsWith("min");
|
|
50
|
+
const r = ROMAN[offset];
|
|
51
|
+
return `${minorish ? r.toLowerCase() : r}${quality.replace(/^(?:maj|min)$/u, "").replace(/^min/u, "").replace(/^maj(?=\d)/u, "maj")}`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The stems in mix order with their gain, pan, high-pass and sidechain depth. */
|
|
55
|
+
const MIX = Object.freeze({
|
|
56
|
+
kick: { gain: 1.0, pan: 0, hp: 0, duck: 0, reverb: 0, delay: 0 },
|
|
57
|
+
clap: { gain: 0.5, pan: 0, hp: 180, duck: 0, reverb: 0.22, delay: 0 },
|
|
58
|
+
hats: { gain: 0.3, pan: 0.28, hp: 5000, duck: 0, reverb: 0.06, delay: 0 },
|
|
59
|
+
perc: { gain: 0.34, pan: -0.32, hp: 220, duck: 0.15, reverb: 0.16, delay: 0.1 },
|
|
60
|
+
bass: { gain: 0.62, pan: 0, hp: 0, duck: 0.85, reverb: 0, delay: 0 },
|
|
61
|
+
chords: { gain: 0.36, pan: 0, hp: 160, duck: 0.6, reverb: 0.3, delay: 0.12 },
|
|
62
|
+
lead: { gain: 0.3, pan: 0.16, hp: 260, duck: 0.3, reverb: 0.34, delay: 0.3 },
|
|
63
|
+
fx: { gain: 0.42, pan: 0, hp: 90, duck: 0, reverb: 0.28, delay: 0 },
|
|
64
|
+
events: { gain: 0.62, pan: 0, hp: 60, duck: 0, reverb: 0.12, delay: 0 },
|
|
65
|
+
});
|
|
66
|
+
export const SCORE_STEMS = Object.freeze(Object.keys(MIX));
|
|
67
|
+
|
|
68
|
+
const sectionAt = (sections, t) => sections.find((s) => t >= s.t0 && t < s.t1) ?? null;
|
|
69
|
+
|
|
70
|
+
/** The beats as an arrangement: intro, builds, the drop on the turn shot, a break before a second drop, an outro. */
|
|
71
|
+
function arrange(beats, turn) {
|
|
72
|
+
const last = beats.length - 1;
|
|
73
|
+
const after = beats.length - 1 - turn;
|
|
74
|
+
return beats.map((b, i) => {
|
|
75
|
+
let kind;
|
|
76
|
+
if (i === turn) kind = "drop";
|
|
77
|
+
else if (i < turn) kind = i === 0 ? "intro" : "build";
|
|
78
|
+
else if (i === last) kind = "outro";
|
|
79
|
+
else kind = after >= 3 && i === turn + 1 ? "break" : "drop";
|
|
80
|
+
return { kind, t0: Number(b.t0), t1: Number(b.t1), beat: i };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The film's score plan: genre, key, tempo, progression, motif, sections, kick times and arrangement
|
|
86
|
+
* fx. Pure and seeded from the treatment, so two films differ and one film always plans the same.
|
|
87
|
+
*/
|
|
88
|
+
export function planScore({ treatment, durationSec, cutTimes = [] }) {
|
|
89
|
+
const style = readStyle(treatment.style);
|
|
90
|
+
const genre = treatment.sound?.genre ?? style.score.genre;
|
|
91
|
+
const kit = KITS[genre];
|
|
92
|
+
const beats = Array.isArray(treatment.beats) && treatment.beats.length ? treatment.beats : [{ t0: 0, t1: durationSec }];
|
|
93
|
+
const fingerprint = [treatment.request, treatment.idea, treatment.story?.turn, treatment.protagonist?.name, ...beats.map((b) => `${b.purpose}|${b.t0}|${b.t1}`)].join("\n");
|
|
94
|
+
const seed = fnv1a32(fingerprint);
|
|
95
|
+
const rand = mulberry32(seed);
|
|
96
|
+
const tonic = seed % 12;
|
|
97
|
+
const mode = rand() < (genre === "deep-house" ? 0.45 : 0.62) ? "minor" : "major";
|
|
98
|
+
const bpm = kit.bpm[0] + Math.floor(rand() * (kit.bpm[1] - kit.bpm[0] + 1));
|
|
99
|
+
const progression = PROGRESSIONS[mode][Math.floor(rand() * PROGRESSIONS[mode].length)];
|
|
100
|
+
const turn = Number.isInteger(treatment.story?.beat) && treatment.story.beat < beats.length ? treatment.story.beat : Math.min(beats.length - 1, Math.floor(beats.length / 2));
|
|
101
|
+
const sections = arrange(beats, turn).map((s, i, all) => ({ ...s, t1: i === all.length - 1 ? durationSec : s.t1 }));
|
|
102
|
+
const drop = sections.find((s) => s.kind === "drop");
|
|
103
|
+
const stepSec = 60 / bpm / 4;
|
|
104
|
+
const barSec = 16 * stepSec;
|
|
105
|
+
// The grid is placed so that a bar line falls exactly on the drop.
|
|
106
|
+
const origin = drop.t0 - Math.ceil(drop.t0 / barSec - 1e-9) * barSec;
|
|
107
|
+
const steps = [];
|
|
108
|
+
for (let n = Math.ceil(-origin / stepSec - 1e-9); origin + n * stepSec < durationSec - 1e-9; n++) {
|
|
109
|
+
const pos = ((n % 16) + 16) % 16;
|
|
110
|
+
const t = origin + n * stepSec + (pos % 2 ? kit.swing * stepSec : 0);
|
|
111
|
+
if (t < 0 || t >= durationSec) continue;
|
|
112
|
+
const bar = Math.floor(n / 16);
|
|
113
|
+
steps.push({ n, pos, bar, t, section: sectionAt(sections, t) });
|
|
114
|
+
}
|
|
115
|
+
const preDrop = drop.t0 - barSec / 2;
|
|
116
|
+
const kicks = steps.filter((s) => s.section && ["build", "drop", "outro"].includes(s.section.kind) && kit.kick.includes(s.pos) && !(s.t >= preDrop && s.t < drop.t0) && !(s.section.kind === "outro" && s.t > s.section.t1 - barSec / 2)).map((s) => +s.t.toFixed(6));
|
|
117
|
+
// A motif of one bar: six to eight notes of the pentatonic scale on a random walk, its last bar lifted.
|
|
118
|
+
const motif = [];
|
|
119
|
+
let degree = Math.floor(rand() * 3);
|
|
120
|
+
for (let pos = 0; pos < 16; pos++) {
|
|
121
|
+
if (rand() < (pos % 4 === 0 ? 0.62 : 0.4)) {
|
|
122
|
+
degree = Math.max(0, Math.min(7, degree + Math.floor(rand() * 5) - 2));
|
|
123
|
+
motif.push({ pos, degree });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const percPattern = Array.from({ length: 16 }, (_, pos) => (pos % 4 !== 0 && rand() < 0.38 ? 1 + Math.floor(rand() * 3) : 0));
|
|
127
|
+
const fx = [];
|
|
128
|
+
sections.forEach((s, i) => {
|
|
129
|
+
if (i === 0) return;
|
|
130
|
+
const into = s.kind === "drop";
|
|
131
|
+
const span = Math.min(into ? 2 * barSec : barSec / 2, s.t0 - sections[i - 1].t0);
|
|
132
|
+
fx.push({ kind: "riser", start: +(s.t0 - span).toFixed(6), t: s.t0, strength: into ? 1 : 0.45 });
|
|
133
|
+
if (into) fx.push({ kind: "impact", t: s.t0 });
|
|
134
|
+
if (sections[i - 1].kind === "drop" && !into) fx.push({ kind: "downlifter", t: s.t0, end: +Math.min(s.t1, s.t0 + barSec).toFixed(6) });
|
|
135
|
+
// A reverse swell of the coming chord breathes into every change that is not a drop.
|
|
136
|
+
if (!into) fx.push({ kind: "reverse", start: +(s.t0 - Math.min(barSec / 2, s.t0 - sections[i - 1].t0)).toFixed(6), t: s.t0 });
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
genre, key: KEY_NAMES[tonic], tonic, mode, bpm, seed,
|
|
140
|
+
progression: progression.map(chordName), chords: progression,
|
|
141
|
+
motif, percPattern, sections, kicks, fx, stepSec, barSec, origin, steps,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- synthesis helpers ----
|
|
146
|
+
// The shared sample rate (SOUND.sampleRate in sound.mjs). Written as a literal because this module
|
|
147
|
+
// joins an import cycle with sound.mjs through treatment.mjs: reading it here would evaluate SOUND
|
|
148
|
+
// before sound.mjs finishes loading, depending on which module loads first.
|
|
149
|
+
const SR = 48000;
|
|
150
|
+
const midiHz = (m) => 440 * 2 ** ((m - 69) / 12);
|
|
151
|
+
// A one-pole coefficient, stable for any cutoff: below 1 Hz it would turn negative and diverge.
|
|
152
|
+
const onePole = (hz) => 1 - Math.exp((-2 * Math.PI * Math.max(1, hz)) / SR);
|
|
153
|
+
|
|
154
|
+
function rbj(type, hz, q = 0.707) {
|
|
155
|
+
const w = (2 * Math.PI * Math.min(hz, SR * 0.45)) / SR;
|
|
156
|
+
const cos = Math.cos(w);
|
|
157
|
+
const alpha = Math.sin(w) / (2 * q);
|
|
158
|
+
const a0 = 1 + alpha;
|
|
159
|
+
const b = type === "hp" ? [(1 + cos) / 2, -(1 + cos), (1 + cos) / 2] : type === "lp" ? [(1 - cos) / 2, 1 - cos, (1 - cos) / 2] : [alpha, 0, -alpha];
|
|
160
|
+
return { b: b.map((v) => v / a0), a: [1, (-2 * cos) / a0, (1 - alpha) / a0] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function filter(x, { b, a }) {
|
|
164
|
+
let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
|
|
165
|
+
for (let i = 0; i < x.length; i++) {
|
|
166
|
+
const v = b[0] * x[i] + b[1] * x1 + b[2] * x2 - a[1] * y1 - a[2] * y2;
|
|
167
|
+
x2 = x1; x1 = x[i]; y2 = y1; y1 = v;
|
|
168
|
+
x[i] = v;
|
|
169
|
+
}
|
|
170
|
+
return x;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
class Stem {
|
|
174
|
+
constructor(n) {
|
|
175
|
+
this.L = new Float64Array(n);
|
|
176
|
+
this.R = new Float64Array(n);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Add a mono buffer at sample s0, panned -1..1 (constant power), scaled by gain. */
|
|
180
|
+
add(s0, mono, pan = 0, gain = 1) {
|
|
181
|
+
const gl = Math.cos(((pan + 1) * Math.PI) / 4) * Math.SQRT2 * gain;
|
|
182
|
+
const gr = Math.sin(((pan + 1) * Math.PI) / 4) * Math.SQRT2 * gain;
|
|
183
|
+
const end = Math.min(this.L.length, s0 + mono.length);
|
|
184
|
+
for (let s = Math.max(0, s0); s < end; s++) {
|
|
185
|
+
this.L[s] += gl * mono[s - s0];
|
|
186
|
+
this.R[s] += gr * mono[s - s0];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const buffer = (sec) => new Float64Array(Math.max(1, Math.round(sec * SR)));
|
|
192
|
+
|
|
193
|
+
function kickVoice() {
|
|
194
|
+
const b = buffer(0.36);
|
|
195
|
+
let ph = 0;
|
|
196
|
+
for (let i = 0; i < b.length; i++) {
|
|
197
|
+
const t = i / SR;
|
|
198
|
+
ph += (2 * Math.PI * (46 + 110 * Math.exp(-t / 0.035))) / SR;
|
|
199
|
+
b[i] = Math.exp(-t / 0.2) * Math.sin(ph) + (t < 0.004 ? 0.3 * (1 - t / 0.004) : 0);
|
|
200
|
+
}
|
|
201
|
+
return b;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function noiseVoice(rand, sec, decay, { hp = 0, lp = 0, bursts = 1, gap = 0.011 } = {}) {
|
|
205
|
+
const b = buffer(sec);
|
|
206
|
+
for (let i = 0; i < b.length; i++) {
|
|
207
|
+
const t = i / SR;
|
|
208
|
+
let env = 0;
|
|
209
|
+
for (let k = 0; k < bursts; k++) if (t >= k * gap) env = Math.max(env, Math.exp(-(t - k * gap) / (k === bursts - 1 ? decay : 0.006)));
|
|
210
|
+
b[i] = (rand() * 2 - 1) * env;
|
|
211
|
+
}
|
|
212
|
+
if (hp) filter(b, rbj("hp", hp));
|
|
213
|
+
if (lp) filter(b, rbj("lp", lp));
|
|
214
|
+
return b;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function toneVoice(hz, sec, decay, { partials = [1], attack = 0.002 } = {}) {
|
|
218
|
+
const b = buffer(sec);
|
|
219
|
+
for (let i = 0; i < b.length; i++) {
|
|
220
|
+
const t = i / SR;
|
|
221
|
+
let v = 0;
|
|
222
|
+
partials.forEach((a, k) => { v += a * Math.sin(2 * Math.PI * hz * (k + 1) * t); });
|
|
223
|
+
b[i] = v * Math.min(1, t / attack) * Math.exp(-t / decay);
|
|
224
|
+
}
|
|
225
|
+
return b;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const SAW = wavetable([1, 0.5, 0.33, 0.25, 0.2, 0.16, 0.14, 0.12, 0.1, 0.08]);
|
|
229
|
+
const SQUARE = wavetable([1, 0, 0.33, 0, 0.2, 0, 0.14, 0, 0.11]);
|
|
230
|
+
|
|
231
|
+
/** A filtered table voice: notes as detuned pairs, an amp envelope and a low-pass that closes over the note. */
|
|
232
|
+
function tableVoice(table, notes, sec, { attack = 0.004, decay = 0.25, sustain = 0, release = 0.05, cutoff = 2400, cutoffEnd = 600, detune = 8, phase = 0 } = {}) {
|
|
233
|
+
const b = buffer(sec + release);
|
|
234
|
+
const hold = Math.round(sec * SR);
|
|
235
|
+
for (const note of notes) {
|
|
236
|
+
for (const cents of [-detune, detune]) {
|
|
237
|
+
const inc = (midiHz(note) * 2 ** (cents / 1200) * WAVETABLE_SIZE) / SR;
|
|
238
|
+
let ph = (phase * WAVETABLE_SIZE + (cents > 0 ? WAVETABLE_SIZE / 3 : 0)) % WAVETABLE_SIZE;
|
|
239
|
+
for (let i = 0; i < b.length; i++) {
|
|
240
|
+
const t = i / SR;
|
|
241
|
+
const env = Math.min(1, t / attack) * (sustain + (1 - sustain) * Math.exp(-t / decay)) * (i < hold ? 1 : Math.max(0, 1 - (i - hold) / (release * SR)));
|
|
242
|
+
ph = (ph + inc) % WAVETABLE_SIZE;
|
|
243
|
+
b[i] += (env * table[ph | 0]) / notes.length;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
let f = 0;
|
|
248
|
+
for (let i = 0; i < b.length; i++) {
|
|
249
|
+
const u = Math.min(1, i / Math.max(1, b.length));
|
|
250
|
+
f += onePole(cutoff + (cutoffEnd - cutoff) * u) * (b[i] - f);
|
|
251
|
+
b[i] = f;
|
|
252
|
+
}
|
|
253
|
+
return b;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function voicing(plan, chord, octave = 48) {
|
|
257
|
+
const [offset, quality] = chord;
|
|
258
|
+
const root = octave + plan.tonic + offset - (offset > 6 ? 12 : 0);
|
|
259
|
+
return QUALITY[quality].map((iv) => root + iv);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The chord under time t: one per bar from the grid's origin, the last bar of the film resolves home. */
|
|
263
|
+
function chordAt(plan, t, durationSec) {
|
|
264
|
+
const bar = Math.floor((t - plan.origin) / plan.barSec);
|
|
265
|
+
if (t >= durationSec - plan.barSec && plan.sections.length > 1) return [0, plan.mode === "major" ? "maj7" : "min9"];
|
|
266
|
+
return plan.chords[((bar % plan.chords.length) + plan.chords.length) % plan.chords.length];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---- the score ----
|
|
270
|
+
|
|
271
|
+
/** Build the score: the mixed track, every stem, the plan and the cue list. */
|
|
272
|
+
export function buildScore({ treatment, frameCount, fps, cutTimes = [] }) {
|
|
273
|
+
const sampleCount = Math.round((frameCount * SR) / fps);
|
|
274
|
+
const durationSec = sampleCount / SR;
|
|
275
|
+
const plan = planScore({ treatment, durationSec, cutTimes });
|
|
276
|
+
const beats = Array.isArray(treatment.beats) ? treatment.beats : [];
|
|
277
|
+
const cues = planCues({ beats, durationSec, cutTimes, events: treatment.sound?.events });
|
|
278
|
+
const rand = mulberry32(plan.seed ^ 0x9e3779b9);
|
|
279
|
+
const stems = Object.fromEntries(SCORE_STEMS.map((k) => [k, new Stem(sampleCount)]));
|
|
280
|
+
const at = (t) => Math.round(t * SR);
|
|
281
|
+
const kick = kickVoice();
|
|
282
|
+
const drop = plan.sections.find((s) => s.kind === "drop");
|
|
283
|
+
|
|
284
|
+
for (const t of plan.kicks) stems.kick.add(at(t), kick);
|
|
285
|
+
for (const step of plan.steps) {
|
|
286
|
+
const sec = step.section;
|
|
287
|
+
if (!sec) continue;
|
|
288
|
+
const { kind } = sec;
|
|
289
|
+
const full = kind === "build" || kind === "drop";
|
|
290
|
+
const progress = (step.t - sec.t0) / Math.max(1e-6, sec.t1 - sec.t0);
|
|
291
|
+
// Clap on two and four; a snare roll that tightens over the last bar into the drop.
|
|
292
|
+
if (full && (step.pos === 4 || step.pos === 12) && !(step.t >= drop.t0 - plan.barSec && step.t < drop.t0)) stems.clap.add(at(step.t), noiseVoice(rand, 0.3, 0.13, { hp: 900, lp: 5200, bursts: 3 }), 0, 1);
|
|
293
|
+
if (step.t >= drop.t0 - plan.barSec && step.t < drop.t0) {
|
|
294
|
+
const u = 1 - (drop.t0 - step.t) / plan.barSec;
|
|
295
|
+
if (u < 0.5 ? step.pos % 2 === 0 : true) stems.clap.add(at(step.t), noiseVoice(rand, 0.12, 0.05, { hp: 700, lp: 6000 }), 0, 0.35 + 0.65 * u);
|
|
296
|
+
if (u >= 0.75) stems.clap.add(at(step.t + plan.stepSec / 2), noiseVoice(rand, 0.08, 0.03, { hp: 700, lp: 6000 }), 0, 0.8 * u);
|
|
297
|
+
}
|
|
298
|
+
// Hats: closed sixteenths with offbeat accents, open hats on the offbeat in the drop.
|
|
299
|
+
const hatsOn = kind === "intro" ? step.pos % 2 === 0 : kind === "break" ? false : kind === "outro" ? step.pos % 2 === 0 : true;
|
|
300
|
+
if (hatsOn) stems.hats.add(at(step.t), noiseVoice(rand, 0.06, 0.018, { hp: 7000 }), 0, (step.pos % 4 === 2 ? 0.9 : 0.5) * (kind === "intro" ? 0.35 + 0.5 * progress : 1));
|
|
301
|
+
if (kind === "drop" && KITS[plan.genre].openHat && step.pos % 4 === 2) stems.hats.add(at(step.t), noiseVoice(rand, 0.3, 0.12, { hp: 6000 }), -0.4, 0.55);
|
|
302
|
+
// Perc: the film's own sixteen-step pattern of rims and toms, everywhere but the break's first half.
|
|
303
|
+
const hit = plan.percPattern[step.pos];
|
|
304
|
+
if (hit && !(kind === "break" && progress < 0.5)) {
|
|
305
|
+
const hz = [0, 520, 380, 760][hit];
|
|
306
|
+
const tone = toneVoice(hz, 0.16, hit === 3 ? 0.03 : 0.06, { partials: [1, 0.3] });
|
|
307
|
+
const click = noiseVoice(rand, 0.02, 0.004, { hp: 2000 });
|
|
308
|
+
for (let i = 0; i < click.length; i++) tone[i] += 0.5 * click[i];
|
|
309
|
+
stems.perc.add(at(step.t), tone, hit === 2 ? -0.5 : 0.35, kind === "intro" ? 0.5 : 0.85);
|
|
310
|
+
}
|
|
311
|
+
if (kind !== "intro" && kind !== "break" && step.pos % 2 === 1) stems.perc.add(at(step.t), noiseVoice(rand, 0.05, 0.012, { hp: 4500 }), 0.6, 0.25);
|
|
312
|
+
// Bass line: the kit's pattern on the chord root, from the build on, out in the break.
|
|
313
|
+
const chord = chordAt(plan, step.t, durationSec);
|
|
314
|
+
const root = 36 + plan.tonic + chord[0] - (chord[0] > 6 ? 12 : 0);
|
|
315
|
+
if ((kind === "build" && progress > 0.25) || kind === "drop" || (kind === "outro" && progress < 0.5)) {
|
|
316
|
+
if (KITS[plan.genre].bass.includes(step.pos)) {
|
|
317
|
+
const octave = step.pos % 8 === 6 && plan.genre !== "deep-house" ? 12 : 0;
|
|
318
|
+
stems.bass.add(at(step.t), tableVoice(SAW, [root + octave], plan.stepSec * 0.9, { decay: 0.12, sustain: 0.5, cutoff: 1400, cutoffEnd: 300, detune: 4 }), 0, 0.75);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Chord stabs on the kit's steps in builds and drops.
|
|
322
|
+
if ((kind === "build" || kind === "drop") && KITS[plan.genre].stab.includes(step.pos)) {
|
|
323
|
+
stems.chords.add(at(step.t), tableVoice(SAW, voicing(plan, chord, 60), plan.stepSec * 1.4, { decay: 0.09, cutoff: kind === "drop" ? 4200 : 1800 + 2200 * progress, cutoffEnd: 900, detune: 11 }), 0, 0.9);
|
|
324
|
+
}
|
|
325
|
+
// Lead: the motif in the drop and the break, an octave up and drier in the break.
|
|
326
|
+
if (kind === "drop" || kind === "break") {
|
|
327
|
+
const note = plan.motif.find((m) => m.pos === step.pos);
|
|
328
|
+
if (note) {
|
|
329
|
+
const scale = PENTA[plan.mode];
|
|
330
|
+
const lift = step.bar % 4 === 3 && note.pos >= 8 ? 2 : 0;
|
|
331
|
+
const d = note.degree + lift;
|
|
332
|
+
const midi = 72 + plan.tonic + scale[d % 5] + 12 * Math.floor(d / 5) + (kind === "break" ? 12 : 0);
|
|
333
|
+
stems.lead.add(at(step.t), tableVoice(SQUARE, [midi], plan.stepSec * 1.8, { decay: 0.16, sustain: 0.2, cutoff: 5200, cutoffEnd: 1400, detune: 6 }), 0, kind === "break" ? 0.6 : 1);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Sustained layers, one chord per bar: the sub under the drop and outro, the pad in the intro,
|
|
339
|
+
// break and outro (and under the drop for the pad genres), opening its filter across the section.
|
|
340
|
+
for (let bar = Math.floor(-plan.origin / plan.barSec) - 1; plan.origin + bar * plan.barSec < durationSec; bar++) {
|
|
341
|
+
const t0 = Math.max(0, plan.origin + bar * plan.barSec);
|
|
342
|
+
const t1 = Math.min(durationSec, plan.origin + (bar + 1) * plan.barSec);
|
|
343
|
+
if (t1 <= t0) continue;
|
|
344
|
+
const chord = chordAt(plan, (t0 + t1) / 2, durationSec);
|
|
345
|
+
const sec = sectionAt(plan.sections, (t0 + t1) / 2);
|
|
346
|
+
if (!sec) continue;
|
|
347
|
+
const root = 36 + plan.tonic + chord[0] - (chord[0] > 6 ? 12 : 0);
|
|
348
|
+
if (sec.kind === "drop" || sec.kind === "outro") {
|
|
349
|
+
const sub = toneVoice(midiHz(root), t1 - t0 + 0.05, 1e6, { attack: 0.01 });
|
|
350
|
+
const tail = Math.round(0.05 * SR);
|
|
351
|
+
for (let i = 0; i < tail; i++) sub[sub.length - 1 - i] *= i / tail;
|
|
352
|
+
stems.bass.add(at(t0), sub, 0, 0.55);
|
|
353
|
+
}
|
|
354
|
+
const padHere = sec.kind === "intro" || sec.kind === "break" || sec.kind === "outro" || (KITS[plan.genre].pad && sec.kind === "drop") || sec.kind === "build";
|
|
355
|
+
if (padHere) {
|
|
356
|
+
// A bar may start before its section (the grid can begin before 0 s), so clamp the progress.
|
|
357
|
+
const u = Math.min(1, Math.max(0, (t0 - sec.t0) / Math.max(1e-6, sec.t1 - sec.t0)));
|
|
358
|
+
const open = sec.kind === "intro" || sec.kind === "build" ? 700 + 3000 * u : sec.kind === "break" ? 2600 : 1800;
|
|
359
|
+
stems.chords.add(at(t0), tableVoice(SAW, voicing(plan, chord, 48), t1 - t0, { attack: 0.25, decay: 1e6, sustain: 1, release: 0.4, cutoff: open, cutoffEnd: open * 1.3, detune: 14, phase: (bar * 0.37) % 1 }), 0, sec.kind === "drop" ? 0.35 : 0.6);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Arrangement fx: risers into each change (the strongest into the drop), the impact on the drop, a
|
|
364
|
+
// downlifter after it, and a reverse swell into each change that is not a drop.
|
|
365
|
+
for (const f of plan.fx) {
|
|
366
|
+
if (f.kind === "riser") {
|
|
367
|
+
const len = Math.max(1, at(f.t) - at(f.start));
|
|
368
|
+
const b = new Float64Array(len);
|
|
369
|
+
let lp = 0;
|
|
370
|
+
let ph = 0;
|
|
371
|
+
for (let i = 0; i < len; i++) {
|
|
372
|
+
const u = i / len;
|
|
373
|
+
lp += onePole(300 + 7000 * u * u) * (rand() * 2 - 1 - lp);
|
|
374
|
+
ph += (2 * Math.PI * midiHz(60 + plan.tonic + 24 * u)) / SR;
|
|
375
|
+
b[i] = f.strength * u * u * (0.8 * lp + 0.25 * Math.sin(ph));
|
|
376
|
+
}
|
|
377
|
+
stems.fx.add(at(f.start), b, 0, 1);
|
|
378
|
+
} else if (f.kind === "impact") {
|
|
379
|
+
const boom = toneVoice(42, 1.6, 0.5, { attack: 0.001 });
|
|
380
|
+
const crash = noiseVoice(rand, 1.4, 0.45, { hp: 3000 });
|
|
381
|
+
for (let i = 0; i < boom.length; i++) boom[i] = 0.9 * boom[i] + 0.35 * (crash[i] ?? 0);
|
|
382
|
+
stems.fx.add(at(f.t), boom, 0, 1);
|
|
383
|
+
} else if (f.kind === "reverse") {
|
|
384
|
+
const sec = Math.max(1 / SR, f.t - f.start);
|
|
385
|
+
const tail = tableVoice(SAW, voicing(plan, chordAt(plan, f.t, durationSec), 60), sec, { attack: 0.002, decay: sec * 0.45, sustain: 0, release: 0.01, cutoff: 4200, cutoffEnd: 900, detune: 10 });
|
|
386
|
+
tail.reverse();
|
|
387
|
+
stems.fx.add(at(f.start), tail, 0, 0.5);
|
|
388
|
+
} else {
|
|
389
|
+
const len = Math.max(1, at(f.end) - at(f.t));
|
|
390
|
+
const b = new Float64Array(len);
|
|
391
|
+
let lp = 0;
|
|
392
|
+
for (let i = 0; i < len; i++) {
|
|
393
|
+
const u = 1 - i / len;
|
|
394
|
+
lp += onePole(200 + 5000 * u * u) * (rand() * 2 - 1 - lp);
|
|
395
|
+
b[i] = 0.6 * u * u * lp;
|
|
396
|
+
}
|
|
397
|
+
stems.fx.add(at(f.t), b, 0, 1);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Cues: the beats' own asks (a hit on a cut, a cadence) and every on-screen event, on its frame.
|
|
402
|
+
const tonicMidi = 72 + plan.tonic;
|
|
403
|
+
for (const cue of cues) {
|
|
404
|
+
if (cue.kind === "hit") stems.fx.add(at(cue.t), toneVoice(55, 0.5, 0.18, { partials: [1, 0.4] }), 0, 0.7);
|
|
405
|
+
else if (cue.kind === "cadence") stems.chords.add(at(cue.t), tableVoice(SAW, voicing(plan, [0, plan.mode === "major" ? "maj9" : "min9"], 60), 1.2, { attack: 0.01, decay: 0.8, cutoff: 3200, cutoffEnd: 800 }), 0, 0.7);
|
|
406
|
+
else if (cue.kind === "event") {
|
|
407
|
+
if (cue.sound === "tap") {
|
|
408
|
+
const tone = toneVoice(midiHz(tonicMidi + 12 + (plan.mode === "major" ? 7 : 3)), 0.3, 0.07);
|
|
409
|
+
const click = noiseVoice(rand, 0.03, 0.006, { hp: 2500 });
|
|
410
|
+
for (let i = 0; i < click.length; i++) tone[i] += 1.4 * click[i];
|
|
411
|
+
stems.events.add(at(cue.t), tone, 0.1, 1);
|
|
412
|
+
} else if (cue.sound === "whoosh") {
|
|
413
|
+
const len = at(0.4);
|
|
414
|
+
const b = new Float64Array(len + at(0.08));
|
|
415
|
+
let lp = 0;
|
|
416
|
+
for (let i = 0; i < b.length; i++) {
|
|
417
|
+
const u = i < len ? i / len : 1 - (i - len) / at(0.08);
|
|
418
|
+
lp += onePole(300 + 3200 * u) * (rand() * 2 - 1 - lp);
|
|
419
|
+
b[i] = 0.7 * u * u * lp;
|
|
420
|
+
}
|
|
421
|
+
stems.events.add(at(cue.t) - len, b, 0, 1);
|
|
422
|
+
} else if (cue.sound === "chime") {
|
|
423
|
+
stems.events.add(at(cue.t), toneVoice(midiHz(tonicMidi + 12), 1.4, 0.5, { partials: [1, 0, 0.2] }), -0.2, 0.5);
|
|
424
|
+
stems.events.add(at(cue.t + 0.09), toneVoice(midiHz(tonicMidi + 19), 1.4, 0.5, { partials: [1, 0, 0.2] }), 0.2, 0.45);
|
|
425
|
+
} else {
|
|
426
|
+
const boom = toneVoice(50, 0.6, 0.2, { attack: 0.001 });
|
|
427
|
+
const snap = noiseVoice(rand, 0.4, 0.1, { hp: 1200 });
|
|
428
|
+
for (let i = 0; i < boom.length; i++) boom[i] = 0.8 * boom[i] + 0.6 * (snap[i] ?? 0);
|
|
429
|
+
stems.events.add(at(cue.t), boom, 0, 1);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// The mix: high-pass, gain and pan per stem, the sidechain from the kick times, then the sends.
|
|
435
|
+
const duck = new Float64Array(sampleCount).fill(1);
|
|
436
|
+
for (const t of plan.kicks) {
|
|
437
|
+
const s0 = at(t);
|
|
438
|
+
for (let i = 0; i < at(0.4) && s0 + i < sampleCount; i++) {
|
|
439
|
+
const dt = i / SR;
|
|
440
|
+
const env = dt < 0.004 ? dt / 0.004 : Math.exp(-(dt - 0.004) / 0.07);
|
|
441
|
+
duck[s0 + i] = Math.min(duck[s0 + i], 1 - env);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const reverbIn = [new Float64Array(sampleCount), new Float64Array(sampleCount)];
|
|
445
|
+
const delayIn = [new Float64Array(sampleCount), new Float64Array(sampleCount)];
|
|
446
|
+
const processed = {};
|
|
447
|
+
for (const name of SCORE_STEMS) {
|
|
448
|
+
const m = MIX[name];
|
|
449
|
+
const { L, R } = stems[name];
|
|
450
|
+
if (m.hp) {
|
|
451
|
+
filter(L, rbj("hp", m.hp));
|
|
452
|
+
filter(R, rbj("hp", m.hp));
|
|
453
|
+
}
|
|
454
|
+
const pl = Math.cos(((m.pan + 1) * Math.PI) / 4) * Math.SQRT2;
|
|
455
|
+
const pr = Math.sin(((m.pan + 1) * Math.PI) / 4) * Math.SQRT2;
|
|
456
|
+
for (let s = 0; s < sampleCount; s++) {
|
|
457
|
+
const g = m.gain * (1 - m.duck * (1 - duck[s]));
|
|
458
|
+
L[s] *= g * pl;
|
|
459
|
+
R[s] *= g * pr;
|
|
460
|
+
reverbIn[0][s] += m.reverb * L[s];
|
|
461
|
+
reverbIn[1][s] += m.reverb * R[s];
|
|
462
|
+
delayIn[0][s] += m.delay * L[s];
|
|
463
|
+
delayIn[1][s] += m.delay * R[s];
|
|
464
|
+
}
|
|
465
|
+
processed[name] = { L, R };
|
|
466
|
+
}
|
|
467
|
+
const reverb = reverbReturn(reverbIn);
|
|
468
|
+
const delay = delayReturn(delayIn, (60 / plan.bpm) * 0.75, (60 / plan.bpm) * 0.5);
|
|
469
|
+
|
|
470
|
+
// The bus: every stem and both returns, compressed, faded at the end, then mastered.
|
|
471
|
+
const busL = new Float64Array(sampleCount);
|
|
472
|
+
const busR = new Float64Array(sampleCount);
|
|
473
|
+
for (const { L, R } of [...Object.values(processed), reverb, delay]) {
|
|
474
|
+
for (let s = 0; s < sampleCount; s++) {
|
|
475
|
+
busL[s] += L[s];
|
|
476
|
+
busR[s] += R[s];
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
let peak = 0;
|
|
480
|
+
for (let s = 0; s < sampleCount; s++) peak = Math.max(peak, Math.abs(busL[s]), Math.abs(busR[s]));
|
|
481
|
+
const norm = peak > 0 ? 1 / peak : 1;
|
|
482
|
+
const comp = compressor(busL, busR, norm);
|
|
483
|
+
const fade = Math.min(at(0.4), Math.floor(sampleCount / 10));
|
|
484
|
+
for (let i = 0; i < fade; i++) {
|
|
485
|
+
busL[sampleCount - 1 - i] *= i / fade;
|
|
486
|
+
busR[sampleCount - 1 - i] *= i / fade;
|
|
487
|
+
}
|
|
488
|
+
const { left, right, gain, lufs } = masterTo(busL, busR);
|
|
489
|
+
const scale = norm * gain * comp.meanGain;
|
|
490
|
+
const out = (st) => ({ left: Float32Array.from(st.L, (v) => v * scale), right: Float32Array.from(st.R, (v) => v * scale) });
|
|
491
|
+
return {
|
|
492
|
+
left, right, sampleCount, plan, cues,
|
|
493
|
+
stems: Object.fromEntries(SCORE_STEMS.map((k) => [k, out(processed[k])])),
|
|
494
|
+
returns: { reverb: out(reverb), delay: out(delay) },
|
|
495
|
+
lufs: +lufs.toFixed(2), peakDbfs: +samplePeakDbfs([left, right]).toFixed(2), loudnessMethod: "BS.1770-4",
|
|
496
|
+
bus: { ratio: comp.ratio, thresholdDbfs: comp.thresholdDbfs, maxReductionDb: +comp.maxReductionDb.toFixed(2) },
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** A small Schroeder reverb per channel: four damped combs into two all-passes, the right channel offset. */
|
|
501
|
+
function reverbReturn([inL, inR]) {
|
|
502
|
+
const pre = rbj("hp", 300);
|
|
503
|
+
const run = (input, spread) => {
|
|
504
|
+
const x = filter(Float64Array.from(input), pre);
|
|
505
|
+
const y = new Float64Array(x.length);
|
|
506
|
+
for (const d of [1687, 1601, 1491, 1422].map((n) => n + spread)) {
|
|
507
|
+
const line = new Float64Array(d);
|
|
508
|
+
let k = 0;
|
|
509
|
+
let damp = 0;
|
|
510
|
+
for (let i = 0; i < x.length; i++) {
|
|
511
|
+
const out = line[k];
|
|
512
|
+
damp = out * 0.7 + damp * 0.3;
|
|
513
|
+
line[k] = x[i] + damp * 0.8;
|
|
514
|
+
y[i] += out * 0.25;
|
|
515
|
+
k = (k + 1) % d;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
for (const d of [556, 441].map((n) => n + spread)) {
|
|
519
|
+
const line = new Float64Array(d);
|
|
520
|
+
let k = 0;
|
|
521
|
+
for (let i = 0; i < y.length; i++) {
|
|
522
|
+
const buf = line[k];
|
|
523
|
+
const v = -0.5 * y[i] + buf;
|
|
524
|
+
line[k] = y[i] + 0.5 * buf;
|
|
525
|
+
y[i] = v;
|
|
526
|
+
k = (k + 1) % d;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return y;
|
|
530
|
+
};
|
|
531
|
+
return { L: run(inL, 0), R: run(inR, 23) };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** A tempo delay: a dotted eighth left and an eighth right, low-passed in the feedback path. */
|
|
535
|
+
function delayReturn([inL, inR], leftSec, rightSec) {
|
|
536
|
+
const run = (input, sec) => {
|
|
537
|
+
const d = Math.max(1, Math.round(sec * SR));
|
|
538
|
+
const y = new Float64Array(input.length);
|
|
539
|
+
const c = onePole(3200);
|
|
540
|
+
let lp = 0;
|
|
541
|
+
for (let i = 0; i < input.length; i++) {
|
|
542
|
+
const back = i >= d ? y[i - d] : 0;
|
|
543
|
+
lp += c * (back - lp);
|
|
544
|
+
y[i] = (i >= d ? input[i - d] : 0) + 0.36 * lp;
|
|
545
|
+
}
|
|
546
|
+
return y;
|
|
547
|
+
};
|
|
548
|
+
return { L: run(inL, leftSec), R: run(inR, rightSec) };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Bus compression in place on the peak-normalized bus: 10 ms attack, 150 ms release, 2.5:1 over -10 dBFS. */
|
|
552
|
+
function compressor(L, R, norm) {
|
|
553
|
+
const thresholdDbfs = -10;
|
|
554
|
+
const ratio = 2.5;
|
|
555
|
+
const att = Math.exp(-1 / (0.01 * SR));
|
|
556
|
+
const rel = Math.exp(-1 / (0.15 * SR));
|
|
557
|
+
let env = 0;
|
|
558
|
+
let sum = 0;
|
|
559
|
+
let maxReductionDb = 0;
|
|
560
|
+
for (let s = 0; s < L.length; s++) {
|
|
561
|
+
const x = Math.max(Math.abs(L[s]), Math.abs(R[s])) * norm;
|
|
562
|
+
env = x > env ? att * env + (1 - att) * x : rel * env + (1 - rel) * x;
|
|
563
|
+
const level = 20 * Math.log10(Math.max(env, 1e-9));
|
|
564
|
+
const over = Math.max(0, level - thresholdDbfs);
|
|
565
|
+
const reductionDb = over - over / ratio;
|
|
566
|
+
maxReductionDb = Math.max(maxReductionDb, reductionDb);
|
|
567
|
+
const g = 10 ** (-reductionDb / 20);
|
|
568
|
+
L[s] *= g;
|
|
569
|
+
R[s] *= g;
|
|
570
|
+
sum += g;
|
|
571
|
+
}
|
|
572
|
+
return { thresholdDbfs, ratio, maxReductionDb, meanGain: L.length ? sum / L.length : 1 };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const rmsDbfs = (st) => {
|
|
576
|
+
let s = 0;
|
|
577
|
+
for (let i = 0; i < st.left.length; i++) s += st.left[i] * st.left[i] + st.right[i] * st.right[i];
|
|
578
|
+
const v = Math.sqrt(s / Math.max(1, 2 * st.left.length));
|
|
579
|
+
return v > 0 ? +(20 * Math.log10(v)).toFixed(2) : -Infinity;
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
/** How far the bass dips right after a kick in the drop, against 150-210 ms later (dB). */
|
|
583
|
+
export function sidechainDepth(score) {
|
|
584
|
+
const drop = score.plan.sections.find((x) => x.kind === "drop");
|
|
585
|
+
const bass = score.stems.bass.left;
|
|
586
|
+
const win = (a, b) => {
|
|
587
|
+
let s = 0;
|
|
588
|
+
for (let i = a; i < b; i++) s += bass[i] * bass[i];
|
|
589
|
+
return Math.sqrt(s / Math.max(1, b - a));
|
|
590
|
+
};
|
|
591
|
+
let near = 0;
|
|
592
|
+
let away = 0;
|
|
593
|
+
for (const k of score.plan.kicks.filter((t) => t >= drop.t0 && t + 0.25 < drop.t1)) {
|
|
594
|
+
const i = Math.round(k * SR);
|
|
595
|
+
near += win(i + Math.round(0.005 * SR), i + Math.round(0.035 * SR));
|
|
596
|
+
away += win(i + Math.round(0.15 * SR), i + Math.round(0.21 * SR));
|
|
597
|
+
}
|
|
598
|
+
return near > 0 && away > 0 ? +(20 * Math.log10(away / near)).toFixed(2) : 0;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Write `sound/score.wav`, every stem and return under `sound/stems/`, and `sound-cues.json`. */
|
|
602
|
+
export function writeScore({ treatment, frameCount, fps, cutTimes = [], dir }) {
|
|
603
|
+
const score = buildScore({ treatment, frameCount, fps, cutTimes });
|
|
604
|
+
const soundDir = path.join(dir, "sound");
|
|
605
|
+
mkdirSync(path.join(soundDir, "stems"), { recursive: true });
|
|
606
|
+
const file = path.join(soundDir, "score.wav");
|
|
607
|
+
const wav = encodeWav({ left: score.left, right: score.right });
|
|
608
|
+
writeFileSync(file, wav);
|
|
609
|
+
const stems = [];
|
|
610
|
+
for (const [name, st] of [...Object.entries(score.stems), ...Object.entries(score.returns).map(([k, v]) => [`return-${k}`, v])]) {
|
|
611
|
+
const rel = `sound/stems/${name}.wav`;
|
|
612
|
+
writeFileSync(path.join(dir, rel), encodeWav(st));
|
|
613
|
+
stems.push({ name, file: rel, rmsDbfs: rmsDbfs(st), ...(name.startsWith("return-") ? { kind: "return" } : { gain: MIX[name].gain, pan: MIX[name].pan, highpassHz: MIX[name].hp, sidechain: MIX[name].duck }) });
|
|
614
|
+
}
|
|
615
|
+
const { plan } = score;
|
|
616
|
+
const summary = {
|
|
617
|
+
schema: "lithermes.motion-sound-cues/v1",
|
|
618
|
+
mode: "score",
|
|
619
|
+
file,
|
|
620
|
+
sha256: createHash("sha256").update(wav).digest("hex"),
|
|
621
|
+
sampleRate: SR,
|
|
622
|
+
sampleCount: score.sampleCount,
|
|
623
|
+
durationSec: +(score.sampleCount / SR).toFixed(6),
|
|
624
|
+
frameCount,
|
|
625
|
+
fps,
|
|
626
|
+
genre: plan.genre,
|
|
627
|
+
key: `${plan.key} ${plan.mode}`,
|
|
628
|
+
tempo: plan.bpm,
|
|
629
|
+
progression: plan.progression.join("–"),
|
|
630
|
+
sections: plan.sections.map(({ kind, t0, t1, beat }) => ({ kind, t0, t1, beat })),
|
|
631
|
+
fx: plan.fx,
|
|
632
|
+
stems,
|
|
633
|
+
sidechainDb: sidechainDepth(score),
|
|
634
|
+
bus: score.bus,
|
|
635
|
+
lufs: score.lufs,
|
|
636
|
+
peakDbfs: score.peakDbfs,
|
|
637
|
+
loudnessMethod: score.loudnessMethod,
|
|
638
|
+
cutTimes: cutTimes.map((t) => Math.round(t * 10000) / 10000),
|
|
639
|
+
cues: score.cues,
|
|
640
|
+
};
|
|
641
|
+
writeFileSync(path.join(dir, "sound-cues.json"), `${JSON.stringify(summary, null, 2)}\n`);
|
|
642
|
+
return summary;
|
|
643
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Shared wavetable synthesis: a normalized single-cycle table of additive
|
|
2
|
+
// partials. A leaf module on purpose: score.mjs builds its timbre tables at
|
|
3
|
+
// load time, and score joins an import cycle with sound.mjs through
|
|
4
|
+
// treatment.mjs, so anything score touches while loading must not live in
|
|
5
|
+
// sound.mjs.
|
|
6
|
+
const TABLE = 4096;
|
|
7
|
+
export { TABLE as WAVETABLE_SIZE };
|
|
8
|
+
|
|
9
|
+
export function wavetable(harmonics) {
|
|
10
|
+
const t = new Float32Array(TABLE + 1);
|
|
11
|
+
let peak = 0;
|
|
12
|
+
for (let i = 0; i < TABLE; i++) {
|
|
13
|
+
let v = 0;
|
|
14
|
+
harmonics.forEach((a, k) => { if (a) v += a * Math.sin((2 * Math.PI * (k + 1) * i) / TABLE); });
|
|
15
|
+
t[i] = v;
|
|
16
|
+
peak = Math.max(peak, Math.abs(v));
|
|
17
|
+
}
|
|
18
|
+
for (let i = 0; i < TABLE; i++) t[i] /= peak || 1;
|
|
19
|
+
t[TABLE] = t[0];
|
|
20
|
+
return t;
|
|
21
|
+
}
|