cuju-render 0.1.0__py3-none-any.whl

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 (382) hide show
  1. cuju/render/__init__.py +759 -0
  2. cuju/render/__main__.py +1065 -0
  3. cuju/render/_export/__init__.py +3 -0
  4. cuju/render/_export/feed.py +542 -0
  5. cuju/render/_export/sizes.py +157 -0
  6. cuju/render/_export/video.py +1618 -0
  7. cuju/render/_export/video_jobs.py +408 -0
  8. cuju/render/_format/__init__.py +3 -0
  9. cuju/render/_format/annotations.py +235 -0
  10. cuju/render/_format/codec.py +307 -0
  11. cuju/render/_format/keep.py +121 -0
  12. cuju/render/_format/recording.py +860 -0
  13. cuju/render/_format/writer.py +54 -0
  14. cuju/render/_guide/__init__.py +3 -0
  15. cuju/render/_guide/doctor.py +523 -0
  16. cuju/render/_guide/help.py +62 -0
  17. cuju/render/_guide/tutorial.py +254 -0
  18. cuju/render/_http/__init__.py +3 -0
  19. cuju/render/_http/commands.py +57 -0
  20. cuju/render/_http/headers.py +103 -0
  21. cuju/render/_http/launch.py +500 -0
  22. cuju/render/_http/launchers.py +408 -0
  23. cuju/render/_http/pwa.py +128 -0
  24. cuju/render/_http/server.py +2105 -0
  25. cuju/render/_http/window.py +360 -0
  26. cuju/render/_moments/__init__.py +3 -0
  27. cuju/render/_moments/captions.py +284 -0
  28. cuju/render/_moments/events.py +1123 -0
  29. cuju/render/_moments/search.py +694 -0
  30. cuju/render/_packages/__init__.py +3 -0
  31. cuju/render/_packages/glsl.py +746 -0
  32. cuju/render/_packages/loudness.py +334 -0
  33. cuju/render/_packages/pack.py +1959 -0
  34. cuju/render/_packages/presentation.py +1376 -0
  35. cuju/render/_packages/vocabulary.py +609 -0
  36. cuju/render/_playing/__init__.py +3 -0
  37. cuju/render/_playing/achievements.py +463 -0
  38. cuju/render/_playing/bench.py +609 -0
  39. cuju/render/_playing/challenges.py +1813 -0
  40. cuju/render/_playing/demo.py +323 -0
  41. cuju/render/_playing/draw.py +211 -0
  42. cuju/render/_playing/errors.py +20 -0
  43. cuju/render/_playing/game.py +645 -0
  44. cuju/render/_playing/limits.py +339 -0
  45. cuju/render/_playing/play.py +926 -0
  46. cuju/render/_playing/policy_worker.py +250 -0
  47. cuju/render/_playing/strict.py +810 -0
  48. cuju/render/_registry/__init__.py +3 -0
  49. cuju/render/_registry/clips.py +394 -0
  50. cuju/render/_registry/joints.py +108 -0
  51. cuju/render/_registry/teams.py +2058 -0
  52. cuju/render/_store/__init__.py +3 -0
  53. cuju/render/_store/home.py +70 -0
  54. cuju/render/_store/library.py +604 -0
  55. cuju/render/_store/samples.py +163 -0
  56. cuju/render/_text/__init__.py +3 -0
  57. cuju/render/_text/languages.py +637 -0
  58. cuju/render/_text/messages.py +1396 -0
  59. cuju/render/_tournaments/__init__.py +3 -0
  60. cuju/render/_tournaments/results.py +547 -0
  61. cuju/render/_tournaments/tournament.py +2742 -0
  62. cuju/render/_tournaments/tournament_cli.py +587 -0
  63. cuju/render/_tournaments/tournament_runner.py +1975 -0
  64. cuju/render/_version.py +6 -0
  65. cuju/render/_watch/__init__.py +3 -0
  66. cuju/render/_watch/grid.py +265 -0
  67. cuju/render/_watch/live.py +664 -0
  68. cuju/render/_watch/notebook.py +79 -0
  69. cuju/render/_watch/show.py +171 -0
  70. cuju/render/samples/sample-11v11.cjr +0 -0
  71. cuju/render/samples/sample-5v5.cjr +0 -0
  72. cuju/render/samples/teams/starter/main.py +33 -0
  73. cuju/render/scenarios/benchmark_full_game_11_vs_11_easy.py +46 -0
  74. cuju/render/scenarios/benchmark_full_game_11_vs_11_hard.py +46 -0
  75. cuju/render/scenarios/benchmark_full_game_11_vs_11_medium.py +46 -0
  76. cuju/render/scenarios/benchmark_full_game_5_vs_5_easy.py +34 -0
  77. cuju/render/scenarios/benchmark_full_game_5_vs_5_hard.py +34 -0
  78. cuju/render/scenarios/benchmark_full_game_5_vs_5_medium.py +34 -0
  79. cuju/render/web/assets/adboard_polygon.webp +0 -0
  80. cuju/render/web/assets/adboard_your_ad_here.webp +0 -0
  81. cuju/render/web/assets/animations/kaykit-rig-medium-combatmelee.glb +0 -0
  82. cuju/render/web/assets/animations/kaykit-rig-medium-general.glb +0 -0
  83. cuju/render/web/assets/animations/kaykit-rig-medium-movementadvanced.glb +0 -0
  84. cuju/render/web/assets/animations/kaykit-rig-medium-movementbasic.glb +0 -0
  85. cuju/render/web/assets/animations/kaykit-rig-medium-simulation.glb +0 -0
  86. cuju/render/web/assets/animations/kaykit-rig-medium-special.glb +0 -0
  87. cuju/render/web/assets/ball.glb +0 -0
  88. cuju/render/web/assets/challenges/3_vs_1_with_keeper.mp4 +0 -0
  89. cuju/render/web/assets/challenges/3_vs_1_with_keeper.webp +0 -0
  90. cuju/render/web/assets/challenges/corner.mp4 +0 -0
  91. cuju/render/web/assets/challenges/corner.webp +0 -0
  92. cuju/render/web/assets/challenges/counterattack_easy.mp4 +0 -0
  93. cuju/render/web/assets/challenges/counterattack_easy.webp +0 -0
  94. cuju/render/web/assets/challenges/counterattack_hard.mp4 +0 -0
  95. cuju/render/web/assets/challenges/counterattack_hard.webp +0 -0
  96. cuju/render/web/assets/challenges/empty_goal.mp4 +0 -0
  97. cuju/render/web/assets/challenges/empty_goal.webp +0 -0
  98. cuju/render/web/assets/challenges/empty_goal_close.mp4 +0 -0
  99. cuju/render/web/assets/challenges/empty_goal_close.webp +0 -0
  100. cuju/render/web/assets/challenges/manifest.json +375 -0
  101. cuju/render/web/assets/challenges/one_on_one.mp4 +0 -0
  102. cuju/render/web/assets/challenges/one_on_one.webp +0 -0
  103. cuju/render/web/assets/challenges/one_on_one_hard.mp4 +0 -0
  104. cuju/render/web/assets/challenges/one_on_one_hard.webp +0 -0
  105. cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.mp4 +0 -0
  106. cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.webp +0 -0
  107. cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.mp4 +0 -0
  108. cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.webp +0 -0
  109. cuju/render/web/assets/challenges/run_to_score.mp4 +0 -0
  110. cuju/render/web/assets/challenges/run_to_score.webp +0 -0
  111. cuju/render/web/assets/challenges/run_to_score_with_keeper.mp4 +0 -0
  112. cuju/render/web/assets/challenges/run_to_score_with_keeper.webp +0 -0
  113. cuju/render/web/assets/challenges/single_goal_versus_lazy.mp4 +0 -0
  114. cuju/render/web/assets/challenges/single_goal_versus_lazy.webp +0 -0
  115. cuju/render/web/assets/crowd.webp +0 -0
  116. cuju/render/web/assets/crowd_normal.webp +0 -0
  117. cuju/render/web/assets/cuju-logo-384.png +0 -0
  118. cuju/render/web/assets/cuju-logo.png +0 -0
  119. cuju/render/web/assets/cuju-logo.svg +46 -0
  120. cuju/render/web/assets/cuju-mark-light.svg +33 -0
  121. cuju/render/web/assets/cuju-mark.svg +33 -0
  122. cuju/render/web/assets/goal.glb +0 -0
  123. cuju/render/web/assets/grass.webp +0 -0
  124. cuju/render/web/assets/hair_black.png +0 -0
  125. cuju/render/web/assets/hair_blonde.png +0 -0
  126. cuju/render/web/assets/hair_brown.png +0 -0
  127. cuju/render/web/assets/hair_darkblonde.png +0 -0
  128. cuju/render/web/assets/hair_long01.glb +0 -0
  129. cuju/render/web/assets/hair_long02.glb +0 -0
  130. cuju/render/web/assets/hair_medium01.glb +0 -0
  131. cuju/render/web/assets/hair_red.png +0 -0
  132. cuju/render/web/assets/hair_short01.glb +0 -0
  133. cuju/render/web/assets/hair_short02.glb +0 -0
  134. cuju/render/web/assets/icons/cuju-192.png +0 -0
  135. cuju/render/web/assets/icons/cuju-512.png +0 -0
  136. cuju/render/web/assets/icons/cuju-maskable-512.png +0 -0
  137. cuju/render/web/assets/kit_goalkeeper.webp +0 -0
  138. cuju/render/web/assets/kit_mask.png +0 -0
  139. cuju/render/web/assets/kit_normal.webp +0 -0
  140. cuju/render/web/assets/manifest.json +601 -0
  141. cuju/render/web/assets/models/kaykit-barbarian.glb +0 -0
  142. cuju/render/web/assets/models/kaykit-barbarian.json +23 -0
  143. cuju/render/web/assets/models/kaykit-knight.glb +0 -0
  144. cuju/render/web/assets/models/kaykit-knight.json +23 -0
  145. cuju/render/web/assets/models/kaykit-mage.glb +0 -0
  146. cuju/render/web/assets/models/kaykit-mage.json +23 -0
  147. cuju/render/web/assets/models/kaykit-mannequin-medium.glb +0 -0
  148. cuju/render/web/assets/models/kaykit-mannequin-medium.json +23 -0
  149. cuju/render/web/assets/models/kaykit-ranger.glb +0 -0
  150. cuju/render/web/assets/models/kaykit-ranger.json +23 -0
  151. cuju/render/web/assets/models/kaykit-rogue-hooded.glb +0 -0
  152. cuju/render/web/assets/models/kaykit-rogue-hooded.json +23 -0
  153. cuju/render/web/assets/models/kaykit-rogue.glb +0 -0
  154. cuju/render/web/assets/models/kaykit-rogue.json +23 -0
  155. cuju/render/web/assets/models/kaykit-skeleton-mage.glb +0 -0
  156. cuju/render/web/assets/models/kaykit-skeleton-mage.json +23 -0
  157. cuju/render/web/assets/models/kaykit-skeleton-minion.glb +0 -0
  158. cuju/render/web/assets/models/kaykit-skeleton-minion.json +23 -0
  159. cuju/render/web/assets/models/kaykit-skeleton-rogue.glb +0 -0
  160. cuju/render/web/assets/models/kaykit-skeleton-rogue.json +23 -0
  161. cuju/render/web/assets/models/kaykit-skeleton-warrior.glb +0 -0
  162. cuju/render/web/assets/models/kaykit-skeleton-warrior.json +23 -0
  163. cuju/render/web/assets/pitch.webp +0 -0
  164. cuju/render/web/assets/pitch_normal.webp +0 -0
  165. cuju/render/web/assets/player.glb +0 -0
  166. cuju/render/web/assets/shoe.webp +0 -0
  167. cuju/render/web/assets/shoe_sole.png +0 -0
  168. cuju/render/web/assets/skin_01.png +0 -0
  169. cuju/render/web/assets/skin_02.png +0 -0
  170. cuju/render/web/assets/skin_03.png +0 -0
  171. cuju/render/web/assets/skin_04.png +0 -0
  172. cuju/render/web/assets/sounds/applause.ogg +0 -0
  173. cuju/render/web/assets/sounds/beach-wave-1.ogg +0 -0
  174. cuju/render/web/assets/sounds/beach-wave-2.ogg +0 -0
  175. cuju/render/web/assets/sounds/beach-wave-3.ogg +0 -0
  176. cuju/render/web/assets/sounds/beach-wave-4.ogg +0 -0
  177. cuju/render/web/assets/sounds/city-horn.ogg +0 -0
  178. cuju/render/web/assets/sounds/city-loop.ogg +0 -0
  179. cuju/render/web/assets/sounds/city-siren.ogg +0 -0
  180. cuju/render/web/assets/sounds/crowd-cheer.ogg +0 -0
  181. cuju/render/web/assets/sounds/crowd-loop.ogg +0 -0
  182. cuju/render/web/assets/sounds/crowd-ooh.ogg +0 -0
  183. cuju/render/web/assets/sounds/gulls.ogg +0 -0
  184. cuju/render/web/assets/sounds/neon-sting.ogg +0 -0
  185. cuju/render/web/assets/sounds/rain-loop.ogg +0 -0
  186. cuju/render/web/assets/sounds/sounds.json +46 -0
  187. cuju/render/web/assets/sounds/space-beep.ogg +0 -0
  188. cuju/render/web/assets/sounds/space-hum.ogg +0 -0
  189. cuju/render/web/assets/sounds/space-radio.ogg +0 -0
  190. cuju/render/web/assets/sounds/whistle-long.ogg +0 -0
  191. cuju/render/web/assets/sounds/whistle-short.ogg +0 -0
  192. cuju/render/web/assets/sounds/wind-loop.ogg +0 -0
  193. cuju/render/web/easy.css +357 -0
  194. cuju/render/web/export.html +23 -0
  195. cuju/render/web/feed.css +262 -0
  196. cuju/render/web/feed.html +28 -0
  197. cuju/render/web/flat.css +120 -0
  198. cuju/render/web/home.css +837 -0
  199. cuju/render/web/index.html +373 -0
  200. cuju/render/web/js/boot.js +50 -0
  201. cuju/render/web/js/broadcast/audio.js +2141 -0
  202. cuju/render/web/js/broadcast/celebrate.js +1238 -0
  203. cuju/render/web/js/broadcast/decisions.js +609 -0
  204. cuju/render/web/js/broadcast/hud.js +352 -0
  205. cuju/render/web/js/broadcast/show.js +3614 -0
  206. cuju/render/web/js/broadcast/transition.js +2231 -0
  207. cuju/render/web/js/broadcast/voices.js +153 -0
  208. cuju/render/web/js/capture.js +101 -0
  209. cuju/render/web/js/core/accessibility.js +90 -0
  210. cuju/render/web/js/core/api.js +429 -0
  211. cuju/render/web/js/core/comfort.js +135 -0
  212. cuju/render/web/js/core/i18n.js +705 -0
  213. cuju/render/web/js/core/icons.js +167 -0
  214. cuju/render/web/js/core/input-settings.js +156 -0
  215. cuju/render/web/js/core/keys.js +277 -0
  216. cuju/render/web/js/core/language-ui.js +125 -0
  217. cuju/render/web/js/core/menu.js +853 -0
  218. cuju/render/web/js/core/names.js +113 -0
  219. cuju/render/web/js/core/page-structure.js +110 -0
  220. cuju/render/web/js/core/scenarios.js +109 -0
  221. cuju/render/web/js/core/spoilers.js +236 -0
  222. cuju/render/web/js/core/typeahead.js +38 -0
  223. cuju/render/web/js/export/dom-picture.js +330 -0
  224. cuju/render/web/js/export/export-host.js +97 -0
  225. cuju/render/web/js/export/feed-dialog.js +191 -0
  226. cuju/render/web/js/export/feed-params.js +266 -0
  227. cuju/render/web/js/export/feed.js +363 -0
  228. cuju/render/web/js/export/mp4-mux.js +295 -0
  229. cuju/render/web/js/export/photo-core.js +313 -0
  230. cuju/render/web/js/export/photo.js +1314 -0
  231. cuju/render/web/js/export/safe-frame.js +59 -0
  232. cuju/render/web/js/export/size-fields.js +103 -0
  233. cuju/render/web/js/export/sizes.js +178 -0
  234. cuju/render/web/js/export/video-boot.js +55 -0
  235. cuju/render/web/js/export/video-browser.js +325 -0
  236. cuju/render/web/js/export/video-export.js +417 -0
  237. cuju/render/web/js/export/video-plan.js +207 -0
  238. cuju/render/web/js/export/video-range.js +185 -0
  239. cuju/render/web/js/export/webm-mux.js +198 -0
  240. cuju/render/web/js/feed-boot.js +151 -0
  241. cuju/render/web/js/feed-frame.js +101 -0
  242. cuju/render/web/js/help/doctor.js +488 -0
  243. cuju/render/web/js/help/firstrun.js +379 -0
  244. cuju/render/web/js/help/help-center.js +438 -0
  245. cuju/render/web/js/help/install-offer.js +164 -0
  246. cuju/render/web/js/help/install-state.js +104 -0
  247. cuju/render/web/js/help/lessons-core.js +207 -0
  248. cuju/render/web/js/help/lessons.js +463 -0
  249. cuju/render/web/js/help/palette.js +584 -0
  250. cuju/render/web/js/help/pwa.js +257 -0
  251. cuju/render/web/js/help/tour-core.js +241 -0
  252. cuju/render/web/js/help/tour.js +461 -0
  253. cuju/render/web/js/main.js +2513 -0
  254. cuju/render/web/js/pages/draw.js +188 -0
  255. cuju/render/web/js/pages/fieldpage.js +530 -0
  256. cuju/render/web/js/pages/home.js +521 -0
  257. cuju/render/web/js/pages/matches.js +779 -0
  258. cuju/render/web/js/pages/moments-ui.js +359 -0
  259. cuju/render/web/js/pages/queue-filters.js +63 -0
  260. cuju/render/web/js/pages/recording-groups.js +80 -0
  261. cuju/render/web/js/pages/recordings.js +1069 -0
  262. cuju/render/web/js/play/bench.js +347 -0
  263. cuju/render/web/js/play/challenges.js +1302 -0
  264. cuju/render/web/js/play/medals.js +127 -0
  265. cuju/render/web/js/play/play.js +1953 -0
  266. cuju/render/web/js/play/teasers.js +196 -0
  267. cuju/render/web/js/play/touch.js +350 -0
  268. cuju/render/web/js/play/tournament-layout.js +487 -0
  269. cuju/render/web/js/play/tournament-results.js +441 -0
  270. cuju/render/web/js/play/tournament-strict.js +560 -0
  271. cuju/render/web/js/play/tournament.js +2585 -0
  272. cuju/render/web/js/players/animate.js +972 -0
  273. cuju/render/web/js/players/clips.js +224 -0
  274. cuju/render/web/js/players/kaykit.js +162 -0
  275. cuju/render/web/js/players/kits.js +676 -0
  276. cuju/render/web/js/players/number-patch.js +82 -0
  277. cuju/render/web/js/players/players.js +2694 -0
  278. cuju/render/web/js/players/recolor.js +893 -0
  279. cuju/render/web/js/players/retarget.js +325 -0
  280. cuju/render/web/js/players/rig-numbers.js +174 -0
  281. cuju/render/web/js/presentation/animator.js +231 -0
  282. cuju/render/web/js/presentation/assign.js +85 -0
  283. cuju/render/web/js/presentation/behaviors.js +440 -0
  284. cuju/render/web/js/presentation/budget.js +209 -0
  285. cuju/render/web/js/presentation/catalog.js +1674 -0
  286. cuju/render/web/js/presentation/clocks.js +72 -0
  287. cuju/render/web/js/presentation/commands.js +301 -0
  288. cuju/render/web/js/presentation/contract.js +240 -0
  289. cuju/render/web/js/presentation/effects.js +904 -0
  290. cuju/render/web/js/presentation/flash.js +605 -0
  291. cuju/render/web/js/presentation/freeze-worklet.js +22 -0
  292. cuju/render/web/js/presentation/glsl.js +464 -0
  293. cuju/render/web/js/presentation/host.js +323 -0
  294. cuju/render/web/js/presentation/index.js +2066 -0
  295. cuju/render/web/js/presentation/loudness-worklet.js +235 -0
  296. cuju/render/web/js/presentation/loudness.js +740 -0
  297. cuju/render/web/js/presentation/moments.js +536 -0
  298. cuju/render/web/js/presentation/post.js +327 -0
  299. cuju/render/web/js/presentation/preview.js +180 -0
  300. cuju/render/web/js/presentation/protocol.js +174 -0
  301. cuju/render/web/js/presentation/reach.js +158 -0
  302. cuju/render/web/js/presentation/rigging.js +130 -0
  303. cuju/render/web/js/presentation/sandbox.js +157 -0
  304. cuju/render/web/js/presentation/shaders.js +235 -0
  305. cuju/render/web/js/presentation/stage.js +402 -0
  306. cuju/render/web/js/presentation/teamaudio.js +722 -0
  307. cuju/render/web/js/presentation/tracks.js +197 -0
  308. cuju/render/web/js/presentation/values.js +81 -0
  309. cuju/render/web/js/recording/annotation-data.js +203 -0
  310. cuju/render/web/js/recording/captions.js +491 -0
  311. cuju/render/web/js/recording/codec.js +185 -0
  312. cuju/render/web/js/recording/decode-worker.js +70 -0
  313. cuju/render/web/js/recording/decode.js +94 -0
  314. cuju/render/web/js/recording/episodes.js +46 -0
  315. cuju/render/web/js/recording/format.js +461 -0
  316. cuju/render/web/js/recording/live.js +348 -0
  317. cuju/render/web/js/recording/moments-query.js +59 -0
  318. cuju/render/web/js/recording/moments.js +149 -0
  319. cuju/render/web/js/recording/pose.js +86 -0
  320. cuju/render/web/js/recording/timeline.js +275 -0
  321. cuju/render/web/js/teams/art.js +50 -0
  322. cuju/render/web/js/teams/clip-editor.js +293 -0
  323. cuju/render/web/js/teams/editor.js +1979 -0
  324. cuju/render/web/js/teams/library.js +344 -0
  325. cuju/render/web/js/teams/painter.js +633 -0
  326. cuju/render/web/js/teams/surprise-ui.js +195 -0
  327. cuju/render/web/js/teams/surprise.js +497 -0
  328. cuju/render/web/js/teams/team-check.js +127 -0
  329. cuju/render/web/js/watch/annotations.js +270 -0
  330. cuju/render/web/js/watch/flat.js +366 -0
  331. cuju/render/web/js/watch/grid.js +436 -0
  332. cuju/render/web/js/watch/pitch2d.js +289 -0
  333. cuju/render/web/js/watch/player-tools.js +192 -0
  334. cuju/render/web/js/watch/presets.js +201 -0
  335. cuju/render/web/js/world/arenas.js +997 -0
  336. cuju/render/web/js/world/ball.js +204 -0
  337. cuju/render/web/js/world/cameras.js +982 -0
  338. cuju/render/web/js/world/crowd.js +376 -0
  339. cuju/render/web/js/world/field.js +227 -0
  340. cuju/render/web/js/world/framing.js +145 -0
  341. cuju/render/web/js/world/occlusion.js +48 -0
  342. cuju/render/web/js/world/pitch.js +703 -0
  343. cuju/render/web/js/world/scene.js +395 -0
  344. cuju/render/web/js/world/sky.js +325 -0
  345. cuju/render/web/js/world/stadium.js +1262 -0
  346. cuju/render/web/js/world/vendor-sky.js +367 -0
  347. cuju/render/web/js/world/weather.js +264 -0
  348. cuju/render/web/languages/en.json +4750 -0
  349. cuju/render/web/lessons.json +137 -0
  350. cuju/render/web/manifest.webmanifest +23 -0
  351. cuju/render/web/style.css +8399 -0
  352. cuju/render/web/sw.js +235 -0
  353. cuju/render/web/vendor/fabric/LICENSE +16 -0
  354. cuju/render/web/vendor/fabric/fabric.min.mjs +1 -0
  355. cuju/render/web/vendor/lucide/LICENSE +43 -0
  356. cuju/render/web/vendor/lucide/icons.js +141 -0
  357. cuju/render/web/vendor/three/LICENSE +21 -0
  358. cuju/render/web/vendor/three/build/three.core.js +60586 -0
  359. cuju/render/web/vendor/three/build/three.module.js +19719 -0
  360. cuju/render/web/vendor/three/examples/jsm/loaders/GLTFLoader.js +4925 -0
  361. cuju/render/web/vendor/three/examples/jsm/postprocessing/EffectComposer.js +365 -0
  362. cuju/render/web/vendor/three/examples/jsm/postprocessing/MaskPass.js +195 -0
  363. cuju/render/web/vendor/three/examples/jsm/postprocessing/OutputPass.js +148 -0
  364. cuju/render/web/vendor/three/examples/jsm/postprocessing/Pass.js +191 -0
  365. cuju/render/web/vendor/three/examples/jsm/postprocessing/RenderPass.js +193 -0
  366. cuju/render/web/vendor/three/examples/jsm/postprocessing/ShaderPass.js +135 -0
  367. cuju/render/web/vendor/three/examples/jsm/postprocessing/UnrealBloomPass.js +541 -0
  368. cuju/render/web/vendor/three/examples/jsm/shaders/CopyShader.js +52 -0
  369. cuju/render/web/vendor/three/examples/jsm/shaders/LuminosityHighPassShader.js +68 -0
  370. cuju/render/web/vendor/three/examples/jsm/shaders/OutputShader.js +103 -0
  371. cuju/render/web/vendor/three/examples/jsm/utils/BufferGeometryUtils.js +1501 -0
  372. cuju/render/web/vendor/three/examples/jsm/utils/SkeletonUtils.js +496 -0
  373. cuju_render-0.1.0.dist-info/METADATA +116 -0
  374. cuju_render-0.1.0.dist-info/RECORD +382 -0
  375. cuju_render-0.1.0.dist-info/WHEEL +4 -0
  376. cuju_render-0.1.0.dist-info/licenses/LICENSE +202 -0
  377. cuju_render-0.1.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +396 -0
  378. cuju_render-0.1.0.dist-info/licenses/LICENSES/CC0-1.0.txt +127 -0
  379. cuju_render-0.1.0.dist-info/licenses/LICENSES/ISC.txt +16 -0
  380. cuju_render-0.1.0.dist-info/licenses/LICENSES/MIT.txt +24 -0
  381. cuju_render-0.1.0.dist-info/licenses/LICENSES/Unlicense.txt +13 -0
  382. cuju_render-0.1.0.dist-info/licenses/NOTICE +35 -0
@@ -0,0 +1,2141 @@
1
+ // Copyright 2026 The cuju Authors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // Sound with the Web Audio API. Everything is synthesized unless assets/sounds lists files of its
5
+ // own, which then stand in for the synthesized sound of the same name, and teams may bring
6
+ // sounds of their own for their players' actions. Browsers only allow audio after a user
7
+ // gesture, so nothing starts until unlock runs from one.
8
+ //
9
+ // Each arena has a soundscape of its own and a crowd. The classic stadium has its big crowd, the
10
+ // beach the sea and a crowd in the open air, the rooftop the city below and a crowd whose voices
11
+ // come back off the towers, the space station the hum of its machines and a crowd in an enclosed
12
+ // gallery, and the neon arcade the buzz of its tubes and a crowd on the grid. The crowds of the
13
+ // other arenas play the stadium's recordings, smaller and colored by the place. Rain and wind
14
+ // are synthesized too and follow the weather. Switching arenas or weather crossfades over about
15
+ // a second.
16
+
17
+ import { Voices } from './voices.js';
18
+
19
+ // The optional files that replace synthesized sounds, by name without the extension. The list
20
+ // in assets/sounds/sounds.json names the files present, so absent ones are never requested, and
21
+ // each sound falls back to its synthesis when its file is absent.
22
+ //
23
+ // The crowd of every arena, crowd-loop, crowd-cheer, crowd-ooh and applause.
24
+ // Every arena, whistle-short and whistle-long.
25
+ // Weather, rain-loop and wind-loop, which no longer play in space.
26
+ // Arena beds played as loops over the arena, waves-loop on the beach, city-loop on the rooftop,
27
+ // hum-loop on the space station and the neon arcade. Each silences the synthesized bed it
28
+ // stands for, the sea, the city or the station hum.
29
+ // The beach, gulls for a gull's call and beach-wave-1 to beach-wave-4 for single breaking waves
30
+ // over the synthesized sea.
31
+ // The rooftop, city-horn for a far car horn and city-siren for a distant siren.
32
+ // The space station, space-beep for a console beep, space-radio for a radio chirp and space-hum, a loop in place of the
33
+ // station hum used when hum-loop is absent.
34
+ // The neon arcade, neon-sting for the goal sting.
35
+ export const SOUND_FILES = [
36
+ 'crowd-loop', 'crowd-cheer', 'crowd-ooh', 'applause', 'whistle-short', 'whistle-long',
37
+ 'rain-loop', 'wind-loop', 'waves-loop', 'city-loop', 'hum-loop',
38
+ 'gulls', 'city-horn', 'city-siren', 'space-beep', 'space-radio', 'space-hum',
39
+ 'neon-sting', 'beach-wave-1', 'beach-wave-2', 'beach-wave-3', 'beach-wave-4',
40
+ ];
41
+ // The sampled waves, of which each breaking wave plays one at random when any is present.
42
+ const BEACH_WAVES = ['beach-wave-1', 'beach-wave-2', 'beach-wave-3', 'beach-wave-4'];
43
+ // The gain of each arena file at its level from loading, so it plays no louder than the sound
44
+ // it replaces. The shipped files were measured offline at the output of the page against their
45
+ // synthesized sounds, and each gain keeps the root mean square of a file and its busiest tenth
46
+ // of a second within a tenth above the synthesis and its peak at or below it, close to parity.
47
+ const ARENA_FILE_GAIN = {
48
+ gulls: 0.0058, 'city-horn': 0.02, 'city-siren': 0.0105, 'space-beep': 0.014,
49
+ 'space-radio': 0.013, 'space-hum': 0.135, 'beach-wave-1': 0.1, 'beach-wave-2': 0.1,
50
+ 'beach-wave-3': 0.1, 'beach-wave-4': 0.1,
51
+ 'neon-sting': 0.2,
52
+ };
53
+
54
+ // The loudness of each synthesized sound that a file may replace, at a gain of one, as the root
55
+ // mean square over its busiest tenth of a second and its peak. A file is scaled once on loading
56
+ // to the lower of the two, so with the gains of the synthesized sound it never plays louder.
57
+ export const FILE_LEVELS = {
58
+ // The stadium crowd, measured against the synthesized murmur at an intensity of 0.3 and 0.9.
59
+ 'crowd-loop': { rms: 0.24, peak: 0.825 },
60
+ // The goal roar, held to the root mean square of the synthesized roar over its length, which
61
+ // keeps its busiest tenth of a second and its peak well under the stadium goal.
62
+ 'crowd-cheer': { rms: 0.0305, peak: 0.094 },
63
+ // The groan at a near miss, near the level of the crowd and well under the goal, since the
64
+ // synthesized shouts it stands for lie far under the crowd.
65
+ 'crowd-ooh': { rms: 0.025, peak: 0.082 },
66
+ // The applause of the full time show, near the level of the crowd and well under the goal,
67
+ // since the synthesized claps it stands for lie far under the crowd.
68
+ applause: { rms: 0.022, peak: 0.07 },
69
+ // The whistles are held to the stadium goal, the loudest sound of the match.
70
+ 'whistle-short': { rms: 0.072, peak: 0.15 },
71
+ 'whistle-long': { rms: 0.072, peak: 0.15 },
72
+ // Loops and arena sounds, chosen so that with their gains they play no louder than the
73
+ // synthesized beds and reactions, which sit under the stadium crowd. Rain is held to the
74
+ // quietest synthesized rain of the arenas, and the city to the city at night.
75
+ 'rain-loop': { rms: 0.0143, peak: 0.3 },
76
+ 'wind-loop': { rms: 0.0228, peak: 0.3 },
77
+ 'waves-loop': { rms: 0.035, peak: 0.4 },
78
+ 'city-loop': { rms: 0.028, peak: 0.32 },
79
+ 'hum-loop': { rms: 0.035, peak: 0.4 },
80
+ 'space-hum': { rms: 0.06, peak: 0.5 },
81
+ gulls: { rms: 0.15, peak: 0.5 },
82
+ 'city-horn': { rms: 0.15, peak: 0.5 },
83
+ 'city-siren': { rms: 0.15, peak: 0.5 },
84
+ 'space-radio': { rms: 0.15, peak: 0.5 },
85
+ 'space-beep': { rms: 0.15, peak: 0.5 },
86
+ 'neon-sting': { rms: 0.15, peak: 0.5 },
87
+ 'beach-wave-1': { rms: 0.15, peak: 0.5 },
88
+ 'beach-wave-2': { rms: 0.15, peak: 0.5 },
89
+ 'beach-wave-3': { rms: 0.15, peak: 0.5 },
90
+ 'beach-wave-4': { rms: 0.15, peak: 0.5 },
91
+ };
92
+ // Files without a measured synthesized sound are brought to a quiet peak.
93
+ const FILE_PEAK = 0.5;
94
+
95
+ // The arenas the sound knows.
96
+ export const SOUND_ARENAS = ['stadium', 'beach', 'rooftop', 'space', 'neon'];
97
+ // How loud the fans' chant is in each arena against the stadium's.
98
+ const FAN_CHANT = { stadium: 1, beach: 0.45, rooftop: 0.45, space: 0.4, neon: 0.45 };
99
+ // The crowd of each arena other than the stadium, from the stadium's recordings. level is the
100
+ // murmur and react the cheers, groans and applause, each against the stadium's, low and high
101
+ // the band the crowd is heard through, dry and wet how much of it reaches the listener directly
102
+ // and through the echo, and slap the delay in seconds of a short echo off nearby walls. The
103
+ // beach crowd is in the open air, the rooftop crowd echoes off the towers around, the space
104
+ // crowd sits in an enclosed gallery and the neon crowd stands on an open grid.
105
+ const ARENA_CROWD = {
106
+ beach: { level: 0.9, react: 0.8, low: 7000, high: 90, dry: 0.8, wet: 0.1, slap: 0 },
107
+ rooftop: { level: 0.65, react: 0.75, low: 5000, high: 140, dry: 0.65, wet: 0.3, slap: 0.23 },
108
+ space: { level: 0.85, react: 0.75, low: 3000, high: 190, dry: 0.5, wet: 0.7, slap: 0.06 },
109
+ neon: { level: 0.85, react: 0.75, low: 5500, high: 110, dry: 0.7, wet: 0.35, slap: 0 },
110
+ };
111
+ // How much of the echo comes back in each arena, the stands ring, the beach is open air.
112
+ const ARENA_ECHO = { stadium: 0.5, beach: 0.12, rooftop: 0.26, space: 0.32, neon: 0.4 };
113
+ // The overall level of each arena's soundscape, measured against the stadium crowd so none of
114
+ // them plays louder.
115
+ const ARENA_TRIM = { stadium: 1, beach: 0.82, rooftop: 1, space: 1, neon: 0.85 };
116
+ // The overall level of the synthesized weather at full strength.
117
+ const WEATHER_GAIN = { rain: 1, wind: 1 };
118
+ // How rain sounds on the ground of each arena, the hiss, the patter of drops and the drumming
119
+ // of heavier drops on a roof.
120
+ const RAIN_SURFACE = {
121
+ stadium: [0.55, 0.7, 1], beach: [1, 0.4, 0.1], rooftop: [0.75, 1, 0.3], space: [0, 0, 0], neon: [0.8, 0.8, 0.3],
122
+ };
123
+ // The time constant of a crossfade, so a switch settles in about a second.
124
+ const FADE = 0.3;
125
+
126
+ function midi(n) {
127
+ return 440 * Math.pow(2, (n - 69) / 12);
128
+ }
129
+
130
+ // Scales a decoded file in place to a level, by its busiest tenth of a second and its peak.
131
+ export function levelBuffer(buffer, level) {
132
+ const window = Math.max(1, Math.floor(buffer.sampleRate / 10));
133
+ let peak = 0;
134
+ let loudest = 0;
135
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
136
+ const d = buffer.getChannelData(ch);
137
+ let sum = 0;
138
+ for (let i = 0; i < d.length; i++) {
139
+ const v = d[i];
140
+ peak = Math.max(peak, Math.abs(v));
141
+ sum += v * v;
142
+ if (i >= window) {
143
+ sum -= d[i - window] * d[i - window];
144
+ }
145
+ if (i >= window - 1) {
146
+ loudest = Math.max(loudest, Math.sqrt(Math.max(0, sum) / window));
147
+ }
148
+ }
149
+ if (d.length < window) {
150
+ loudest = Math.max(loudest, Math.sqrt(sum / window));
151
+ }
152
+ }
153
+ if (peak < 1e-5) {
154
+ return buffer;
155
+ }
156
+ const byPeak = (level ? level.peak : FILE_PEAK) / peak;
157
+ const byRms = level && loudest > 1e-6 ? level.rms / loudest : Infinity;
158
+ const scale = Math.min(byPeak, byRms);
159
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
160
+ const d = buffer.getChannelData(ch);
161
+ for (let i = 0; i < d.length; i++) {
162
+ d[i] *= scale;
163
+ }
164
+ }
165
+ return buffer;
166
+ }
167
+
168
+ // A team's own sounds are cut to this length and their peaks brought to one level, so an
169
+ // upload can neither run long nor play louder than the rest of the page.
170
+ const TEAM_SOUND_SECONDS = 4;
171
+ const TEAM_SOUND_PEAK = 0.7;
172
+ const TEAM_SLOT_GAIN = { goal: 0.55, celebration: 0.5, chant: 0.3, shot: 0.4, pass: 0.3, tackle: 0.4, save: 0.45, kick: 0.35 };
173
+ // The shortest time between two plays of one team's slot, so a run of passes does not stutter.
174
+ const TEAM_SLOT_GAP = { kick: 0.25, pass: 0.5, shot: 0.8, tackle: 0.8, save: 1, goal: 3, celebration: 4, chant: 20 };
175
+
176
+ function noiseBuffer(ctx, seconds, kind) {
177
+ const length = Math.floor(ctx.sampleRate * seconds);
178
+ const buffer = ctx.createBuffer(2, length, ctx.sampleRate);
179
+ for (let ch = 0; ch < 2; ch++) {
180
+ const data = buffer.getChannelData(ch);
181
+ let b0 = 0;
182
+ let b1 = 0;
183
+ let b2 = 0;
184
+ for (let i = 0; i < length; i++) {
185
+ const white = Math.random() * 2 - 1;
186
+ if (kind === 'pink') {
187
+ // Paul Kellet's economical pink filter.
188
+ b0 = 0.99765 * b0 + white * 0.099046;
189
+ b1 = 0.963 * b1 + white * 0.2965164;
190
+ b2 = 0.57 * b2 + white * 1.0526913;
191
+ data[i] = (b0 + b1 + b2 + white * 0.1848) * 0.2;
192
+ } else if (kind === 'brown') {
193
+ // Integrated noise falls off with frequency, a distant rumble rather than a hiss.
194
+ b0 = (b0 + 0.02 * white) / 1.02;
195
+ data[i] = b0 * 3.5;
196
+ } else {
197
+ data[i] = white * 0.5;
198
+ }
199
+ }
200
+ loopFade(data, Math.floor(ctx.sampleRate * 0.25));
201
+ }
202
+ return buffer;
203
+ }
204
+
205
+ // Drops of rain, rendered once. Many small ticks and a few round plips at random moments, most
206
+ // of them faint, so a loop of it sounds like rain on a surface rather than steady noise.
207
+ function dropsBuffer(ctx, seconds = 3, perSecond = 320) {
208
+ const rate = ctx.sampleRate;
209
+ const length = Math.floor(rate * seconds);
210
+ const buffer = ctx.createBuffer(2, length, rate);
211
+ for (let ch = 0; ch < 2; ch++) {
212
+ const data = buffer.getChannelData(ch);
213
+ const count = Math.floor(seconds * perSecond);
214
+ for (let n = 0; n < count; n++) {
215
+ const at = Math.floor(Math.random() * length);
216
+ const amp = 0.1 + 0.9 * Math.random() ** 3;
217
+ if (Math.random() < 0.75) {
218
+ const decay = rate * (0.0008 + Math.random() * 0.0025);
219
+ const span = Math.floor(decay * 5);
220
+ for (let i = 0; i < span; i++) {
221
+ data[(at + i) % length] += amp * (Math.random() * 2 - 1) * Math.exp(-i / decay);
222
+ }
223
+ } else {
224
+ const f = 1800 + Math.random() * 3200;
225
+ const decay = rate * (0.002 + Math.random() * 0.004);
226
+ const span = Math.floor(decay * 5);
227
+ for (let i = 0; i < span; i++) {
228
+ data[(at + i) % length] += amp * 0.7 * Math.sin((2 * Math.PI * f * i) / rate) * Math.exp(-i / decay);
229
+ }
230
+ }
231
+ }
232
+ }
233
+ let peak = 0;
234
+ for (let ch = 0; ch < 2; ch++) {
235
+ const data = buffer.getChannelData(ch);
236
+ for (let i = 0; i < length; i++) {
237
+ peak = Math.max(peak, Math.abs(data[i]));
238
+ }
239
+ }
240
+ for (let ch = 0; ch < 2; ch++) {
241
+ const data = buffer.getChannelData(ch);
242
+ for (let i = 0; i < length; i++) {
243
+ data[i] *= peak > 0 ? 0.8 / peak : 1;
244
+ }
245
+ }
246
+ return buffer;
247
+ }
248
+
249
+ // The two ends of a loop fade into each other, so it has no click as it repeats.
250
+ function loopFade(data, fade) {
251
+ const length = data.length;
252
+ for (let i = 0; i < fade; i++) {
253
+ const w = i / fade;
254
+ data[i] = data[i] * w + data[length - fade + i] * (1 - w);
255
+ }
256
+ }
257
+
258
+ // The reverberation of a large open stand, decaying noise that darkens as it fades, the way air
259
+ // and seats swallow the high end of an echo first.
260
+ function stadiumImpulse(ctx, seconds = 2.4) {
261
+ const length = Math.floor(ctx.sampleRate * seconds);
262
+ const buffer = ctx.createBuffer(2, length, ctx.sampleRate);
263
+ for (let ch = 0; ch < 2; ch++) {
264
+ const data = buffer.getChannelData(ch);
265
+ let low = 0;
266
+ for (let i = 0; i < length; i++) {
267
+ const f = i / length;
268
+ const white = Math.random() * 2 - 1;
269
+ const k = 0.5 - 0.45 * f;
270
+ low += (white - low) * k;
271
+ // A short gap before the first reflections, the stands lie some way off.
272
+ const onset = Math.min(1, i / (ctx.sampleRate * 0.03));
273
+ data[i] = low * onset * Math.pow(1 - f, 2.6) * 0.6;
274
+ }
275
+ }
276
+ return buffer;
277
+ }
278
+
279
+ // Many people talking at once, rendered once offline. Each voice is noise shaped by a band in
280
+ // the range of speech and cut into syllables, phrases and pauses, so the bed sounds like a crowd
281
+ // murmuring rather than steady noise.
282
+ async function renderBabble(sampleRate, seconds = 9, voices = 34) {
283
+ const Offline = window.OfflineAudioContext || window.webkitOfflineAudioContext;
284
+ if (!Offline) {
285
+ return null;
286
+ }
287
+ const ctx = new Offline(2, Math.floor(sampleRate * seconds), sampleRate);
288
+ const pink = noiseBuffer(ctx, 3, 'pink');
289
+ const bus = ctx.createGain();
290
+ const lowpass = ctx.createBiquadFilter();
291
+ lowpass.type = 'lowpass';
292
+ lowpass.frequency.value = 1700;
293
+ lowpass.Q.value = 0.4;
294
+ bus.connect(lowpass).connect(ctx.destination);
295
+ for (let v = 0; v < voices; v++) {
296
+ const src = ctx.createBufferSource();
297
+ src.buffer = pink;
298
+ src.loop = true;
299
+ src.playbackRate.value = 0.8 + Math.random() * 0.5;
300
+ const band = ctx.createBiquadFilter();
301
+ band.type = 'bandpass';
302
+ band.frequency.value = 280 + Math.random() * 820;
303
+ band.Q.value = 1.6 + Math.random() * 2.4;
304
+ const amp = ctx.createGain();
305
+ amp.gain.value = 0;
306
+ const pan = ctx.createStereoPanner ? ctx.createStereoPanner() : null;
307
+ if (pan) {
308
+ pan.pan.value = Math.random() * 1.8 - 0.9;
309
+ }
310
+ src.connect(band).connect(amp);
311
+ (pan ? amp.connect(pan) : amp).connect(bus);
312
+ let t = Math.random() * 1.5;
313
+ while (t < seconds) {
314
+ // A phrase of syllables, then a pause.
315
+ const phraseEnd = t + 0.8 + Math.random() * 3;
316
+ const level = 0.3 + Math.random() * 0.7;
317
+ while (t < phraseEnd && t < seconds) {
318
+ const syllable = 0.07 + Math.random() * 0.2;
319
+ amp.gain.setTargetAtTime(level * (0.5 + Math.random() * 0.5), t, 0.02);
320
+ amp.gain.setTargetAtTime(0.05 * level, t + syllable, 0.03);
321
+ t += syllable + 0.03 + Math.random() * 0.12;
322
+ }
323
+ amp.gain.setTargetAtTime(0, t, 0.08);
324
+ t += 0.4 + Math.random() * 2.5;
325
+ }
326
+ src.start(0, Math.random() * 2.5);
327
+ }
328
+ const buffer = await ctx.startRendering();
329
+ let peak = 0;
330
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
331
+ const data = buffer.getChannelData(ch);
332
+ for (let i = 0; i < data.length; i++) {
333
+ peak = Math.max(peak, Math.abs(data[i]));
334
+ }
335
+ }
336
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
337
+ const data = buffer.getChannelData(ch);
338
+ const scale = peak > 0 ? 0.9 / peak : 1;
339
+ for (let i = 0; i < data.length; i++) {
340
+ data[i] *= scale;
341
+ }
342
+ loopFade(data, Math.floor(sampleRate * 0.6));
343
+ }
344
+ return buffer;
345
+ }
346
+
347
+ // A team's sound, cut to its longest length with a short fade and scaled to one peak.
348
+ function tameBuffer(ctx, decoded) {
349
+ const length = Math.min(decoded.length, Math.floor(decoded.sampleRate * TEAM_SOUND_SECONDS));
350
+ const out = ctx.createBuffer(decoded.numberOfChannels, length, decoded.sampleRate);
351
+ let peak = 0;
352
+ for (let ch = 0; ch < decoded.numberOfChannels; ch++) {
353
+ const src = decoded.getChannelData(ch);
354
+ for (let i = 0; i < length; i++) {
355
+ peak = Math.max(peak, Math.abs(src[i]));
356
+ }
357
+ }
358
+ const scale = peak > 1e-4 ? TEAM_SOUND_PEAK / peak : 0;
359
+ const fade = Math.min(length, Math.floor(decoded.sampleRate * 0.2));
360
+ for (let ch = 0; ch < decoded.numberOfChannels; ch++) {
361
+ const src = decoded.getChannelData(ch);
362
+ const dst = out.getChannelData(ch);
363
+ for (let i = 0; i < length; i++) {
364
+ const tail = i > length - fade ? (length - i) / fade : 1;
365
+ dst[i] = src[i] * scale * tail;
366
+ }
367
+ }
368
+ return out;
369
+ }
370
+
371
+ function makeGain(ctx, value) {
372
+ const g = ctx.createGain();
373
+ g.gain.value = value;
374
+ return g;
375
+ }
376
+
377
+ function makeFilter(ctx, type, frequency, q = 0.7) {
378
+ const f = ctx.createBiquadFilter();
379
+ f.type = type;
380
+ f.frequency.value = frequency;
381
+ f.Q.value = q;
382
+ return f;
383
+ }
384
+
385
+ // A stereo pan, or a plain pass through where the browser has none.
386
+ function makePan(ctx, value) {
387
+ if (ctx.createStereoPanner) {
388
+ const p = ctx.createStereoPanner();
389
+ p.pan.value = value;
390
+ return p;
391
+ }
392
+ return makeGain(ctx, 1);
393
+ }
394
+
395
+ // A noise buffer played from a random point, looping, until a given time.
396
+ function noiseShot(ctx, buffer, t, end, rate = 1) {
397
+ const src = ctx.createBufferSource();
398
+ src.buffer = buffer;
399
+ src.loop = true;
400
+ src.playbackRate.value = rate;
401
+ src.start(t, Math.random() * Math.max(0.01, buffer.duration - 0.01));
402
+ src.stop(end);
403
+ return src;
404
+ }
405
+
406
+ // A swell of a gain up to a peak and back to silence, smooth at both ends.
407
+ function swellGain(param, t, peak, attack, hold, release) {
408
+ const top = Math.max(peak, 0.00011);
409
+ param.setValueAtTime(0.0001, t);
410
+ param.exponentialRampToValueAtTime(top, t + attack);
411
+ param.setValueAtTime(top, t + attack + hold);
412
+ param.exponentialRampToValueAtTime(0.0001, t + attack + hold + release);
413
+ }
414
+
415
+ // One soundscape, an arena's or a weather's. Everything in it reaches the listener through two
416
+ // fades, a direct one and one into the echo, which rise and fall together when it comes and
417
+ // goes, and its timers stop once it is done.
418
+ class Scape {
419
+ constructor(sound, name, always = false) {
420
+ const ctx = sound.ctx;
421
+ this.sound = sound;
422
+ this.ctx = ctx;
423
+ this.name = name;
424
+ this.always = always;
425
+ this.fade = makeGain(ctx, 0);
426
+ this.fade.connect(sound.mix.ambience.dry);
427
+ this.wetFade = makeGain(ctx, 0);
428
+ this.wetFade.connect(sound.mix.ambience.wet);
429
+ this.crowdFade = makeGain(ctx, 0);
430
+ this.crowdFade.connect(sound.mix.crowd.dry);
431
+ this.crowdWetFade = makeGain(ctx, 0);
432
+ this.crowdWetFade.connect(sound.mix.crowd.wet);
433
+ this.sources = [];
434
+ this.timers = new Set();
435
+ this.live = true;
436
+ this.target = 0;
437
+ this.x = sound.intensity;
438
+ }
439
+
440
+ // Whether its sounds may start now, it must be the arena shown on a page that is playing.
441
+ get current() {
442
+ return this.live && this.sound.audible && (this.always || this.sound.arena === this.name);
443
+ }
444
+
445
+ setLevel(level) {
446
+ if (!this.live || Math.abs(level - this.target) < 1e-4) {
447
+ return;
448
+ }
449
+ this.target = level;
450
+ const t = this.ctx.currentTime;
451
+ this.fade.gain.setTargetAtTime(level, t, FADE);
452
+ this.wetFade.gain.setTargetAtTime(level, t, FADE);
453
+ this.crowdFade.gain.setTargetAtTime(level, t, FADE);
454
+ this.crowdWetFade.gain.setTargetAtTime(level, t, FADE);
455
+ }
456
+
457
+ // Sends a node into the scape, directly and into the echo.
458
+ send(node, dry = 1, wet = 0.3, channel = this.name === 'stadium' ? 'crowd' : 'ambience') {
459
+ const out = this.sound.voices.tap(this.ctx, node);
460
+ if (dry > 0) {
461
+ out.connect(makeGain(this.ctx, dry)).connect(channel === 'crowd' ? this.crowdFade : this.fade);
462
+ }
463
+ if (wet > 0) {
464
+ out.connect(makeGain(this.ctx, wet)).connect(channel === 'crowd' ? this.crowdWetFade : this.wetFade);
465
+ }
466
+ return node;
467
+ }
468
+
469
+ // A looping buffer from a random point, kept until the scape ends.
470
+ loop(buffer, rate = 1) {
471
+ const src = this.ctx.createBufferSource();
472
+ src.buffer = buffer;
473
+ src.loop = true;
474
+ src.playbackRate.value = rate;
475
+ src.start(0, Math.random() * Math.max(0.01, buffer.duration - 0.01));
476
+ return this.keep(src);
477
+ }
478
+
479
+ keep(node) {
480
+ this.sources.push(node);
481
+ return node;
482
+ }
483
+
484
+ later(fn, ms) {
485
+ fn = this.sound.voices.later(fn);
486
+ const id = setTimeout(() => {
487
+ this.timers.delete(id);
488
+ if (this.live) {
489
+ fn();
490
+ }
491
+ }, ms);
492
+ this.timers.add(id);
493
+ }
494
+
495
+ // Runs a sound now and then while the scape is current, delay gives the next wait in
496
+ // milliseconds.
497
+ every(fn, delay, first = null) {
498
+ const run = () => {
499
+ if (this.current) {
500
+ fn();
501
+ }
502
+ this.later(run, delay());
503
+ };
504
+ this.later(run, first === null ? delay() : first);
505
+ }
506
+
507
+ dispose() {
508
+ this.live = false;
509
+ for (const id of this.timers) {
510
+ clearTimeout(id);
511
+ }
512
+ this.timers.clear();
513
+ for (const src of this.sources) {
514
+ try {
515
+ src.stop();
516
+ } catch {
517
+ // A source already stopped has nothing to stop.
518
+ }
519
+ }
520
+ this.sources = [];
521
+ this.fade.disconnect();
522
+ this.wetFade.disconnect();
523
+ this.crowdFade.disconnect();
524
+ this.crowdWetFade.disconnect();
525
+ }
526
+ }
527
+
528
+ export class Sound {
529
+ constructor(base = 'assets/sounds/') {
530
+ this.base = base;
531
+ this.ctx = null;
532
+ this.volume = 0.5;
533
+ this.levels = { crowd: 1, ambience: 1, effects: 1, music: 0.8 };
534
+ this.muted = true;
535
+ this.intensity = 0.3;
536
+ this.ready = false;
537
+ // Whether the match is playing on a visible page. A paused or hidden match fades out and
538
+ // the context suspends, so nothing keeps sounding.
539
+ this.active = true;
540
+ this.files = new Map();
541
+ this.teams = [null, null];
542
+ this.teamBuffers = [new Map(), new Map()];
543
+ this.lastPlayed = [new Map(), new Map()];
544
+ this.ambience = new Map();
545
+ this.ambienceWanted = new Map();
546
+ // The arena shown and how far into the night it is, from 0 by day to 1 at night.
547
+ this.arena = 'stadium';
548
+ this.night = 0;
549
+ this.scapes = new Map();
550
+ this.weather = { rain: null, wind: null };
551
+ this.lastOoh = -Infinity;
552
+ // The one-shot sounds of the play, each event a voice that a seek fades out.
553
+ this.voices = new Voices();
554
+ }
555
+
556
+ // Fades every one-shot sound out at once, for a seek, a restart or another match. The beds of
557
+ // the arena, the crowd's murmur and the weather play on.
558
+ stopEvents() {
559
+ if (this.ctx) {
560
+ this.voices.stopAll(this.ctx);
561
+ }
562
+ }
563
+
564
+ setActive(on) {
565
+ on = !!on;
566
+ if (on === this.active) {
567
+ return;
568
+ }
569
+ this.active = on;
570
+ if (!this.ctx) {
571
+ return;
572
+ }
573
+ clearTimeout(this.suspendTimer);
574
+ if (on) {
575
+ this.ctx.resume().catch(() => {});
576
+ this.applyGain();
577
+ } else {
578
+ this.master.gain.setTargetAtTime(0, this.ctx.currentTime, 0.06);
579
+ this.suspendTimer = setTimeout(() => {
580
+ if (!this.active && this.ctx.state === 'running') {
581
+ this.ctx.suspend().catch(() => {});
582
+ }
583
+ }, 400);
584
+ }
585
+ }
586
+
587
+ // Whether a sound may start now, the page must be unmuted, visible and playing.
588
+ get audible() {
589
+ return this.ready && !this.muted && this.active;
590
+ }
591
+
592
+ // Creates the audio graph, must run inside a user gesture handler. A context may be handed
593
+ // in instead, an offline one renders the sound for a test without playing it.
594
+ unlock(context = null) {
595
+ if (this.ctx) {
596
+ if (this.ctx.state === 'suspended' && this.active) {
597
+ this.ctx.resume();
598
+ }
599
+ return;
600
+ }
601
+ const AudioCtx = window.AudioContext || window.webkitAudioContext;
602
+ if (!context && !AudioCtx) {
603
+ return;
604
+ }
605
+ const ctx = context || new AudioCtx();
606
+ this.ctx = ctx;
607
+ this.master = ctx.createGain();
608
+ this.master.gain.value = this.muted || !this.active ? 0 : this.volume;
609
+ const comp = ctx.createDynamicsCompressor();
610
+ comp.threshold.value = -14;
611
+ comp.ratio.value = 3;
612
+ this.master.connect(comp).connect(ctx.destination);
613
+ // Team sound puts a true peak limiter between the compressor and the device.
614
+ this.comp = comp;
615
+ // The crowd and its echo share a bus, the echo comes back quieter than the direct sound.
616
+ this.reverb = ctx.createConvolver();
617
+ this.reverb.buffer = stadiumImpulse(ctx);
618
+ this.reverbReturn = ctx.createGain();
619
+ this.reverbReturn.gain.value = ARENA_ECHO[this.arena];
620
+ this.reverb.connect(this.reverbReturn).connect(this.master);
621
+ this.mix = {};
622
+ for (const [name, level] of Object.entries(this.levels)) {
623
+ const dry = makeGain(ctx, level), wet = makeGain(ctx, level);
624
+ dry.connect(this.master);
625
+ wet.connect(this.reverb);
626
+ this.mix[name] = { dry, wet };
627
+ }
628
+ this.pink = noiseBuffer(ctx, 4, 'pink');
629
+ this.brown = noiseBuffer(ctx, 6, 'brown');
630
+ this.white = noiseBuffer(ctx, 2, 'white');
631
+ this.scapes.set('stadium', new Scape(this, 'stadium'));
632
+ this.buildCrowd();
633
+ this.ready = true;
634
+ this.loadFilesWhenHeard();
635
+ this.loadTeamSounds();
636
+ this.babbleReady = renderBabble(ctx.sampleRate).then((babble) => {
637
+ if (babble && !this.files.has('crowd-loop')) {
638
+ this.useCrowdBuffer(babble, false);
639
+ }
640
+ }).catch((err) => console.warn('crowd murmur not rendered', err));
641
+ this.scheduleCrowd();
642
+ this.applyArena();
643
+ this.updateWeather();
644
+ }
645
+
646
+ // Sends a node to the master directly and to the stadium echo.
647
+ route(node, dry = 1, wet = 0.4, channel = 'effects') {
648
+ node = this.voices.tap(this.ctx, node);
649
+ const d = this.ctx.createGain();
650
+ d.gain.value = dry;
651
+ node.connect(d).connect(this.mix[channel].dry);
652
+ if (wet > 0) {
653
+ const w = this.ctx.createGain();
654
+ w.gain.value = wet;
655
+ node.connect(w).connect(this.mix[channel].wet);
656
+ }
657
+ }
658
+
659
+ async loadFiles() {
660
+ let names = [];
661
+ try {
662
+ const res = await fetch(`${this.base}sounds.json`, { cache: 'no-cache' });
663
+ if (res.ok) {
664
+ const list = await res.json();
665
+ names = Array.isArray(list.files) ? list.files.filter((f) => typeof f === 'string') : [];
666
+ }
667
+ } catch {
668
+ // Without a list, every sound stays synthesized.
669
+ }
670
+ await Promise.all(names.map(async (file) => {
671
+ const name = file.replace(/\.[a-z0-9]+$/i, '');
672
+ if (!SOUND_FILES.includes(name) || /[/\\]/.test(file)) {
673
+ return;
674
+ }
675
+ try {
676
+ const res = await fetch(this.base + file);
677
+ if (!res.ok) {
678
+ return;
679
+ }
680
+ const decoded = await this.ctx.decodeAudioData(await res.arrayBuffer());
681
+ this.files.set(name, levelBuffer(decoded, FILE_LEVELS[name] || null));
682
+ } catch (err) {
683
+ console.warn('sound file not used', file, err);
684
+ }
685
+ }));
686
+ if (this.files.has('crowd-loop')) {
687
+ this.useCrowdBuffer(this.files.get('crowd-loop'), true);
688
+ }
689
+ for (const [name, level] of this.ambienceWanted) {
690
+ this.setAmbience(name, level);
691
+ }
692
+ this.updateBeds();
693
+ this.updateWeather();
694
+ }
695
+
696
+ // A distant crowd. Two copies of a murmur run apart in time, one darker for a calm crowd and
697
+ // one brighter that swells with excitement, over a low rumble. Shouts and claps rise from it
698
+ // now and then, and all of it reaches the listener through the echo of the stands.
699
+ buildCrowd() {
700
+ const ctx = this.ctx;
701
+ this.crowdBus = ctx.createGain();
702
+ this.crowdBus.gain.value = 1;
703
+ this.scapes.get('stadium').send(this.crowdBus, 0.8, 0.5);
704
+ const layer = (buffer, low, high, gain, rate = 1) => {
705
+ const src = ctx.createBufferSource();
706
+ src.buffer = buffer;
707
+ src.loop = true;
708
+ src.playbackRate.value = rate;
709
+ const highpass = ctx.createBiquadFilter();
710
+ highpass.type = 'highpass';
711
+ highpass.frequency.value = high;
712
+ const filter = ctx.createBiquadFilter();
713
+ filter.type = 'lowpass';
714
+ filter.frequency.value = low;
715
+ filter.Q.value = 0.5;
716
+ const amp = ctx.createGain();
717
+ amp.gain.value = gain;
718
+ const swell = ctx.createGain();
719
+ swell.gain.value = 1;
720
+ src.connect(highpass).connect(filter).connect(amp).connect(swell).connect(this.crowdBus);
721
+ src.start(0, Math.random() * Math.max(0.1, buffer.duration - 0.1));
722
+ return { src, filter, amp, swell, highpass };
723
+ };
724
+ this.rumble = layer(this.brown, 380, 60, 0.025);
725
+ this.murmur = layer(this.brown, 520, 70, 0.04);
726
+ this.roar = layer(this.pink, 700, 160, 0);
727
+ this.setIntensity(this.intensity);
728
+ }
729
+
730
+ // Puts a murmur, rendered or from a file, in place of the noise layers.
731
+ useCrowdBuffer(buffer, fromFile) {
732
+ const ctx = this.ctx;
733
+ const swap = (layer, rate, offset) => {
734
+ const src = ctx.createBufferSource();
735
+ src.buffer = buffer;
736
+ src.loop = true;
737
+ src.playbackRate.value = rate;
738
+ src.connect(layer.highpass);
739
+ const t = ctx.currentTime;
740
+ src.start(t, offset % buffer.duration);
741
+ try {
742
+ layer.src.stop(t + 0.05);
743
+ } catch {
744
+ // A layer never started has nothing to stop.
745
+ }
746
+ layer.src = src;
747
+ };
748
+ swap(this.murmur, 1, Math.random() * buffer.duration);
749
+ swap(this.roar, fromFile ? 1 : 1.12, Math.random() * buffer.duration);
750
+ this.murmur.filter.frequency.value = fromFile ? 6000 : 1500;
751
+ this.murmur.highpass.frequency.value = fromFile ? 30 : 140;
752
+ this.roar.highpass.frequency.value = fromFile ? 120 : 220;
753
+ this.crowdReal = true;
754
+ this.setIntensity(this.intensity);
755
+ }
756
+
757
+ // A murmur of a few dozen people, rendered once for the crowds of the other arenas while their
758
+ // recordings load or when they are absent.
759
+ smallBabble() {
760
+ if (!this.smallBabbleReady) {
761
+ this.smallBabbleReady = renderBabble(this.ctx.sampleRate, 8, 9).catch((err) => {
762
+ console.warn('small crowd not rendered', err);
763
+ return null;
764
+ });
765
+ }
766
+ return this.smallBabbleReady;
767
+ }
768
+
769
+ // Slow random swells of the murmur and the shouts and claps that rise from the crowd, each
770
+ // timer reschedules itself and plays nothing while the page is silent or the stadium is
771
+ // elsewhere.
772
+ scheduleCrowd() {
773
+ const here = () => this.audible && this.arena === 'stadium';
774
+ const swell = () => {
775
+ if (here()) {
776
+ const t = this.ctx.currentTime;
777
+ this.murmur.swell.gain.setTargetAtTime(0.7 + Math.random() * 0.6, t, 1.6);
778
+ this.roar.swell.gain.setTargetAtTime(0.6 + Math.random() * 0.8, t, 1.2);
779
+ }
780
+ this.swellTimer = setTimeout(swell, 1500 + Math.random() * 3000);
781
+ };
782
+ const shout = () => {
783
+ const x = this.intensity;
784
+ if (here() && !this.files.has('crowd-loop')) {
785
+ const count = Math.random() < 0.25 + x * 0.4 ? 2 + Math.floor(Math.random() * 3) : 1;
786
+ for (let i = 0; i < count; i++) {
787
+ this.shout(this.ctx.currentTime + Math.random() * 0.35, 0.5 + x * 0.6);
788
+ }
789
+ }
790
+ this.shoutTimer = setTimeout(shout, (2000 + Math.random() * 6000) / (0.6 + x));
791
+ };
792
+ const claps = () => {
793
+ const x = this.intensity;
794
+ if (here() && !this.files.has('crowd-loop') && Math.random() < 0.35 + x * 0.5) {
795
+ this.claps(4 + Math.floor(Math.random() * (10 + x * 25)), 0.4 + x * 0.6);
796
+ }
797
+ this.clapTimer = setTimeout(claps, 3500 + Math.random() * 7000);
798
+ };
799
+ // Fans chant in every arena, softer in the smaller ones.
800
+ const chant = () => {
801
+ if (this.audible && FAN_CHANT[this.arena]) {
802
+ const side = Math.random() < 0.5 ? 0 : 1;
803
+ this.teamSound(side, 'chant', null, true) || this.teamSound(1 - side, 'chant', null, true);
804
+ }
805
+ this.chantTimer = setTimeout(chant, 45000 + Math.random() * 75000);
806
+ };
807
+ swell();
808
+ this.shoutTimer = setTimeout(shout, 1500);
809
+ this.clapTimer = setTimeout(claps, 3000);
810
+ this.chantTimer = setTimeout(chant, 30000 + Math.random() * 30000);
811
+ }
812
+
813
+ // Where the stadium crowd's voices go, mostly into the echo of the stands.
814
+ stadiumPlace(dry, wet) {
815
+ return { scape: this.scapes.get('stadium'), dry, wet };
816
+ }
817
+
818
+ // One distant voice calling out, a buzzing tone through two vowel resonances, mostly heard
819
+ // through the echo of the stands, or in the scape and with the length a place gives. A
820
+ // rising voice is a cheer.
821
+ shout(t, level = 1, place = null) {
822
+ const p = place || this.stadiumPlace(0.25, 0.9);
823
+ const ctx = this.ctx;
824
+ const vowels = [[750, 1200], [500, 900], [450, 1900], [650, 1100], [350, 2200]];
825
+ const [f1, f2] = vowels[Math.floor(Math.random() * vowels.length)];
826
+ const f0 = 140 + Math.random() * 180;
827
+ const length = p.length ? p.length * (0.7 + Math.random() * 0.6) : 0.25 + Math.random() * 0.55;
828
+ const osc = ctx.createOscillator();
829
+ osc.type = 'sawtooth';
830
+ osc.frequency.setValueAtTime(f0, t);
831
+ const end = p.rise ? f0 * (1.1 + Math.random() * 0.3) : f0 * (0.85 + Math.random() * 0.35);
832
+ osc.frequency.linearRampToValueAtTime(end, t + length);
833
+ const out = ctx.createGain();
834
+ out.gain.setValueAtTime(0.0001, t);
835
+ out.gain.exponentialRampToValueAtTime(0.006 * level, t + 0.05);
836
+ out.gain.exponentialRampToValueAtTime(0.0001, t + length);
837
+ const pan = ctx.createStereoPanner ? ctx.createStereoPanner() : null;
838
+ for (const [f, q, g] of [[f1, 6, 1], [f2, 8, 0.6]]) {
839
+ const band = ctx.createBiquadFilter();
840
+ band.type = 'bandpass';
841
+ band.frequency.value = f;
842
+ band.Q.value = q;
843
+ const bg = ctx.createGain();
844
+ bg.gain.value = g;
845
+ osc.connect(band).connect(bg).connect(out);
846
+ }
847
+ const dark = ctx.createBiquadFilter();
848
+ dark.type = 'lowpass';
849
+ dark.frequency.value = 1800;
850
+ out.connect(dark);
851
+ if (pan) {
852
+ pan.pan.value = Math.random() * 1.6 - 0.8;
853
+ dark.connect(pan);
854
+ p.scape.send(pan, p.dry, p.wet, 'crowd');
855
+ } else {
856
+ p.scape.send(dark, p.dry, p.wet, 'crowd');
857
+ }
858
+ osc.start(t);
859
+ osc.stop(t + length + 0.05);
860
+ }
861
+
862
+ // A ripple of hand claps, short bands of noise spread over a second or so.
863
+ claps(count, level = 1, place = null) {
864
+ const p = place || this.stadiumPlace(0.3, 0.9);
865
+ const ctx = this.ctx;
866
+ const t0 = ctx.currentTime + 0.02;
867
+ const span = 0.4 + count * 0.05;
868
+ const bus = ctx.createGain();
869
+ bus.gain.value = 0.012 * level;
870
+ const band = ctx.createBiquadFilter();
871
+ band.type = 'bandpass';
872
+ band.frequency.value = p.band || 1300 + Math.random() * 700;
873
+ band.Q.value = 0.9;
874
+ bus.connect(band);
875
+ p.scape.send(band, p.dry, p.wet, 'crowd');
876
+ for (let i = 0; i < count; i++) {
877
+ const t = t0 + Math.random() * span;
878
+ const src = ctx.createBufferSource();
879
+ src.buffer = this.white;
880
+ const g = ctx.createGain();
881
+ g.gain.setValueAtTime(0.0001, t);
882
+ g.gain.exponentialRampToValueAtTime(0.5 + Math.random() * 0.5, t + 0.002);
883
+ g.gain.exponentialRampToValueAtTime(0.0001, t + 0.03);
884
+ src.connect(g).connect(bus);
885
+ src.start(t, Math.random() * 1.5);
886
+ src.stop(t + 0.04);
887
+ }
888
+ }
889
+
890
+ setVolume(v) {
891
+ this.volume = v;
892
+ this.applyGain();
893
+ }
894
+
895
+ setChannel(name, value) {
896
+ if (!(name in this.levels) || !Number.isFinite(value)) return;
897
+ this.levels[name] = Math.max(0, Math.min(1, value));
898
+ for (const gain of Object.values(this.mix?.[name] || {})) {
899
+ gain.gain.setTargetAtTime(this.levels[name], this.ctx.currentTime, 0.05);
900
+ }
901
+ }
902
+
903
+ setMuted(m) {
904
+ this.muted = m;
905
+ this.applyGain();
906
+ this.loadFilesWhenHeard();
907
+ }
908
+
909
+ // The sound files load the first time the page may be heard, so a muted page fetches none.
910
+ loadFilesWhenHeard() {
911
+ if (this.ready && !this.muted && !this.filesReady) {
912
+ this.filesReady = this.loadFiles();
913
+ }
914
+ }
915
+
916
+ applyGain() {
917
+ if (this.master) {
918
+ this.master.gain.setTargetAtTime(this.muted || !this.active ? 0 : this.volume, this.ctx.currentTime, 0.05);
919
+ }
920
+ }
921
+
922
+ // Moves the listener with the camera, so a sound placed on the pitch comes from its place.
923
+ setListener(camera) {
924
+ if (!this.ready) {
925
+ return;
926
+ }
927
+ const l = this.ctx.listener;
928
+ const p = camera.position;
929
+ const f = camera.getWorldDirection(this._dir || (this._dir = camera.position.clone()));
930
+ const t = this.ctx.currentTime;
931
+ if (l.positionX) {
932
+ l.positionX.setTargetAtTime(p.x, t, 0.05);
933
+ l.positionY.setTargetAtTime(p.y, t, 0.05);
934
+ l.positionZ.setTargetAtTime(p.z, t, 0.05);
935
+ l.forwardX.setTargetAtTime(f.x, t, 0.05);
936
+ l.forwardY.setTargetAtTime(f.y, t, 0.05);
937
+ l.forwardZ.setTargetAtTime(f.z, t, 0.05);
938
+ l.upX.value = 0;
939
+ l.upY.value = 1;
940
+ l.upZ.value = 0;
941
+ } else {
942
+ l.setPosition(p.x, p.y, p.z);
943
+ l.setOrientation(f.x, f.y, f.z, 0, 1, 0);
944
+ }
945
+ }
946
+
947
+ // Excitement from 0 to 1, eased in the audio thread. The levels stay soft, the crowd is a
948
+ // bed under the match and never louder than the effects. Each arena answers in its own way.
949
+ setIntensity(x) {
950
+ this.intensity = x;
951
+ if (!this.ready || !this.murmur) {
952
+ return;
953
+ }
954
+ const t = this.ctx.currentTime;
955
+ const real = this.crowdReal;
956
+ this.rumble.amp.gain.setTargetAtTime(real ? 0.012 : 0.022 + x * 0.01, t, 0.8);
957
+ this.murmur.amp.gain.setTargetAtTime((real ? 0.05 : 0.045) * (1 - x * 0.35) + 0.01, t, 0.8);
958
+ this.roar.amp.gain.setTargetAtTime(x * x * (real ? 0.07 : 0.06), t, 0.6);
959
+ this.roar.filter.frequency.setTargetAtTime((real ? 2200 : 900) + x * 900, t, 0.8);
960
+ for (const s of this.scapes.values()) {
961
+ if (s.intensity && Math.abs(x - s.x) > 0.01) {
962
+ s.x = x;
963
+ s.intensity(x);
964
+ }
965
+ }
966
+ }
967
+
968
+ // The arena shown and the daylight there, from 0 at night to 1 by day. The soundscape of
969
+ // the arena fades in while the last one fades out.
970
+ setSurroundings(arena, day = 1) {
971
+ this.arena = SOUND_ARENAS.includes(arena) ? arena : 'stadium';
972
+ this.night = Math.max(0, Math.min(1, 1 - (Number.isFinite(day) ? day : 1)));
973
+ if (this.ctx) {
974
+ this.applyArena();
975
+ this.updateWeather();
976
+ }
977
+ }
978
+
979
+ applyArena() {
980
+ const name = this.arena;
981
+ if (!this.scapes.has(name)) {
982
+ this.scapes.set(name, this.buildScape(name));
983
+ }
984
+ for (const [n, s] of this.scapes) {
985
+ const on = n === name;
986
+ s.setLevel(on ? ARENA_TRIM[n] : 0);
987
+ if (s.setNight) {
988
+ s.setNight(this.night);
989
+ }
990
+ if (on) {
991
+ clearTimeout(s.retireTimer);
992
+ } else if (n !== 'stadium') {
993
+ this.retire(s, () => this.arena === n, () => this.scapes.delete(n));
994
+ }
995
+ }
996
+ this.reverbReturn.gain.setTargetAtTime(ARENA_ECHO[name], this.ctx.currentTime, FADE);
997
+ this.updateBeds();
998
+ }
999
+
1000
+ // Ends a scape once it is no longer wanted and has faded to silence.
1001
+ retire(s, wanted, done) {
1002
+ clearTimeout(s.retireTimer);
1003
+ const check = () => {
1004
+ if (wanted() || !s.live) {
1005
+ return;
1006
+ }
1007
+ if (s.fade.gain.value > 0.002) {
1008
+ s.retireTimer = setTimeout(check, 1000);
1009
+ return;
1010
+ }
1011
+ s.dispose();
1012
+ done();
1013
+ };
1014
+ s.retireTimer = setTimeout(check, 3000);
1015
+ }
1016
+
1017
+ // A file for an arena's steady bed, when present, plays in place of the synthesized one.
1018
+ updateBeds() {
1019
+ if (!this.ctx) {
1020
+ return;
1021
+ }
1022
+ const t = this.ctx.currentTime;
1023
+ for (const s of this.scapes.values()) {
1024
+ if (s.crowdFile) {
1025
+ s.crowdFile();
1026
+ }
1027
+ let replaced = s.bedFile ? this.files.has(`${s.bedFile}-loop`) : false;
1028
+ // Loops of an arena's own, each in place of one synthesized part, unless a file
1029
+ // of the bed already plays over it.
1030
+ for (const fb of s.fileBeds || []) {
1031
+ const buffer = replaced ? null : this.files.get(fb.name);
1032
+ if (buffer && !fb.src) {
1033
+ fb.src = s.loop(buffer, 1);
1034
+ fb.src.connect(makeGain(this.ctx, ARENA_FILE_GAIN[fb.name] || 0.05)).connect(fb.out);
1035
+ }
1036
+ if (fb.mute === s.bed) {
1037
+ replaced = replaced || !!buffer;
1038
+ } else {
1039
+ fb.mute.gain.setTargetAtTime(buffer ? 0 : 1, t, FADE);
1040
+ }
1041
+ }
1042
+ if (s.bed) {
1043
+ s.bed.gain.setTargetAtTime(replaced ? 0 : 1, t, FADE);
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ // Plays an arena's file once into its scape, and returns whether there was one.
1049
+ arenaFile(s, name, dry, wet, { rate = 1, pan = 0, gain = 1 } = {}) {
1050
+ const buffer = this.files.get(name);
1051
+ if (!buffer) {
1052
+ return false;
1053
+ }
1054
+ const src = this.ctx.createBufferSource();
1055
+ src.buffer = buffer;
1056
+ src.playbackRate.value = rate;
1057
+ const p = makePan(this.ctx, pan);
1058
+ src.connect(makeGain(this.ctx, (ARENA_FILE_GAIN[name] || 0.03) * gain)).connect(p);
1059
+ s.send(p, dry, wet);
1060
+ src.start();
1061
+ return true;
1062
+ }
1063
+
1064
+ buildScape(name) {
1065
+ const s = new Scape(this, name);
1066
+ if (name === 'beach') {
1067
+ this.buildBeach(s);
1068
+ } else if (name === 'rooftop') {
1069
+ this.buildRooftop(s);
1070
+ } else if (name === 'space') {
1071
+ this.buildSpace(s);
1072
+ } else if (name === 'neon') {
1073
+ this.buildNeon(s);
1074
+ }
1075
+ if (s.intensity) {
1076
+ s.intensity(this.intensity);
1077
+ }
1078
+ return s;
1079
+ }
1080
+
1081
+ // The crowd of an arena other than the stadium, the stadium's recordings heard through the
1082
+ // band, the echo and the walls of the place, smaller and softer than the stadium crowd. The
1083
+ // murmur swells with the play and a second, brighter copy roars as play nears a goal. Until
1084
+ // the files load, or without them, a murmur of a few dozen people rendered once stands in,
1085
+ // with a shout or a few claps now and then.
1086
+ arenaCrowd(s, name) {
1087
+ const ctx = this.ctx;
1088
+ const c = ARENA_CROWD[name];
1089
+ s.crowd = makeGain(ctx, 0);
1090
+ s.cheer = makeGain(ctx, 1);
1091
+ const input = makeFilter(ctx, 'highpass', c.high, 0.5);
1092
+ const band = makeFilter(ctx, 'lowpass', c.low, 0.5);
1093
+ input.connect(band);
1094
+ const out = makeGain(ctx, 1);
1095
+ band.connect(out);
1096
+ if (c.slap > 0) {
1097
+ // A short echo off the walls around, darker with each return.
1098
+ const delay = ctx.createDelay(1);
1099
+ delay.delayTime.value = c.slap;
1100
+ const back = makeFilter(ctx, 'lowpass', 2400, 0.5);
1101
+ const feedback = makeGain(ctx, 0.28);
1102
+ band.connect(delay).connect(back).connect(feedback).connect(delay);
1103
+ back.connect(makeGain(ctx, 0.45)).connect(out);
1104
+ }
1105
+ s.send(out, c.dry, c.wet, 'crowd');
1106
+ // The crowd's own sound, before the place, which an offline render can measure alone.
1107
+ s.crowdOut = out;
1108
+ s.crowd.connect(s.cheer).connect(input);
1109
+ const babble = makeGain(ctx, 0.06 * c.level);
1110
+ babble.connect(s.crowd);
1111
+ this.smallBabble().then((buffer) => {
1112
+ if (buffer && s.live && !s.murmur) {
1113
+ s.loop(buffer, 1).connect(babble);
1114
+ }
1115
+ });
1116
+ // The recordings take over from the rendered murmur once they load.
1117
+ s.crowdFile = () => {
1118
+ const buffer = this.files.get('crowd-loop');
1119
+ if (!buffer || s.murmur) {
1120
+ return;
1121
+ }
1122
+ babble.gain.setTargetAtTime(0, ctx.currentTime, FADE);
1123
+ s.murmur = makeGain(ctx, 0.055 * c.level);
1124
+ s.loop(buffer, 1).connect(s.murmur).connect(s.crowd);
1125
+ s.roar = makeGain(ctx, 0);
1126
+ s.loop(buffer, 1).connect(makeFilter(ctx, 'highpass', 300, 0.5)).connect(s.roar).connect(s.crowd);
1127
+ s.crowdIntensity(this.intensity);
1128
+ };
1129
+ s.crowdIntensity = (x) => {
1130
+ const t = ctx.currentTime;
1131
+ s.crowd.gain.setTargetAtTime(0.75 + 0.5 * x, t, 0.8);
1132
+ if (s.roar) {
1133
+ s.roar.gain.setTargetAtTime(x * x * 0.06 * c.level, t, 0.6);
1134
+ }
1135
+ };
1136
+ const place = { scape: s, dry: c.dry * 0.8, wet: c.wet * 1.6 + 0.1 };
1137
+ const synthesized = () => s.current && !this.files.has('crowd-loop');
1138
+ s.every(() => {
1139
+ if (synthesized()) {
1140
+ const x = this.intensity;
1141
+ this.shout(ctx.currentTime + Math.random() * 0.3, (0.35 + x * 0.5) * 0.8, place);
1142
+ }
1143
+ }, () => (4000 + Math.random() * 10000) / (0.6 + this.intensity), 2500);
1144
+ s.every(() => {
1145
+ const x = this.intensity;
1146
+ if (synthesized() && Math.random() < 0.2 + x * 0.4) {
1147
+ this.claps(3 + Math.floor(Math.random() * (4 + x * 8)), 0.35 + x * 0.4, place);
1148
+ }
1149
+ }, () => 6000 + Math.random() * 9000, 5000);
1150
+ // A reaction from the stadium's recordings, at the stadium's gain times the arena's.
1151
+ const react = (file, gain) => {
1152
+ const buffer = this.files.get(file);
1153
+ if (!buffer) {
1154
+ return false;
1155
+ }
1156
+ const src = ctx.createBufferSource();
1157
+ src.buffer = buffer;
1158
+ src.connect(makeGain(ctx, gain * c.react)).connect(input);
1159
+ src.start();
1160
+ return true;
1161
+ };
1162
+ s.goal = (own) => {
1163
+ const t = ctx.currentTime;
1164
+ const k = own ? 0.7 : 1;
1165
+ s.cheer.gain.setTargetAtTime(1.6, t + 0.1, 0.4);
1166
+ s.cheer.gain.setTargetAtTime(1, t + 3, 1.5);
1167
+ if (react('crowd-cheer', own ? 0.35 : 0.55)) {
1168
+ return;
1169
+ }
1170
+ const count = 6 + Math.floor(Math.random() * 5);
1171
+ for (let i = 0; i < count; i++) {
1172
+ this.shout(t + 0.1 + Math.random() * 2.2, (1.1 + Math.random() * 0.6) * 0.8 * k, { ...place, length: 0.75, rise: true });
1173
+ }
1174
+ s.later(() => this.audible && this.claps(16 + Math.floor(Math.random() * 8), 0.9 * k, place), 700);
1175
+ s.later(() => this.audible && this.claps(10, 0.7 * k, place), 2200);
1176
+ };
1177
+ s.applause = () => {
1178
+ if (react('applause', 0.45)) {
1179
+ return;
1180
+ }
1181
+ this.claps(28, 1, place);
1182
+ s.later(() => this.audible && this.claps(20, 0.8, place), 900);
1183
+ };
1184
+ s.ooh = () => {
1185
+ if (react('crowd-ooh', 0.45)) {
1186
+ return;
1187
+ }
1188
+ const t = ctx.currentTime;
1189
+ for (let i = 0; i < 3; i++) {
1190
+ this.shout(t + Math.random() * 0.25, 0.9, place);
1191
+ }
1192
+ };
1193
+ }
1194
+
1195
+ // The beach. The wash of the sea, waves that break and run back up the sand at irregular
1196
+ // times, a light breeze, gulls now and then by day, and a crowd on the sand.
1197
+ buildBeach(s) {
1198
+ const ctx = this.ctx;
1199
+ s.bed = makeGain(ctx, 1);
1200
+ s.bedFile = 'waves';
1201
+ s.send(s.bed, 1, 0.08);
1202
+ s.loop(this.brown, 0.85).connect(makeFilter(ctx, 'lowpass', 360, 0.5)).connect(makeGain(ctx, 0.035)).connect(s.bed);
1203
+ s.loop(this.pink, 0.9).connect(makeFilter(ctx, 'highpass', 1200, 0.5)).connect(makeFilter(ctx, 'lowpass', 3400, 0.5))
1204
+ .connect(makeGain(ctx, 0.003)).connect(s.bed);
1205
+ s.every(() => this.wave(s), () => 4500 + Math.random() * 7500, 600);
1206
+ // The sea breeze, a soft band of noise that rises and falls.
1207
+ const breeze = makeGain(ctx, 0.008);
1208
+ const breezeBand = makeFilter(ctx, 'bandpass', 520, 0.5);
1209
+ s.loop(this.pink, 0.7).connect(breezeBand).connect(breeze);
1210
+ s.send(breeze, 1, 0);
1211
+ s.every(() => {
1212
+ const t = ctx.currentTime;
1213
+ breeze.gain.setTargetAtTime(0.004 + Math.random() * 0.01, t, 1.2);
1214
+ breezeBand.frequency.setTargetAtTime(380 + Math.random() * 360, t, 1.5);
1215
+ }, () => 2000 + Math.random() * 4000, 0);
1216
+ s.every(() => this.gull(s), () => (14000 + Math.random() * 30000) * (1 + this.night * 4), 6000);
1217
+ this.arenaCrowd(s, 'beach');
1218
+ s.intensity = (x) => s.crowdIntensity(x);
1219
+ }
1220
+
1221
+ // One wave, a low roar that brightens as it curls and breaks, then the fizz of foam running
1222
+ // back down the sand.
1223
+ wave(s) {
1224
+ const sampled = BEACH_WAVES.filter((n) => this.files.has(n));
1225
+ if (sampled.length) {
1226
+ const name = sampled[Math.floor(Math.random() * sampled.length)];
1227
+ if (this.arenaFile(s, name, 0.8, 0.3, { rate: 0.92 + Math.random() * 0.16, pan: Math.random() * 1.2 - 0.6 })) {
1228
+ return;
1229
+ }
1230
+ }
1231
+ const ctx = this.ctx;
1232
+ const t = ctx.currentTime + 0.05;
1233
+ const size = 0.45 + Math.random() * 0.55;
1234
+ const rise = 1.3 + Math.random() * 1.4;
1235
+ const crash = t + rise;
1236
+ const tail = 2.6 + Math.random() * 2.8;
1237
+ const end = crash + tail * 2.2;
1238
+ const pan = makePan(ctx, Math.random() * 1.2 - 0.6);
1239
+ pan.connect(s.bed);
1240
+ const body = noiseShot(ctx, this.brown, t, end, 0.9 + Math.random() * 0.2);
1241
+ const low = makeFilter(ctx, 'lowpass', 220, 0.6);
1242
+ low.frequency.setValueAtTime(220, t);
1243
+ low.frequency.exponentialRampToValueAtTime(480 + 520 * size, crash);
1244
+ low.frequency.setTargetAtTime(230, crash, tail * 0.45);
1245
+ const bodyGain = ctx.createGain();
1246
+ bodyGain.gain.setValueAtTime(0.0001, t);
1247
+ bodyGain.gain.exponentialRampToValueAtTime(0.07 * size, crash);
1248
+ bodyGain.gain.setTargetAtTime(0.0001, crash + 0.15, tail * 0.4);
1249
+ body.connect(low).connect(bodyGain).connect(pan);
1250
+ const foam = noiseShot(ctx, this.pink, crash - 0.2, end, 1);
1251
+ const band = makeFilter(ctx, 'bandpass', 2400, 0.6);
1252
+ band.frequency.setValueAtTime(2800, crash);
1253
+ band.frequency.setTargetAtTime(1800, crash, tail * 0.5);
1254
+ const foamGain = ctx.createGain();
1255
+ foamGain.gain.setValueAtTime(0.0001, crash - 0.2);
1256
+ foamGain.gain.exponentialRampToValueAtTime(0.01 * size, crash + 0.35);
1257
+ foamGain.gain.setTargetAtTime(0.0001, crash + 0.6, tail * 0.45);
1258
+ foam.connect(band).connect(makeFilter(ctx, 'lowpass', 4500, 0.5)).connect(foamGain).connect(pan);
1259
+ }
1260
+
1261
+ // A gull far off, a short series of falling calls with a little rasp.
1262
+ gull(s) {
1263
+ if (this.arenaFile(s, 'gulls', 0.7, 0.3, { rate: 0.9 + Math.random() * 0.2, pan: Math.random() * 1.6 - 0.8 })) {
1264
+ return;
1265
+ }
1266
+ const ctx = this.ctx;
1267
+ const calls = 1 + Math.floor(Math.random() * 4);
1268
+ const base = 0.85 + Math.random() * 0.3;
1269
+ const level = 0.4 + Math.random() * 0.6;
1270
+ const pan = makePan(ctx, Math.random() * 1.6 - 0.8);
1271
+ const band = makeFilter(ctx, 'bandpass', 1700 * base, 1.1);
1272
+ band.connect(pan);
1273
+ s.send(pan, 0.7, 0.3);
1274
+ let t = ctx.currentTime + 0.05;
1275
+ for (let i = 0; i < calls; i++) {
1276
+ const length = i === 0 ? 0.32 + Math.random() * 0.12 : 0.16 + Math.random() * 0.1;
1277
+ const osc = ctx.createOscillator();
1278
+ osc.type = 'triangle';
1279
+ osc.frequency.setValueAtTime(1350 * base, t);
1280
+ osc.frequency.linearRampToValueAtTime(2050 * base, t + 0.045);
1281
+ osc.frequency.exponentialRampToValueAtTime(1150 * base, t + length);
1282
+ const vib = ctx.createOscillator();
1283
+ vib.frequency.value = 26 + Math.random() * 10;
1284
+ vib.connect(makeGain(ctx, 35)).connect(osc.frequency);
1285
+ const g = ctx.createGain();
1286
+ g.gain.setValueAtTime(0.0001, t);
1287
+ g.gain.exponentialRampToValueAtTime(0.008 * level, t + 0.03);
1288
+ g.gain.exponentialRampToValueAtTime(0.0001, t + length);
1289
+ osc.connect(g).connect(band);
1290
+ osc.start(t);
1291
+ osc.stop(t + length + 0.05);
1292
+ vib.start(t);
1293
+ vib.stop(t + length + 0.05);
1294
+ t += length + 0.08 + Math.random() * 0.1;
1295
+ }
1296
+ }
1297
+
1298
+ // The city rooftop. Traffic far below and the wash of the city, a car passing, the odd horn
1299
+ // or siren a long way off, wind at height around the railings, and a crowd in a small stand
1300
+ // whose voices come back off the towers around. The city
1301
+ // quietens at night.
1302
+ buildRooftop(s) {
1303
+ const ctx = this.ctx;
1304
+ s.bed = makeGain(ctx, 1);
1305
+ s.bedFile = 'city';
1306
+ s.send(s.bed, 1, 0.12);
1307
+ const rumble = makeGain(ctx, 0.035);
1308
+ s.loop(this.brown, 0.7).connect(makeFilter(ctx, 'lowpass', 170, 0.5)).connect(rumble).connect(s.bed);
1309
+ const wash = makeGain(ctx, 0.006);
1310
+ s.loop(this.pink, 0.55).connect(makeFilter(ctx, 'bandpass', 520, 0.5)).connect(makeFilter(ctx, 'lowpass', 1200, 0.5))
1311
+ .connect(wash).connect(s.bed);
1312
+ s.setNight = (n) => {
1313
+ const t = ctx.currentTime;
1314
+ rumble.gain.setTargetAtTime(0.035 * (1 - 0.45 * n), t, FADE);
1315
+ wash.gain.setTargetAtTime(0.006 * (1 - 0.5 * n), t, FADE);
1316
+ };
1317
+ s.every(() => this.carPass(s), () => (5000 + Math.random() * 9000) * (1 + this.night * 0.8), 2500);
1318
+ s.every(() => this.horn(s), () => (16000 + Math.random() * 30000) * (1 + this.night * 1.5), 9000);
1319
+ s.every(() => this.siren(s), () => 60000 + Math.random() * 100000, 35000 + Math.random() * 30000);
1320
+ // Wind at height, a steady band of air with gusts, and a faint tone where it meets the
1321
+ // railings.
1322
+ const wind = makeGain(ctx, 0.006);
1323
+ const windBand = makeFilter(ctx, 'bandpass', 340, 0.8);
1324
+ s.loop(this.pink, 0.75).connect(windBand).connect(wind);
1325
+ s.send(wind, 1, 0);
1326
+ const rail = makeGain(ctx, 0);
1327
+ s.loop(this.pink, 1.1).connect(makeFilter(ctx, 'bandpass', 1250, 10)).connect(rail);
1328
+ s.send(rail, 1, 0.1);
1329
+ s.every(() => {
1330
+ const t = ctx.currentTime;
1331
+ const gust = Math.random();
1332
+ wind.gain.setTargetAtTime(0.004 + gust * 0.008, t, 1);
1333
+ windBand.frequency.setTargetAtTime(280 + gust * 220, t, 1.2);
1334
+ rail.gain.setTargetAtTime(gust > 0.7 ? 0.01 * (gust - 0.7) : 0, t, 0.9);
1335
+ }, () => 1800 + Math.random() * 3500, 0);
1336
+ this.arenaCrowd(s, 'rooftop');
1337
+ const goal = s.goal;
1338
+ s.goal = (own) => {
1339
+ goal(own);
1340
+ // The city honks along now and then.
1341
+ if (Math.random() < 0.5) {
1342
+ s.later(() => this.audible && this.horn(s, 2), 1600);
1343
+ }
1344
+ };
1345
+ s.intensity = (x) => s.crowdIntensity(x);
1346
+ }
1347
+
1348
+ // A car passing in the street far below, a swell of tyre noise that sweeps across.
1349
+ carPass(s) {
1350
+ const ctx = this.ctx;
1351
+ const t = ctx.currentTime + 0.05;
1352
+ const length = 2.5 + Math.random() * 2;
1353
+ const level = 0.006 * (0.5 + Math.random() * 0.5) * (1 - 0.4 * this.night);
1354
+ const src = noiseShot(ctx, this.pink, t, t + length + 0.1, 0.8);
1355
+ const band = makeFilter(ctx, 'bandpass', 380, 0.8);
1356
+ band.frequency.setValueAtTime(380, t);
1357
+ band.frequency.linearRampToValueAtTime(820, t + length * 0.5);
1358
+ band.frequency.linearRampToValueAtTime(420, t + length);
1359
+ const g = ctx.createGain();
1360
+ g.gain.setValueAtTime(0.0001, t);
1361
+ g.gain.exponentialRampToValueAtTime(level, t + length * 0.5);
1362
+ g.gain.exponentialRampToValueAtTime(0.0001, t + length);
1363
+ const from = Math.random() < 0.5 ? -0.7 : 0.7;
1364
+ const pan = makePan(ctx, from);
1365
+ if (pan.pan) {
1366
+ pan.pan.setValueAtTime(from, t);
1367
+ pan.pan.linearRampToValueAtTime(-from, t + length);
1368
+ }
1369
+ src.connect(band).connect(g).connect(pan).connect(s.bed);
1370
+ }
1371
+
1372
+ // A car horn a few streets away, one or two toots, dulled by distance and echoing off the
1373
+ // towers.
1374
+ horn(s, toots = Math.random() < 0.4 ? 2 : 1) {
1375
+ if (this.arenaFile(s, 'city-horn', 0.5, 0.9, { rate: 0.92 + Math.random() * 0.16, pan: Math.random() * 1.6 - 0.8 })) {
1376
+ return;
1377
+ }
1378
+ const ctx = this.ctx;
1379
+ const f = 380 + Math.random() * 120;
1380
+ const pan = makePan(ctx, Math.random() * 1.6 - 0.8);
1381
+ const low = makeFilter(ctx, 'lowpass', 900, 0.6);
1382
+ low.connect(pan);
1383
+ s.send(pan, 0.5, 0.9);
1384
+ let t = ctx.currentTime + 0.05;
1385
+ for (let i = 0; i < toots; i++) {
1386
+ const length = 0.18 + Math.random() * 0.27;
1387
+ const g = ctx.createGain();
1388
+ g.gain.setValueAtTime(0.0001, t);
1389
+ g.gain.exponentialRampToValueAtTime(0.003, t + 0.02);
1390
+ g.gain.setValueAtTime(0.003, t + length);
1391
+ g.gain.exponentialRampToValueAtTime(0.0001, t + length + 0.06);
1392
+ g.connect(low);
1393
+ for (const m of [1, 1.26]) {
1394
+ const o = ctx.createOscillator();
1395
+ o.type = 'square';
1396
+ o.frequency.value = f * m;
1397
+ o.connect(g);
1398
+ o.start(t);
1399
+ o.stop(t + length + 0.1);
1400
+ }
1401
+ t += length + 0.12;
1402
+ }
1403
+ }
1404
+
1405
+ // A siren far across the city, a slow wail that drifts past.
1406
+ siren(s) {
1407
+ if (this.arenaFile(s, 'city-siren', 0.3, 1, { rate: 0.95 + Math.random() * 0.1, pan: Math.random() * 1.6 - 0.8 })) {
1408
+ return;
1409
+ }
1410
+ const ctx = this.ctx;
1411
+ const t = ctx.currentTime + 0.05;
1412
+ const length = 6 + Math.random() * 3;
1413
+ const osc = ctx.createOscillator();
1414
+ osc.frequency.value = 900;
1415
+ const lfo = ctx.createOscillator();
1416
+ lfo.frequency.value = 0.3 + Math.random() * 0.1;
1417
+ lfo.connect(makeGain(ctx, 280)).connect(osc.frequency);
1418
+ const g = ctx.createGain();
1419
+ swellGain(g.gain, t, 0.002, 2, length - 4.5, 2.5);
1420
+ const pan = makePan(ctx, 0);
1421
+ if (pan.pan) {
1422
+ const from = Math.random() * 1.4 - 0.7;
1423
+ pan.pan.setValueAtTime(from, t);
1424
+ pan.pan.linearRampToValueAtTime(-from * 0.6, t + length);
1425
+ }
1426
+ osc.connect(makeFilter(ctx, 'lowpass', 1400, 0.5)).connect(g).connect(pan);
1427
+ s.send(pan, 0.4, 0.8);
1428
+ osc.start(t);
1429
+ osc.stop(t + length + 0.1);
1430
+ lfo.start(t);
1431
+ lfo.stop(t + length + 0.1);
1432
+ }
1433
+
1434
+ // The space station. No air outside, so only the station itself, a deep slow hum, soft
1435
+ // machinery and airflow, gentle beeps and radio chirps, and a crowd in the enclosed galleries.
1436
+ // A goal is answered by a chime and the crowd's cheer.
1437
+ buildSpace(s) {
1438
+ const ctx = this.ctx;
1439
+ s.bed = makeGain(ctx, 1);
1440
+ s.bedFile = 'hum';
1441
+ s.send(s.bed, 1, 0.2);
1442
+ const humFile = makeGain(ctx, 1);
1443
+ s.send(humFile, 1, 0.2);
1444
+ s.fileBeds = [{ name: 'space-hum', mute: s.bed, out: humFile }];
1445
+ const hum = makeGain(ctx, 0.014);
1446
+ const humLow = makeFilter(ctx, 'lowpass', 380, 0.5);
1447
+ humLow.connect(hum).connect(s.bed);
1448
+ for (const [f, level] of [[55, 0.5], [110.4, 0.3], [165.2, 0.1], [220.7, 0.04]]) {
1449
+ const o = s.keep(ctx.createOscillator());
1450
+ o.frequency.value = f;
1451
+ o.connect(makeGain(ctx, level)).connect(humLow);
1452
+ o.start();
1453
+ }
1454
+ // A slow breath in the hum.
1455
+ const lfo = s.keep(ctx.createOscillator());
1456
+ lfo.frequency.value = 0.07;
1457
+ lfo.connect(makeGain(ctx, 0.004)).connect(hum.gain);
1458
+ lfo.start();
1459
+ s.loop(this.brown, 0.5).connect(makeFilter(ctx, 'lowpass', 150, 0.5)).connect(makeGain(ctx, 0.012)).connect(s.bed);
1460
+ const air = makeGain(ctx, 0.015);
1461
+ const airLow = makeFilter(ctx, 'lowpass', 1500, 0.5);
1462
+ s.loop(this.pink, 1).connect(makeFilter(ctx, 'highpass', 300, 0.5)).connect(airLow).connect(air).connect(s.bed);
1463
+ s.every(() => this.pump(s), () => 6000 + Math.random() * 6000, 3000);
1464
+ s.every(() => this.beep(s), () => 7000 + Math.random() * 13000, 4000);
1465
+ s.every(() => this.radio(s), () => 16000 + Math.random() * 24000, 11000);
1466
+ // A soft proximity ping that quickens as play nears a goal.
1467
+ s.every(() => {
1468
+ if (this.intensity > 0.55) {
1469
+ this.ping(s, (this.intensity - 0.55) / 0.45);
1470
+ }
1471
+ }, () => 700 + (1 - this.intensity) * 1600, 1000);
1472
+ this.arenaCrowd(s, 'space');
1473
+ s.intensity = (x) => {
1474
+ const t = ctx.currentTime;
1475
+ airLow.frequency.setTargetAtTime(1300 + x * 700, t, 0.8);
1476
+ air.gain.setTargetAtTime(0.012 + x * 0.006, t, 0.8);
1477
+ s.crowdIntensity(x);
1478
+ };
1479
+ const cheer = s.goal;
1480
+ s.goal = (own) => {
1481
+ this.chime(s, [84, 88, 91, 96], 0.14, 0.02);
1482
+ cheer(own);
1483
+ };
1484
+ s.fanfare = () => {
1485
+ this.chime(s, [79, 84, 88, 91, 88, 91, 96], 0.2, 0.02);
1486
+ cheer(false);
1487
+ s.later(() => this.audible && s.applause(), 900);
1488
+ };
1489
+ }
1490
+
1491
+ // The machinery cycling, a soft whoosh of air through a duct and now and then a servo.
1492
+ pump(s) {
1493
+ const ctx = this.ctx;
1494
+ const t = ctx.currentTime + 0.05;
1495
+ const length = 2.2 + Math.random() * 1.2;
1496
+ const src = noiseShot(ctx, this.pink, t, t + length + 0.1, 0.7);
1497
+ const band = makeFilter(ctx, 'bandpass', 220, 1.2);
1498
+ band.frequency.setValueAtTime(220, t);
1499
+ band.frequency.linearRampToValueAtTime(480, t + length * 0.4);
1500
+ band.frequency.linearRampToValueAtTime(260, t + length);
1501
+ const g = ctx.createGain();
1502
+ swellGain(g.gain, t, 0.006, length * 0.4, 0, length * 0.6);
1503
+ src.connect(band).connect(g).connect(s.bed);
1504
+ if (Math.random() < 0.4) {
1505
+ const o = ctx.createOscillator();
1506
+ o.type = 'triangle';
1507
+ const at = t + 0.3 + Math.random();
1508
+ o.frequency.setValueAtTime(180, at);
1509
+ o.frequency.linearRampToValueAtTime(260, at + 0.8);
1510
+ const og = ctx.createGain();
1511
+ swellGain(og.gain, at, 0.003, 0.15, 0.5, 0.25);
1512
+ o.connect(makeFilter(ctx, 'lowpass', 800, 0.5)).connect(og).connect(s.bed);
1513
+ o.start(at);
1514
+ o.stop(at + 1);
1515
+ }
1516
+ }
1517
+
1518
+ // A tone with a soft start and a ringing fade, the voice of the station's consoles.
1519
+ tone(s, f, t, length, level, dry = 0.7, wet = 0.4, type = 'sine') {
1520
+ const ctx = this.ctx;
1521
+ const o = ctx.createOscillator();
1522
+ o.type = type;
1523
+ o.frequency.value = f;
1524
+ const g = ctx.createGain();
1525
+ g.gain.setValueAtTime(0.0001, t);
1526
+ g.gain.exponentialRampToValueAtTime(Math.max(level, 0.00011), t + 0.008);
1527
+ g.gain.exponentialRampToValueAtTime(0.0001, t + length);
1528
+ o.connect(g);
1529
+ s.send(g, dry, wet);
1530
+ o.start(t);
1531
+ o.stop(t + length + 0.02);
1532
+ }
1533
+
1534
+ beep(s) {
1535
+ if (this.arenaFile(s, 'space-beep', 0.7, 0.4, { pan: Math.random() * 1.2 - 0.6 })) {
1536
+ return;
1537
+ }
1538
+ const t = this.ctx.currentTime + 0.05;
1539
+ const tunes = [[91], [88, 93], [93, 88], [91, 91], [86, 91, 95]];
1540
+ const tune = tunes[Math.floor(Math.random() * tunes.length)];
1541
+ tune.forEach((n, i) => this.tone(s, midi(n), t + i * 0.13, 0.12, 0.006));
1542
+ }
1543
+
1544
+ // A radio chirp, a breath of static and a quick rising tone, like a call from another deck.
1545
+ radio(s) {
1546
+ if (this.arenaFile(s, 'space-radio', 0.6, 0.5, { pan: Math.random() * 1.2 - 0.6 })) {
1547
+ return;
1548
+ }
1549
+ const ctx = this.ctx;
1550
+ const t = ctx.currentTime + 0.05;
1551
+ const src = noiseShot(ctx, this.white, t, t + 0.2);
1552
+ const g = ctx.createGain();
1553
+ swellGain(g.gain, t, 0.004, 0.02, 0.08, 0.06);
1554
+ src.connect(makeFilter(ctx, 'bandpass', 1800, 0.8)).connect(g);
1555
+ s.send(g, 0.7, 0.2);
1556
+ const count = Math.random() < 0.5 ? 1 : 2;
1557
+ for (let i = 0; i < count; i++) {
1558
+ const at = t + 0.18 + i * 0.16;
1559
+ const o = ctx.createOscillator();
1560
+ o.frequency.setValueAtTime(1400, at);
1561
+ o.frequency.exponentialRampToValueAtTime(2500, at + 0.06);
1562
+ o.frequency.exponentialRampToValueAtTime(1800, at + 0.12);
1563
+ const og = ctx.createGain();
1564
+ swellGain(og.gain, at, 0.004, 0.01, 0.08, 0.04);
1565
+ o.connect(og);
1566
+ s.send(og, 0.7, 0.3);
1567
+ o.start(at);
1568
+ o.stop(at + 0.15);
1569
+ }
1570
+ }
1571
+
1572
+ ping(s, k) {
1573
+ this.tone(s, midi(86), this.ctx.currentTime + 0.03, 0.35, 0.002 + 0.004 * k, 0.6, 0.6);
1574
+ }
1575
+
1576
+ // Soft bells, each note a few sine partials that ring and fade.
1577
+ chime(s, notes, gap, level) {
1578
+ const t0 = this.ctx.currentTime + 0.05;
1579
+ notes.forEach((n, i) => {
1580
+ const t = t0 + i * gap;
1581
+ const last = i === notes.length - 1;
1582
+ const ring = last ? 2.2 : 1.1;
1583
+ this.tone(s, midi(n), t, ring, level, 0.7, 0.5);
1584
+ this.tone(s, midi(n) * 2.0, t, ring * 0.6, level * 0.3, 0.7, 0.5);
1585
+ this.tone(s, midi(n) * 3.0, t, ring * 0.35, level * 0.08, 0.7, 0.5);
1586
+ });
1587
+ }
1588
+
1589
+ // The neon arcade. The buzz of the tubes, a low electric hum with a fizz that flickers, now
1590
+ // and then a far arcade blip, a crowd on the grid, and arcade stings on goals.
1591
+ buildNeon(s) {
1592
+ const ctx = this.ctx;
1593
+ s.bed = makeGain(ctx, 1);
1594
+ s.bedFile = 'hum';
1595
+ s.send(s.bed, 1, 0.3);
1596
+ const hum = makeGain(ctx, 0.0035);
1597
+ const humLow = makeFilter(ctx, 'lowpass', 700, 0.7);
1598
+ humLow.connect(hum).connect(s.bed);
1599
+ for (const [f, level, type] of [[60, 1, 'sine'], [120, 0.5, 'sawtooth'], [180, 0.15, 'sawtooth']]) {
1600
+ const o = s.keep(ctx.createOscillator());
1601
+ o.type = type;
1602
+ o.frequency.value = f;
1603
+ o.connect(makeGain(ctx, level)).connect(humLow);
1604
+ o.start();
1605
+ }
1606
+ const fizz = makeGain(ctx, 0.001);
1607
+ s.loop(this.white, 1).connect(makeFilter(ctx, 'bandpass', 5200, 0.8)).connect(fizz).connect(s.bed);
1608
+ s.every(() => {
1609
+ const t = ctx.currentTime;
1610
+ fizz.gain.setTargetAtTime(0.0005 + Math.random() * 0.0015, t, 0.15);
1611
+ }, () => 300 + Math.random() * 1500, 0);
1612
+ // The stings go through an echo on the dotted eighth, darkening as it repeats.
1613
+ const fx = makeGain(ctx, 1);
1614
+ s.send(fx, 1, 0.35);
1615
+ const delay = ctx.createDelay(1);
1616
+ delay.delayTime.value = 0.47;
1617
+ const feedback = makeGain(ctx, 0.3);
1618
+ const delayLow = makeFilter(ctx, 'lowpass', 2400, 0.5);
1619
+ delay.connect(delayLow).connect(feedback).connect(delay);
1620
+ delayLow.connect(makeGain(ctx, 0.5)).connect(fx);
1621
+ s.fx = fx;
1622
+ s.echo = delay;
1623
+ // A far arcade cabinet blips now and then.
1624
+ s.every(() => {
1625
+ const t = ctx.currentTime + 0.05;
1626
+ const base = 72 + Math.floor(Math.random() * 12);
1627
+ const count = 2 + Math.floor(Math.random() * 3);
1628
+ for (let i = 0; i < count; i++) {
1629
+ this.tone(s, midi(base + (Math.random() < 0.5 ? 0 : 7) + i * 2), t + i * 0.09, 0.07, 0.0025, 0.4, 0.5, 'square');
1630
+ }
1631
+ }, () => 9000 + Math.random() * 16000, 5000);
1632
+ this.arenaCrowd(s, 'neon');
1633
+ s.intensity = (x) => s.crowdIntensity(x);
1634
+ const cheer = s.goal;
1635
+ s.goal = (own) => {
1636
+ if (!this.arenaFile(s, 'neon-sting', 1, 0.35, { gain: own ? 0.7 : 1 })) {
1637
+ this.sting(s, [72, 76, 79, 84, 88, 91, 96], 0.06, own ? 0.01 : 0.014);
1638
+ this.padSwell(s, [72, 76, 79, 84], 0.5, 2.6, own ? 0.012 : 0.016);
1639
+ }
1640
+ cheer(own);
1641
+ };
1642
+ s.fanfare = () => {
1643
+ this.sting(s, [67, 72, 76, 79], 0.14, 0.012, 0);
1644
+ this.sting(s, [76, 79, 84], 0.14, 0.012, 0.7);
1645
+ this.padSwell(s, [60, 67, 72, 76], 1.2, 3.2, 0.016);
1646
+ cheer(false);
1647
+ s.later(() => this.audible && s.applause(), 900);
1648
+ };
1649
+ }
1650
+
1651
+ // A quick arcade run of notes, square and a softer octave above, through the echo.
1652
+ sting(s, notes, gap, level, at = 0) {
1653
+ const ctx = this.ctx;
1654
+ const t0 = ctx.currentTime + 0.05 + at;
1655
+ const low = makeFilter(ctx, 'lowpass', 3200, 0.6);
1656
+ low.connect(s.fx);
1657
+ low.connect(makeGain(ctx, 0.4)).connect(s.echo);
1658
+ notes.forEach((n, i) => {
1659
+ const t = t0 + i * gap;
1660
+ const last = i === notes.length - 1;
1661
+ const length = last ? 0.5 : gap * 1.6;
1662
+ for (const [type, mul, k] of [['square', 1, 1], ['triangle', 2, 0.4]]) {
1663
+ const o = ctx.createOscillator();
1664
+ o.type = type;
1665
+ o.frequency.value = midi(n) * mul;
1666
+ const g = ctx.createGain();
1667
+ g.gain.setValueAtTime(0.0001, t);
1668
+ g.gain.exponentialRampToValueAtTime(level * k, t + 0.005);
1669
+ g.gain.setValueAtTime(level * k, t + length * 0.5);
1670
+ g.gain.exponentialRampToValueAtTime(0.0001, t + length);
1671
+ o.connect(g).connect(low);
1672
+ o.start(t);
1673
+ o.stop(t + length + 0.02);
1674
+ }
1675
+ });
1676
+ }
1677
+
1678
+ // A bright chord that swells and settles, under a sting.
1679
+ padSwell(s, notes, at, length, level) {
1680
+ const ctx = this.ctx;
1681
+ const t = ctx.currentTime + 0.05 + at;
1682
+ const low = makeFilter(ctx, 'lowpass', 800, 1);
1683
+ low.frequency.setValueAtTime(800, t);
1684
+ low.frequency.linearRampToValueAtTime(2800, t + length * 0.3);
1685
+ low.frequency.linearRampToValueAtTime(1000, t + length);
1686
+ const g = ctx.createGain();
1687
+ swellGain(g.gain, t, level, length * 0.25, length * 0.2, length * 0.55);
1688
+ low.connect(g).connect(s.fx);
1689
+ for (const n of notes) {
1690
+ for (const detune of [-8, 8]) {
1691
+ const o = ctx.createOscillator();
1692
+ o.type = 'sawtooth';
1693
+ o.frequency.value = midi(n);
1694
+ o.detune.value = detune;
1695
+ o.connect(low);
1696
+ o.start(t);
1697
+ o.stop(t + length + 0.05);
1698
+ }
1699
+ }
1700
+ }
1701
+
1702
+ // Rain and wind follow the weather, from files when present and synthesized otherwise. The
1703
+ // station in orbit has no weather.
1704
+ updateWeather() {
1705
+ if (!this.ctx) {
1706
+ return;
1707
+ }
1708
+ for (const kind of ['rain', 'wind']) {
1709
+ const wanted = this.arena === 'space' ? 0 : Math.max(0, Math.min(1, this.ambienceWanted.get(kind) || 0));
1710
+ const file = this.files.has(`${kind}-loop`);
1711
+ this.fileLoop(kind, file ? wanted : 0);
1712
+ const level = file ? 0 : wanted;
1713
+ let w = this.weather[kind];
1714
+ if (level > 0 && !w) {
1715
+ w = this.weather[kind] = kind === 'rain' ? this.buildRain() : this.buildWind();
1716
+ }
1717
+ if (!w) {
1718
+ continue;
1719
+ }
1720
+ w.setLevel(level * WEATHER_GAIN[kind]);
1721
+ if (w.setStrength) {
1722
+ w.setStrength(level);
1723
+ }
1724
+ if (w.setSurface) {
1725
+ w.setSurface(this.arena);
1726
+ }
1727
+ if (level > 0) {
1728
+ clearTimeout(w.retireTimer);
1729
+ } else {
1730
+ this.retire(w, () => w.target > 0, () => {
1731
+ if (this.weather[kind] === w) {
1732
+ this.weather[kind] = null;
1733
+ }
1734
+ });
1735
+ }
1736
+ }
1737
+ }
1738
+
1739
+ // Rain, a soft hiss, the patter of drops and on the stadium roof the drumming of heavier
1740
+ // ones, balanced for the ground of each arena.
1741
+ buildRain() {
1742
+ const ctx = this.ctx;
1743
+ const w = new Scape(this, 'rain', true);
1744
+ if (!this.drops) {
1745
+ this.drops = dropsBuffer(ctx);
1746
+ }
1747
+ const hiss = makeGain(ctx, 0);
1748
+ w.loop(this.pink, 1).connect(makeFilter(ctx, 'highpass', 450, 0.5)).connect(makeFilter(ctx, 'lowpass', 3600, 0.5)).connect(hiss);
1749
+ w.send(hiss, 1, 0.1);
1750
+ const patter = makeGain(ctx, 0);
1751
+ w.loop(this.drops, 1).connect(makeFilter(ctx, 'lowpass', 6000, 0.5)).connect(patter);
1752
+ w.send(patter, 1, 0.12);
1753
+ const drum = makeGain(ctx, 0);
1754
+ w.loop(this.drops, 0.45).connect(makeFilter(ctx, 'lowpass', 1300, 0.6)).connect(drum);
1755
+ w.send(drum, 1, 0.3);
1756
+ w.setSurface = (arena) => {
1757
+ if (arena === w.arena) {
1758
+ return;
1759
+ }
1760
+ w.arena = arena;
1761
+ const [h, p, d] = RAIN_SURFACE[arena] || RAIN_SURFACE.stadium;
1762
+ const t = ctx.currentTime;
1763
+ hiss.gain.setTargetAtTime(0.032 * h, t, FADE);
1764
+ patter.gain.setTargetAtTime(0.025 * p, t, FADE);
1765
+ drum.gain.setTargetAtTime(0.05 * d, t, FADE);
1766
+ };
1767
+ return w;
1768
+ }
1769
+
1770
+ // Wind, two bands of air that gust and shift apart, and a low rumble when it blows hard.
1771
+ buildWind() {
1772
+ const ctx = this.ctx;
1773
+ const w = new Scape(this, 'wind', true);
1774
+ const layers = [];
1775
+ const base = makeGain(ctx, 0.04);
1776
+ base.connect(w.fade);
1777
+ for (const [f, q, side] of [[320, 0.9, -0.5], [620, 1.3, 0.5]]) {
1778
+ const band = makeFilter(ctx, 'bandpass', f, q);
1779
+ const g = makeGain(ctx, 0.5);
1780
+ w.loop(this.pink, 0.8 + Math.random() * 0.3).connect(band).connect(g).connect(makePan(ctx, side)).connect(base);
1781
+ layers.push({ band, g, f });
1782
+ }
1783
+ const rumble = makeGain(ctx, 0);
1784
+ w.loop(this.brown, 0.6).connect(makeFilter(ctx, 'lowpass', 140, 0.5)).connect(rumble).connect(w.fade);
1785
+ w.strength = 0;
1786
+ w.setStrength = (k) => {
1787
+ w.strength = k;
1788
+ rumble.gain.setTargetAtTime(0.02 * k * k, ctx.currentTime, FADE);
1789
+ };
1790
+ w.every(() => {
1791
+ const t = ctx.currentTime;
1792
+ const k = w.strength;
1793
+ for (const { band, g, f } of layers) {
1794
+ g.gain.setTargetAtTime(0.35 + Math.random() * (0.4 + 0.5 * k), t, 0.6 + Math.random() * 1.2);
1795
+ band.frequency.setTargetAtTime(f * (0.7 + Math.random() * 0.7) * (1 + 0.3 * k), t, 1);
1796
+ }
1797
+ }, () => 1200 + Math.random() * 2800, 0);
1798
+ return w;
1799
+ }
1800
+
1801
+ // An ambience file played in a loop, rain, wind, waves, city or hum, faded to a level.
1802
+ fileLoop(name, level) {
1803
+ const buffer = this.files.get(`${name}-loop`);
1804
+ let entry = this.ambience.get(name);
1805
+ if (!buffer || (!entry && level <= 0)) {
1806
+ return;
1807
+ }
1808
+ if (!entry) {
1809
+ const src = this.ctx.createBufferSource();
1810
+ src.buffer = buffer;
1811
+ src.loop = true;
1812
+ const g = this.ctx.createGain();
1813
+ g.gain.value = 0;
1814
+ src.connect(g);
1815
+ this.route(g, 1, 0.1, 'ambience');
1816
+ src.start(0, Math.random() * buffer.duration);
1817
+ entry = { src, gain: g };
1818
+ this.ambience.set(name, entry);
1819
+ }
1820
+ entry.gain.gain.setTargetAtTime(Math.max(0, Math.min(1, level)) * 0.35, this.ctx.currentTime, FADE);
1821
+ }
1822
+
1823
+ // A panner at a point of three's frame, sounds there fall off with distance from the camera.
1824
+ panner(position, ref = 12) {
1825
+ const panner = this.ctx.createPanner();
1826
+ panner.panningModel = 'HRTF';
1827
+ panner.distanceModel = 'inverse';
1828
+ panner.refDistance = ref;
1829
+ panner.rolloffFactor = 0.8;
1830
+ if (panner.positionX) {
1831
+ panner.positionX.value = position.x;
1832
+ panner.positionY.value = position.y;
1833
+ panner.positionZ.value = position.z;
1834
+ } else {
1835
+ panner.setPosition(position.x, position.y, position.z);
1836
+ }
1837
+ return panner;
1838
+ }
1839
+
1840
+ // Plays a decoded buffer once through an optional panner, at a gain and rate.
1841
+ playBuffer(buffer, { gain = 1, rate = 1, position = null, wet = 0.15, when = 0, channel = 'effects' } = {}) {
1842
+ const ctx = this.ctx;
1843
+ const src = ctx.createBufferSource();
1844
+ src.buffer = buffer;
1845
+ src.playbackRate.value = rate;
1846
+ const level = ctx.createGain();
1847
+ level.gain.value = gain;
1848
+ src.connect(level);
1849
+ const g = this.voices.tap(ctx, level, src, when);
1850
+ if (position) {
1851
+ g.connect(this.panner(position)).connect(this.mix[channel].dry);
1852
+ if (wet > 0) {
1853
+ const w = ctx.createGain();
1854
+ w.gain.value = wet;
1855
+ g.connect(w).connect(this.mix[channel].wet);
1856
+ }
1857
+ } else {
1858
+ this.route(g, 1, wet, channel);
1859
+ }
1860
+ src.start(ctx.currentTime + when);
1861
+ return src;
1862
+ }
1863
+
1864
+ // A pea whistle, two close tones trilled by the pea at about 30 Hz, or the whistle files.
1865
+ whistle(blasts = [0.35]) {
1866
+ if (!this.audible) {
1867
+ return;
1868
+ }
1869
+ const ctx = this.ctx;
1870
+ let t = ctx.currentTime + 0.02;
1871
+ for (const length of blasts) {
1872
+ const file = this.files.get(length >= 0.6 ? 'whistle-long' : 'whistle-short') || null;
1873
+ if (file) {
1874
+ this.playBuffer(file, { gain: 0.5, when: t - ctx.currentTime });
1875
+ t += Math.max(length, file.duration) + 0.12;
1876
+ continue;
1877
+ }
1878
+ const out = ctx.createGain();
1879
+ out.gain.setValueAtTime(0, t);
1880
+ out.gain.linearRampToValueAtTime(0.22, t + 0.02);
1881
+ out.gain.setValueAtTime(0.22, t + length - 0.05);
1882
+ out.gain.linearRampToValueAtTime(0, t + length);
1883
+ const filter = ctx.createBiquadFilter();
1884
+ filter.type = 'bandpass';
1885
+ filter.frequency.value = 2900;
1886
+ filter.Q.value = 3;
1887
+ out.connect(filter);
1888
+ this.route(filter, 1, 0.2);
1889
+ const trill = ctx.createOscillator();
1890
+ trill.frequency.value = 28 + Math.random() * 6;
1891
+ const depth = ctx.createGain();
1892
+ depth.gain.value = 140;
1893
+ trill.connect(depth);
1894
+ for (const f of [2850, 3010]) {
1895
+ const o = ctx.createOscillator();
1896
+ o.type = 'triangle';
1897
+ o.frequency.value = f;
1898
+ depth.connect(o.frequency);
1899
+ o.connect(out);
1900
+ o.start(t);
1901
+ o.stop(t + length + 0.02);
1902
+ }
1903
+ trill.start(t);
1904
+ trill.stop(t + length + 0.02);
1905
+ t += length + 0.18;
1906
+ }
1907
+ }
1908
+
1909
+ // The arena's answer to a goal, with the scoring team's own goal sound when it has one. In
1910
+ // the stadium a swelling roar over the bed, a few seconds long.
1911
+ goal(team = -1) {
1912
+ if (!this.audible) {
1913
+ return;
1914
+ }
1915
+ const own = team >= 0 && this.teamSound(team, 'goal');
1916
+ if (this.arena !== 'stadium') {
1917
+ const s = this.scapes.get(this.arena);
1918
+ if (s && s.goal) {
1919
+ s.goal(own);
1920
+ }
1921
+ return;
1922
+ }
1923
+ const stadium = this.scapes.get('stadium');
1924
+ if (this.files.has('crowd-cheer')) {
1925
+ const g = this.ctx.createGain();
1926
+ stadium.send(g, 1, 0.3);
1927
+ const src = this.ctx.createBufferSource();
1928
+ src.buffer = this.files.get('crowd-cheer');
1929
+ src.connect(makeGain(this.ctx, own ? 0.35 : 0.55)).connect(g);
1930
+ src.start();
1931
+ return;
1932
+ }
1933
+ const ctx = this.ctx;
1934
+ const t = ctx.currentTime;
1935
+ const src = ctx.createBufferSource();
1936
+ src.buffer = this.roar.src.buffer || this.pink;
1937
+ src.loop = true;
1938
+ src.playbackRate.value = 1.18;
1939
+ // A dark swell, low passed and well under the effects, so the roar never turns to hiss.
1940
+ const filter = ctx.createBiquadFilter();
1941
+ filter.type = 'lowpass';
1942
+ filter.frequency.setValueAtTime(500, t);
1943
+ filter.frequency.linearRampToValueAtTime(1300, t + 0.8);
1944
+ filter.frequency.linearRampToValueAtTime(700, t + 6);
1945
+ filter.Q.value = 0.5;
1946
+ const highpass = ctx.createBiquadFilter();
1947
+ highpass.type = 'highpass';
1948
+ highpass.frequency.value = 140;
1949
+ const g = ctx.createGain();
1950
+ const peak = own ? 0.1 : 0.16;
1951
+ g.gain.setValueAtTime(0.0001, t);
1952
+ g.gain.exponentialRampToValueAtTime(peak, t + 0.6);
1953
+ g.gain.setValueAtTime(peak, t + 2.5);
1954
+ g.gain.exponentialRampToValueAtTime(0.0001, t + 7);
1955
+ src.connect(highpass).connect(filter).connect(g);
1956
+ stadium.send(g, 0.8, 0.6);
1957
+ src.start(t, Math.random() * 2);
1958
+ src.stop(t + 7.2);
1959
+ // Shouts and applause break out of the roar.
1960
+ for (let i = 0; i < 6; i++) {
1961
+ this.shout(t + 0.2 + Math.random() * 2.5, 1.6);
1962
+ }
1963
+ setTimeout(this.voices.later(() => this.audible && this.arena === 'stadium' && this.claps(40, 1.2)), 1500);
1964
+ }
1965
+
1966
+ // A groan at a near miss, the stadium crowd's from a file when one is present, else a
1967
+ // falling murmur, or the arena's own.
1968
+ ooh() {
1969
+ if (!this.audible) {
1970
+ return;
1971
+ }
1972
+ // Never twice within a few seconds, a save and the ball running wide are one moment.
1973
+ const now = this.ctx.currentTime;
1974
+ if (now - this.lastOoh < 4) {
1975
+ return;
1976
+ }
1977
+ this.lastOoh = now;
1978
+ if (this.arena !== 'stadium') {
1979
+ const s = this.scapes.get(this.arena);
1980
+ if (s && s.ooh) {
1981
+ s.ooh();
1982
+ }
1983
+ return;
1984
+ }
1985
+ if (this.files.has('crowd-ooh')) {
1986
+ this.playBuffer(this.files.get('crowd-ooh'), { gain: 0.45, wet: 0.3, channel: 'crowd' });
1987
+ return;
1988
+ }
1989
+ const t = this.ctx.currentTime;
1990
+ for (let i = 0; i < 5; i++) {
1991
+ this.shout(t + Math.random() * 0.2, 1.2);
1992
+ }
1993
+ }
1994
+
1995
+ // Applause, the file when present, else a long ripple of claps, or the arena's own.
1996
+ applause() {
1997
+ if (!this.audible) {
1998
+ return;
1999
+ }
2000
+ if (this.arena !== 'stadium') {
2001
+ const s = this.scapes.get(this.arena);
2002
+ if (s && s.applause) {
2003
+ s.applause();
2004
+ }
2005
+ return;
2006
+ }
2007
+ if (this.files.has('applause')) {
2008
+ this.playBuffer(this.files.get('applause'), { gain: 0.45, wet: 0.3, channel: 'crowd' });
2009
+ return;
2010
+ }
2011
+ this.claps(60, 1.2);
2012
+ setTimeout(this.voices.later(() => this.audible && this.arena === 'stadium' && this.claps(50, 1)), 900);
2013
+ }
2014
+
2015
+ // A short fanfare like brass for the winners, detuned saws through a swelling low pass, and
2016
+ // the crowd cheering along. The space station and the neon arcade play their own.
2017
+ fanfare() {
2018
+ if (!this.audible) {
2019
+ return;
2020
+ }
2021
+ const own = this.scapes.get(this.arena);
2022
+ if (own && own.fanfare) {
2023
+ own.fanfare();
2024
+ return;
2025
+ }
2026
+ const ctx = this.ctx;
2027
+ const t0 = ctx.currentTime + 0.05;
2028
+ const notes = [
2029
+ [392, 0, 0.16], [392, 0.18, 0.16], [392, 0.36, 0.16], [523.3, 0.54, 0.7],
2030
+ [466.2, 1.3, 0.45], [523.3, 1.8, 0.22], [587.3, 2.05, 0.22], [659.3, 2.3, 1.6],
2031
+ ];
2032
+ const chord = [[261.6, 2.3, 1.6], [392, 2.3, 1.6], [523.3, 2.3, 1.6]];
2033
+ for (const [f, at, len] of [...notes, ...chord]) {
2034
+ const out = ctx.createGain();
2035
+ const start = t0 + at;
2036
+ out.gain.setValueAtTime(0.0001, start);
2037
+ out.gain.exponentialRampToValueAtTime(0.12, start + 0.04);
2038
+ out.gain.setValueAtTime(0.12, start + len * 0.7);
2039
+ out.gain.exponentialRampToValueAtTime(0.0001, start + len);
2040
+ const filter = ctx.createBiquadFilter();
2041
+ filter.type = 'lowpass';
2042
+ filter.Q.value = 1.2;
2043
+ filter.frequency.setValueAtTime(f * 1.5, start);
2044
+ filter.frequency.linearRampToValueAtTime(f * 5, start + 0.08);
2045
+ filter.frequency.linearRampToValueAtTime(f * 3, start + len);
2046
+ this.route(out, 1, 0.25);
2047
+ filter.connect(out);
2048
+ for (const detune of [-7, 0, 7]) {
2049
+ const o = ctx.createOscillator();
2050
+ o.type = 'sawtooth';
2051
+ o.frequency.value = f;
2052
+ o.detune.value = detune;
2053
+ o.connect(filter);
2054
+ o.start(start);
2055
+ o.stop(start + len + 0.05);
2056
+ }
2057
+ }
2058
+ // The crowd cheers along.
2059
+ this.goal();
2060
+ this.applause();
2061
+ }
2062
+
2063
+ // The two teams of the match, registry entries with an id and their list of sounds, or
2064
+ // null for a team with none. Their sounds load once the audio is unlocked, and load again
2065
+ // when the server's sounds_version of the entry changes, as a replaced sound changes it.
2066
+ setTeams(teams) {
2067
+ const key = (t) => (t && t.id ? `${t.id}:${(t.sounds || []).join(',')}:${t.sounds_version || ''}` : '');
2068
+ const same = this.teams.every((t, i) => key(t) === key(teams[i]));
2069
+ this.teams = [teams[0] || null, teams[1] || null];
2070
+ if (!same) {
2071
+ this.teamBuffers = [new Map(), new Map()];
2072
+ this.loadTeamSounds();
2073
+ }
2074
+ }
2075
+
2076
+ async loadTeamSounds() {
2077
+ if (!this.ctx) {
2078
+ return;
2079
+ }
2080
+ const teams = this.teams;
2081
+ await Promise.all(teams.map(async (team, side) => {
2082
+ if (!team || !team.id || !Array.isArray(team.sounds)) {
2083
+ return;
2084
+ }
2085
+ const buffers = this.teamBuffers[side];
2086
+ await Promise.all(team.sounds.map(async (slot) => {
2087
+ try {
2088
+ const res = await fetch(`/api/teams/${encodeURIComponent(team.id)}/sounds/${encodeURIComponent(slot)}`, { cache: 'no-store' });
2089
+ if (!res.ok) {
2090
+ return;
2091
+ }
2092
+ const decoded = await this.ctx.decodeAudioData(await res.arrayBuffer());
2093
+ if (this.teams[side] === team) {
2094
+ buffers.set(slot, tameBuffer(this.ctx, decoded));
2095
+ }
2096
+ } catch (err) {
2097
+ console.warn('team sound not used', team.id, slot, err);
2098
+ }
2099
+ }));
2100
+ }));
2101
+ }
2102
+
2103
+ // Plays a team's own sound for one of its slots, from a point of three's frame when given,
2104
+ // and returns whether the team had one, so the caller can skip its built-in sound. Chants come
2105
+ // from the team's end of the ground, where its fans stand, and only where there are fans.
2106
+ teamSound(team, slot, position = null, fans = false) {
2107
+ if (!this.audible || (team !== 0 && team !== 1)) {
2108
+ return false;
2109
+ }
2110
+ const buffer = this.teamBuffers[team].get(slot);
2111
+ if (!buffer) {
2112
+ return false;
2113
+ }
2114
+ const crowd = fans ? FAN_CHANT[this.arena] || 0 : 1;
2115
+ if (crowd <= 0) {
2116
+ return false;
2117
+ }
2118
+ const now = this.ctx.currentTime;
2119
+ const last = this.lastPlayed[team].get(slot) || -Infinity;
2120
+ if (now - last < (TEAM_SLOT_GAP[slot] || 0.5)) {
2121
+ return true;
2122
+ }
2123
+ this.lastPlayed[team].set(slot, now);
2124
+ const where = position || (fans ? { x: team === 0 ? -62 : 62, y: 12, z: 0 } : null);
2125
+ const wet = fans ? 0.6 * (this.arena === 'stadium' ? 1 : 0.5) : 0.2;
2126
+ this.playBuffer(buffer, { gain: (TEAM_SLOT_GAIN[slot] || 0.4) * crowd, position: where, wet,
2127
+ channel: fans ? 'crowd' : 'effects' });
2128
+ return true;
2129
+ }
2130
+ }
2131
+
2132
+ // Each event of the play sounds as one voice, with how long it is taken to last in seconds when
2133
+ // its buffers do not tell. A sound an event calls in turn, such as a team's goal sound inside
2134
+ // the crowd's goal, joins the same voice.
2135
+ const EVENT_LENGTH = { whistle: 4, goal: 9, ooh: 4, applause: 5, fanfare: 6, teamSound: 1 };
2136
+ for (const [name, length] of Object.entries(EVENT_LENGTH)) {
2137
+ const play = Sound.prototype[name];
2138
+ Sound.prototype[name] = function event(...args) {
2139
+ return this.voices.run(this.ctx, () => play.apply(this, args), length);
2140
+ };
2141
+ }