@prestyj/cli 5.28.1 → 5.29.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/assets/motion/bin/contact-sheet.mjs +9 -3
- package/assets/motion/bin/cues.mjs +337 -0
- package/assets/motion/bin/library.mjs +53 -10
- package/assets/motion/bin/motion-blur.mjs +943 -0
- package/assets/motion/bin/motion-check.mjs +354 -3
- package/assets/motion/bin/music-fit.mjs +436 -0
- package/assets/motion/bin/pdf-extract.mjs +22 -10
- package/assets/motion/bin/reference-study.mjs +346 -0
- package/assets/motion/bin/score-synth.mjs +1052 -93
- package/assets/motion/library/README.md +50 -14
- package/assets/motion/library/kit/moves.js +1981 -0
- package/assets/motion/library/library.json +232 -0
- package/assets/motion/library/pieces/camera-rig/meta.json +13 -0
- package/assets/motion/library/pieces/camera-rig/piece.html +153 -0
- package/assets/motion/library/pieces/camera-rig/preview.jpg +0 -0
- package/assets/motion/library/pieces/chain-knock/meta.json +13 -0
- package/assets/motion/library/pieces/chain-knock/piece.html +195 -0
- package/assets/motion/library/pieces/chain-knock/preview.jpg +0 -0
- package/assets/motion/library/pieces/gather-to-logo/meta.json +13 -0
- package/assets/motion/library/pieces/gather-to-logo/piece.html +159 -0
- package/assets/motion/library/pieces/gather-to-logo/preview.jpg +0 -0
- package/assets/motion/library/pieces/morph-carry/meta.json +13 -0
- package/assets/motion/library/pieces/morph-carry/piece.html +173 -0
- package/assets/motion/library/pieces/morph-carry/preview.jpg +0 -0
- package/assets/motion/library/pieces/one-shape-journey/meta.json +13 -0
- package/assets/motion/library/pieces/one-shape-journey/piece.html +195 -0
- package/assets/motion/library/pieces/one-shape-journey/preview.jpg +0 -0
- package/assets/motion/library/pieces/open-from-subject/meta.json +13 -0
- package/assets/motion/library/pieces/open-from-subject/piece.html +168 -0
- package/assets/motion/library/pieces/open-from-subject/preview.jpg +0 -0
- package/assets/motion/library/pieces/request-to-result/meta.json +13 -0
- package/assets/motion/library/pieces/request-to-result/piece.html +212 -0
- package/assets/motion/library/pieces/request-to-result/preview.jpg +0 -0
- package/assets/motion/library/pieces/scale-dive/meta.json +13 -0
- package/assets/motion/library/pieces/scale-dive/piece.html +321 -0
- package/assets/motion/library/pieces/scale-dive/preview.jpg +0 -0
- package/assets/motion/library/pieces/screen-replica-steps/meta.json +13 -0
- package/assets/motion/library/pieces/screen-replica-steps/piece.html +366 -0
- package/assets/motion/library/pieces/screen-replica-steps/preview.jpg +0 -0
- package/assets/motion/library/pieces/zoom-into-card/meta.json +13 -0
- package/assets/motion/library/pieces/zoom-into-card/piece.html +179 -0
- package/assets/motion/library/pieces/zoom-into-card/preview.jpg +0 -0
- package/assets/motion/library/sheets/diagram.jpg +0 -0
- package/assets/motion/library/sheets/frame.jpg +0 -0
- package/assets/motion/library/sheets/transition.jpg +0 -0
- package/assets/motion/library/sheets/ui.jpg +0 -0
- package/assets/motion/references/build-sheet.md +206 -0
- package/assets/motion/references/runtime/determinism-rules.md +1 -1
- package/assets/motion/references/runtime/gsap-easing-and-stagger.md +29 -29
- package/assets/motion/references/runtime/inputs-and-assets.md +7 -12
- package/assets/motion/references/runtime/lint-validate-inspect.md +3 -3
- package/assets/motion/references/runtime/minimal-composition.md +1 -1
- package/assets/motion/references/runtime/preview-render.md +3 -3
- package/assets/motion/skills/app-walkthrough/SKILL.md +66 -0
- package/assets/motion/skills/before-after/SKILL.md +53 -0
- package/assets/motion/skills/brand-kit/SKILL.md +3 -3
- package/assets/motion/skills/dev-tool-video/SKILL.md +57 -0
- package/assets/motion/skills/launch-video/SKILL.md +62 -0
- package/assets/motion/skills/match-reference/SKILL.md +58 -0
- package/assets/motion/skills/motion/SKILL.md +78 -85
- package/assets/motion/skills/source-ingest/SKILL.md +17 -6
- package/assets/motion/skills/website-video/SKILL.md +59 -0
- package/assets/skills/bulletproof/SKILL.md +36 -11
- package/assets/skills/bulletproof/references/agent-surface.md +19 -9
- package/assets/skills/bulletproof/references/audit-protocol.md +20 -5
- package/assets/skills/bulletproof/references/platform-playbooks.md +5 -4
- package/assets/skills/bulletproof/references/provenance.md +26 -1
- package/assets/skills/bulletproof/references/secure-defaults.md +6 -5
- package/assets/skills/bulletproof/references/supply-chain.md +21 -17
- package/assets/skills/bulletproof/references/threat-landscape.md +28 -26
- package/assets/skills/bulletproof/references/verification.md +2 -0
- package/assets/skills/clarify/SKILL.md +25 -16
- package/assets/skills/code-review/SKILL.md +71 -13
- package/assets/skills/code-review/references/agent-diffs.md +27 -0
- package/assets/skills/code-review/references/tests.md +19 -0
- package/assets/skills/compliance-guard/SKILL.md +20 -5
- package/assets/skills/compliance-guard/references/artifacts.md +1 -1
- package/assets/skills/compliance-guard/references/eu-uk.md +16 -16
- package/assets/skills/compliance-guard/references/lawsuit-vectors.md +5 -5
- package/assets/skills/compliance-guard/references/provenance.md +41 -2
- package/assets/skills/compliance-guard/references/sector-gates.md +3 -3
- package/assets/skills/compliance-guard/references/security-baseline.md +2 -2
- package/assets/skills/compliance-guard/references/trigger-map.md +5 -5
- package/assets/skills/compliance-guard/references/us.md +27 -21
- package/assets/skills/durable/SKILL.md +87 -79
- package/assets/skills/durable/references/agent-db-safety.md +69 -0
- package/assets/skills/durable/references/backups-and-runtime.md +19 -12
- package/assets/skills/durable/references/migrations-and-schema.md +13 -6
- package/assets/skills/evidence-led-ui/SKILL.md +69 -127
- package/assets/skills/evidence-led-ui/references/anti-defaults.md +107 -208
- package/assets/skills/evidence-led-ui/references/direction.md +124 -0
- package/assets/skills/evidence-led-ui/references/production-contract.md +8 -0
- package/assets/skills/evidence-led-ui/references/provenance.md +24 -1
- package/assets/skills/lean/SKILL.md +90 -71
- package/assets/skills/lean/references/memory-and-processes.md +3 -2
- package/assets/skills/lean/references/playbooks.md +37 -12
- package/assets/skills/refactoring/SKILL.md +24 -3
- package/assets/skills/refactoring/references/agent-pitfalls.md +4 -1
- package/assets/skills/refactoring/references/legacy.md +21 -0
- package/assets/skills/root-cause/SKILL.md +20 -10
- package/assets/skills/shared-language/SKILL.md +16 -14
- package/assets/skills/tdd/SKILL.md +27 -15
- package/dist/app-sidecar.js +203 -47
- package/dist/app-sidecar.js.map +1 -1
- package/dist/cli.js +17 -26
- package/dist/cli.js.map +1 -1
- package/dist/core/acceptance-checks.d.ts +48 -0
- package/dist/core/acceptance-checks.js +144 -0
- package/dist/core/acceptance-checks.js.map +1 -0
- package/dist/core/agent-session.d.ts +106 -72
- package/dist/core/agent-session.js +539 -399
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/agents.d.ts +6 -5
- package/dist/core/agents.js.map +1 -1
- package/dist/core/ask-user.d.ts +90 -8
- package/dist/core/ask-user.js +124 -13
- package/dist/core/ask-user.js.map +1 -1
- package/dist/core/bundled-agents.js +1 -3
- package/dist/core/bundled-agents.js.map +1 -1
- package/dist/core/cache-diagnostics.d.ts +68 -0
- package/dist/core/cache-diagnostics.js +196 -0
- package/dist/core/cache-diagnostics.js.map +1 -0
- package/dist/core/cache-expiry.d.ts +87 -0
- package/dist/core/cache-expiry.js +111 -0
- package/dist/core/cache-expiry.js.map +1 -0
- package/dist/core/compaction/compactor.js +78 -48
- package/dist/core/compaction/compactor.js.map +1 -1
- package/dist/core/compaction/plan-step-policy.d.ts +46 -0
- package/dist/core/compaction/plan-step-policy.js +57 -0
- package/dist/core/compaction/plan-step-policy.js.map +1 -0
- package/dist/core/destructive-git-guard.d.ts +90 -0
- package/dist/core/destructive-git-guard.js +871 -0
- package/dist/core/destructive-git-guard.js.map +1 -0
- package/dist/core/event-bus.d.ts +3 -0
- package/dist/core/event-bus.js +5 -0
- package/dist/core/event-bus.js.map +1 -1
- package/dist/core/injection-detect.d.ts +38 -0
- package/dist/core/injection-detect.js +232 -0
- package/dist/core/injection-detect.js.map +1 -0
- package/dist/core/keep-awake.d.ts +88 -0
- package/dist/core/keep-awake.js +251 -0
- package/dist/core/keep-awake.js.map +1 -0
- package/dist/core/mcp/client.d.ts +72 -0
- package/dist/core/mcp/client.js +264 -41
- package/dist/core/mcp/client.js.map +1 -1
- package/dist/core/mcp/content.js +6 -2
- package/dist/core/mcp/content.js.map +1 -1
- package/dist/core/mcp/store.d.ts +6 -1
- package/dist/core/mcp/store.js +12 -1
- package/dist/core/mcp/store.js.map +1 -1
- package/dist/core/mcp/types.d.ts +18 -0
- package/dist/core/model-unavailable.d.ts +14 -0
- package/dist/core/model-unavailable.js +23 -0
- package/dist/core/model-unavailable.js.map +1 -0
- package/dist/core/node-debugger.d.ts +148 -0
- package/dist/core/node-debugger.js +642 -0
- package/dist/core/node-debugger.js.map +1 -0
- package/dist/core/package-threats.d.ts +18 -0
- package/dist/core/package-threats.js +168 -0
- package/dist/core/package-threats.js.map +1 -0
- package/dist/core/persistent-shell.d.ts +58 -6
- package/dist/core/persistent-shell.js +331 -49
- package/dist/core/persistent-shell.js.map +1 -1
- package/dist/core/process-manager.d.ts +14 -0
- package/dist/core/process-manager.js +61 -0
- package/dist/core/process-manager.js.map +1 -1
- package/dist/core/progress/git-xp.js +8 -14
- package/dist/core/progress/git-xp.js.map +1 -1
- package/dist/core/session-history.d.ts +12 -0
- package/dist/core/session-history.js +27 -0
- package/dist/core/session-history.js.map +1 -1
- package/dist/core/session-manager.d.ts +13 -1
- package/dist/core/session-manager.js +38 -18
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-summary-index.d.ts +37 -0
- package/dist/core/session-summary-index.js +172 -0
- package/dist/core/session-summary-index.js.map +1 -0
- package/dist/core/settings-manager.d.ts +2 -0
- package/dist/core/settings-manager.js +10 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/shell-threats-popular-packages.d.ts +11 -0
- package/dist/core/shell-threats-popular-packages.js +675 -0
- package/dist/core/shell-threats-popular-packages.js.map +1 -0
- package/dist/core/shell-threats.d.ts +8 -0
- package/dist/core/shell-threats.js +186 -0
- package/dist/core/shell-threats.js.map +1 -0
- package/dist/core/skills.js +3 -1
- package/dist/core/skills.js.map +1 -1
- package/dist/core/stream-rules.d.ts +30 -0
- package/dist/core/stream-rules.js +151 -0
- package/dist/core/stream-rules.js.map +1 -0
- package/dist/core/subagent-manager.d.ts +20 -5
- package/dist/core/subagent-manager.js +22 -8
- package/dist/core/subagent-manager.js.map +1 -1
- package/dist/core/subagent-receipt.d.ts +54 -0
- package/dist/core/subagent-receipt.js +276 -0
- package/dist/core/subagent-receipt.js.map +1 -0
- package/dist/core/subagent-turn-record.d.ts +2 -0
- package/dist/core/subagent-turn-record.js.map +1 -1
- package/dist/core/test-impact.d.ts +73 -0
- package/dist/core/test-impact.js +467 -0
- package/dist/core/test-impact.js.map +1 -0
- package/dist/core/thinking-level.d.ts +1 -1
- package/dist/core/thinking-level.js +1 -1
- package/dist/core/thinking-level.js.map +1 -1
- package/dist/core/verification-gate.d.ts +2 -0
- package/dist/core/verification-gate.js +4 -0
- package/dist/core/verification-gate.js.map +1 -1
- package/dist/core/verification-snapshot.js +3 -5
- package/dist/core/verification-snapshot.js.map +1 -1
- package/dist/core/workspace-guard.d.ts +18 -7
- package/dist/core/workspace-guard.js +227 -60
- package/dist/core/workspace-guard.js.map +1 -1
- package/dist/interactive.js +2 -1
- package/dist/interactive.js.map +1 -1
- package/dist/modes/subagent-worker-mode.js +34 -6
- package/dist/modes/subagent-worker-mode.js.map +1 -1
- package/dist/motion-agent/motion-agent.d.ts +6 -2
- package/dist/motion-agent/motion-agent.js +5 -7
- package/dist/motion-agent/motion-agent.js.map +1 -1
- package/dist/motion-agent/motion-prompt.d.ts +1 -1
- package/dist/motion-agent/motion-prompt.js +14 -19
- package/dist/motion-agent/motion-prompt.js.map +1 -1
- package/dist/motion-agent/motion-review.d.ts +10 -3
- package/dist/motion-agent/motion-review.js +15 -7
- package/dist/motion-agent/motion-review.js.map +1 -1
- package/dist/motion-agent/motion-studio-context.js +1 -1
- package/dist/motion-agent/motion-studio-context.js.map +1 -1
- package/dist/system-prompt.js +3 -1
- package/dist/system-prompt.js.map +1 -1
- package/dist/test-support/keep-alive.d.ts +14 -0
- package/dist/test-support/keep-alive.js +17 -0
- package/dist/test-support/keep-alive.js.map +1 -0
- package/dist/tools/ask-user.js +3 -3
- package/dist/tools/ask-user.js.map +1 -1
- package/dist/tools/bash-read-evidence.d.ts +10 -0
- package/dist/tools/bash-read-evidence.js +133 -0
- package/dist/tools/bash-read-evidence.js.map +1 -0
- package/dist/tools/bash.d.ts +10 -1
- package/dist/tools/bash.js +115 -7
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/debug.d.ts +54 -0
- package/dist/tools/debug.js +233 -0
- package/dist/tools/debug.js.map +1 -0
- package/dist/tools/edit.js +12 -4
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/goals.d.ts +1 -1
- package/dist/tools/index.d.ts +19 -2
- package/dist/tools/index.js +47 -7
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/prompt-hints.js +2 -0
- package/dist/tools/prompt-hints.js.map +1 -1
- package/dist/tools/read-tracker.d.ts +5 -0
- package/dist/tools/read-tracker.js +19 -9
- package/dist/tools/read-tracker.js.map +1 -1
- package/dist/tools/read.js +3 -2
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/skill.js +5 -0
- package/dist/tools/skill.js.map +1 -1
- package/dist/tools/subagent-control.js +44 -8
- package/dist/tools/subagent-control.js.map +1 -1
- package/dist/tools/subagent-shared.d.ts +29 -8
- package/dist/tools/subagent-shared.js +45 -14
- package/dist/tools/subagent-shared.js.map +1 -1
- package/dist/tools/subagent.d.ts +8 -2
- package/dist/tools/subagent.js +28 -10
- package/dist/tools/subagent.js.map +1 -1
- package/dist/tools/task-output.js +3 -2
- package/dist/tools/task-output.js.map +1 -1
- package/dist/tools/task-send.d.ts +1 -1
- package/dist/tools/task-send.js +15 -1
- package/dist/tools/task-send.js.map +1 -1
- package/dist/tools/tool-tiers.d.ts +2 -2
- package/dist/tools/tool-tiers.js +3 -2
- package/dist/tools/tool-tiers.js.map +1 -1
- package/dist/tools/truncate.d.ts +21 -0
- package/dist/tools/truncate.js +187 -0
- package/dist/tools/truncate.js.map +1 -1
- package/dist/tools/ui-adopt.js +2 -0
- package/dist/tools/ui-adopt.js.map +1 -1
- package/dist/ui/App.d.ts +0 -4
- package/dist/ui/App.js +5 -28
- package/dist/ui/App.js.map +1 -1
- package/dist/ui/components/ActivityIndicator.js +1 -0
- package/dist/ui/components/ActivityIndicator.js.map +1 -1
- package/dist/ui/hooks/useAgentLoop.d.ts +1 -8
- package/dist/ui/hooks/useAgentLoop.js +1 -119
- package/dist/ui/hooks/useAgentLoop.js.map +1 -1
- package/dist/ui/render.d.ts +0 -4
- package/dist/ui/render.js +0 -2
- package/dist/ui/render.js.map +1 -1
- package/dist/utils/git.d.ts +77 -0
- package/dist/utils/git.js +285 -21
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/github-ci.js +2 -1
- package/dist/utils/github-ci.js.map +1 -1
- package/dist/utils/github.js +11 -9
- package/dist/utils/github.js.map +1 -1
- package/dist/utils/image.d.ts +14 -0
- package/dist/utils/image.js +16 -0
- package/dist/utils/image.js.map +1 -1
- package/dist/utils/process.d.ts +20 -0
- package/dist/utils/process.js +98 -0
- package/dist/utils/process.js.map +1 -1
- package/package.json +5 -5
- package/assets/motion/references/motion-language.md +0 -128
- package/assets/motion/skills/video-qa/SKILL.md +0 -89
- package/dist/core/ideal-review-subagent.d.ts +0 -56
- package/dist/core/ideal-review-subagent.js +0 -112
- package/dist/core/ideal-review-subagent.js.map +0 -1
- package/dist/core/ideal-review.d.ts +0 -82
- package/dist/core/ideal-review.js +0 -242
- package/dist/core/ideal-review.js.map +0 -1
- package/dist/motion-agent/motion-check-tool.d.ts +0 -35
- package/dist/motion-agent/motion-check-tool.js +0 -514
- package/dist/motion-agent/motion-check-tool.js.map +0 -1
|
@@ -13,20 +13,98 @@
|
|
|
13
13
|
// "bpm": 120, "duration": 20, "seed": 7,
|
|
14
14
|
// "key": "A", "scale": "minor", // used when chords are omitted
|
|
15
15
|
// "chords": ["Am", "F", "C", "G"], // one per bar, cycled
|
|
16
|
-
// "style": "pulse", // pulse | cinematic | minimal
|
|
16
|
+
// "style": "pulse", // pulse | cinematic | minimal | lofi
|
|
17
17
|
// "sections": [{ "name": "intro", "from": 0, "energy": 0.3 }, ...],
|
|
18
18
|
// "hits": [{ "t": 1.5, "sfx": "whoosh" }, { "beat": 16, "sfx": "impact" }, { "bar": 8, "sfx": "riser" }],
|
|
19
19
|
// "beatTimes": [0.51, 1.02, ...], // optional: measured beats of a supplied song
|
|
20
|
-
// "music": true
|
|
20
|
+
// "music": true, // false => sfx.wav only (e.g. over a supplied song)
|
|
21
|
+
// "cues": "cues.json", // optional: hits exported from the composition by cues.mjs
|
|
22
|
+
// "room": "room", // none | booth | room | hall: one shared space for every sound
|
|
23
|
+
// "duck": 0.55, // music level under the heaviest hit (1 = no ducking)
|
|
24
|
+
// "ending": "resolve", // resolve (home chord, fade) | cut
|
|
25
|
+
// "air": true // camera air from the cues file's camera curve
|
|
21
26
|
// }
|
|
27
|
+
//
|
|
28
|
+
// Every effect is built from a material (wood, glass, air, felt, paper,
|
|
29
|
+
// plastic): a few damped partials plus a short noise burst, never a bare
|
|
30
|
+
// sine. Each sound has its own distance (how much reaches the room); a hit
|
|
31
|
+
// may set "send" 0..1. Hits closer than 50 ms keep only the stronger one.
|
|
32
|
+
// tempo-map.json reports which hits the music buries and a music volume
|
|
33
|
+
// that keeps them audible.
|
|
22
34
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
23
|
-
import { join, resolve } from "node:path";
|
|
35
|
+
import { dirname, join, resolve } from "node:path";
|
|
24
36
|
|
|
25
37
|
const SR = 48000;
|
|
38
|
+
const STYLES = ["pulse", "cinematic", "minimal", "lofi"];
|
|
26
39
|
const SFX_NAMES = [
|
|
27
|
-
"whoosh",
|
|
28
|
-
"
|
|
40
|
+
"whoosh",
|
|
41
|
+
"whoosh-short",
|
|
42
|
+
"riser",
|
|
43
|
+
"impact",
|
|
44
|
+
"sub-drop",
|
|
45
|
+
"pop",
|
|
46
|
+
"click",
|
|
47
|
+
"tick",
|
|
48
|
+
"glitch",
|
|
49
|
+
"shimmer",
|
|
50
|
+
"sting",
|
|
51
|
+
"typing",
|
|
52
|
+
"swell",
|
|
53
|
+
"snap",
|
|
54
|
+
"tap",
|
|
55
|
+
"press",
|
|
56
|
+
"knock",
|
|
57
|
+
"chime",
|
|
58
|
+
"breath",
|
|
59
|
+
"thud",
|
|
60
|
+
"plip",
|
|
61
|
+
"paper",
|
|
29
62
|
];
|
|
63
|
+
// How far away each sound sits: the share of it that reaches the room. Close,
|
|
64
|
+
// small contacts stay dry; air and long tones fill the space.
|
|
65
|
+
const DEFAULT_SEND = {
|
|
66
|
+
tap: 0.12,
|
|
67
|
+
press: 0.12,
|
|
68
|
+
click: 0.12,
|
|
69
|
+
typing: 0.1,
|
|
70
|
+
tick: 0.3,
|
|
71
|
+
snap: 0.22,
|
|
72
|
+
knock: 0.28,
|
|
73
|
+
plip: 0.3,
|
|
74
|
+
pop: 0.3,
|
|
75
|
+
paper: 0.35,
|
|
76
|
+
chime: 0.45,
|
|
77
|
+
glitch: 0.18,
|
|
78
|
+
thud: 0.38,
|
|
79
|
+
impact: 0.4,
|
|
80
|
+
"sub-drop": 0.32,
|
|
81
|
+
sting: 0.5,
|
|
82
|
+
breath: 0.6,
|
|
83
|
+
whoosh: 0.6,
|
|
84
|
+
"whoosh-short": 0.5,
|
|
85
|
+
riser: 0.55,
|
|
86
|
+
swell: 0.6,
|
|
87
|
+
shimmer: 0.6,
|
|
88
|
+
};
|
|
89
|
+
// Long sounds that lead into or ring under an event; never merged away.
|
|
90
|
+
const LONG_SOUNDS = new Set(["riser", "swell", "typing", "sting", "shimmer"]);
|
|
91
|
+
const MERGE_WINDOW = 0.05;
|
|
92
|
+
// Low sounds are judged against the music in the low band, the rest in the
|
|
93
|
+
// presence band where ears pick out detail.
|
|
94
|
+
const LOW_SOUNDS = new Set(["thud", "impact", "sub-drop", "sting"]);
|
|
95
|
+
const BURIED_DB = -6;
|
|
96
|
+
// Where each sound is loudest, relative to its time (default: the first 120 ms).
|
|
97
|
+
// Risers and swells arrive at their time, so they are heard just before it.
|
|
98
|
+
const HEARD_AT = {
|
|
99
|
+
riser: [-0.25, 0],
|
|
100
|
+
swell: [-0.25, 0],
|
|
101
|
+
breath: [0.2, 0.55],
|
|
102
|
+
whoosh: [0.2, 0.55],
|
|
103
|
+
"whoosh-short": [0.06, 0.26],
|
|
104
|
+
paper: [0.06, 0.3],
|
|
105
|
+
typing: [0, 1],
|
|
106
|
+
shimmer: [0, 0.4],
|
|
107
|
+
};
|
|
30
108
|
|
|
31
109
|
function fail(message) {
|
|
32
110
|
process.stderr.write(`${message}\n`);
|
|
@@ -41,25 +119,58 @@ function validate(raw) {
|
|
|
41
119
|
if (!(bpm >= 40 && bpm <= 220)) fail("bpm must be between 40 and 220");
|
|
42
120
|
if (!(duration > 0 && duration <= 900)) fail("duration must be between 0 and 900 seconds");
|
|
43
121
|
const style = raw.style ?? "pulse";
|
|
44
|
-
if (!
|
|
45
|
-
const sections =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
122
|
+
if (!STYLES.includes(style)) fail(`unknown style: ${style}. Use one of: ${STYLES.join(", ")}`);
|
|
123
|
+
const sections =
|
|
124
|
+
Array.isArray(raw.sections) && raw.sections.length
|
|
125
|
+
? raw.sections
|
|
126
|
+
.map((s, i) => {
|
|
127
|
+
const from = Number(s.from);
|
|
128
|
+
const energy = Number(s.energy);
|
|
129
|
+
if (!(from >= 0) || !(energy >= 0 && energy <= 1))
|
|
130
|
+
fail(`section ${i}: needs from >= 0 and energy 0..1`);
|
|
131
|
+
return { name: String(s.name ?? `section-${i + 1}`), from, energy };
|
|
132
|
+
})
|
|
133
|
+
.sort((a, b) => a.from - b.from)
|
|
134
|
+
: [{ name: "main", from: 0, energy: 0.7 }];
|
|
53
135
|
const hits = (Array.isArray(raw.hits) ? raw.hits : []).map((h, i) => {
|
|
54
|
-
if (!SFX_NAMES.includes(h.sfx))
|
|
55
|
-
|
|
136
|
+
if (!SFX_NAMES.includes(h.sfx))
|
|
137
|
+
fail(`hit ${i}: unknown sfx "${h.sfx}". Use one of: ${SFX_NAMES.join(", ")}`);
|
|
138
|
+
if (h.t === undefined && h.beat === undefined && h.bar === undefined)
|
|
139
|
+
fail(`hit ${i}: needs t, beat or bar`);
|
|
56
140
|
const gain = h.gain === undefined ? 1 : Number(h.gain);
|
|
57
141
|
if (!(gain >= 0 && gain <= 2)) fail(`hit ${i}: gain must be 0..2`);
|
|
58
|
-
|
|
142
|
+
if (h.send !== undefined && !(Number(h.send) >= 0 && Number(h.send) <= 1))
|
|
143
|
+
fail(`hit ${i}: send must be 0..1`);
|
|
144
|
+
return { ...h, gain, ...(h.send === undefined ? {} : { send: Number(h.send) }) };
|
|
59
145
|
});
|
|
60
|
-
const beatTimes = Array.isArray(raw.beatTimes)
|
|
146
|
+
const beatTimes = Array.isArray(raw.beatTimes)
|
|
147
|
+
? raw.beatTimes
|
|
148
|
+
.map(Number)
|
|
149
|
+
.filter((n) => n >= 0)
|
|
150
|
+
.sort((a, b) => a - b)
|
|
151
|
+
: null;
|
|
152
|
+
const room = raw.room ?? "room";
|
|
153
|
+
if (!Object.hasOwn(ROOMS, room))
|
|
154
|
+
fail(`unknown room: ${room}. Use one of: ${Object.keys(ROOMS).join(", ")}`);
|
|
155
|
+
const duck = raw.duck === undefined ? 0.55 : Number(raw.duck);
|
|
156
|
+
if (!(duck >= 0 && duck <= 1))
|
|
157
|
+
fail("duck must be 0..1 (the music level under a hit; 1 = no ducking)");
|
|
158
|
+
if (raw.cues !== undefined && (typeof raw.cues !== "string" || !raw.cues))
|
|
159
|
+
fail("cues must be the path of a cues.json file");
|
|
160
|
+
const ending = raw.ending ?? "resolve";
|
|
161
|
+
if (!["resolve", "cut"].includes(ending)) fail(`unknown ending: ${ending}. Use resolve or cut`);
|
|
61
162
|
return {
|
|
62
|
-
bpm,
|
|
163
|
+
bpm,
|
|
164
|
+
duration,
|
|
165
|
+
style,
|
|
166
|
+
sections,
|
|
167
|
+
hits,
|
|
168
|
+
beatTimes,
|
|
169
|
+
room,
|
|
170
|
+
duck,
|
|
171
|
+
ending,
|
|
172
|
+
air: raw.air !== false,
|
|
173
|
+
cues: raw.cues,
|
|
63
174
|
seed: Number.isInteger(raw.seed) ? raw.seed : 1,
|
|
64
175
|
key: String(raw.key ?? "A"),
|
|
65
176
|
scale: raw.scale === "major" ? "major" : "minor",
|
|
@@ -68,6 +179,57 @@ function validate(raw) {
|
|
|
68
179
|
};
|
|
69
180
|
}
|
|
70
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Hits from a cues.json written by cues.mjs: the composition's own event
|
|
184
|
+
* times, so a retimed animation moves its sounds with it.
|
|
185
|
+
*/
|
|
186
|
+
async function loadCues(path, duration) {
|
|
187
|
+
let file;
|
|
188
|
+
try {
|
|
189
|
+
file = JSON.parse(await readFile(path, "utf8"));
|
|
190
|
+
} catch (error) {
|
|
191
|
+
fail(`Could not read cues ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
192
|
+
}
|
|
193
|
+
if (file?.version !== 1 || !Array.isArray(file.cues))
|
|
194
|
+
fail(`${path}: expected a cues file (version 1) from cues.mjs`);
|
|
195
|
+
let air = null;
|
|
196
|
+
if (file.air !== undefined) {
|
|
197
|
+
const { rate, start, values } = file.air ?? {};
|
|
198
|
+
if (
|
|
199
|
+
!(rate >= 10 && rate <= 240) ||
|
|
200
|
+
!(start >= 0) ||
|
|
201
|
+
!Array.isArray(values) ||
|
|
202
|
+
!values.every((v) => Number.isFinite(v) && v >= 0)
|
|
203
|
+
)
|
|
204
|
+
fail(`${path}: the camera curve (air) is malformed; export the cues again`);
|
|
205
|
+
air = { rate, start, values };
|
|
206
|
+
}
|
|
207
|
+
const hits = file.cues.map((c, i) => {
|
|
208
|
+
const label = `cue ${i}${c?.name ? ` (${c.name})` : ""}`;
|
|
209
|
+
if (!SFX_NAMES.includes(c?.sfx))
|
|
210
|
+
fail(`${label}: unknown sfx "${c?.sfx}". Use one of: ${SFX_NAMES.join(", ")}`);
|
|
211
|
+
const t = Number(c.t);
|
|
212
|
+
if (!(t >= 0 && t < duration))
|
|
213
|
+
fail(`${label}: t ${c.t} is outside the score's 0..${duration} s`);
|
|
214
|
+
const gain = c.gain === undefined ? 1 : Number(c.gain);
|
|
215
|
+
if (!(gain >= 0 && gain <= 2)) fail(`${label}: gain must be 0..2`);
|
|
216
|
+
const pan = c.pan === undefined ? undefined : Number(c.pan);
|
|
217
|
+
if (pan !== undefined && !(pan >= -1 && pan <= 1)) fail(`${label}: pan must be -1..1`);
|
|
218
|
+
const send = c.send === undefined ? undefined : Number(c.send);
|
|
219
|
+
if (send !== undefined && !(send >= 0 && send <= 1)) fail(`${label}: send must be 0..1`);
|
|
220
|
+
return {
|
|
221
|
+
t,
|
|
222
|
+
sfx: c.sfx,
|
|
223
|
+
gain,
|
|
224
|
+
...(pan === undefined ? {} : { pan }),
|
|
225
|
+
...(send === undefined ? {} : { send }),
|
|
226
|
+
source: "cue",
|
|
227
|
+
...(c.name ? { name: String(c.name).slice(0, 60) } : {}),
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
return { hits, air };
|
|
231
|
+
}
|
|
232
|
+
|
|
71
233
|
// ---------- music theory ----------
|
|
72
234
|
const NOTE = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
|
|
73
235
|
function parseRoot(name) {
|
|
@@ -85,7 +247,8 @@ function chordNotes(name) {
|
|
|
85
247
|
}
|
|
86
248
|
function defaultChords(key, scale) {
|
|
87
249
|
const r = parseRoot(key);
|
|
88
|
-
const name = (semi, minor) =>
|
|
250
|
+
const name = (semi, minor) =>
|
|
251
|
+
`${Object.keys(NOTE).find((k) => NOTE[k] === (r + semi) % 12) ?? "C"}${minor ? "m" : ""}`;
|
|
89
252
|
// i-VI-III-VII (minor) or I-V-vi-IV (major); fall back to the root when a degree is sharp.
|
|
90
253
|
return scale === "minor"
|
|
91
254
|
? [name(0, true), name(8, false), name(3, false), name(10, false)]
|
|
@@ -107,21 +270,46 @@ function rng(seed) {
|
|
|
107
270
|
|
|
108
271
|
// ---------- DSP helpers ----------
|
|
109
272
|
function biquad(type, freq, q) {
|
|
110
|
-
let b0,
|
|
273
|
+
let b0,
|
|
274
|
+
b1,
|
|
275
|
+
b2,
|
|
276
|
+
a1,
|
|
277
|
+
a2,
|
|
278
|
+
x1 = 0,
|
|
279
|
+
x2 = 0,
|
|
280
|
+
y1 = 0,
|
|
281
|
+
y2 = 0;
|
|
111
282
|
const set = (f) => {
|
|
112
283
|
const w = (2 * Math.PI * Math.min(Math.max(f, 20), SR * 0.45)) / SR;
|
|
113
284
|
const alpha = Math.sin(w) / (2 * q);
|
|
114
285
|
const c = Math.cos(w);
|
|
115
286
|
const a0 = 1 + alpha;
|
|
116
|
-
if (type === "lp") {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
287
|
+
if (type === "lp") {
|
|
288
|
+
b0 = (1 - c) / 2;
|
|
289
|
+
b1 = 1 - c;
|
|
290
|
+
b2 = (1 - c) / 2;
|
|
291
|
+
} else if (type === "hp") {
|
|
292
|
+
b0 = (1 + c) / 2;
|
|
293
|
+
b1 = -(1 + c);
|
|
294
|
+
b2 = (1 + c) / 2;
|
|
295
|
+
} else {
|
|
296
|
+
b0 = alpha;
|
|
297
|
+
b1 = 0;
|
|
298
|
+
b2 = -alpha;
|
|
299
|
+
}
|
|
300
|
+
b0 /= a0;
|
|
301
|
+
b1 /= a0;
|
|
302
|
+
b2 /= a0;
|
|
303
|
+
a1 = (-2 * c) / a0;
|
|
304
|
+
a2 = (1 - alpha) / a0;
|
|
120
305
|
};
|
|
121
306
|
set(freq);
|
|
122
307
|
const run = (x) => {
|
|
123
308
|
const y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
|
|
124
|
-
x2 = x1;
|
|
309
|
+
x2 = x1;
|
|
310
|
+
x1 = x;
|
|
311
|
+
y2 = y1;
|
|
312
|
+
y1 = y;
|
|
125
313
|
return y;
|
|
126
314
|
};
|
|
127
315
|
run.set = set;
|
|
@@ -158,6 +346,107 @@ class Bus {
|
|
|
158
346
|
}
|
|
159
347
|
|
|
160
348
|
const buf = (sec) => new Float32Array(Math.max(1, Math.round(sec * SR)));
|
|
349
|
+
|
|
350
|
+
// ---------- one shared room ----------
|
|
351
|
+
// Every sound goes through the same small space, so the effects and the music
|
|
352
|
+
// read as one place instead of a pile of separate recordings. A plain
|
|
353
|
+
// feedback-comb + allpass reverb: `size` scales the delay lines, `decay` is the
|
|
354
|
+
// comb feedback, `damp` darkens each echo, `mix` is the wet level.
|
|
355
|
+
const ROOMS = {
|
|
356
|
+
none: null,
|
|
357
|
+
booth: { size: 0.45, decay: 0.68, damp: 0.45, mix: 0.12, predelay: 0.004 },
|
|
358
|
+
room: { size: 0.75, decay: 0.76, damp: 0.38, mix: 0.16, predelay: 0.008 },
|
|
359
|
+
hall: { size: 1.25, decay: 0.84, damp: 0.3, mix: 0.2, predelay: 0.018 },
|
|
360
|
+
};
|
|
361
|
+
// Mutually prime delay lengths (samples at 48 kHz, before `size`), so echoes don't stack.
|
|
362
|
+
const COMB_DELAYS = [1433, 1601, 1867, 2053, 2251, 2399];
|
|
363
|
+
const ALLPASS_DELAYS = [337, 557, 829];
|
|
364
|
+
const STEREO_SPREAD = 31;
|
|
365
|
+
|
|
366
|
+
function reverbChannel(input, room, spread) {
|
|
367
|
+
const out = new Float32Array(input.length);
|
|
368
|
+
const pre = Math.round(room.predelay * SR);
|
|
369
|
+
for (const base of COMB_DELAYS) {
|
|
370
|
+
const len = Math.max(1, Math.round(base * room.size) + spread);
|
|
371
|
+
const line = new Float32Array(len);
|
|
372
|
+
let pos = 0,
|
|
373
|
+
low = 0;
|
|
374
|
+
for (let i = 0; i < input.length; i++) {
|
|
375
|
+
const echo = line[pos];
|
|
376
|
+
low = echo * (1 - room.damp) + low * room.damp;
|
|
377
|
+
const x = i >= pre ? input[i - pre] : 0;
|
|
378
|
+
line[pos] = x + low * room.decay;
|
|
379
|
+
out[i] += echo / COMB_DELAYS.length;
|
|
380
|
+
pos = pos + 1 === len ? 0 : pos + 1;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
for (const base of ALLPASS_DELAYS) {
|
|
384
|
+
const len = Math.max(1, Math.round(base * room.size) + spread);
|
|
385
|
+
const line = new Float32Array(len);
|
|
386
|
+
let pos = 0;
|
|
387
|
+
for (let i = 0; i < out.length; i++) {
|
|
388
|
+
const delayed = line[pos];
|
|
389
|
+
const x = out[i];
|
|
390
|
+
line[pos] = x + delayed * 0.5;
|
|
391
|
+
out[i] = delayed - x * 0.5;
|
|
392
|
+
pos = pos + 1 === len ? 0 : pos + 1;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Add the room's echo of `source` (default: `bus` itself) to `bus`; `send` scales how much enters. */
|
|
399
|
+
function applyRoom(bus, room, send, source = bus) {
|
|
400
|
+
if (!room || send <= 0) return;
|
|
401
|
+
const mono = new Float32Array(bus.n);
|
|
402
|
+
for (let i = 0; i < bus.n; i++) mono[i] = (source.l[i] + source.r[i]) * 0.5 * send;
|
|
403
|
+
const wetL = reverbChannel(mono, room, 0);
|
|
404
|
+
const wetR = reverbChannel(mono, room, STEREO_SPREAD);
|
|
405
|
+
for (let i = 0; i < bus.n; i++) {
|
|
406
|
+
bus.l[i] += wetL[i] * room.mix * 4;
|
|
407
|
+
bus.r[i] += wetR[i] * room.mix * 4;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------- ducking ----------
|
|
412
|
+
// How hard each sound pushes the music down, and how long it holds there.
|
|
413
|
+
const DUCK_WEIGHT = {
|
|
414
|
+
impact: 1,
|
|
415
|
+
sting: 1,
|
|
416
|
+
"sub-drop": 1,
|
|
417
|
+
riser: 0.7,
|
|
418
|
+
whoosh: 0.6,
|
|
419
|
+
"whoosh-short": 0.5,
|
|
420
|
+
swell: 0.5,
|
|
421
|
+
shimmer: 0.4,
|
|
422
|
+
glitch: 0.5,
|
|
423
|
+
snap: 0.4,
|
|
424
|
+
pop: 0.3,
|
|
425
|
+
click: 0.15,
|
|
426
|
+
tick: 0.15,
|
|
427
|
+
typing: 0.2,
|
|
428
|
+
};
|
|
429
|
+
const DUCK_ATTACK = 0.015;
|
|
430
|
+
const DUCK_RELEASE = 0.35;
|
|
431
|
+
|
|
432
|
+
/** Music gain over time: dips under each hit, deepest for the heavy ones. */
|
|
433
|
+
function duckCurve(n, hits, duck) {
|
|
434
|
+
const curve = new Float32Array(n).fill(1);
|
|
435
|
+
if (duck >= 1) return curve;
|
|
436
|
+
for (const hit of hits) {
|
|
437
|
+
const weight = (DUCK_WEIGHT[hit.sfx] ?? 0.3) * Math.min(1, hit.gain);
|
|
438
|
+
const floor = 1 - (1 - duck) * weight;
|
|
439
|
+
const hold = 0.08 + 0.25 * weight;
|
|
440
|
+
const from = Math.round((hit.t - DUCK_ATTACK) * SR);
|
|
441
|
+
const to = Math.round((hit.t + hold + DUCK_RELEASE) * SR);
|
|
442
|
+
for (let i = Math.max(0, from); i < Math.min(n, to); i++) {
|
|
443
|
+
const t = i / SR - hit.t;
|
|
444
|
+
const depth = t < 0 ? 1 + t / DUCK_ATTACK : t < hold ? 1 : 1 - (t - hold) / DUCK_RELEASE;
|
|
445
|
+
curve[i] = Math.min(curve[i], 1 - (1 - floor) * Math.max(0, Math.min(1, depth)));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return curve;
|
|
449
|
+
}
|
|
161
450
|
const env = (t, attack, decay) => (t < attack ? t / attack : Math.exp(-(t - attack) / decay));
|
|
162
451
|
|
|
163
452
|
// ---------- instruments (each returns mono samples) ----------
|
|
@@ -168,7 +457,9 @@ function kick(punch = 1) {
|
|
|
168
457
|
const t = i / SR;
|
|
169
458
|
const f = 45 + 110 * Math.exp(-t / 0.035);
|
|
170
459
|
phase += (2 * Math.PI * f) / SR;
|
|
171
|
-
out[i] =
|
|
460
|
+
out[i] =
|
|
461
|
+
Math.tanh(Math.sin(phase) * 1.6 * punch) * Math.exp(-t / 0.22) +
|
|
462
|
+
(t < 0.004 ? (1 - t / 0.004) * 0.3 : 0);
|
|
172
463
|
}
|
|
173
464
|
return out;
|
|
174
465
|
}
|
|
@@ -185,13 +476,15 @@ function clap(rand) {
|
|
|
185
476
|
function hat(rand, open = false) {
|
|
186
477
|
const out = buf(open ? 0.25 : 0.06);
|
|
187
478
|
const hp = biquad("hp", 7500, 0.8);
|
|
188
|
-
for (let i = 0; i < out.length; i++)
|
|
479
|
+
for (let i = 0; i < out.length; i++)
|
|
480
|
+
out[i] = hp(rand() * 2 - 1) * Math.exp(-(i / SR) / (open ? 0.09 : 0.018));
|
|
189
481
|
return out;
|
|
190
482
|
}
|
|
191
483
|
function saw(freq, seconds, cutoff, attack, decay, detune = 0) {
|
|
192
484
|
const out = buf(seconds);
|
|
193
485
|
const lp = biquad("lp", cutoff, 0.9);
|
|
194
|
-
let p1 = 0,
|
|
486
|
+
let p1 = 0,
|
|
487
|
+
p2 = 0.37;
|
|
195
488
|
for (let i = 0; i < out.length; i++) {
|
|
196
489
|
const t = i / SR;
|
|
197
490
|
p1 = (p1 + (freq * (1 + detune)) / SR) % 1;
|
|
@@ -200,17 +493,191 @@ function saw(freq, seconds, cutoff, attack, decay, detune = 0) {
|
|
|
200
493
|
}
|
|
201
494
|
return out;
|
|
202
495
|
}
|
|
203
|
-
|
|
496
|
+
// ---------- lo-fi ----------
|
|
497
|
+
// A dusty electric piano, a round bass, a soft boom-bap kit on swung eighths,
|
|
498
|
+
// vinyl under everything and a slow tape wobble over the whole bed.
|
|
499
|
+
|
|
500
|
+
/** Electric piano: a sine body, a struck tine that fades first and a slow tremolo. */
|
|
501
|
+
function keyNote(rand, freq, seconds, velocity) {
|
|
204
502
|
const out = buf(seconds);
|
|
503
|
+
const release = Math.min(0.12, seconds / 3);
|
|
504
|
+
let body = rand();
|
|
505
|
+
let tine = rand();
|
|
506
|
+
let bell = rand();
|
|
507
|
+
for (let i = 0; i < out.length; i++) {
|
|
508
|
+
const t = i / SR;
|
|
509
|
+
body += freq / SR;
|
|
510
|
+
tine += (freq * 2) / SR;
|
|
511
|
+
bell += (freq * 6.95) / SR;
|
|
512
|
+
const tail = Math.min(1, (seconds - t) / release);
|
|
513
|
+
const amp = Math.min(1, t / 0.004) * Math.exp(-t / 1.4) * Math.max(0, tail);
|
|
514
|
+
const trem = 1 + 0.12 * Math.sin(2 * Math.PI * 4.3 * t);
|
|
515
|
+
out[i] =
|
|
516
|
+
Math.tanh(
|
|
517
|
+
(Math.sin(2 * Math.PI * body) * 0.8 +
|
|
518
|
+
Math.sin(2 * Math.PI * tine) * 0.32 * Math.exp(-t / 0.15) +
|
|
519
|
+
Math.sin(2 * Math.PI * bell) * 0.07 * Math.exp(-t / 0.045)) *
|
|
520
|
+
1.3,
|
|
521
|
+
) *
|
|
522
|
+
amp *
|
|
523
|
+
trem *
|
|
524
|
+
velocity;
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** A round, warm bass: a sine with a little second harmonic and a soft attack. */
|
|
530
|
+
function roundBass(freq, seconds) {
|
|
531
|
+
const out = buf(seconds);
|
|
532
|
+
const release = Math.min(0.08, seconds / 3);
|
|
205
533
|
let phase = 0;
|
|
206
534
|
for (let i = 0; i < out.length; i++) {
|
|
207
535
|
const t = i / SR;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
536
|
+
phase += freq / SR;
|
|
537
|
+
const amp =
|
|
538
|
+
Math.min(1, t / 0.012) *
|
|
539
|
+
Math.exp(-t / 0.9) *
|
|
540
|
+
Math.max(0, Math.min(1, (seconds - t) / release));
|
|
541
|
+
out[i] =
|
|
542
|
+
Math.tanh((Math.sin(2 * Math.PI * phase) + 0.22 * Math.sin(4 * Math.PI * phase)) * 1.4) * amp;
|
|
543
|
+
}
|
|
544
|
+
return out;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** A soft, dark snare: a short body tone under band-passed noise. */
|
|
548
|
+
function softSnare(rand) {
|
|
549
|
+
const out = buf(0.28);
|
|
550
|
+
const bp = biquad("bp", 1700, 0.7);
|
|
551
|
+
const lp = biquad("lp", 4800, 0.7);
|
|
552
|
+
for (let i = 0; i < out.length; i++) {
|
|
553
|
+
const t = i / SR;
|
|
554
|
+
const noise = bp(rand() * 2 - 1) * Math.exp(-t / 0.075) * 2.4;
|
|
555
|
+
const body = Math.sin(2 * Math.PI * 185 * t) * Math.exp(-t / 0.045) * 0.6;
|
|
556
|
+
out[i] = lp(noise + body);
|
|
211
557
|
}
|
|
212
558
|
return out;
|
|
213
559
|
}
|
|
560
|
+
|
|
561
|
+
/** Record-surface hiss and crackle for the whole bed. */
|
|
562
|
+
function vinyl(rand, seconds) {
|
|
563
|
+
const left = buf(seconds);
|
|
564
|
+
const right = buf(seconds);
|
|
565
|
+
const hissL = biquad("bp", 3200, 0.5);
|
|
566
|
+
const hissR = biquad("bp", 3200, 0.5);
|
|
567
|
+
for (let i = 0; i < left.length; i++) {
|
|
568
|
+
left[i] = hissL(rand() * 2 - 1) * 0.018;
|
|
569
|
+
right[i] = hissR(rand() * 2 - 1) * 0.018;
|
|
570
|
+
}
|
|
571
|
+
// Crackle: short clicks at random times, a few louder pops.
|
|
572
|
+
const clicks = Math.round(seconds * 9);
|
|
573
|
+
for (let k = 0; k < clicks; k++) {
|
|
574
|
+
const at = Math.floor(rand() * left.length);
|
|
575
|
+
const loud = rand() < 0.12 ? 0.32 : 0.09;
|
|
576
|
+
const len = 12 + Math.floor(rand() * 30);
|
|
577
|
+
const pan = rand();
|
|
578
|
+
for (let j = 0; j < len && at + j < left.length; j++) {
|
|
579
|
+
const v = (rand() * 2 - 1) * loud * (1 - j / len);
|
|
580
|
+
left[at + j] += v * (1 - pan);
|
|
581
|
+
right[at + j] += v * pan;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return { left, right };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Tape wobble: a slowly varying delay bends the pitch of the whole bed, then darkens it. */
|
|
588
|
+
function tapeWobble(bus) {
|
|
589
|
+
for (const channel of [bus.l, bus.r]) {
|
|
590
|
+
const source = Float32Array.from(channel);
|
|
591
|
+
const lp = biquad("lp", 6200, 0.7);
|
|
592
|
+
for (let i = 0; i < channel.length; i++) {
|
|
593
|
+
const t = i / SR;
|
|
594
|
+
const delay =
|
|
595
|
+
(0.006 +
|
|
596
|
+
0.0022 * Math.sin(2 * Math.PI * 0.55 * t) +
|
|
597
|
+
0.0006 * Math.sin(2 * Math.PI * 3.1 * t)) *
|
|
598
|
+
SR;
|
|
599
|
+
const pos = i - delay;
|
|
600
|
+
const j = Math.floor(pos);
|
|
601
|
+
const frac = pos - j;
|
|
602
|
+
const a = j >= 0 && j < source.length ? source[j] : 0;
|
|
603
|
+
const b = j + 1 >= 0 && j + 1 < source.length ? source[j + 1] : 0;
|
|
604
|
+
channel[i] = lp(a + (b - a) * frac);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Adds the key's own 7th to a triad, the colour a lo-fi chord is built on: a
|
|
611
|
+
* major 7th where the key has it, else a minor 7th.
|
|
612
|
+
*/
|
|
613
|
+
function withSeventh(chord, keyNotes) {
|
|
614
|
+
if (chord.tones.length > 3) return chord;
|
|
615
|
+
return { ...chord, tones: [...chord.tones, keyNotes.has((chord.root + 11) % 12) ? 11 : 10] };
|
|
616
|
+
}
|
|
617
|
+
const SCALE_STEPS = { major: [0, 2, 4, 5, 7, 9, 11], minor: [0, 2, 3, 5, 7, 8, 10] };
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The lo-fi bed on swung eighths. The off-beat eighth lands `SWING` of the way
|
|
621
|
+
* through each beat, so everything leans back. Energy brings the kit in:
|
|
622
|
+
* keys and vinyl alone below 0.25, kick and snare from 0.25, hats from 0.4,
|
|
623
|
+
* the full kit from 0.5.
|
|
624
|
+
*/
|
|
625
|
+
const SWING = 0.6;
|
|
626
|
+
function lofiBed(
|
|
627
|
+
music,
|
|
628
|
+
{ rand, beatsIn, beatSec, chordAt, sectionAt, duration, pumpCurve, keyNotes },
|
|
629
|
+
) {
|
|
630
|
+
const kickSamples = kick(0.7);
|
|
631
|
+
const drums = new Bus(duration + 2.5);
|
|
632
|
+
const keys = new Bus(duration + 2.5);
|
|
633
|
+
const bass = new Bus(duration + 2.5);
|
|
634
|
+
const off = beatSec * SWING;
|
|
635
|
+
beatsIn.forEach((t, i) => {
|
|
636
|
+
const e = sectionAt(t).energy;
|
|
637
|
+
const inBar = i % 4;
|
|
638
|
+
const chord = withSeventh(chordAt(t), keyNotes);
|
|
639
|
+
if (e >= 0.25) {
|
|
640
|
+
if (inBar === 0) drums.add(t, kickSamples, 0.8);
|
|
641
|
+
if (e >= 0.5 && (inBar === 1 || inBar === 2)) drums.add(t + off, kickSamples, 0.55);
|
|
642
|
+
if (inBar === 1 || inBar === 3) drums.add(t + 0.012, softSnare(rand), 0.42, 0.04);
|
|
643
|
+
if (inBar === 0 || (e >= 0.5 && inBar === 2)) {
|
|
644
|
+
const s0 = Math.round(t * SR);
|
|
645
|
+
for (let k = 0; k < 0.22 * SR && s0 + k < pumpCurve.length; k++)
|
|
646
|
+
pumpCurve[s0 + k] = Math.min(pumpCurve[s0 + k], 0.72 + 0.28 * (k / (0.22 * SR)) ** 0.6);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (e >= 0.4) {
|
|
650
|
+
drums.add(t, hat(rand), 0.06 + rand() * 0.03, 0.25);
|
|
651
|
+
drums.add(t + off, hat(rand), 0.035 + rand() * 0.025, 0.25);
|
|
652
|
+
}
|
|
653
|
+
// Keys: a chord on the bar, answered on the swung "and" of two.
|
|
654
|
+
if (inBar === 0 || inBar === 1) {
|
|
655
|
+
const at = inBar === 0 ? t : t + off;
|
|
656
|
+
const length = inBar === 0 ? beatSec * 1.55 : beatSec * 2.35;
|
|
657
|
+
chord.tones.forEach((tone, idx) => {
|
|
658
|
+
const note = keyNote(rand, hz(chord.root + tone + 60), length, 0.85 - idx * 0.08);
|
|
659
|
+
keys.add(at + idx * 0.011, note, 0.16 * (0.6 + 0.4 * e), idx % 2 ? 0.3 : -0.3);
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
// Bass: the root on the bar and on the swung "and" of three.
|
|
663
|
+
if (e >= 0.15 && (inBar === 0 || inBar === 2)) {
|
|
664
|
+
const at = inBar === 0 ? t : t + off;
|
|
665
|
+
bass.add(at, roundBass(hz(chord.root + 36), beatSec * (inBar === 0 ? 1.5 : 0.9)), 0.42);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
// Keep the keys out of the bass's range so the low end stays round, not muddy.
|
|
669
|
+
const keyHighPassL = biquad("hp", 170, 0.7);
|
|
670
|
+
const keyHighPassR = biquad("hp", 170, 0.7);
|
|
671
|
+
for (let i = 0; i < music.n; i++) {
|
|
672
|
+
const p = pumpCurve[i];
|
|
673
|
+
music.l[i] += drums.l[i] + (keyHighPassL(keys.l[i]) + bass.l[i]) * p;
|
|
674
|
+
music.r[i] += drums.r[i] + (keyHighPassR(keys.r[i]) + bass.r[i]) * p;
|
|
675
|
+
}
|
|
676
|
+
const { left, right } = vinyl(rand, duration + 0.5);
|
|
677
|
+
music.addStereo(0, left, right, 1);
|
|
678
|
+
tapeWobble(music);
|
|
679
|
+
}
|
|
680
|
+
|
|
214
681
|
function noiseSweep(rand, seconds, fFrom, fTo, shape) {
|
|
215
682
|
const out = buf(seconds);
|
|
216
683
|
const bp = biquad("bp", fFrom, 1.4);
|
|
@@ -222,57 +689,216 @@ function noiseSweep(rand, seconds, fFrom, fTo, shape) {
|
|
|
222
689
|
return out;
|
|
223
690
|
}
|
|
224
691
|
|
|
692
|
+
// ---------- materials ----------
|
|
693
|
+
// A real object rings at several partials at once and starts with a short
|
|
694
|
+
// burst of noise; a bare sine reads as a toy. Each material below is a few
|
|
695
|
+
// damped partials ([ratio, decay s, level]) plus its own attack.
|
|
696
|
+
function partials(rand, f0, list, seconds, drop = 0) {
|
|
697
|
+
const out = buf(seconds);
|
|
698
|
+
const ramp = 0.0006 * SR;
|
|
699
|
+
const tail = Math.min(out.length, 0.02 * SR);
|
|
700
|
+
for (const [ratio, decay, level] of list) {
|
|
701
|
+
let phase = rand() * Math.PI * 2;
|
|
702
|
+
for (let i = 0; i < out.length; i++) {
|
|
703
|
+
const t = i / SR;
|
|
704
|
+
phase += (2 * Math.PI * f0 * ratio * (1 + drop * Math.exp(-t / 0.03))) / SR;
|
|
705
|
+
// Fade in over 0.6 ms and out over the last 20 ms, so no edge clicks.
|
|
706
|
+
const edge = Math.min(1, i / ramp, (out.length - i) / tail);
|
|
707
|
+
out[i] += Math.sin(phase) * level * Math.exp(-t / decay) * edge;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return out;
|
|
711
|
+
}
|
|
712
|
+
/** Add a filtered noise burst (the contact itself) to the start of `out`. */
|
|
713
|
+
function attack(rand, out, freq, q, seconds, level) {
|
|
714
|
+
const bp = biquad("bp", freq, q);
|
|
715
|
+
const n = Math.min(out.length, Math.max(1, Math.round(seconds * SR)));
|
|
716
|
+
for (let i = 0; i < n; i++) out[i] += bp(rand() * 2 - 1) * level * 3 * (1 - i / n) ** 2;
|
|
717
|
+
return out;
|
|
718
|
+
}
|
|
719
|
+
const soften = (out, drive) => out.map((v) => Math.tanh(v * drive) / Math.tanh(drive));
|
|
720
|
+
|
|
721
|
+
/** Wood: a short hollow body that drops slightly in pitch, and a dry snap. */
|
|
722
|
+
function wood(rand, f = 190, bright = 1) {
|
|
723
|
+
const out = partials(
|
|
724
|
+
rand,
|
|
725
|
+
f,
|
|
726
|
+
[
|
|
727
|
+
[1, 0.075, 1],
|
|
728
|
+
[2.63, 0.034, 0.45],
|
|
729
|
+
[4.9, 0.017, 0.2],
|
|
730
|
+
],
|
|
731
|
+
0.3,
|
|
732
|
+
0.07,
|
|
733
|
+
);
|
|
734
|
+
return soften(attack(rand, out, 2100 * bright, 1.3, 0.006, 0.7), 1.2);
|
|
735
|
+
}
|
|
736
|
+
/** Glass: a clear tone with bright, inharmonic overtones that die first. */
|
|
737
|
+
function glass(rand, f = 1320, length = 1) {
|
|
738
|
+
const out = partials(
|
|
739
|
+
rand,
|
|
740
|
+
f,
|
|
741
|
+
[
|
|
742
|
+
[1, 0.5 * length, 1],
|
|
743
|
+
[2.32, 0.24 * length, 0.38],
|
|
744
|
+
[4.18, 0.12 * length, 0.2],
|
|
745
|
+
[6.71, 0.06 * length, 0.1],
|
|
746
|
+
],
|
|
747
|
+
0.2 + 0.9 * length,
|
|
748
|
+
);
|
|
749
|
+
return attack(rand, out, 3500, 2, 0.002, 0.35);
|
|
750
|
+
}
|
|
751
|
+
/** Plastic: a key or a button, tiny and close. */
|
|
752
|
+
function plastic(rand, f = 1150) {
|
|
753
|
+
const out = partials(
|
|
754
|
+
rand,
|
|
755
|
+
f * (0.92 + rand() * 0.16),
|
|
756
|
+
[
|
|
757
|
+
[1, 0.022, 1],
|
|
758
|
+
[2.08, 0.011, 0.4],
|
|
759
|
+
],
|
|
760
|
+
0.06,
|
|
761
|
+
);
|
|
762
|
+
return attack(rand, out, 3800, 1.1, 0.003, 0.8);
|
|
763
|
+
}
|
|
764
|
+
/** Felt: a soft landing, mostly weight, with the thump of contact. */
|
|
765
|
+
function felt(rand, f = 66, length = 1) {
|
|
766
|
+
const out = partials(
|
|
767
|
+
rand,
|
|
768
|
+
f,
|
|
769
|
+
[
|
|
770
|
+
[1, 0.3 * length, 1],
|
|
771
|
+
[1.48, 0.11 * length, 0.25],
|
|
772
|
+
[2.9, 0.04, 0.08],
|
|
773
|
+
],
|
|
774
|
+
0.25 + 0.9 * length,
|
|
775
|
+
0.9,
|
|
776
|
+
);
|
|
777
|
+
return soften(attack(rand, out, 380, 0.7, 0.012, 0.6), 1.5);
|
|
778
|
+
}
|
|
779
|
+
/** Air: breath through two moving resonances, swelling then falling away. */
|
|
780
|
+
function breath(rand, seconds, from, to, peakAt = 0.6) {
|
|
781
|
+
const out = buf(seconds);
|
|
782
|
+
const a = biquad("bp", from, 2.2);
|
|
783
|
+
const b = biquad("bp", from * 1.5, 3);
|
|
784
|
+
const lp = biquad("lp", 6000, 0.7);
|
|
785
|
+
for (let i = 0; i < out.length; i++) {
|
|
786
|
+
const k = i / out.length;
|
|
787
|
+
if (i % 64 === 0) {
|
|
788
|
+
const f = from * (to / from) ** k;
|
|
789
|
+
a.set(f);
|
|
790
|
+
b.set(f * 1.5);
|
|
791
|
+
}
|
|
792
|
+
const shape = k < peakAt ? (k / peakAt) ** 1.6 : ((1 - k) / (1 - peakAt)) ** 1.2;
|
|
793
|
+
const x = rand() * 2 - 1;
|
|
794
|
+
out[i] = lp(a(x) * 2.6 + b(x) * 1.4) * shape;
|
|
795
|
+
}
|
|
796
|
+
return out;
|
|
797
|
+
}
|
|
798
|
+
/** A drop of water: a resonance that rises as it closes. */
|
|
799
|
+
function droplet(rand, f = 520) {
|
|
800
|
+
const out = buf(0.11);
|
|
801
|
+
let phase = 0;
|
|
802
|
+
for (let i = 0; i < out.length; i++) {
|
|
803
|
+
const t = i / SR;
|
|
804
|
+
phase += (2 * Math.PI * f * (1 + 0.9 * (1 - Math.exp(-t / 0.02)))) / SR;
|
|
805
|
+
out[i] = Math.sin(phase) * Math.exp(-t / 0.045) * Math.min(1, t / 0.0008);
|
|
806
|
+
}
|
|
807
|
+
return attack(rand, out, 2600, 1.5, 0.002, 0.12);
|
|
808
|
+
}
|
|
809
|
+
/** Paper: a dry, grainy slide with a little body under it. */
|
|
810
|
+
function paper(rand, seconds = 0.35) {
|
|
811
|
+
const out = buf(seconds);
|
|
812
|
+
const hiss = biquad("bp", 2200, 0.9);
|
|
813
|
+
const body = biquad("bp", 700, 0.7);
|
|
814
|
+
const lp = biquad("lp", 6500, 0.7);
|
|
815
|
+
let grain = 0;
|
|
816
|
+
let next = 0;
|
|
817
|
+
for (let i = 0; i < out.length; i++) {
|
|
818
|
+
if (i >= next) {
|
|
819
|
+
grain = 0.4 + rand() * 0.6;
|
|
820
|
+
next = i + Math.round((0.008 + rand() * 0.012) * SR);
|
|
821
|
+
}
|
|
822
|
+
grain *= 0.9995;
|
|
823
|
+
const x = rand() * 2 - 1;
|
|
824
|
+
out[i] =
|
|
825
|
+
lp(hiss(x) * 2.2 + body(x) * 0.8) * grain * Math.sin(Math.PI * (i / out.length)) ** 1.2;
|
|
826
|
+
}
|
|
827
|
+
return out;
|
|
828
|
+
}
|
|
829
|
+
/** Two short pieces end to end, `gap` seconds apart (press then release). */
|
|
830
|
+
function pair(first, second, gap, secondLevel) {
|
|
831
|
+
const off = Math.round(gap * SR);
|
|
832
|
+
const out = buf((off + second.length) / SR);
|
|
833
|
+
for (let i = 0; i < first.length && i < out.length; i++) out[i] += first[i];
|
|
834
|
+
for (let i = 0; i < second.length && off + i < out.length; i++)
|
|
835
|
+
out[off + i] += second[i] * secondLevel;
|
|
836
|
+
return out;
|
|
837
|
+
}
|
|
838
|
+
function mix(base, extra, level) {
|
|
839
|
+
const out = buf(Math.max(base.length, extra.length) / SR);
|
|
840
|
+
for (let i = 0; i < base.length; i++) out[i] += base[i];
|
|
841
|
+
for (let i = 0; i < extra.length; i++) out[i] += extra[i] * level;
|
|
842
|
+
return out;
|
|
843
|
+
}
|
|
844
|
+
|
|
225
845
|
// ---------- sound effects ----------
|
|
226
846
|
function sfx(name, rand, beatSec, chord) {
|
|
227
847
|
switch (name) {
|
|
848
|
+
case "tap":
|
|
849
|
+
return plastic(rand);
|
|
850
|
+
case "press":
|
|
851
|
+
return pair(plastic(rand, 980), plastic(rand, 1320), 0.07, 0.55);
|
|
852
|
+
case "knock":
|
|
853
|
+
return wood(rand);
|
|
854
|
+
case "chime":
|
|
855
|
+
return glass(rand, hz(chord.root + chord.tones[0] + 84));
|
|
856
|
+
case "breath":
|
|
228
857
|
case "whoosh":
|
|
229
|
-
return
|
|
858
|
+
return breath(rand, 0.7, 280, 3600);
|
|
230
859
|
case "whoosh-short":
|
|
231
|
-
return
|
|
860
|
+
return breath(rand, 0.32, 600, 5200, 0.5);
|
|
861
|
+
case "thud":
|
|
862
|
+
return felt(rand);
|
|
863
|
+
case "plip":
|
|
864
|
+
case "pop":
|
|
865
|
+
return droplet(rand, name === "pop" ? 600 : 520);
|
|
866
|
+
case "paper":
|
|
867
|
+
return paper(rand);
|
|
232
868
|
case "riser": {
|
|
233
869
|
const len = beatSec * 4;
|
|
234
870
|
const n = noiseSweep(rand, len, 200, 9000, (k) => k ** 2.2);
|
|
235
|
-
const tone =
|
|
236
|
-
for (let i = 0; i < n.length; i++) n[i] = n[i] * 0.8 + tone[i] * 0.
|
|
871
|
+
const tone = saw(hz(chord.root + 48), len, 1400, len * 0.9, 99, 0.004);
|
|
872
|
+
for (let i = 0; i < n.length; i++) n[i] = n[i] * 0.8 + tone[i] * 0.3 * (i / n.length) ** 2;
|
|
237
873
|
return n;
|
|
238
874
|
}
|
|
239
|
-
case "impact":
|
|
240
|
-
|
|
241
|
-
const hit = noiseSweep(rand, 0.25, 4000, 300, (k) => Math.exp(-k * 6));
|
|
242
|
-
for (let i = 0; i < hit.length; i++) body[i] += hit[i] * 0.6;
|
|
243
|
-
return body.map((v) => Math.tanh(v * 1.4));
|
|
244
|
-
}
|
|
875
|
+
case "impact":
|
|
876
|
+
return mix(felt(rand, 60, 1.2), wood(rand, 140, 0.8), 0.5);
|
|
245
877
|
case "sub-drop":
|
|
246
|
-
return
|
|
247
|
-
case "
|
|
248
|
-
return
|
|
249
|
-
case "click": {
|
|
250
|
-
const out = buf(0.025);
|
|
251
|
-
const hp = biquad("hp", 2500, 0.7);
|
|
252
|
-
for (let i = 0; i < out.length; i++) out[i] = hp(rand() * 2 - 1) * Math.exp(-(i / SR) / 0.004);
|
|
253
|
-
return out;
|
|
254
|
-
}
|
|
878
|
+
return felt(rand, 46, 1.4);
|
|
879
|
+
case "click":
|
|
880
|
+
return plastic(rand, 1500);
|
|
255
881
|
case "tick":
|
|
256
|
-
return
|
|
257
|
-
case "snap":
|
|
258
|
-
|
|
259
|
-
const bp = biquad("bp", 2200, 3);
|
|
260
|
-
for (let i = 0; i < out.length; i++) out[i] = bp(rand() * 2 - 1) * Math.exp(-(i / SR) / 0.012) * 3;
|
|
261
|
-
return out;
|
|
262
|
-
}
|
|
882
|
+
return glass(rand, 2400, 0.12);
|
|
883
|
+
case "snap":
|
|
884
|
+
return wood(rand, 420, 1.5);
|
|
263
885
|
case "glitch": {
|
|
264
886
|
const out = buf(0.3);
|
|
265
|
-
let hold = 0,
|
|
887
|
+
let hold = 0,
|
|
888
|
+
v = 0;
|
|
266
889
|
for (let i = 0; i < out.length; i++) {
|
|
267
|
-
if (hold-- <= 0) {
|
|
268
|
-
|
|
890
|
+
if (hold-- <= 0) {
|
|
891
|
+
hold = Math.floor(rand() * 900) + 60;
|
|
892
|
+
v = (rand() * 2 - 1) * (rand() < 0.3 ? 0 : 1);
|
|
893
|
+
}
|
|
894
|
+
out[i] = (Math.round(v * 6) / 6) * 0.7;
|
|
269
895
|
}
|
|
270
896
|
return out;
|
|
271
897
|
}
|
|
272
898
|
case "shimmer": {
|
|
273
899
|
const out = buf(1.4);
|
|
274
900
|
chord.tones.forEach((tone, idx) => {
|
|
275
|
-
const note =
|
|
901
|
+
const note = glass(rand, hz(chord.root + tone + 84), 0.9);
|
|
276
902
|
const off = Math.round(idx * 0.08 * SR);
|
|
277
903
|
for (let i = 0; i < note.length && i + off < out.length; i++) out[i + off] += note[i] * 0.3;
|
|
278
904
|
});
|
|
@@ -302,9 +928,10 @@ function sfx(name, rand, beatSec, chord) {
|
|
|
302
928
|
const out = buf(1.2);
|
|
303
929
|
let t = 0;
|
|
304
930
|
while (t < 1.1) {
|
|
305
|
-
const c =
|
|
931
|
+
const c = plastic(rand, 1050);
|
|
306
932
|
const off = Math.round(t * SR);
|
|
307
|
-
for (let i = 0; i < c.length && off + i < out.length; i++)
|
|
933
|
+
for (let i = 0; i < c.length && off + i < out.length; i++)
|
|
934
|
+
out[off + i] += c[i] * (0.5 + rand() * 0.5);
|
|
308
935
|
t += 0.06 + rand() * 0.07;
|
|
309
936
|
}
|
|
310
937
|
return out;
|
|
@@ -319,21 +946,51 @@ function render(score) {
|
|
|
319
946
|
const rand = rng(score.seed);
|
|
320
947
|
const beatSec = 60 / score.bpm;
|
|
321
948
|
const barSec = beatSec * 4;
|
|
322
|
-
const beats =
|
|
949
|
+
const beats =
|
|
950
|
+
score.beatTimes ??
|
|
951
|
+
Array.from({ length: Math.ceil(score.duration / beatSec) + 1 }, (_, i) => i * beatSec);
|
|
323
952
|
const within = (t) => t >= 0 && t < score.duration;
|
|
324
953
|
const beatsIn = beats.filter(within);
|
|
325
954
|
const bars = beatsIn.filter((_, i) => i % 4 === 0);
|
|
326
955
|
const chordNames = score.chords ?? defaultChords(score.key, score.scale);
|
|
327
956
|
const chords = chordNames.map(chordNotes);
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const
|
|
957
|
+
// A resolving ending comes home: the last bar (or the last two, when the
|
|
958
|
+
// final one is short) plays the first chord of the progression.
|
|
959
|
+
const homeFrom =
|
|
960
|
+
score.ending === "resolve" && bars.length > 1
|
|
961
|
+
? (bars.filter((b) => b <= score.duration - barSec * 0.75).at(-1) ?? Infinity)
|
|
962
|
+
: Infinity;
|
|
963
|
+
const chordAt = (t) =>
|
|
964
|
+
t >= homeFrom - 1e-6
|
|
965
|
+
? chords[0]
|
|
966
|
+
: chords[Math.max(0, bars.filter((b) => b <= t + 1e-6).length - 1) % chords.length];
|
|
967
|
+
const sectionAt = (t) =>
|
|
968
|
+
score.sections.filter((s) => s.from <= t + 1e-6).at(-1) ?? score.sections[0];
|
|
969
|
+
const beatTime = (b) =>
|
|
970
|
+
b < beats.length ? beats[b] : beats.at(-1) + (b - beats.length + 1) * beatSec;
|
|
331
971
|
|
|
332
972
|
const music = new Bus(score.duration + 2.5);
|
|
333
973
|
const effects = new Bus(score.duration + 2.5);
|
|
974
|
+
// What each effect sends to the room, so near sounds stay dry and far ones bloom.
|
|
975
|
+
const roomSend = new Bus(score.duration + 2.5);
|
|
976
|
+
const place = (start, samples, gain, pan, sound, send) => {
|
|
977
|
+
effects.add(start, samples, gain, pan);
|
|
978
|
+
roomSend.add(start, samples, gain * (send ?? DEFAULT_SEND[sound] ?? 0.3), pan);
|
|
979
|
+
};
|
|
334
980
|
const pumpCurve = new Float32Array(music.n).fill(1);
|
|
335
981
|
|
|
336
|
-
if (score.music) {
|
|
982
|
+
if (score.music && score.style === "lofi") {
|
|
983
|
+
lofiBed(music, {
|
|
984
|
+
rand,
|
|
985
|
+
beatsIn,
|
|
986
|
+
beatSec,
|
|
987
|
+
chordAt,
|
|
988
|
+
sectionAt,
|
|
989
|
+
duration: score.duration,
|
|
990
|
+
pumpCurve,
|
|
991
|
+
keyNotes: new Set(SCALE_STEPS[score.scale].map((step) => (parseRoot(score.key) + step) % 12)),
|
|
992
|
+
});
|
|
993
|
+
} else if (score.music) {
|
|
337
994
|
const kickSamples = kick();
|
|
338
995
|
const cinematic = score.style === "cinematic";
|
|
339
996
|
const minimal = score.style === "minimal";
|
|
@@ -356,7 +1013,8 @@ function render(score) {
|
|
|
356
1013
|
music.add(t + (3 * beatSec) / 4, hat(rand), 0.06, -0.3);
|
|
357
1014
|
}
|
|
358
1015
|
}
|
|
359
|
-
if (minimal && e > 0)
|
|
1016
|
+
if (minimal && e > 0)
|
|
1017
|
+
music.add(t, sfx("tick", rand, beatSec, chordAt(t)), 0.05 + 0.08 * e, i % 2 ? 0.4 : -0.4);
|
|
360
1018
|
});
|
|
361
1019
|
|
|
362
1020
|
// Bass on eighths (pulse) or held roots (cinematic), following the bar's chord.
|
|
@@ -369,7 +1027,8 @@ function render(score) {
|
|
|
369
1027
|
if (cinematic) {
|
|
370
1028
|
if (bars.includes(t)) bassBus.add(t, saw(f, barSec, 380, 0.02, barSec * 0.6), 0.5);
|
|
371
1029
|
} else {
|
|
372
|
-
for (const off of [0, beatSec / 2])
|
|
1030
|
+
for (const off of [0, beatSec / 2])
|
|
1031
|
+
bassBus.add(t + off, saw(f, beatSec / 2, 300 + 900 * e, 0.004, 0.12), 0.42);
|
|
373
1032
|
}
|
|
374
1033
|
}
|
|
375
1034
|
// Pads: one chord per bar, slow attack; brighter with energy.
|
|
@@ -381,7 +1040,14 @@ function render(score) {
|
|
|
381
1040
|
const left = new Float32Array(Math.round(len * SR));
|
|
382
1041
|
const right = new Float32Array(left.length);
|
|
383
1042
|
c.tones.forEach((tone, idx) => {
|
|
384
|
-
const note = saw(
|
|
1043
|
+
const note = saw(
|
|
1044
|
+
hz(c.root + tone + 60),
|
|
1045
|
+
len,
|
|
1046
|
+
700 + 2200 * e,
|
|
1047
|
+
barSec * 0.35,
|
|
1048
|
+
barSec * 0.9,
|
|
1049
|
+
0.006 + idx * 0.002,
|
|
1050
|
+
);
|
|
385
1051
|
for (let i = 0; i < note.length && i < left.length; i++) {
|
|
386
1052
|
left[i] += note[i] * (idx % 2 ? 0.7 : 1);
|
|
387
1053
|
right[i] += note[i] * (idx % 2 ? 1 : 0.7);
|
|
@@ -402,27 +1068,143 @@ function render(score) {
|
|
|
402
1068
|
const prev = score.sections[i - 1];
|
|
403
1069
|
if (s.energy - prev.energy >= 0.35 && s.from >= barSec) {
|
|
404
1070
|
const riser = sfx("riser", rand, beatSec, chordAt(s.from));
|
|
405
|
-
|
|
406
|
-
|
|
1071
|
+
place(s.from - riser.length / SR, riser, 0.35, 0, "riser");
|
|
1072
|
+
place(s.from, sfx("impact", rand, beatSec, chordAt(s.from)), 0.55, 0, "impact");
|
|
407
1073
|
}
|
|
408
1074
|
});
|
|
409
1075
|
}
|
|
410
1076
|
|
|
411
|
-
// Explicit hits, resolved to seconds on the same grid
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
1077
|
+
// Explicit hits, resolved to seconds on the same grid, then thinned: two
|
|
1078
|
+
// short sounds closer than MERGE_WINDOW read as one, so keep the stronger.
|
|
1079
|
+
const timed = score.hits
|
|
1080
|
+
.map((h, i) => {
|
|
1081
|
+
const t =
|
|
1082
|
+
h.t !== undefined
|
|
1083
|
+
? Number(h.t)
|
|
1084
|
+
: h.beat !== undefined
|
|
1085
|
+
? beatTime(Number(h.beat))
|
|
1086
|
+
: beatTime(Number(h.bar) * 4);
|
|
1087
|
+
if (!(t >= 0)) fail(`hit ${i}: resolved to an invalid time`);
|
|
1088
|
+
return { ...h, t, order: i };
|
|
1089
|
+
})
|
|
1090
|
+
.sort((a, b) => a.t - b.t || a.order - b.order);
|
|
1091
|
+
const strength = (h) => (DUCK_WEIGHT[h.sfx] ?? 0.3) * h.gain;
|
|
1092
|
+
const kept = [];
|
|
1093
|
+
const merged = [];
|
|
1094
|
+
for (const h of timed) {
|
|
1095
|
+
const near = LONG_SOUNDS.has(h.sfx)
|
|
1096
|
+
? undefined
|
|
1097
|
+
: kept.findLast((k) => !LONG_SOUNDS.has(k.sfx) && h.t - k.t < MERGE_WINDOW);
|
|
1098
|
+
if (!near) kept.push(h);
|
|
1099
|
+
else if (strength(h) > strength(near)) {
|
|
1100
|
+
kept[kept.indexOf(near)] = h;
|
|
1101
|
+
merged.push({ t: Number(near.t.toFixed(4)), sfx: near.sfx, into: h.sfx });
|
|
1102
|
+
} else merged.push({ t: Number(h.t.toFixed(4)), sfx: h.sfx, into: near.sfx });
|
|
1103
|
+
}
|
|
1104
|
+
const resolvedHits = kept.map((h, i) => {
|
|
1105
|
+
const t = h.t;
|
|
415
1106
|
const samples = sfx(h.sfx, rand, beatSec, chordAt(t));
|
|
416
1107
|
// A riser/swell placed at t should *arrive* at t.
|
|
417
1108
|
const start = h.sfx === "riser" || h.sfx === "swell" ? t - samples.length / SR : t;
|
|
418
|
-
const pan =
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
1109
|
+
const pan =
|
|
1110
|
+
typeof h.pan === "number"
|
|
1111
|
+
? Math.max(-1, Math.min(1, h.pan))
|
|
1112
|
+
: /^(whoosh|breath)/.test(h.sfx)
|
|
1113
|
+
? i % 2
|
|
1114
|
+
? 0.35
|
|
1115
|
+
: -0.35
|
|
1116
|
+
: 0;
|
|
1117
|
+
const base =
|
|
1118
|
+
{
|
|
1119
|
+
impact: 0.6,
|
|
1120
|
+
sting: 0.6,
|
|
1121
|
+
"sub-drop": 0.6,
|
|
1122
|
+
thud: 0.6,
|
|
1123
|
+
riser: 0.4,
|
|
1124
|
+
whoosh: 0.4,
|
|
1125
|
+
breath: 0.4,
|
|
1126
|
+
"whoosh-short": 0.35,
|
|
1127
|
+
glitch: 0.3,
|
|
1128
|
+
shimmer: 0.3,
|
|
1129
|
+
swell: 0.35,
|
|
1130
|
+
}[h.sfx] ?? 0.4;
|
|
1131
|
+
place(start, samples, base * h.gain, pan, h.sfx, h.send);
|
|
1132
|
+
return {
|
|
1133
|
+
t: Number(t.toFixed(4)),
|
|
1134
|
+
sfx: h.sfx,
|
|
1135
|
+
gain: h.gain,
|
|
1136
|
+
...(h.source ? { source: h.source } : {}),
|
|
1137
|
+
...(h.name ? { name: h.name } : {}),
|
|
1138
|
+
};
|
|
422
1139
|
});
|
|
423
1140
|
|
|
1141
|
+
// Camera air: the operated camera's own motion, heard as air moving past.
|
|
1142
|
+
// Silent while the camera is still, brighter and louder as it travels.
|
|
1143
|
+
let airPeak = 0;
|
|
1144
|
+
if (score.air && score.airCurve) {
|
|
1145
|
+
const { rate, start, values } = score.airCurve;
|
|
1146
|
+
const left = new Float32Array(effects.n);
|
|
1147
|
+
const right = new Float32Array(effects.n);
|
|
1148
|
+
const filters = [0, 1].map(() => ({
|
|
1149
|
+
a: biquad("bp", 300, 1.1),
|
|
1150
|
+
b: biquad("bp", 660, 1.6),
|
|
1151
|
+
lp: biquad("lp", 7000, 0.7),
|
|
1152
|
+
}));
|
|
1153
|
+
let level = 0;
|
|
1154
|
+
for (let i = 0; i < effects.n; i++) {
|
|
1155
|
+
const k = (i / SR - start) * rate;
|
|
1156
|
+
const j = Math.floor(k);
|
|
1157
|
+
const v =
|
|
1158
|
+
j < 0 || j >= values.length
|
|
1159
|
+
? 0
|
|
1160
|
+
: values[j] + ((values[j + 1] ?? values[j]) - values[j]) * (k - j);
|
|
1161
|
+
const target = Math.min(1, v / 4000) ** 1.3;
|
|
1162
|
+
level += (target - level) * 0.0015; // ~14 ms smoothing
|
|
1163
|
+
if (i % 64 === 0)
|
|
1164
|
+
for (const f of filters) {
|
|
1165
|
+
f.a.set(220 + 2600 * level);
|
|
1166
|
+
f.b.set((220 + 2600 * level) * 2.2);
|
|
1167
|
+
}
|
|
1168
|
+
if (level < 0.02) continue;
|
|
1169
|
+
airPeak = Math.max(airPeak, level);
|
|
1170
|
+
const [fl, fr] = filters;
|
|
1171
|
+
const xl = rand() * 2 - 1;
|
|
1172
|
+
const xr = rand() * 2 - 1;
|
|
1173
|
+
left[i] = fl.lp(fl.a(xl) * 2 + fl.b(xl)) * 0.12 * level;
|
|
1174
|
+
right[i] = fr.lp(fr.a(xr) * 2 + fr.b(xr)) * 0.12 * level;
|
|
1175
|
+
}
|
|
1176
|
+
effects.addStereo(0, left, right);
|
|
1177
|
+
roomSend.addStereo(0, left, right, 0.5);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Duck the music under the hits, then put everything in the same room.
|
|
1181
|
+
if (score.music) {
|
|
1182
|
+
const curve = duckCurve(music.n, resolvedHits, score.duck);
|
|
1183
|
+
for (let i = 0; i < music.n; i++) {
|
|
1184
|
+
music.l[i] *= curve[i];
|
|
1185
|
+
music.r[i] *= curve[i];
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
const room = ROOMS[score.room];
|
|
1189
|
+
applyRoom(effects, room, 1, roomSend);
|
|
1190
|
+
// The bed gets a lighter send of the same room so the two sit together.
|
|
1191
|
+
if (score.music) applyRoom(music, room, 0.35);
|
|
1192
|
+
// Resolve: let the home chord breathe out over the last stretch instead of stopping.
|
|
1193
|
+
if (score.music && score.ending === "resolve") {
|
|
1194
|
+
const fade = Math.min(score.duration * 0.25, Math.max(barSec, 1.5));
|
|
1195
|
+
const from = Math.round((score.duration - fade) * SR);
|
|
1196
|
+
const to = Math.round(score.duration * SR);
|
|
1197
|
+
for (let i = Math.max(0, from); i < music.n; i++) {
|
|
1198
|
+
const k = i >= to ? 1 : (i - from) / (to - from);
|
|
1199
|
+
const g = i >= to ? 0 : Math.cos((k * Math.PI) / 2) ** 1.5;
|
|
1200
|
+
music.l[i] *= g;
|
|
1201
|
+
music.r[i] *= g;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
424
1205
|
return {
|
|
425
|
-
music,
|
|
1206
|
+
music,
|
|
1207
|
+
effects,
|
|
426
1208
|
map: {
|
|
427
1209
|
bpm: score.bpm,
|
|
428
1210
|
beatSec: Number(beatSec.toFixed(5)),
|
|
@@ -430,13 +1212,146 @@ function render(score) {
|
|
|
430
1212
|
duration: score.duration,
|
|
431
1213
|
beats: beatsIn.map((t) => Number(t.toFixed(4))),
|
|
432
1214
|
bars: bars.map((t) => Number(t.toFixed(4))),
|
|
433
|
-
sections: score.sections.map((s, i) => ({
|
|
1215
|
+
sections: score.sections.map((s, i) => ({
|
|
1216
|
+
...s,
|
|
1217
|
+
to: score.sections[i + 1]?.from ?? score.duration,
|
|
1218
|
+
})),
|
|
434
1219
|
chords: chordNames,
|
|
435
|
-
|
|
1220
|
+
room: score.room,
|
|
1221
|
+
duck: score.duck,
|
|
1222
|
+
ending: score.music ? score.ending : null,
|
|
1223
|
+
hits: resolvedHits.map(({ gain, ...hit }) => hit).sort((a, b) => a.t - b.t),
|
|
1224
|
+
...(merged.length ? { merged } : {}),
|
|
1225
|
+
...(score.airCurve ? { air: score.air ? { peak: Number(airPeak.toFixed(3)) } : "off" } : {}),
|
|
436
1226
|
},
|
|
437
1227
|
};
|
|
438
1228
|
}
|
|
439
1229
|
|
|
1230
|
+
// ---------- balance ----------
|
|
1231
|
+
// The picture's sounds must be heard over the music. For each hit, compare
|
|
1232
|
+
// the effects and the music in the hit's own window and band, at the levels
|
|
1233
|
+
// they are written at and played back at equal volume.
|
|
1234
|
+
function bandRms(bus, gain, from, to, band) {
|
|
1235
|
+
const pre = Math.round(0.05 * SR);
|
|
1236
|
+
const s0 = Math.max(0, Math.round(from * SR) - pre);
|
|
1237
|
+
const s1 = Math.min(bus.n, Math.round(to * SR));
|
|
1238
|
+
const [lo, hi] = band;
|
|
1239
|
+
const hp = biquad("hp", lo, 0.7);
|
|
1240
|
+
const lp = biquad("lp", hi, 0.7);
|
|
1241
|
+
let sum = 0;
|
|
1242
|
+
let count = 0;
|
|
1243
|
+
for (let i = s0; i < s1; i++) {
|
|
1244
|
+
const y = lp(hp((bus.l[i] + bus.r[i]) * 0.5 * gain));
|
|
1245
|
+
if (i >= s0 + pre) {
|
|
1246
|
+
sum += y * y;
|
|
1247
|
+
count++;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return count ? Math.sqrt(sum / count) : 0;
|
|
1251
|
+
}
|
|
1252
|
+
function balance(music, musicGain, effects, effectsGain, hits) {
|
|
1253
|
+
const rows = hits.map((h) => {
|
|
1254
|
+
const band = LOW_SOUNDS.has(h.sfx) ? [60, 400] : [1000, 5000];
|
|
1255
|
+
const [a, b] = HEARD_AT[h.sfx] ?? [0, 0.12];
|
|
1256
|
+
const [from, to] = [h.t + a, h.t + b];
|
|
1257
|
+
const fx = bandRms(effects, effectsGain, from, to, band);
|
|
1258
|
+
const bed = bandRms(music, musicGain, from, to, band);
|
|
1259
|
+
const db = fx > 0 && bed > 0 ? 20 * Math.log10(fx / bed) : fx > 0 ? 99 : -99;
|
|
1260
|
+
return {
|
|
1261
|
+
t: h.t,
|
|
1262
|
+
sfx: h.sfx,
|
|
1263
|
+
...(h.name ? { name: h.name } : {}),
|
|
1264
|
+
db: Math.max(-99, Math.min(99, Math.round(db * 10) / 10)),
|
|
1265
|
+
};
|
|
1266
|
+
});
|
|
1267
|
+
const worst = rows.reduce((m, r) => Math.min(m, r.db), Infinity);
|
|
1268
|
+
// Turn the music down until the most buried hit sits at BURIED_DB.
|
|
1269
|
+
const musicVolume =
|
|
1270
|
+
rows.length && worst < BURIED_DB
|
|
1271
|
+
? Math.max(0.2, Math.floor(10 ** ((worst - BURIED_DB) / 20) * 20) / 20)
|
|
1272
|
+
: 1;
|
|
1273
|
+
return {
|
|
1274
|
+
buried: rows.filter((r) => r.db < BURIED_DB),
|
|
1275
|
+
musicVolume,
|
|
1276
|
+
effectsVolume: 1,
|
|
1277
|
+
threshold: BURIED_DB,
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// ---------- mud ----------
|
|
1282
|
+
// Piled-up note tails collect in the low mids (~100-300 Hz) and turn a mix
|
|
1283
|
+
// muddy. Measure each half-second window of the mix as played (music at the
|
|
1284
|
+
// balance report's suggested volume plus effects): the share of its energy in
|
|
1285
|
+
// that band, and which bus puts more there.
|
|
1286
|
+
const MUD_BAND = [100, 300];
|
|
1287
|
+
const MUD_WINDOW = 0.5;
|
|
1288
|
+
// The share is spectral, so a louder mix is not a muddier one. Measured on
|
|
1289
|
+
// EZ's own scores (4 styles x 6 keys/seeds x 70/100/128 bpm, 16 s with four
|
|
1290
|
+
// sections and ten typical hits; 72 renders, 2304 windows): window share
|
|
1291
|
+
// median 0.079, p95 0.203, p99 0.308, max 0.401 (lofi in B minor); by style
|
|
1292
|
+
// max pulse 0.305, cinematic 0.368, minimal 0.332, lofi 0.401; whole-mix
|
|
1293
|
+
// share 0.03-0.154. The darkest normal bed found (lofi in B at 40 bpm) peaks
|
|
1294
|
+
// at 0.434. A deliberately muddy score (wooden knocks piled every 60 ms, their
|
|
1295
|
+
// 190 Hz bodies overlapping) measures 0.45-0.53 in the piled windows.
|
|
1296
|
+
const MUD_THRESHOLD = 0.45;
|
|
1297
|
+
/** Energy per window: [total, low-mid band] for one bus at one gain. */
|
|
1298
|
+
function windowEnergy(bus, gain, n, windows) {
|
|
1299
|
+
const per = Math.round(MUD_WINDOW * SR);
|
|
1300
|
+
const total = new Float64Array(windows);
|
|
1301
|
+
const band = new Float64Array(windows);
|
|
1302
|
+
if (!gain) return { total, band };
|
|
1303
|
+
const hp1 = biquad("hp", MUD_BAND[0], 0.7);
|
|
1304
|
+
const hp2 = biquad("hp", MUD_BAND[0], 0.7);
|
|
1305
|
+
const lp1 = biquad("lp", MUD_BAND[1], 0.7);
|
|
1306
|
+
const lp2 = biquad("lp", MUD_BAND[1], 0.7);
|
|
1307
|
+
for (let i = 0; i < n; i++) {
|
|
1308
|
+
const x = (bus.l[i] + bus.r[i]) * 0.5 * gain;
|
|
1309
|
+
const y = lp2(lp1(hp2(hp1(x))));
|
|
1310
|
+
const w = Math.min(windows - 1, Math.floor(i / per));
|
|
1311
|
+
total[w] += x * x;
|
|
1312
|
+
band[w] += y * y;
|
|
1313
|
+
}
|
|
1314
|
+
return { total, band };
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* @param {Bus} music @param {number} musicGain @param {Bus} effects
|
|
1318
|
+
* @param {number} effectsGain @param {number} duration
|
|
1319
|
+
*/
|
|
1320
|
+
function mud(music, musicGain, effects, effectsGain, duration) {
|
|
1321
|
+
const n = Math.min(music.n, Math.round(duration * SR));
|
|
1322
|
+
const windows = Math.max(1, Math.ceil(n / Math.round(MUD_WINDOW * SR)));
|
|
1323
|
+
const m = windowEnergy(music, musicGain, n, windows);
|
|
1324
|
+
const e = windowEnergy(effects, effectsGain, n, windows);
|
|
1325
|
+
const round = (v) => Math.round(v * 1000) / 1000;
|
|
1326
|
+
const rows = [];
|
|
1327
|
+
let sumTotal = 0;
|
|
1328
|
+
let sumBand = 0;
|
|
1329
|
+
for (let w = 0; w < windows; w++) {
|
|
1330
|
+
// Buses are summed as energies (uncorrelated sources).
|
|
1331
|
+
const total = m.total[w] + e.total[w];
|
|
1332
|
+
const band = m.band[w] + e.band[w];
|
|
1333
|
+
sumTotal += total;
|
|
1334
|
+
sumBand += band;
|
|
1335
|
+
// Ignore near-silence: a share of nothing is not mud.
|
|
1336
|
+
if (total / (Math.round(MUD_WINDOW * SR) || 1) < 1e-6) continue;
|
|
1337
|
+
const share = band / total;
|
|
1338
|
+
if (share > MUD_THRESHOLD)
|
|
1339
|
+
rows.push({
|
|
1340
|
+
from: round(w * MUD_WINDOW),
|
|
1341
|
+
to: round(Math.min(duration, (w + 1) * MUD_WINDOW)),
|
|
1342
|
+
share: round(share),
|
|
1343
|
+
bus: m.band[w] >= e.band[w] ? "music" : "effects",
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
return {
|
|
1347
|
+
windows: rows,
|
|
1348
|
+
share: round(sumTotal > 0 ? sumBand / sumTotal : 0),
|
|
1349
|
+
threshold: MUD_THRESHOLD,
|
|
1350
|
+
band: MUD_BAND,
|
|
1351
|
+
window: MUD_WINDOW,
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
|
|
440
1355
|
// ---------- mastering + WAV ----------
|
|
441
1356
|
function master(bus, duration, targetPeak) {
|
|
442
1357
|
const n = Math.round(duration * SR);
|
|
@@ -447,12 +1362,22 @@ function master(bus, duration, targetPeak) {
|
|
|
447
1362
|
peak = Math.max(peak, Math.abs(bus.l[i]), Math.abs(bus.r[i]));
|
|
448
1363
|
}
|
|
449
1364
|
const g = peak > 0 ? targetPeak / peak : 0;
|
|
1365
|
+
bus.gain = g;
|
|
450
1366
|
const fade = Math.min(n, Math.round(0.03 * SR));
|
|
451
1367
|
const out = Buffer.alloc(44 + n * 4);
|
|
452
|
-
out.write("RIFF", 0);
|
|
453
|
-
out.
|
|
454
|
-
out.
|
|
455
|
-
out.write("
|
|
1368
|
+
out.write("RIFF", 0);
|
|
1369
|
+
out.writeUInt32LE(36 + n * 4, 4);
|
|
1370
|
+
out.write("WAVE", 8);
|
|
1371
|
+
out.write("fmt ", 12);
|
|
1372
|
+
out.writeUInt32LE(16, 16);
|
|
1373
|
+
out.writeUInt16LE(1, 20);
|
|
1374
|
+
out.writeUInt16LE(2, 22);
|
|
1375
|
+
out.writeUInt32LE(SR, 24);
|
|
1376
|
+
out.writeUInt32LE(SR * 4, 28);
|
|
1377
|
+
out.writeUInt16LE(4, 32);
|
|
1378
|
+
out.writeUInt16LE(16, 34);
|
|
1379
|
+
out.write("data", 36);
|
|
1380
|
+
out.writeUInt32LE(n * 4, 40);
|
|
456
1381
|
for (let i = 0; i < n; i++) {
|
|
457
1382
|
const f = i >= n - fade ? (n - i) / fade : 1;
|
|
458
1383
|
out.writeInt16LE(Math.round(Math.max(-1, Math.min(1, bus.l[i] * g * f)) * 32767), 44 + i * 4);
|
|
@@ -467,9 +1392,16 @@ let raw;
|
|
|
467
1392
|
try {
|
|
468
1393
|
raw = JSON.parse(await readFile(resolve(scoreArg), "utf8"));
|
|
469
1394
|
} catch (error) {
|
|
470
|
-
fail(
|
|
1395
|
+
fail(
|
|
1396
|
+
`Could not read score ${scoreArg}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1397
|
+
);
|
|
471
1398
|
}
|
|
472
1399
|
const score = validate(raw);
|
|
1400
|
+
if (score.cues) {
|
|
1401
|
+
const loaded = await loadCues(resolve(dirname(resolve(scoreArg)), score.cues), score.duration);
|
|
1402
|
+
score.hits.push(...loaded.hits);
|
|
1403
|
+
score.airCurve = loaded.air;
|
|
1404
|
+
}
|
|
473
1405
|
const outDir = resolve(outArg);
|
|
474
1406
|
await mkdir(outDir, { recursive: true });
|
|
475
1407
|
const { music, effects, map } = render(score);
|
|
@@ -478,9 +1410,36 @@ if (score.music) {
|
|
|
478
1410
|
await writeFile(join(outDir, "music.wav"), master(music, score.duration, 0.79).wav); // ≈ -2 dBFS peak
|
|
479
1411
|
written.push("music.wav");
|
|
480
1412
|
}
|
|
481
|
-
if (map.hits.length || score.music) {
|
|
1413
|
+
if (map.hits.length || score.music || map.air?.peak) {
|
|
482
1414
|
await writeFile(join(outDir, "sfx.wav"), master(effects, score.duration, 0.71).wav); // ≈ -3 dBFS peak
|
|
483
1415
|
written.push("sfx.wav");
|
|
484
1416
|
}
|
|
1417
|
+
if (score.music && map.hits.length)
|
|
1418
|
+
map.balance = balance(music, music.gain, effects, effects.gain, map.hits);
|
|
1419
|
+
if (written.length)
|
|
1420
|
+
map.mud = mud(
|
|
1421
|
+
music,
|
|
1422
|
+
score.music ? (music.gain ?? 0) * (map.balance?.musicVolume ?? 1) : 0,
|
|
1423
|
+
effects,
|
|
1424
|
+
effects.gain ?? 0,
|
|
1425
|
+
score.duration,
|
|
1426
|
+
);
|
|
485
1427
|
await writeFile(join(outDir, "tempo-map.json"), `${JSON.stringify(map, null, 2)}\n`);
|
|
486
|
-
process.stdout.write(
|
|
1428
|
+
process.stdout.write(
|
|
1429
|
+
`${JSON.stringify({
|
|
1430
|
+
ok: true,
|
|
1431
|
+
outDir,
|
|
1432
|
+
files: [...written, "tempo-map.json"],
|
|
1433
|
+
bpm: map.bpm,
|
|
1434
|
+
beats: map.beats.length,
|
|
1435
|
+
hits: map.hits.length,
|
|
1436
|
+
cueHits: map.hits.filter((h) => h.source === "cue").length,
|
|
1437
|
+
room: score.room,
|
|
1438
|
+
...(map.merged ? { merged: map.merged.length } : {}),
|
|
1439
|
+
...(map.air && map.air !== "off" ? { cameraAir: map.air.peak } : {}),
|
|
1440
|
+
...(map.balance
|
|
1441
|
+
? { buried: map.balance.buried.length, musicVolume: map.balance.musicVolume }
|
|
1442
|
+
: {}),
|
|
1443
|
+
...(map.mud ? { muddy: map.mud.windows.length } : {}),
|
|
1444
|
+
})}\n`,
|
|
1445
|
+
);
|