@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.
Files changed (316) hide show
  1. package/assets/motion/bin/contact-sheet.mjs +9 -3
  2. package/assets/motion/bin/cues.mjs +337 -0
  3. package/assets/motion/bin/library.mjs +53 -10
  4. package/assets/motion/bin/motion-blur.mjs +943 -0
  5. package/assets/motion/bin/motion-check.mjs +354 -3
  6. package/assets/motion/bin/music-fit.mjs +436 -0
  7. package/assets/motion/bin/pdf-extract.mjs +22 -10
  8. package/assets/motion/bin/reference-study.mjs +346 -0
  9. package/assets/motion/bin/score-synth.mjs +1052 -93
  10. package/assets/motion/library/README.md +50 -14
  11. package/assets/motion/library/kit/moves.js +1981 -0
  12. package/assets/motion/library/library.json +232 -0
  13. package/assets/motion/library/pieces/camera-rig/meta.json +13 -0
  14. package/assets/motion/library/pieces/camera-rig/piece.html +153 -0
  15. package/assets/motion/library/pieces/camera-rig/preview.jpg +0 -0
  16. package/assets/motion/library/pieces/chain-knock/meta.json +13 -0
  17. package/assets/motion/library/pieces/chain-knock/piece.html +195 -0
  18. package/assets/motion/library/pieces/chain-knock/preview.jpg +0 -0
  19. package/assets/motion/library/pieces/gather-to-logo/meta.json +13 -0
  20. package/assets/motion/library/pieces/gather-to-logo/piece.html +159 -0
  21. package/assets/motion/library/pieces/gather-to-logo/preview.jpg +0 -0
  22. package/assets/motion/library/pieces/morph-carry/meta.json +13 -0
  23. package/assets/motion/library/pieces/morph-carry/piece.html +173 -0
  24. package/assets/motion/library/pieces/morph-carry/preview.jpg +0 -0
  25. package/assets/motion/library/pieces/one-shape-journey/meta.json +13 -0
  26. package/assets/motion/library/pieces/one-shape-journey/piece.html +195 -0
  27. package/assets/motion/library/pieces/one-shape-journey/preview.jpg +0 -0
  28. package/assets/motion/library/pieces/open-from-subject/meta.json +13 -0
  29. package/assets/motion/library/pieces/open-from-subject/piece.html +168 -0
  30. package/assets/motion/library/pieces/open-from-subject/preview.jpg +0 -0
  31. package/assets/motion/library/pieces/request-to-result/meta.json +13 -0
  32. package/assets/motion/library/pieces/request-to-result/piece.html +212 -0
  33. package/assets/motion/library/pieces/request-to-result/preview.jpg +0 -0
  34. package/assets/motion/library/pieces/scale-dive/meta.json +13 -0
  35. package/assets/motion/library/pieces/scale-dive/piece.html +321 -0
  36. package/assets/motion/library/pieces/scale-dive/preview.jpg +0 -0
  37. package/assets/motion/library/pieces/screen-replica-steps/meta.json +13 -0
  38. package/assets/motion/library/pieces/screen-replica-steps/piece.html +366 -0
  39. package/assets/motion/library/pieces/screen-replica-steps/preview.jpg +0 -0
  40. package/assets/motion/library/pieces/zoom-into-card/meta.json +13 -0
  41. package/assets/motion/library/pieces/zoom-into-card/piece.html +179 -0
  42. package/assets/motion/library/pieces/zoom-into-card/preview.jpg +0 -0
  43. package/assets/motion/library/sheets/diagram.jpg +0 -0
  44. package/assets/motion/library/sheets/frame.jpg +0 -0
  45. package/assets/motion/library/sheets/transition.jpg +0 -0
  46. package/assets/motion/library/sheets/ui.jpg +0 -0
  47. package/assets/motion/references/build-sheet.md +206 -0
  48. package/assets/motion/references/runtime/determinism-rules.md +1 -1
  49. package/assets/motion/references/runtime/gsap-easing-and-stagger.md +29 -29
  50. package/assets/motion/references/runtime/inputs-and-assets.md +7 -12
  51. package/assets/motion/references/runtime/lint-validate-inspect.md +3 -3
  52. package/assets/motion/references/runtime/minimal-composition.md +1 -1
  53. package/assets/motion/references/runtime/preview-render.md +3 -3
  54. package/assets/motion/skills/app-walkthrough/SKILL.md +66 -0
  55. package/assets/motion/skills/before-after/SKILL.md +53 -0
  56. package/assets/motion/skills/brand-kit/SKILL.md +3 -3
  57. package/assets/motion/skills/dev-tool-video/SKILL.md +57 -0
  58. package/assets/motion/skills/launch-video/SKILL.md +62 -0
  59. package/assets/motion/skills/match-reference/SKILL.md +58 -0
  60. package/assets/motion/skills/motion/SKILL.md +78 -85
  61. package/assets/motion/skills/source-ingest/SKILL.md +17 -6
  62. package/assets/motion/skills/website-video/SKILL.md +59 -0
  63. package/assets/skills/bulletproof/SKILL.md +36 -11
  64. package/assets/skills/bulletproof/references/agent-surface.md +19 -9
  65. package/assets/skills/bulletproof/references/audit-protocol.md +20 -5
  66. package/assets/skills/bulletproof/references/platform-playbooks.md +5 -4
  67. package/assets/skills/bulletproof/references/provenance.md +26 -1
  68. package/assets/skills/bulletproof/references/secure-defaults.md +6 -5
  69. package/assets/skills/bulletproof/references/supply-chain.md +21 -17
  70. package/assets/skills/bulletproof/references/threat-landscape.md +28 -26
  71. package/assets/skills/bulletproof/references/verification.md +2 -0
  72. package/assets/skills/clarify/SKILL.md +25 -16
  73. package/assets/skills/code-review/SKILL.md +71 -13
  74. package/assets/skills/code-review/references/agent-diffs.md +27 -0
  75. package/assets/skills/code-review/references/tests.md +19 -0
  76. package/assets/skills/compliance-guard/SKILL.md +20 -5
  77. package/assets/skills/compliance-guard/references/artifacts.md +1 -1
  78. package/assets/skills/compliance-guard/references/eu-uk.md +16 -16
  79. package/assets/skills/compliance-guard/references/lawsuit-vectors.md +5 -5
  80. package/assets/skills/compliance-guard/references/provenance.md +41 -2
  81. package/assets/skills/compliance-guard/references/sector-gates.md +3 -3
  82. package/assets/skills/compliance-guard/references/security-baseline.md +2 -2
  83. package/assets/skills/compliance-guard/references/trigger-map.md +5 -5
  84. package/assets/skills/compliance-guard/references/us.md +27 -21
  85. package/assets/skills/durable/SKILL.md +87 -79
  86. package/assets/skills/durable/references/agent-db-safety.md +69 -0
  87. package/assets/skills/durable/references/backups-and-runtime.md +19 -12
  88. package/assets/skills/durable/references/migrations-and-schema.md +13 -6
  89. package/assets/skills/evidence-led-ui/SKILL.md +69 -127
  90. package/assets/skills/evidence-led-ui/references/anti-defaults.md +107 -208
  91. package/assets/skills/evidence-led-ui/references/direction.md +124 -0
  92. package/assets/skills/evidence-led-ui/references/production-contract.md +8 -0
  93. package/assets/skills/evidence-led-ui/references/provenance.md +24 -1
  94. package/assets/skills/lean/SKILL.md +90 -71
  95. package/assets/skills/lean/references/memory-and-processes.md +3 -2
  96. package/assets/skills/lean/references/playbooks.md +37 -12
  97. package/assets/skills/refactoring/SKILL.md +24 -3
  98. package/assets/skills/refactoring/references/agent-pitfalls.md +4 -1
  99. package/assets/skills/refactoring/references/legacy.md +21 -0
  100. package/assets/skills/root-cause/SKILL.md +20 -10
  101. package/assets/skills/shared-language/SKILL.md +16 -14
  102. package/assets/skills/tdd/SKILL.md +27 -15
  103. package/dist/app-sidecar.js +203 -47
  104. package/dist/app-sidecar.js.map +1 -1
  105. package/dist/cli.js +17 -26
  106. package/dist/cli.js.map +1 -1
  107. package/dist/core/acceptance-checks.d.ts +48 -0
  108. package/dist/core/acceptance-checks.js +144 -0
  109. package/dist/core/acceptance-checks.js.map +1 -0
  110. package/dist/core/agent-session.d.ts +106 -72
  111. package/dist/core/agent-session.js +539 -399
  112. package/dist/core/agent-session.js.map +1 -1
  113. package/dist/core/agents.d.ts +6 -5
  114. package/dist/core/agents.js.map +1 -1
  115. package/dist/core/ask-user.d.ts +90 -8
  116. package/dist/core/ask-user.js +124 -13
  117. package/dist/core/ask-user.js.map +1 -1
  118. package/dist/core/bundled-agents.js +1 -3
  119. package/dist/core/bundled-agents.js.map +1 -1
  120. package/dist/core/cache-diagnostics.d.ts +68 -0
  121. package/dist/core/cache-diagnostics.js +196 -0
  122. package/dist/core/cache-diagnostics.js.map +1 -0
  123. package/dist/core/cache-expiry.d.ts +87 -0
  124. package/dist/core/cache-expiry.js +111 -0
  125. package/dist/core/cache-expiry.js.map +1 -0
  126. package/dist/core/compaction/compactor.js +78 -48
  127. package/dist/core/compaction/compactor.js.map +1 -1
  128. package/dist/core/compaction/plan-step-policy.d.ts +46 -0
  129. package/dist/core/compaction/plan-step-policy.js +57 -0
  130. package/dist/core/compaction/plan-step-policy.js.map +1 -0
  131. package/dist/core/destructive-git-guard.d.ts +90 -0
  132. package/dist/core/destructive-git-guard.js +871 -0
  133. package/dist/core/destructive-git-guard.js.map +1 -0
  134. package/dist/core/event-bus.d.ts +3 -0
  135. package/dist/core/event-bus.js +5 -0
  136. package/dist/core/event-bus.js.map +1 -1
  137. package/dist/core/injection-detect.d.ts +38 -0
  138. package/dist/core/injection-detect.js +232 -0
  139. package/dist/core/injection-detect.js.map +1 -0
  140. package/dist/core/keep-awake.d.ts +88 -0
  141. package/dist/core/keep-awake.js +251 -0
  142. package/dist/core/keep-awake.js.map +1 -0
  143. package/dist/core/mcp/client.d.ts +72 -0
  144. package/dist/core/mcp/client.js +264 -41
  145. package/dist/core/mcp/client.js.map +1 -1
  146. package/dist/core/mcp/content.js +6 -2
  147. package/dist/core/mcp/content.js.map +1 -1
  148. package/dist/core/mcp/store.d.ts +6 -1
  149. package/dist/core/mcp/store.js +12 -1
  150. package/dist/core/mcp/store.js.map +1 -1
  151. package/dist/core/mcp/types.d.ts +18 -0
  152. package/dist/core/model-unavailable.d.ts +14 -0
  153. package/dist/core/model-unavailable.js +23 -0
  154. package/dist/core/model-unavailable.js.map +1 -0
  155. package/dist/core/node-debugger.d.ts +148 -0
  156. package/dist/core/node-debugger.js +642 -0
  157. package/dist/core/node-debugger.js.map +1 -0
  158. package/dist/core/package-threats.d.ts +18 -0
  159. package/dist/core/package-threats.js +168 -0
  160. package/dist/core/package-threats.js.map +1 -0
  161. package/dist/core/persistent-shell.d.ts +58 -6
  162. package/dist/core/persistent-shell.js +331 -49
  163. package/dist/core/persistent-shell.js.map +1 -1
  164. package/dist/core/process-manager.d.ts +14 -0
  165. package/dist/core/process-manager.js +61 -0
  166. package/dist/core/process-manager.js.map +1 -1
  167. package/dist/core/progress/git-xp.js +8 -14
  168. package/dist/core/progress/git-xp.js.map +1 -1
  169. package/dist/core/session-history.d.ts +12 -0
  170. package/dist/core/session-history.js +27 -0
  171. package/dist/core/session-history.js.map +1 -1
  172. package/dist/core/session-manager.d.ts +13 -1
  173. package/dist/core/session-manager.js +38 -18
  174. package/dist/core/session-manager.js.map +1 -1
  175. package/dist/core/session-summary-index.d.ts +37 -0
  176. package/dist/core/session-summary-index.js +172 -0
  177. package/dist/core/session-summary-index.js.map +1 -0
  178. package/dist/core/settings-manager.d.ts +2 -0
  179. package/dist/core/settings-manager.js +10 -0
  180. package/dist/core/settings-manager.js.map +1 -1
  181. package/dist/core/shell-threats-popular-packages.d.ts +11 -0
  182. package/dist/core/shell-threats-popular-packages.js +675 -0
  183. package/dist/core/shell-threats-popular-packages.js.map +1 -0
  184. package/dist/core/shell-threats.d.ts +8 -0
  185. package/dist/core/shell-threats.js +186 -0
  186. package/dist/core/shell-threats.js.map +1 -0
  187. package/dist/core/skills.js +3 -1
  188. package/dist/core/skills.js.map +1 -1
  189. package/dist/core/stream-rules.d.ts +30 -0
  190. package/dist/core/stream-rules.js +151 -0
  191. package/dist/core/stream-rules.js.map +1 -0
  192. package/dist/core/subagent-manager.d.ts +20 -5
  193. package/dist/core/subagent-manager.js +22 -8
  194. package/dist/core/subagent-manager.js.map +1 -1
  195. package/dist/core/subagent-receipt.d.ts +54 -0
  196. package/dist/core/subagent-receipt.js +276 -0
  197. package/dist/core/subagent-receipt.js.map +1 -0
  198. package/dist/core/subagent-turn-record.d.ts +2 -0
  199. package/dist/core/subagent-turn-record.js.map +1 -1
  200. package/dist/core/test-impact.d.ts +73 -0
  201. package/dist/core/test-impact.js +467 -0
  202. package/dist/core/test-impact.js.map +1 -0
  203. package/dist/core/thinking-level.d.ts +1 -1
  204. package/dist/core/thinking-level.js +1 -1
  205. package/dist/core/thinking-level.js.map +1 -1
  206. package/dist/core/verification-gate.d.ts +2 -0
  207. package/dist/core/verification-gate.js +4 -0
  208. package/dist/core/verification-gate.js.map +1 -1
  209. package/dist/core/verification-snapshot.js +3 -5
  210. package/dist/core/verification-snapshot.js.map +1 -1
  211. package/dist/core/workspace-guard.d.ts +18 -7
  212. package/dist/core/workspace-guard.js +227 -60
  213. package/dist/core/workspace-guard.js.map +1 -1
  214. package/dist/interactive.js +2 -1
  215. package/dist/interactive.js.map +1 -1
  216. package/dist/modes/subagent-worker-mode.js +34 -6
  217. package/dist/modes/subagent-worker-mode.js.map +1 -1
  218. package/dist/motion-agent/motion-agent.d.ts +6 -2
  219. package/dist/motion-agent/motion-agent.js +5 -7
  220. package/dist/motion-agent/motion-agent.js.map +1 -1
  221. package/dist/motion-agent/motion-prompt.d.ts +1 -1
  222. package/dist/motion-agent/motion-prompt.js +14 -19
  223. package/dist/motion-agent/motion-prompt.js.map +1 -1
  224. package/dist/motion-agent/motion-review.d.ts +10 -3
  225. package/dist/motion-agent/motion-review.js +15 -7
  226. package/dist/motion-agent/motion-review.js.map +1 -1
  227. package/dist/motion-agent/motion-studio-context.js +1 -1
  228. package/dist/motion-agent/motion-studio-context.js.map +1 -1
  229. package/dist/system-prompt.js +3 -1
  230. package/dist/system-prompt.js.map +1 -1
  231. package/dist/test-support/keep-alive.d.ts +14 -0
  232. package/dist/test-support/keep-alive.js +17 -0
  233. package/dist/test-support/keep-alive.js.map +1 -0
  234. package/dist/tools/ask-user.js +3 -3
  235. package/dist/tools/ask-user.js.map +1 -1
  236. package/dist/tools/bash-read-evidence.d.ts +10 -0
  237. package/dist/tools/bash-read-evidence.js +133 -0
  238. package/dist/tools/bash-read-evidence.js.map +1 -0
  239. package/dist/tools/bash.d.ts +10 -1
  240. package/dist/tools/bash.js +115 -7
  241. package/dist/tools/bash.js.map +1 -1
  242. package/dist/tools/debug.d.ts +54 -0
  243. package/dist/tools/debug.js +233 -0
  244. package/dist/tools/debug.js.map +1 -0
  245. package/dist/tools/edit.js +12 -4
  246. package/dist/tools/edit.js.map +1 -1
  247. package/dist/tools/goals.d.ts +1 -1
  248. package/dist/tools/index.d.ts +19 -2
  249. package/dist/tools/index.js +47 -7
  250. package/dist/tools/index.js.map +1 -1
  251. package/dist/tools/prompt-hints.js +2 -0
  252. package/dist/tools/prompt-hints.js.map +1 -1
  253. package/dist/tools/read-tracker.d.ts +5 -0
  254. package/dist/tools/read-tracker.js +19 -9
  255. package/dist/tools/read-tracker.js.map +1 -1
  256. package/dist/tools/read.js +3 -2
  257. package/dist/tools/read.js.map +1 -1
  258. package/dist/tools/skill.js +5 -0
  259. package/dist/tools/skill.js.map +1 -1
  260. package/dist/tools/subagent-control.js +44 -8
  261. package/dist/tools/subagent-control.js.map +1 -1
  262. package/dist/tools/subagent-shared.d.ts +29 -8
  263. package/dist/tools/subagent-shared.js +45 -14
  264. package/dist/tools/subagent-shared.js.map +1 -1
  265. package/dist/tools/subagent.d.ts +8 -2
  266. package/dist/tools/subagent.js +28 -10
  267. package/dist/tools/subagent.js.map +1 -1
  268. package/dist/tools/task-output.js +3 -2
  269. package/dist/tools/task-output.js.map +1 -1
  270. package/dist/tools/task-send.d.ts +1 -1
  271. package/dist/tools/task-send.js +15 -1
  272. package/dist/tools/task-send.js.map +1 -1
  273. package/dist/tools/tool-tiers.d.ts +2 -2
  274. package/dist/tools/tool-tiers.js +3 -2
  275. package/dist/tools/tool-tiers.js.map +1 -1
  276. package/dist/tools/truncate.d.ts +21 -0
  277. package/dist/tools/truncate.js +187 -0
  278. package/dist/tools/truncate.js.map +1 -1
  279. package/dist/tools/ui-adopt.js +2 -0
  280. package/dist/tools/ui-adopt.js.map +1 -1
  281. package/dist/ui/App.d.ts +0 -4
  282. package/dist/ui/App.js +5 -28
  283. package/dist/ui/App.js.map +1 -1
  284. package/dist/ui/components/ActivityIndicator.js +1 -0
  285. package/dist/ui/components/ActivityIndicator.js.map +1 -1
  286. package/dist/ui/hooks/useAgentLoop.d.ts +1 -8
  287. package/dist/ui/hooks/useAgentLoop.js +1 -119
  288. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  289. package/dist/ui/render.d.ts +0 -4
  290. package/dist/ui/render.js +0 -2
  291. package/dist/ui/render.js.map +1 -1
  292. package/dist/utils/git.d.ts +77 -0
  293. package/dist/utils/git.js +285 -21
  294. package/dist/utils/git.js.map +1 -1
  295. package/dist/utils/github-ci.js +2 -1
  296. package/dist/utils/github-ci.js.map +1 -1
  297. package/dist/utils/github.js +11 -9
  298. package/dist/utils/github.js.map +1 -1
  299. package/dist/utils/image.d.ts +14 -0
  300. package/dist/utils/image.js +16 -0
  301. package/dist/utils/image.js.map +1 -1
  302. package/dist/utils/process.d.ts +20 -0
  303. package/dist/utils/process.js +98 -0
  304. package/dist/utils/process.js.map +1 -1
  305. package/package.json +5 -5
  306. package/assets/motion/references/motion-language.md +0 -128
  307. package/assets/motion/skills/video-qa/SKILL.md +0 -89
  308. package/dist/core/ideal-review-subagent.d.ts +0 -56
  309. package/dist/core/ideal-review-subagent.js +0 -112
  310. package/dist/core/ideal-review-subagent.js.map +0 -1
  311. package/dist/core/ideal-review.d.ts +0 -82
  312. package/dist/core/ideal-review.js +0 -242
  313. package/dist/core/ideal-review.js.map +0 -1
  314. package/dist/motion-agent/motion-check-tool.d.ts +0 -35
  315. package/dist/motion-agent/motion-check-tool.js +0 -514
  316. 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 // false => sfx.wav only (e.g. over a supplied song)
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", "whoosh-short", "riser", "impact", "sub-drop", "pop", "click", "tick",
28
- "glitch", "shimmer", "sting", "typing", "swell", "snap",
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 (!["pulse", "cinematic", "minimal"].includes(style)) fail(`unknown style: ${style}`);
45
- const sections = Array.isArray(raw.sections) && raw.sections.length
46
- ? raw.sections.map((s, i) => {
47
- const from = Number(s.from);
48
- const energy = Number(s.energy);
49
- if (!(from >= 0) || !(energy >= 0 && energy <= 1)) fail(`section ${i}: needs from >= 0 and energy 0..1`);
50
- return { name: String(s.name ?? `section-${i + 1}`), from, energy };
51
- }).sort((a, b) => a.from - b.from)
52
- : [{ name: "main", from: 0, energy: 0.7 }];
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)) fail(`hit ${i}: unknown sfx "${h.sfx}". Use one of: ${SFX_NAMES.join(", ")}`);
55
- if (h.t === undefined && h.beat === undefined && h.bar === undefined) fail(`hit ${i}: needs t, beat or bar`);
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
- return { ...h, gain };
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) ? raw.beatTimes.map(Number).filter((n) => n >= 0).sort((a, b) => a - b) : null;
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, duration, style, sections, hits, beatTimes,
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) => `${Object.keys(NOTE).find((k) => NOTE[k] === (r + semi) % 12) ?? "C"}${minor ? "m" : ""}`;
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, b1, b2, a1, a2, x1 = 0, x2 = 0, y1 = 0, y2 = 0;
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") { b0 = (1 - c) / 2; b1 = 1 - c; b2 = (1 - c) / 2; }
117
- else if (type === "hp") { b0 = (1 + c) / 2; b1 = -(1 + c); b2 = (1 + c) / 2; }
118
- else { b0 = alpha; b1 = 0; b2 = -alpha; }
119
- b0 /= a0; b1 /= a0; b2 /= a0; a1 = (-2 * c) / a0; a2 = (1 - alpha) / a0;
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; x1 = x; y2 = y1; y1 = y;
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] = Math.tanh(Math.sin(phase) * 1.6 * punch) * Math.exp(-t / 0.22) + (t < 0.004 ? (1 - t / 0.004) * 0.3 : 0);
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++) out[i] = hp(rand() * 2 - 1) * Math.exp(-(i / SR) / (open ? 0.09 : 0.018));
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, p2 = 0.37;
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
- function sine(f0, f1, seconds, decay, attack = 0.002) {
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
- const f = f1 + (f0 - f1) * Math.exp(-t / (seconds / 4));
209
- phase += (2 * Math.PI * f) / SR;
210
- out[i] = Math.sin(phase) * env(t, attack, decay);
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 noiseSweep(rand, 0.7, 300, 5000, (k) => Math.sin(Math.PI * k) ** 2);
858
+ return breath(rand, 0.7, 280, 3600);
230
859
  case "whoosh-short":
231
- return noiseSweep(rand, 0.32, 600, 6000, (k) => Math.sin(Math.PI * k) ** 1.5);
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 = sine(110, 440, len, 99, len * 0.9);
236
- for (let i = 0; i < n.length; i++) n[i] = n[i] * 0.8 + tone[i] * 0.25 * (i / n.length) ** 2;
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
- const body = sine(90, 42, 1.4, 0.45);
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 sine(95, 28, 1.2, 0.5);
247
- case "pop":
248
- return sine(900, 320, 0.09, 0.03);
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 sine(2400, 2200, 0.04, 0.008);
257
- case "snap": {
258
- const out = buf(0.08);
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, v = 0;
887
+ let hold = 0,
888
+ v = 0;
266
889
  for (let i = 0; i < out.length; i++) {
267
- if (hold-- <= 0) { hold = Math.floor(rand() * 900) + 60; v = (rand() * 2 - 1) * (rand() < 0.3 ? 0 : 1); }
268
- out[i] = Math.round(v * 6) / 6 * 0.7;
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 = sine(hz(chord.root + tone + 84), hz(chord.root + tone + 84), 1.4 - idx * 0.08, 0.5);
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 = sfx("click", rand, beatSec, chord);
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++) out[off + i] += c[i] * (0.5 + rand() * 0.5);
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 = score.beatTimes ?? Array.from({ length: Math.ceil(score.duration / beatSec) + 1 }, (_, i) => i * beatSec);
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
- const chordAt = (t) => chords[Math.max(0, bars.filter((b) => b <= t + 1e-6).length - 1) % chords.length];
329
- const sectionAt = (t) => score.sections.filter((s) => s.from <= t + 1e-6).at(-1) ?? score.sections[0];
330
- const beatTime = (b) => (b < beats.length ? beats[b] : beats.at(-1) + (b - beats.length + 1) * beatSec);
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) music.add(t, sfx("tick", rand, beatSec, chordAt(t)), 0.05 + 0.08 * e, i % 2 ? 0.4 : -0.4);
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]) bassBus.add(t + off, saw(f, beatSec / 2, 300 + 900 * e, 0.004, 0.12), 0.42);
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(hz(c.root + tone + 60), len, 700 + 2200 * e, barSec * 0.35, barSec * 0.9, 0.006 + idx * 0.002);
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
- effects.add(s.from - riser.length / SR, riser, 0.35);
406
- effects.add(s.from, sfx("impact", rand, beatSec, chordAt(s.from)), 0.55);
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
- const resolvedHits = score.hits.map((h, i) => {
413
- const t = h.t !== undefined ? Number(h.t) : h.beat !== undefined ? beatTime(Number(h.beat)) : beatTime(Number(h.bar) * 4);
414
- if (!(t >= 0)) fail(`hit ${i}: resolved to an invalid time`);
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 = typeof h.pan === "number" ? Math.max(-1, Math.min(1, h.pan)) : h.sfx.startsWith("whoosh") ? (i % 2 ? 0.35 : -0.35) : 0;
419
- const base = { impact: 0.6, sting: 0.6, "sub-drop": 0.6, riser: 0.4, whoosh: 0.4, "whoosh-short": 0.35, glitch: 0.3, shimmer: 0.3, swell: 0.35 }[h.sfx] ?? 0.4;
420
- effects.add(start, samples, base * h.gain, pan);
421
- return { t: Number(t.toFixed(4)), sfx: h.sfx };
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, effects,
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) => ({ ...s, to: score.sections[i + 1]?.from ?? score.duration })),
1215
+ sections: score.sections.map((s, i) => ({
1216
+ ...s,
1217
+ to: score.sections[i + 1]?.from ?? score.duration,
1218
+ })),
434
1219
  chords: chordNames,
435
- hits: resolvedHits,
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); out.writeUInt32LE(36 + n * 4, 4); out.write("WAVE", 8);
453
- out.write("fmt ", 12); out.writeUInt32LE(16, 16); out.writeUInt16LE(1, 20); out.writeUInt16LE(2, 22);
454
- out.writeUInt32LE(SR, 24); out.writeUInt32LE(SR * 4, 28); out.writeUInt16LE(4, 32); out.writeUInt16LE(16, 34);
455
- out.write("data", 36); out.writeUInt32LE(n * 4, 40);
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(`Could not read score ${scoreArg}: ${error instanceof Error ? error.message : String(error)}`);
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(`${JSON.stringify({ ok: true, outDir, files: [...written, "tempo-map.json"], bpm: map.bpm, beats: map.beats.length, hits: map.hits.length })}\n`);
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
+ );