frameyard 0.7.0 → 0.9.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 (334) hide show
  1. package/CHANGELOG.md +164 -7
  2. package/GLOSSARY.md +152 -18
  3. package/README.md +328 -285
  4. package/dist/assets/integrations.d.ts +8 -4
  5. package/dist/assets/integrations.d.ts.map +1 -1
  6. package/dist/assets/integrations.js +8 -4
  7. package/dist/assets/integrations.js.map +1 -1
  8. package/dist/assets/provenance.d.ts +2 -2
  9. package/dist/assets/provenance.d.ts.map +1 -1
  10. package/dist/assets/provenance.js +5 -3
  11. package/dist/assets/provenance.js.map +1 -1
  12. package/dist/assets/tts-supertonic.d.ts +28 -0
  13. package/dist/assets/tts-supertonic.d.ts.map +1 -0
  14. package/dist/assets/tts-supertonic.js +156 -0
  15. package/dist/assets/tts-supertonic.js.map +1 -0
  16. package/dist/assets/tts.d.ts +18 -6
  17. package/dist/assets/tts.d.ts.map +1 -1
  18. package/dist/assets/tts.js +219 -97
  19. package/dist/assets/tts.js.map +1 -1
  20. package/dist/cli.js +1705 -106
  21. package/dist/cli.js.map +1 -1
  22. package/dist/composition/descriptor.d.ts +17 -3
  23. package/dist/composition/descriptor.d.ts.map +1 -1
  24. package/dist/composition/descriptor.js +51 -28
  25. package/dist/composition/descriptor.js.map +1 -1
  26. package/dist/composition/format.d.ts +12 -0
  27. package/dist/composition/format.d.ts.map +1 -0
  28. package/dist/composition/format.js +28 -0
  29. package/dist/composition/format.js.map +1 -0
  30. package/dist/composition/index.d.ts +2 -1
  31. package/dist/composition/index.d.ts.map +1 -1
  32. package/dist/composition/index.js +1 -0
  33. package/dist/composition/index.js.map +1 -1
  34. package/dist/context.d.ts +82 -0
  35. package/dist/context.d.ts.map +1 -0
  36. package/dist/context.js +277 -0
  37. package/dist/context.js.map +1 -0
  38. package/dist/contracts.d.ts +80 -6
  39. package/dist/contracts.d.ts.map +1 -1
  40. package/dist/contracts.js +3 -0
  41. package/dist/contracts.js.map +1 -1
  42. package/dist/doctor.d.ts +14 -1
  43. package/dist/doctor.d.ts.map +1 -1
  44. package/dist/doctor.js +166 -7
  45. package/dist/doctor.js.map +1 -1
  46. package/dist/errors.d.ts +1 -1
  47. package/dist/errors.d.ts.map +1 -1
  48. package/dist/errors.js.map +1 -1
  49. package/dist/fonts/add.d.ts +41 -0
  50. package/dist/fonts/add.d.ts.map +1 -0
  51. package/dist/fonts/add.js +259 -0
  52. package/dist/fonts/add.js.map +1 -0
  53. package/dist/fonts/binary.d.ts +4 -0
  54. package/dist/fonts/binary.d.ts.map +1 -0
  55. package/dist/fonts/binary.js +21 -0
  56. package/dist/fonts/binary.js.map +1 -0
  57. package/dist/fonts/css.d.ts +4 -0
  58. package/dist/fonts/css.d.ts.map +1 -0
  59. package/dist/fonts/css.js +50 -0
  60. package/dist/fonts/css.js.map +1 -0
  61. package/dist/fonts/fontsource.d.ts +107 -0
  62. package/dist/fonts/fontsource.d.ts.map +1 -0
  63. package/dist/fonts/fontsource.js +565 -0
  64. package/dist/fonts/fontsource.js.map +1 -0
  65. package/dist/fonts/index.d.ts +11 -0
  66. package/dist/fonts/index.d.ts.map +1 -0
  67. package/dist/fonts/index.js +11 -0
  68. package/dist/fonts/index.js.map +1 -0
  69. package/dist/fonts/install.d.ts +24 -0
  70. package/dist/fonts/install.d.ts.map +1 -0
  71. package/dist/fonts/install.js +145 -0
  72. package/dist/fonts/install.js.map +1 -0
  73. package/dist/fonts/manifest.d.ts +21 -0
  74. package/dist/fonts/manifest.d.ts.map +1 -0
  75. package/dist/fonts/manifest.js +96 -0
  76. package/dist/fonts/manifest.js.map +1 -0
  77. package/dist/fonts/options.d.ts +9 -0
  78. package/dist/fonts/options.d.ts.map +1 -0
  79. package/dist/fonts/options.js +66 -0
  80. package/dist/fonts/options.js.map +1 -0
  81. package/dist/fonts/schema.d.ts +4 -0
  82. package/dist/fonts/schema.d.ts.map +1 -0
  83. package/dist/fonts/schema.js +316 -0
  84. package/dist/fonts/schema.js.map +1 -0
  85. package/dist/fonts/summary.d.ts +16 -0
  86. package/dist/fonts/summary.d.ts.map +1 -0
  87. package/dist/fonts/summary.js +22 -0
  88. package/dist/fonts/summary.js.map +1 -0
  89. package/dist/fonts/types.d.ts +56 -0
  90. package/dist/fonts/types.d.ts.map +1 -0
  91. package/dist/fonts/types.js +8 -0
  92. package/dist/fonts/types.js.map +1 -0
  93. package/dist/fonts/verify.d.ts +33 -0
  94. package/dist/fonts/verify.d.ts.map +1 -0
  95. package/dist/fonts/verify.js +200 -0
  96. package/dist/fonts/verify.js.map +1 -0
  97. package/dist/html/document-adapter.d.ts +3 -3
  98. package/dist/html/document-adapter.d.ts.map +1 -1
  99. package/dist/html/document-adapter.js +1 -1
  100. package/dist/html/document-adapter.js.map +1 -1
  101. package/dist/html/index.d.ts +1 -1
  102. package/dist/html/index.d.ts.map +1 -1
  103. package/dist/html/parse.d.ts +10 -2
  104. package/dist/html/parse.d.ts.map +1 -1
  105. package/dist/html/parse.js +14 -5
  106. package/dist/html/parse.js.map +1 -1
  107. package/dist/html/validate.d.ts +12 -1
  108. package/dist/html/validate.d.ts.map +1 -1
  109. package/dist/html/validate.js +292 -61
  110. package/dist/html/validate.js.map +1 -1
  111. package/dist/index.d.ts +4 -4
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.js +2 -2
  114. package/dist/index.js.map +1 -1
  115. package/dist/inspect.d.ts +61 -0
  116. package/dist/inspect.d.ts.map +1 -0
  117. package/dist/inspect.js +325 -0
  118. package/dist/inspect.js.map +1 -0
  119. package/dist/lint.d.ts +63 -0
  120. package/dist/lint.d.ts.map +1 -0
  121. package/dist/lint.js +265 -0
  122. package/dist/lint.js.map +1 -0
  123. package/dist/policy.d.ts +69 -0
  124. package/dist/policy.d.ts.map +1 -0
  125. package/dist/policy.js +361 -0
  126. package/dist/policy.js.map +1 -0
  127. package/dist/preview/main.d.ts +2 -2
  128. package/dist/preview/main.d.ts.map +1 -1
  129. package/dist/preview/main.js +35 -14
  130. package/dist/preview/main.js.map +1 -1
  131. package/dist/preview/timeline-data.d.ts +17 -65
  132. package/dist/preview/timeline-data.d.ts.map +1 -1
  133. package/dist/preview/timeline-host.d.ts.map +1 -1
  134. package/dist/preview/timeline-host.js +73 -38
  135. package/dist/preview/timeline-host.js.map +1 -1
  136. package/dist/preview/timeline-main.d.ts.map +1 -1
  137. package/dist/preview/timeline-main.js +47 -3
  138. package/dist/preview/timeline-main.js.map +1 -1
  139. package/dist/project.d.ts +13 -8
  140. package/dist/project.d.ts.map +1 -1
  141. package/dist/project.js +20 -17
  142. package/dist/project.js.map +1 -1
  143. package/dist/renderer/browser.d.ts +2 -2
  144. package/dist/renderer/browser.d.ts.map +1 -1
  145. package/dist/renderer/browser.js +10 -4
  146. package/dist/renderer/browser.js.map +1 -1
  147. package/dist/renderer/ffmpeg.d.ts +44 -1
  148. package/dist/renderer/ffmpeg.d.ts.map +1 -1
  149. package/dist/renderer/ffmpeg.js +371 -73
  150. package/dist/renderer/ffmpeg.js.map +1 -1
  151. package/dist/renderer/index.d.ts +40 -4
  152. package/dist/renderer/index.d.ts.map +1 -1
  153. package/dist/renderer/index.js +100 -25
  154. package/dist/renderer/index.js.map +1 -1
  155. package/dist/renderer/keyframe-expressions.d.ts +39 -0
  156. package/dist/renderer/keyframe-expressions.d.ts.map +1 -0
  157. package/dist/renderer/keyframe-expressions.js +180 -0
  158. package/dist/renderer/keyframe-expressions.js.map +1 -0
  159. package/dist/renderer/timeline.d.ts +2 -0
  160. package/dist/renderer/timeline.d.ts.map +1 -1
  161. package/dist/renderer/timeline.js +168 -106
  162. package/dist/renderer/timeline.js.map +1 -1
  163. package/dist/runtime/host.d.ts.map +1 -1
  164. package/dist/runtime/host.js +159 -31
  165. package/dist/runtime/host.js.map +1 -1
  166. package/dist/scaffold.d.ts +3 -0
  167. package/dist/scaffold.d.ts.map +1 -1
  168. package/dist/scaffold.js +48 -317
  169. package/dist/scaffold.js.map +1 -1
  170. package/dist/server/composition-html.d.ts +15 -7
  171. package/dist/server/composition-html.d.ts.map +1 -1
  172. package/dist/server/composition-html.js +72 -16
  173. package/dist/server/composition-html.js.map +1 -1
  174. package/dist/server/server.d.ts.map +1 -1
  175. package/dist/server/server.js +56 -6
  176. package/dist/server/server.js.map +1 -1
  177. package/dist/server/timeline-preview-data.d.ts.map +1 -1
  178. package/dist/server/timeline-preview-data.js +12 -23
  179. package/dist/server/timeline-preview-data.js.map +1 -1
  180. package/dist/server/timeline-preview-html.d.ts.map +1 -1
  181. package/dist/server/timeline-preview-html.js +20 -3
  182. package/dist/server/timeline-preview-html.js.map +1 -1
  183. package/dist/templates.d.ts +16 -0
  184. package/dist/templates.d.ts.map +1 -0
  185. package/dist/templates.js +186 -0
  186. package/dist/templates.js.map +1 -0
  187. package/dist/timeline/easing.d.ts +36 -0
  188. package/dist/timeline/easing.d.ts.map +1 -0
  189. package/dist/timeline/easing.js +225 -0
  190. package/dist/timeline/easing.js.map +1 -0
  191. package/dist/timeline/keyframes.d.ts +23 -0
  192. package/dist/timeline/keyframes.d.ts.map +1 -0
  193. package/dist/timeline/keyframes.js +95 -0
  194. package/dist/timeline/keyframes.js.map +1 -0
  195. package/dist/timeline/plan.d.ts +6 -12
  196. package/dist/timeline/plan.d.ts.map +1 -1
  197. package/dist/timeline/plan.js +17 -30
  198. package/dist/timeline/plan.js.map +1 -1
  199. package/dist/timeline/resolve.d.ts +16 -3
  200. package/dist/timeline/resolve.d.ts.map +1 -1
  201. package/dist/timeline/resolve.js +119 -13
  202. package/dist/timeline/resolve.js.map +1 -1
  203. package/dist/timeline/scene.d.ts +126 -0
  204. package/dist/timeline/scene.d.ts.map +1 -0
  205. package/dist/timeline/scene.js +109 -0
  206. package/dist/timeline/scene.js.map +1 -0
  207. package/dist/timeline/schema.d.ts +38 -0
  208. package/dist/timeline/schema.d.ts.map +1 -1
  209. package/dist/timeline/schema.js +290 -9
  210. package/dist/timeline/schema.js.map +1 -1
  211. package/dist/timeline/transitions.d.ts +21 -0
  212. package/dist/timeline/transitions.d.ts.map +1 -0
  213. package/dist/timeline/transitions.js +23 -0
  214. package/dist/timeline/transitions.js.map +1 -0
  215. package/dist/version.d.ts +1 -1
  216. package/dist/version.js +1 -1
  217. package/docs/adr/0007-make-html-documents-the-composition-unit.md +2 -2
  218. package/docs/adr/0008-share-the-composition-document-grammar.md +1 -1
  219. package/docs/adr/0010-make-timelines-the-render-unit.md +3 -2
  220. package/docs/adr/0011-compose-timeline-frames-in-a-live-browser-host.md +1 -1
  221. package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +1 -1
  222. package/docs/adr/0014-compose-timeline-clips-with-css-blend-modes.md +38 -0
  223. package/docs/adr/0015-inherit-composition-format-from-the-project.md +28 -0
  224. package/docs/adr/0016-author-compositions-as-html-fragments.md +32 -0
  225. package/docs/adr/0017-render-timeless-compositions-as-stills.md +99 -0
  226. package/docs/adr/0018-animate-timeline-clips-with-keyframes.md +35 -0
  227. package/docs/adr/0019-make-same-track-overlap-the-transition-window.md +36 -0
  228. package/docs/adr/0020-crossfade-audio-clips-on-audio-tracks.md +27 -0
  229. package/docs/adr/0021-project-timeline-scenes-once.md +32 -0
  230. package/docs/adr/0022-gate-heavy-operations-with-a-workspace-resource-policy.md +35 -0
  231. package/docs/adr/0023-pin-fonts-as-project-assets.md +98 -0
  232. package/docs/migration-0.4.md +5 -3
  233. package/docs/migration-0.8.md +163 -0
  234. package/docs/preview-testing.md +23 -3
  235. package/docs/{v0.1-design.md → v0.x-design.md} +86 -53
  236. package/examples/blend/README.md +106 -0
  237. package/examples/blend/assets/ink.mp4 +0 -0
  238. package/examples/blend/compositions/aurora.html +55 -0
  239. package/examples/blend/compositions/backdrop.html +74 -0
  240. package/examples/blend/compositions/labels.html +149 -0
  241. package/examples/blend/fy.config.js +11 -0
  242. package/examples/blend/scripts/make-ink.sh +26 -0
  243. package/examples/blend/timelines/blend.timeline.json +155 -0
  244. package/examples/demo/README.md +2 -2
  245. package/examples/demo/compositions/demo.html +243 -251
  246. package/examples/intro/README.md +71 -0
  247. package/examples/intro/assets/fonts/figtree/LICENSE.txt +93 -0
  248. package/examples/intro/assets/fonts/figtree/figtree-latin-wght-normal.woff2 +0 -0
  249. package/examples/intro/assets/fonts/instrument-serif/LICENSE.txt +93 -0
  250. package/examples/intro/assets/fonts/instrument-serif/instrument-serif-latin-400-italic.woff2 +0 -0
  251. package/examples/intro/assets/fonts/space-mono/LICENSE.txt +93 -0
  252. package/examples/intro/assets/fonts/space-mono/space-mono-latin-400-normal.woff2 +0 -0
  253. package/examples/intro/assets/fonts/space-mono/space-mono-latin-700-normal.woff2 +0 -0
  254. package/examples/intro/assets/grain.png +0 -0
  255. package/examples/intro/assets/score.ogg +0 -0
  256. package/examples/intro/compositions/brand.html +412 -0
  257. package/examples/intro/compositions/chrome.controller.js +63 -0
  258. package/examples/intro/compositions/chrome.html +256 -0
  259. package/examples/intro/compositions/lattice.controller.js +148 -0
  260. package/examples/intro/compositions/lattice.html +308 -0
  261. package/examples/intro/compositions/opening.html +504 -0
  262. package/examples/intro/compositions/studio.html +930 -0
  263. package/examples/intro/compositions/theme.css +218 -0
  264. package/examples/intro/fy.config.js +13 -0
  265. package/examples/intro/fy.fonts.json +100 -0
  266. package/examples/intro/scripts/make-grain.mjs +79 -0
  267. package/examples/intro/scripts/make-score.mjs +702 -0
  268. package/examples/intro/timelines/intro.timeline.json +60 -0
  269. package/examples/keyframes/README.md +76 -0
  270. package/examples/keyframes/compositions/backdrop.html +60 -0
  271. package/examples/keyframes/compositions/card.html +99 -0
  272. package/examples/keyframes/fy.config.js +10 -0
  273. package/examples/keyframes/timelines/keyframes.timeline.json +60 -0
  274. package/examples/nested/README.md +112 -0
  275. package/examples/nested/assets/pulse.wav +0 -0
  276. package/examples/nested/compositions/backdrop.html +84 -0
  277. package/examples/nested/compositions/card.html +275 -0
  278. package/examples/nested/compositions/overlay.html +307 -0
  279. package/examples/nested/fy.config.js +13 -0
  280. package/examples/nested/scripts/make-pulse.sh +20 -0
  281. package/examples/nested/timelines/chapter-a.timeline.json +39 -0
  282. package/examples/nested/timelines/chapter-b.timeline.json +39 -0
  283. package/examples/nested/timelines/nested-reel.timeline.json +52 -0
  284. package/examples/static/README.md +17 -0
  285. package/examples/static/compositions/still.html +37 -0
  286. package/examples/static/fy.config.js +9 -0
  287. package/examples/static/timelines/still.timeline.json +13 -0
  288. package/examples/transitions/README.md +128 -0
  289. package/examples/transitions/assets/alpha.wav +0 -0
  290. package/examples/transitions/assets/beta.wav +0 -0
  291. package/examples/transitions/assets/gamma.wav +0 -0
  292. package/examples/transitions/compositions/alpha.html +56 -0
  293. package/examples/transitions/compositions/beta.html +56 -0
  294. package/examples/transitions/compositions/gamma.html +56 -0
  295. package/examples/transitions/fy.config.js +12 -0
  296. package/examples/transitions/timelines/audio-crossfade.timeline.json +44 -0
  297. package/examples/transitions/timelines/transitions.timeline.json +31 -0
  298. package/examples/video/README.md +4 -4
  299. package/examples/video/compositions/video-mp4.html +349 -357
  300. package/examples/video/compositions/video-overlay.html +337 -345
  301. package/examples/video/compositions/video.html +349 -357
  302. package/examples/webgl/README.md +1 -1
  303. package/examples/webgl/compositions/webgl.html +205 -213
  304. package/package.json +13 -5
  305. package/scaffolds/init/AGENTS.md +131 -0
  306. package/scaffolds/init/README.md +56 -0
  307. package/scaffolds/init/compositions/hello.html +79 -0
  308. package/scaffolds/init/fy.config.js +9 -0
  309. package/scaffolds/init/gitignore.template +1 -0
  310. package/scaffolds/init/package.json +11 -0
  311. package/scaffolds/init/props/hello.json +4 -0
  312. package/scaffolds/init/props/main.json +6 -0
  313. package/scaffolds/init/timelines/main.timeline.json +20 -0
  314. package/scaffolds/local-models/LOCAL-MODELS.md +77 -0
  315. package/scaffolds/local-models/fy.policy.json +5 -0
  316. package/scaffolds/local-models/opencode/opencode.jsonc +17 -0
  317. package/scaffolds/local-models/pi/APPEND_SYSTEM.md +21 -0
  318. package/scaffolds/local-models/pi/sandbox.json +15 -0
  319. package/scaffolds/local-models/pi/settings.json +15 -0
  320. package/skills/frameyard/README.md +1 -0
  321. package/skills/frameyard/SKILL.md +59 -59
  322. package/skills/frameyard/references/audio-assets.md +32 -3
  323. package/skills/frameyard/references/common-mistakes.md +81 -7
  324. package/skills/frameyard/references/controllers.md +5 -4
  325. package/skills/frameyard/references/deterministic-authoring.md +49 -37
  326. package/skills/frameyard/references/diagnostics.md +42 -4
  327. package/skills/frameyard/references/failure-recovery.md +56 -10
  328. package/skills/frameyard/references/fonts.md +132 -0
  329. package/skills/frameyard/references/json-results.md +165 -10
  330. package/skills/frameyard/references/quick-reference.md +222 -0
  331. package/skills/frameyard/references/timelines.md +109 -15
  332. /package/examples/demo/{video.config.js → fy.config.js} +0 -0
  333. /package/examples/video/{video.config.js → fy.config.js} +0 -0
  334. /package/examples/webgl/{video.config.js → fy.config.js} +0 -0
@@ -0,0 +1,702 @@
1
+ #!/usr/bin/env node
2
+ // Renders the 20-second original score for the Frameyard intro.
3
+ //
4
+ // The score is synthesized from scratch so the example stays self-contained:
5
+ // no samples, no model downloads, no licensing questions. Every random choice
6
+ // comes from a fixed-seed LCG, so regenerating the WAV is deterministic.
7
+ //
8
+ // Structure (120 BPM, D minor -> D major), aligned with the scene cuts:
9
+ // 0.0 – 4.5 author: sub drone, heartbeat kick, air
10
+ // 4.5 – 9.5 compose: Bb pad, plucked arpeggio, hats
11
+ // 9.5 – 14.5 render: F pad, four-on-the-floor, riser
12
+ // 14.5 – 20.0 frameyard: D major, impact, shimmer, tail
13
+ //
14
+ // The WAV synthesis is deterministic. The default Ogg Vorbis encode uses
15
+ // ffmpeg's libvorbis at q6 with bitexact flags, so the same toolchain
16
+ // reproduces the same bytes.
17
+ //
18
+ // Run from anywhere:
19
+ // node examples/intro/scripts/make-score.mjs
20
+ // node examples/intro/scripts/make-score.mjs /tmp/score.ogg
21
+ // node examples/intro/scripts/make-score.mjs /tmp/score.wav
22
+
23
+ import { execFileSync } from "node:child_process";
24
+ import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
25
+ import { tmpdir } from "node:os";
26
+ import path from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ const here = path.dirname(fileURLToPath(import.meta.url));
30
+ const out = process.argv[2] ? path.resolve(process.argv[2]) : path.join(here, "..", "assets", "score.ogg");
31
+
32
+ const SR = 48000;
33
+ const DURATION = 20;
34
+ const TAIL = 2.5;
35
+ const N = Math.ceil((DURATION + TAIL) * SR);
36
+ const OUT = Math.round(DURATION * SR);
37
+ const TAU = Math.PI * 2;
38
+ const BPM = 120;
39
+ const BEAT = 60 / BPM;
40
+
41
+ let seed = 0x51ed270b;
42
+ function random() {
43
+ seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
44
+ return seed / 0x100000000;
45
+ }
46
+ function noise() {
47
+ return random() * 2 - 1;
48
+ }
49
+
50
+ function hz(midi) {
51
+ return 440 * Math.pow(2, (midi - 69) / 12);
52
+ }
53
+ function smoothstep(value) {
54
+ const x = Math.max(0, Math.min(1, value));
55
+ return x * x * (3 - 2 * x);
56
+ }
57
+ function envelope(t, duration, attack, release) {
58
+ if (t < 0 || t > duration) {
59
+ return 0;
60
+ }
61
+ return smoothstep(t / attack) * smoothstep((duration - t) / release);
62
+ }
63
+ function panGains(pan) {
64
+ const p = (Math.max(-1, Math.min(1, pan)) + 1) / 2;
65
+ return [Math.cos(p * Math.PI * 0.5), Math.sin(p * Math.PI * 0.5)];
66
+ }
67
+
68
+ // Buses. Pad and bass are filtered after the notes are rendered; percussion,
69
+ // arpeggio, and effects stay mostly dry with selected reverb sends.
70
+ const padL = new Float64Array(N);
71
+ const padR = new Float64Array(N);
72
+ const bassL = new Float64Array(N);
73
+ const bassR = new Float64Array(N);
74
+ const arpL = new Float64Array(N);
75
+ const arpR = new Float64Array(N);
76
+ const percL = new Float64Array(N);
77
+ const percR = new Float64Array(N);
78
+ const fxL = new Float64Array(N);
79
+ const fxR = new Float64Array(N);
80
+ const revSend = new Float64Array(N);
81
+ const delaySend = new Float64Array(N);
82
+ const duck = new Float64Array(N).fill(1);
83
+
84
+ function addPadNote(start, duration, midi, gain, pan, brightness = 1) {
85
+ const frequency = hz(midi);
86
+ const length = Math.round(duration * SR);
87
+ const startIndex = Math.round(start * SR);
88
+ const [gainL, gainR] = panGains(pan);
89
+ const detunes = [-0.0055, 0, 0.0055];
90
+ const harmonics = brightness > 1 ? 6 : 5;
91
+
92
+ for (const detune of detunes) {
93
+ const voiceFrequency = frequency * (1 + detune);
94
+ let phase = random() * TAU;
95
+ let lfoPhase = random() * TAU;
96
+ const voicePan = pan + (detune < 0 ? -0.08 : detune > 0 ? 0.08 : 0);
97
+ const [voiceL, voiceR] = panGains(voicePan);
98
+ for (let i = 0; i < length; i += 1) {
99
+ const index = startIndex + i;
100
+ if (index < 0 || index >= N) {
101
+ break;
102
+ }
103
+ const t = i / SR;
104
+ const env = envelope(t, duration, 0.85, 1.35);
105
+ const breath = 0.84 + 0.16 * Math.sin(lfoPhase + t * 0.62);
106
+ phase += (TAU * voiceFrequency) / SR;
107
+ if (phase > TAU) {
108
+ phase -= TAU;
109
+ }
110
+ let sample = 0;
111
+ for (let harmonic = 1; harmonic <= harmonics; harmonic += 1) {
112
+ sample += Math.sin(phase * harmonic) / Math.pow(harmonic, 1.72);
113
+ }
114
+ sample *= gain * env * breath;
115
+ padL[index] += sample * gainL * voiceL;
116
+ padR[index] += sample * gainR * voiceR;
117
+ revSend[index] += sample * 0.05;
118
+ }
119
+ }
120
+ }
121
+
122
+ function addSub(start, duration, midi, gain) {
123
+ const frequency = hz(midi);
124
+ const length = Math.round(duration * SR);
125
+ const startIndex = Math.round(start * SR);
126
+ let phase = 0;
127
+ for (let i = 0; i < length; i += 1) {
128
+ const index = startIndex + i;
129
+ if (index < 0 || index >= N) {
130
+ break;
131
+ }
132
+ const t = i / SR;
133
+ const env = envelope(t, duration, 0.9, 1.6);
134
+ phase += (TAU * frequency) / SR;
135
+ if (phase > TAU) {
136
+ phase -= TAU;
137
+ }
138
+ const sample = (Math.sin(phase) + 0.18 * Math.sin(phase * 2)) * env * gain;
139
+ bassL[index] += sample;
140
+ bassR[index] += sample;
141
+ }
142
+ }
143
+
144
+ function addBassNote(start, duration, midi, gain) {
145
+ const frequency = hz(midi);
146
+ const length = Math.round(duration * SR);
147
+ const startIndex = Math.round(start * SR);
148
+ let phase = random() * TAU;
149
+ for (let i = 0; i < length; i += 1) {
150
+ const index = startIndex + i;
151
+ if (index < 0 || index >= N) {
152
+ break;
153
+ }
154
+ const t = i / SR;
155
+ const env = envelope(t, duration, 0.008, 0.06) * Math.exp(-t * 3.2);
156
+ phase += (TAU * frequency) / SR;
157
+ if (phase > TAU) {
158
+ phase -= TAU;
159
+ }
160
+ const sample = (Math.sin(phase) + 0.42 * Math.sin(phase * 2) + 0.16 * Math.sin(phase * 3)) * env * gain;
161
+ bassL[index] += sample;
162
+ bassR[index] += sample;
163
+ }
164
+ }
165
+
166
+ function addKick(start, gain, decay = 0.42, startPitch = 112, endPitch = 42) {
167
+ const length = Math.round((decay * 5.5 + 0.08) * SR);
168
+ const startIndex = Math.round(start * SR);
169
+ let phase = 0;
170
+ for (let i = 0; i < length; i += 1) {
171
+ const index = startIndex + i;
172
+ if (index < 0 || index >= N) {
173
+ break;
174
+ }
175
+ const t = i / SR;
176
+ const frequency = endPitch + (startPitch - endPitch) * Math.exp(-t * 17);
177
+ phase += (TAU * frequency) / SR;
178
+ if (phase > TAU) {
179
+ phase -= TAU;
180
+ }
181
+ const body = Math.sin(phase) * Math.exp(-t / decay) * (1 - Math.exp(-t * 320));
182
+ const click = noise() * Math.exp(-t * 95) * 0.08;
183
+ const sample = (body + click) * gain;
184
+ percL[index] += sample;
185
+ percR[index] += sample;
186
+ revSend[index] += sample * 0.04;
187
+ }
188
+
189
+ const duckStart = Math.round(start * SR);
190
+ for (let i = 0; i < Math.round(0.42 * SR); i += 1) {
191
+ const index = duckStart + i;
192
+ if (index < 0 || index >= N) {
193
+ break;
194
+ }
195
+ const t = i / SR;
196
+ duck[index] = Math.max(0.4, duck[index] * (1 - 0.24 * Math.exp(-t * 10) * (1 - Math.exp(-t * 110))));
197
+ }
198
+ }
199
+
200
+ function addHat(start, gain, decay = 0.035, pan = 0.25, tone = 0.8) {
201
+ const length = Math.round((decay * 6 + 0.01) * SR);
202
+ const startIndex = Math.round(start * SR);
203
+ const [gainL, gainR] = panGains(pan);
204
+ let previousInput = 0;
205
+ let previousOutput = 0;
206
+ const coefficient = 0.58;
207
+ for (let i = 0; i < length; i += 1) {
208
+ const index = startIndex + i;
209
+ if (index < 0 || index >= N) {
210
+ break;
211
+ }
212
+ const t = i / SR;
213
+ const input = noise();
214
+ const highpassed = coefficient * (previousOutput + input - previousInput);
215
+ previousInput = input;
216
+ previousOutput = highpassed;
217
+ const metallic = Math.sin(TAU * 9100 * t) * 0.18 * tone;
218
+ const sample = (highpassed + metallic) * Math.exp(-t / decay) * gain;
219
+ percL[index] += sample * gainL;
220
+ percR[index] += sample * gainR;
221
+ }
222
+ }
223
+
224
+ function addClap(start, gain, pan = 0) {
225
+ const [gainL, gainR] = panGains(pan);
226
+ const bursts = [0, 0.012, 0.026];
227
+ for (const offset of bursts) {
228
+ const startIndex = Math.round((start + offset) * SR);
229
+ const length = Math.round(0.22 * SR);
230
+ let low = 0;
231
+ let band = 0;
232
+ for (let i = 0; i < length; i += 1) {
233
+ const index = startIndex + i;
234
+ if (index < 0 || index >= N) {
235
+ break;
236
+ }
237
+ const t = i / SR;
238
+ const f = 2 * Math.sin((Math.PI * 1650) / SR);
239
+ low += f * band;
240
+ const high = noise() - low - 1.1 * band;
241
+ band += f * high;
242
+ const sample = band * Math.exp(-t * 34) * gain;
243
+ percL[index] += sample * gainL;
244
+ percR[index] += sample * gainR;
245
+ revSend[index] += sample * 0.12;
246
+ }
247
+ }
248
+ }
249
+
250
+ function addPluck(start, duration, midi, gain, pan) {
251
+ const frequency = hz(midi);
252
+ const length = Math.round(duration * SR);
253
+ const startIndex = Math.round(start * SR);
254
+ const [gainL, gainR] = panGains(pan);
255
+ let phase = random() * TAU;
256
+ for (let i = 0; i < length; i += 1) {
257
+ const index = startIndex + i;
258
+ if (index < 0 || index >= N) {
259
+ break;
260
+ }
261
+ const t = i / SR;
262
+ const env = (1 - Math.exp(-t * 260)) * Math.exp(-t * 9.5);
263
+ phase += (TAU * frequency) / SR;
264
+ if (phase > TAU) {
265
+ phase -= TAU;
266
+ }
267
+ const sample = (Math.sin(phase) + 0.36 * Math.sin(phase * 2) + 0.13 * Math.sin(phase * 3)) * env * gain;
268
+ arpL[index] += sample * gainL;
269
+ arpR[index] += sample * gainR;
270
+ delaySend[index] += sample * 0.8;
271
+ revSend[index] += sample * 0.16;
272
+ }
273
+ }
274
+
275
+ function addRiser(start, duration, gain) {
276
+ const length = Math.round(duration * SR);
277
+ const startIndex = Math.round(start * SR);
278
+ let low = 0;
279
+ let band = 0;
280
+ let sweepPhase = 0;
281
+ for (let i = 0; i < length; i += 1) {
282
+ const index = startIndex + i;
283
+ if (index < 0 || index >= N) {
284
+ break;
285
+ }
286
+ const t = i / SR;
287
+ const progress = t / duration;
288
+ const cutoff = 240 * Math.pow(28, progress);
289
+ const f = Math.min(1.6, 2 * Math.sin((Math.PI * cutoff) / SR));
290
+ low += f * band;
291
+ const high = noise() - low - 1.35 * band;
292
+ band += f * high;
293
+ const sweepFrequency = 180 * Math.pow(7, progress);
294
+ sweepPhase += (TAU * sweepFrequency) / SR;
295
+ if (sweepPhase > TAU) {
296
+ sweepPhase -= TAU;
297
+ }
298
+ const env = Math.pow(progress, 2.2) * Math.min(1, (duration - t) / 0.035);
299
+ const sample = (band * 0.8 + Math.sin(sweepPhase) * 0.18) * env * gain;
300
+ const pan = -0.55 + progress * 1.1;
301
+ const [gainL, gainR] = panGains(pan);
302
+ fxL[index] += sample * gainL;
303
+ fxR[index] += sample * gainR;
304
+ revSend[index] += sample * 0.3;
305
+ }
306
+ }
307
+
308
+ function addImpact(start, gain) {
309
+ addKick(start, gain, 1.15, 86, 28);
310
+ const length = Math.round(1.4 * SR);
311
+ const startIndex = Math.round(start * SR);
312
+ let phase = 0;
313
+ for (let i = 0; i < length; i += 1) {
314
+ const index = startIndex + i;
315
+ if (index < 0 || index >= N) {
316
+ break;
317
+ }
318
+ const t = i / SR;
319
+ const env = Math.exp(-t * 2.6) * (1 - Math.exp(-t * 180));
320
+ phase += (TAU * 58) / SR;
321
+ if (phase > TAU) {
322
+ phase -= TAU;
323
+ }
324
+ const ring = Math.sin(phase) * 0.5 + Math.sin(phase * 3) * 0.08;
325
+ const sample = ring * env * gain * 0.4;
326
+ fxL[index] += sample;
327
+ fxR[index] += sample;
328
+ revSend[index] += sample * 0.5;
329
+ }
330
+
331
+ const noiseLength = Math.round(0.5 * SR);
332
+ for (let i = 0; i < noiseLength; i += 1) {
333
+ const index = startIndex + i;
334
+ if (index < 0 || index >= N) {
335
+ break;
336
+ }
337
+ const t = i / SR;
338
+ const sample = noise() * Math.exp(-t * 16) * gain * 0.16;
339
+ fxL[index] += sample;
340
+ fxR[index] += sample;
341
+ revSend[index] += sample * 0.6;
342
+ }
343
+ }
344
+
345
+ function addAirNote(start, duration, midi, gain, pan) {
346
+ const frequency = hz(midi);
347
+ const length = Math.round(duration * SR);
348
+ const startIndex = Math.round(start * SR);
349
+ const [gainL, gainR] = panGains(pan);
350
+ let phase = random() * TAU;
351
+ let phase2 = random() * TAU;
352
+ for (let i = 0; i < length; i += 1) {
353
+ const index = startIndex + i;
354
+ if (index < 0 || index >= N) {
355
+ break;
356
+ }
357
+ const t = i / SR;
358
+ const env = envelope(t, duration, 1.15, 3.4);
359
+ phase += (TAU * frequency) / SR;
360
+ phase2 += (TAU * frequency * 1.004) / SR;
361
+ if (phase > TAU) phase -= TAU;
362
+ if (phase2 > TAU) phase2 -= TAU;
363
+ const sample = (Math.sin(phase) * 0.72 + Math.sin(phase2) * 0.28) * env * gain;
364
+ arpL[index] += sample * gainL;
365
+ arpR[index] += sample * gainR;
366
+ revSend[index] += sample * 0.4;
367
+ }
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Arrangement
372
+ // ---------------------------------------------------------------------------
373
+
374
+ const chords = [
375
+ { start: 0, end: 5.0, notes: [38, 45, 50, 53, 57], root: 26, gain: 0.009 },
376
+ { start: 4.2, end: 9.8, notes: [34, 41, 46, 50, 53], root: 22, gain: 0.01 },
377
+ { start: 9.2, end: 14.8, notes: [29, 36, 41, 45, 48], root: 17, gain: 0.011 },
378
+ { start: 14.2, end: 20.5, notes: [38, 45, 50, 54, 57, 62], root: 26, gain: 0.011 },
379
+ ];
380
+
381
+ for (const chord of chords) {
382
+ const duration = chord.end - chord.start + 0.9;
383
+ chord.notes.forEach((note, index) => {
384
+ const pan = chord.notes.length === 1 ? 0 : (index / (chord.notes.length - 1) - 0.5) * 0.62;
385
+ addPadNote(chord.start, duration, note, chord.gain, pan, 1.2);
386
+ });
387
+ addSub(chord.start, chord.end - chord.start + 0.35, chord.root, 0.025);
388
+ }
389
+
390
+ // Heartbeat and groove.
391
+ addImpact(0.02, 0.4);
392
+ for (const time of [1.0, 2.0, 3.0, 4.0]) {
393
+ addKick(time, 0.24, 0.28, 92, 46);
394
+ }
395
+ for (const time of [4.5, 5.5, 6.5, 7.5, 8.5]) {
396
+ addKick(time, 0.55, 0.34, 108, 44);
397
+ }
398
+ for (let time = 9.5; time < 14.5; time += 0.5) {
399
+ addKick(time, time === 9.5 || time === 13.5 ? 0.72 : 0.56, 0.34, 108, 44);
400
+ }
401
+ addImpact(4.5, 0.5);
402
+ addImpact(9.5, 0.68);
403
+ addImpact(14.5, 0.95);
404
+ addImpact(15.15, 0.32);
405
+
406
+ // Hats: sparse before the lift, driving in the middle.
407
+ for (let time = 4.5, index = 0; time < 14.5; time += 0.25, index += 1) {
408
+ const accent = index % 4 === 2 ? 0.32 : 0.2;
409
+ addHat(time, accent, index % 4 === 2 ? 0.07 : 0.035, index % 2 === 0 ? -0.28 : 0.3, 0.9);
410
+ }
411
+ for (let time = 9.5; time < 14.5; time += 0.5) {
412
+ addClap(time + 0.25, 0.3, 0);
413
+ }
414
+
415
+ // Bass pulses follow the chord roots. The offbeats are softer.
416
+ const bassPattern = [
417
+ { at: 0, length: 0.24, gain: 0.06 },
418
+ { at: 0.25, length: 0.2, gain: 0.038 },
419
+ { at: 0.375, length: 0.12, gain: 0.024 },
420
+ ];
421
+ for (let time = 4.5; time < 14.5; time += 0.5) {
422
+ const chord = chords.find((entry) => time >= entry.start && time < entry.end) ?? chords[chords.length - 1];
423
+ for (const pulse of bassPattern) {
424
+ const at = time + pulse.at;
425
+ if (at < 14.5) {
426
+ addBassNote(at, pulse.length, chord.root + 12, pulse.gain);
427
+ }
428
+ }
429
+ }
430
+
431
+ // Plucked arpeggio. The pattern changes register at the lift.
432
+ const arpPattern = [0, 2, 4, 3, 2, 4, 1, 3];
433
+ for (let time = 4.5, index = 0; time < 14.5; time += 0.25, index += 1) {
434
+ const chord = chords.find((entry) => time >= entry.start && time < entry.end) ?? chords[chords.length - 1];
435
+ const note = chord.notes[arpPattern[index % arpPattern.length] % chord.notes.length] + 12;
436
+ const lift = time >= 9.5 ? 1 : 0;
437
+ addPluck(time, 0.62, note + lift * 12, time >= 9.5 ? 0.15 : 0.13, index % 2 === 0 ? -0.34 : 0.34);
438
+ if (time >= 9.5 && index % 4 === 3) {
439
+ addPluck(time + 0.125, 0.3, note + 24, 0.05, 0.1);
440
+ }
441
+ }
442
+
443
+ // Risers and transition air.
444
+ addRiser(2.4, 2.0, 0.16);
445
+ addRiser(7.1, 2.3, 0.2);
446
+ addRiser(12.0, 2.4, 0.28);
447
+
448
+ // Resolution: high air chord, sparkles, and a low final swell.
449
+ const airNotes = [74, 78, 81, 86, 90];
450
+ airNotes.forEach((note, index) => {
451
+ addAirNote(14.55, 5.1, note, 0.026, (index / (airNotes.length - 1) - 0.5) * 1.2);
452
+ });
453
+ const sparkleNotes = [98, 93, 90, 98, 95, 102];
454
+ const sparkleTimes = [15.4, 16.1, 16.9, 17.7, 18.4, 19.0];
455
+ sparkleTimes.forEach((time, index) => {
456
+ addPluck(time, 1.3, sparkleNotes[index], 0.05, index % 2 === 0 ? -0.66 : 0.66);
457
+ revSend[Math.round(time * SR)] += 0.02;
458
+ });
459
+ addSub(17.6, 2.5, 26, 0.055);
460
+ addAirNote(17.9, 2.4, 62, 0.022, 0);
461
+ addAirNote(17.9, 2.4, 69, 0.018, -0.4);
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // Filters and mixing
465
+ // ---------------------------------------------------------------------------
466
+
467
+ function lowpassBus(input, cutoffAt, q = 1.0) {
468
+ const output = new Float64Array(N);
469
+ let low = 0;
470
+ let band = 0;
471
+ for (let i = 0; i < N; i += 1) {
472
+ const cutoff = Math.max(40, Math.min(0.42 * SR, cutoffAt(i / SR)));
473
+ const f = 2 * Math.sin((Math.PI * cutoff) / SR);
474
+ low += f * band;
475
+ const high = input[i] - low - q * band;
476
+ band += f * high;
477
+ output[i] = low;
478
+ }
479
+ return output;
480
+ }
481
+
482
+ function highpassBus(input, cutoff) {
483
+ const output = new Float64Array(N);
484
+ const rc = 1 / (TAU * cutoff);
485
+ const alpha = rc / (rc + 1 / SR);
486
+ let previousInput = 0;
487
+ let previousOutput = 0;
488
+ for (let i = 0; i < N; i += 1) {
489
+ const value = input[i];
490
+ previousOutput = alpha * (previousOutput + value - previousInput);
491
+ previousInput = value;
492
+ output[i] = previousOutput;
493
+ }
494
+ return output;
495
+ }
496
+
497
+ function padCutoff(t) {
498
+ if (t < 4.5) {
499
+ return 420 + (t / 4.5) * 380;
500
+ }
501
+ if (t < 9.5) {
502
+ return 800 + ((t - 4.5) / 5) * 1000;
503
+ }
504
+ if (t < 14.5) {
505
+ return 1800 + ((t - 9.5) / 5) * 3000;
506
+ }
507
+ return 4800 * Math.pow(0.22, Math.min(1, (t - 14.5) / 5.2));
508
+ }
509
+
510
+ function bassCutoff(t) {
511
+ return 180 + 60 * Math.sin(Math.min(1, t / 20) * Math.PI);
512
+ }
513
+
514
+ const padFilteredL = highpassBus(lowpassBus(padL, padCutoff, 0.9), 75);
515
+ const padFilteredR = highpassBus(lowpassBus(padR, padCutoff, 0.9), 75);
516
+ const bassFilteredL = lowpassBus(bassL, bassCutoff, 0.7);
517
+ const bassFilteredR = lowpassBus(bassR, bassCutoff, 0.7);
518
+
519
+ // Simple stereo Schroeder reverb. Four damped combs feed two allpasses per
520
+ // channel; different delay lengths keep the tail wide without a convolver.
521
+ function reverb(input) {
522
+ const left = new Float64Array(N);
523
+ const right = new Float64Array(N);
524
+
525
+ function channel(target, lengths, spread) {
526
+ const combs = lengths.map((seconds, index) => ({
527
+ buffer: new Float64Array(Math.max(1, Math.round(seconds * SR))),
528
+ index: 0,
529
+ damp: 0,
530
+ feedback: 0.76 - index * 0.025,
531
+ }));
532
+ const allpasses = [0.0051 * spread, 0.0017 * spread].map((seconds) => ({
533
+ buffer: new Float64Array(Math.max(1, Math.round(seconds * SR))),
534
+ index: 0,
535
+ }));
536
+ const combMix = new Float64Array(N);
537
+ for (let i = 0; i < N; i += 1) {
538
+ let sum = 0;
539
+ for (const comb of combs) {
540
+ const delayed = comb.buffer[comb.index];
541
+ comb.damp = delayed * 0.62 + comb.damp * 0.38;
542
+ comb.buffer[comb.index] = input[i] + comb.damp * comb.feedback;
543
+ comb.index = (comb.index + 1) % comb.buffer.length;
544
+ sum += delayed;
545
+ }
546
+ combMix[i] = sum / combs.length;
547
+ }
548
+ let signal = combMix;
549
+ for (const allpass of allpasses) {
550
+ const next = new Float64Array(N);
551
+ for (let i = 0; i < N; i += 1) {
552
+ const delayed = allpass.buffer[allpass.index];
553
+ next[i] = -signal[i] + delayed;
554
+ allpass.buffer[allpass.index] = signal[i] + delayed * 0.5;
555
+ allpass.index = (allpass.index + 1) % allpass.buffer.length;
556
+ }
557
+ signal = next;
558
+ }
559
+ target.set(signal);
560
+ }
561
+
562
+ channel(left, [0.0297, 0.0371, 0.0411, 0.0437], 1);
563
+ channel(right, [0.0311, 0.0389, 0.0427, 0.0451], 1.03);
564
+ return { left, right };
565
+ }
566
+
567
+ // Ping-pong delay for the arpeggio: dotted eighth, filtered feedback.
568
+ function pingPong(input, delaySeconds, feedback = 0.34) {
569
+ const left = new Float64Array(N);
570
+ const right = new Float64Array(N);
571
+ const delay = Math.max(1, Math.round(delaySeconds * SR));
572
+ const bufferLeft = new Float64Array(N);
573
+ const bufferRight = new Float64Array(N);
574
+ for (let i = 0; i < N; i += 1) {
575
+ const readLeft = i >= delay ? bufferRight[i - delay] : 0;
576
+ const readRight = i >= delay ? bufferLeft[i - delay] : 0;
577
+ bufferLeft[i] = input[i] + readLeft * feedback;
578
+ bufferRight[i] = readRight * feedback;
579
+ left[i] = i >= delay ? bufferLeft[i - delay] * 0.9 : 0;
580
+ right[i] = i >= delay ? bufferRight[i - delay] * 0.9 : 0;
581
+ }
582
+ return { left, right };
583
+ }
584
+
585
+ const reverbInput = new Float64Array(N);
586
+ for (let i = 0; i < N; i += 1) {
587
+ reverbInput[i] = revSend[i] + (fxL[i] + fxR[i]) * 0.22;
588
+ }
589
+ const wet = reverb(reverbInput);
590
+ const delay = pingPong(delaySend, BEAT * 0.75, 0.36);
591
+
592
+ const mixL = new Float64Array(OUT);
593
+ const mixR = new Float64Array(OUT);
594
+ for (let i = 0; i < OUT; i += 1) {
595
+ const padGain = duck[i];
596
+ const bassGain = duck[i];
597
+ const dryL = padFilteredL[i] * padGain + bassFilteredL[i] * bassGain + arpL[i] + percL[i] + fxL[i];
598
+ const dryR = padFilteredR[i] * padGain + bassFilteredR[i] * bassGain + arpR[i] + percR[i] + fxR[i];
599
+ mixL[i] = dryL + wet.left[i] * 0.72 + delay.left[i] * 0.24;
600
+ mixR[i] = dryR + wet.right[i] * 0.72 + delay.right[i] * 0.24;
601
+ }
602
+
603
+ // Master: DC/rumble removal, gentle saturation, normalization, fades.
604
+ function highpass(input, cutoff) {
605
+ const rc = 1 / (TAU * cutoff);
606
+ const alpha = rc / (rc + 1 / SR);
607
+ let previousInput = 0;
608
+ let previousOutput = 0;
609
+ for (let i = 0; i < OUT; i += 1) {
610
+ const value = input[i];
611
+ previousOutput = alpha * (previousOutput + value - previousInput);
612
+ previousInput = value;
613
+ input[i] = previousOutput;
614
+ }
615
+ }
616
+
617
+ highpass(mixL, 24);
618
+ highpass(mixR, 24);
619
+
620
+ for (let i = 0; i < OUT; i += 1) {
621
+ mixL[i] = Math.tanh(mixL[i] * 1.08);
622
+ mixR[i] = Math.tanh(mixR[i] * 1.08);
623
+ }
624
+
625
+ let peak = 0;
626
+ for (let i = 0; i < OUT; i += 1) {
627
+ peak = Math.max(peak, Math.abs(mixL[i]), Math.abs(mixR[i]));
628
+ }
629
+ const normalize = peak > 0 ? 0.72 / peak : 1;
630
+ const fadeIn = Math.round(0.04 * SR);
631
+ const fadeOutStart = Math.round(19.25 * SR);
632
+ for (let i = 0; i < OUT; i += 1) {
633
+ const fade = smoothstep(i / fadeIn) * smoothstep((OUT - i) / (OUT - fadeOutStart));
634
+ mixL[i] *= normalize * fade;
635
+ mixR[i] *= normalize * fade;
636
+ }
637
+
638
+ // 16-bit PCM WAV with TPDF dither.
639
+ const bytes = 44 + OUT * 4;
640
+ const wav = Buffer.alloc(bytes);
641
+ wav.write("RIFF", 0, "ascii");
642
+ wav.writeUInt32LE(36 + OUT * 4, 4);
643
+ wav.write("WAVE", 8, "ascii");
644
+ wav.write("fmt ", 12, "ascii");
645
+ wav.writeUInt32LE(16, 16);
646
+ wav.writeUInt16LE(1, 20);
647
+ wav.writeUInt16LE(2, 22);
648
+ wav.writeUInt32LE(SR, 24);
649
+ wav.writeUInt32LE(SR * 4, 28);
650
+ wav.writeUInt16LE(4, 32);
651
+ wav.writeUInt16LE(16, 34);
652
+ wav.write("data", 36, "ascii");
653
+ wav.writeUInt32LE(OUT * 4, 40);
654
+
655
+ let offset = 44;
656
+ for (let i = 0; i < OUT; i += 1) {
657
+ for (const value of [mixL[i], mixR[i]]) {
658
+ const dither = (random() - random()) / 65536;
659
+ const sample = Math.max(-1, Math.min(1, value + dither));
660
+ wav.writeInt16LE(Math.round(sample * 32767), offset);
661
+ offset += 2;
662
+ }
663
+ }
664
+
665
+ mkdirSync(path.dirname(out), { recursive: true });
666
+ if (out.endsWith(".wav")) {
667
+ writeFileSync(out, wav);
668
+ } else {
669
+ const work = mkdtempSync(path.join(tmpdir(), "frameyard-score-"));
670
+ const master = path.join(work, "score.wav");
671
+ try {
672
+ writeFileSync(master, wav);
673
+ execFileSync(
674
+ "ffmpeg",
675
+ [
676
+ "-y",
677
+ "-v",
678
+ "error",
679
+ "-fflags",
680
+ "+bitexact",
681
+ "-i",
682
+ master,
683
+ "-map_metadata",
684
+ "-1",
685
+ "-c:a",
686
+ "libvorbis",
687
+ "-q:a",
688
+ "6",
689
+ "-fflags",
690
+ "+bitexact",
691
+ out,
692
+ ],
693
+ { stdio: "inherit" },
694
+ );
695
+ } finally {
696
+ rmSync(work, { recursive: true, force: true });
697
+ }
698
+ }
699
+ const size = statSync(out).size;
700
+ console.log(
701
+ `wrote ${path.relative(process.cwd(), out)} (${(size / 1024).toFixed(0)} KiB, peak ${(20 * Math.log10(0.72)).toFixed(1)} dBFS)`,
702
+ );