synthos 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (380) hide show
  1. package/README.md +215 -65
  2. package/default-pages/application/page.html +42 -0
  3. package/default-pages/application/page.json +10 -0
  4. package/default-pages/elevenlabs_effects_studio/page.html +1363 -0
  5. package/default-pages/elevenlabs_effects_studio/page.json +11 -0
  6. package/default-pages/elevenlabs_voice_studio/page.html +801 -0
  7. package/default-pages/elevenlabs_voice_studio/page.json +11 -0
  8. package/default-pages/{json_tools.html → json_tools/page.html} +13 -11
  9. package/default-pages/json_tools/page.json +10 -0
  10. package/default-pages/my_notes/notes/a1b2c3d4-e5f6-7890-abcd-ef1234567890.json +5 -0
  11. package/default-pages/my_notes/page.html +132 -0
  12. package/default-pages/{my_notes.json → my_notes/page.json} +2 -2
  13. package/default-pages/neon_asteroids/files/Ambient_Space.mp3 +0 -0
  14. package/default-pages/neon_asteroids/files/Ambient_Space2.mp3 +0 -0
  15. package/default-pages/neon_asteroids/files/Ambient_Space3.mp3 +0 -0
  16. package/default-pages/neon_asteroids/files/Asteroid_Explosion.mp3 +0 -0
  17. package/default-pages/neon_asteroids/files/Hyperspace_Jump.mp3 +0 -0
  18. package/default-pages/neon_asteroids/files/Laser_Fire.mp3 +0 -0
  19. package/default-pages/neon_asteroids/files/Menu_Navigate.mp3 +0 -0
  20. package/default-pages/neon_asteroids/files/Power_Up_Collect.mp3 +0 -0
  21. package/default-pages/neon_asteroids/files/Saucer_Alert.mp3 +0 -0
  22. package/default-pages/neon_asteroids/files/Ship_Thrust.mp3 +0 -0
  23. package/default-pages/neon_asteroids/files/effects.json +74 -0
  24. package/default-pages/neon_asteroids/page.html +1822 -0
  25. package/default-pages/{neon_asteroids.json → neon_asteroids/page.json} +3 -3
  26. package/default-pages/oregon_trail/page.html +323 -0
  27. package/default-pages/oregon_trail/page.json +12 -0
  28. package/default-pages/retro_game_starter/page.html +1308 -0
  29. package/default-pages/retro_game_starter/page.json +12 -0
  30. package/default-pages/{sidebar_builder.html → sidebar_page/page.html} +12 -10
  31. package/default-pages/sidebar_page/page.json +10 -0
  32. package/default-pages/{solar_explorer.html → solar_explorer/page.html} +24 -29
  33. package/default-pages/{solar_explorer.json → solar_explorer/page.json} +4 -4
  34. package/default-pages/{solar_tutorial.html → solar_tutorial/page.html} +12 -10
  35. package/default-pages/solar_tutorial/page.json +10 -0
  36. package/default-pages/{two-panel_builder.html → two-panel_page/page.html} +13 -11
  37. package/default-pages/two-panel_page/page.json +10 -0
  38. package/default-pages/us_map/page.html +193 -0
  39. package/default-pages/us_map/page.json +12 -0
  40. package/default-pages/us_map_1850/page.html +326 -0
  41. package/default-pages/us_map_1850/page.json +12 -0
  42. package/default-pages/western_cities_1850/page.html +527 -0
  43. package/default-pages/western_cities_1850/page.json +12 -0
  44. package/default-themes/aurora-dawn.json +19 -0
  45. package/default-themes/aurora-dawn.v3.css +198 -0
  46. package/default-themes/aurora-dusk.json +19 -0
  47. package/default-themes/aurora-dusk.v3.css +200 -0
  48. package/default-themes/cosmos-dawn.json +19 -0
  49. package/default-themes/cosmos-dawn.v3.css +198 -0
  50. package/default-themes/cosmos-dusk.json +19 -0
  51. package/default-themes/cosmos-dusk.v3.css +200 -0
  52. package/default-themes/high-contrast-dark.json +19 -0
  53. package/default-themes/high-contrast-dark.v3.css +200 -0
  54. package/default-themes/high-contrast-light.json +19 -0
  55. package/default-themes/high-contrast-light.v3.css +198 -0
  56. package/default-themes/{nebula-dawn.css → nebula-dawn.v2.css} +134 -0
  57. package/default-themes/nebula-dawn.v3.css +199 -0
  58. package/default-themes/{nebula-dusk.css → nebula-dusk.v2.css} +128 -0
  59. package/default-themes/nebula-dusk.v3.css +201 -0
  60. package/default-themes/solar-flare-dawn.json +19 -0
  61. package/default-themes/solar-flare-dawn.v3.css +198 -0
  62. package/default-themes/solar-flare-dusk.json +19 -0
  63. package/default-themes/solar-flare-dusk.v3.css +200 -0
  64. package/dist/agents/a2a/a2aProvider.d.ts.map +1 -0
  65. package/dist/agents/a2a/a2aProvider.js +126 -0
  66. package/dist/agents/a2a/a2aProvider.js.map +1 -0
  67. package/dist/agents/discovery.d.ts.map +1 -0
  68. package/dist/agents/discovery.js +52 -0
  69. package/dist/agents/discovery.js.map +1 -0
  70. package/dist/agents/index.d.ts +7 -0
  71. package/dist/agents/index.d.ts.map +1 -0
  72. package/dist/agents/index.js +20 -0
  73. package/dist/agents/index.js.map +1 -0
  74. package/dist/agents/openclaw/gatewayManager.d.ts +117 -0
  75. package/dist/agents/openclaw/gatewayManager.d.ts.map +1 -0
  76. package/dist/agents/openclaw/gatewayManager.js +486 -0
  77. package/dist/agents/openclaw/gatewayManager.js.map +1 -0
  78. package/dist/agents/openclaw/openclawProvider.d.ts.map +1 -0
  79. package/dist/agents/openclaw/openclawProvider.js +237 -0
  80. package/dist/agents/openclaw/openclawProvider.js.map +1 -0
  81. package/dist/agents/openclaw/sshTunnelManager.d.ts +25 -0
  82. package/dist/agents/openclaw/sshTunnelManager.d.ts.map +1 -0
  83. package/dist/agents/openclaw/sshTunnelManager.js +359 -0
  84. package/dist/agents/openclaw/sshTunnelManager.js.map +1 -0
  85. package/dist/agents/types.d.ts.map +1 -0
  86. package/dist/agents/types.js +6 -0
  87. package/dist/agents/types.js.map +1 -0
  88. package/dist/builders/anthropic.d.ts +31 -0
  89. package/dist/builders/anthropic.d.ts.map +1 -0
  90. package/dist/builders/anthropic.js +227 -0
  91. package/dist/builders/anthropic.js.map +1 -0
  92. package/dist/builders/fireworksai.d.ts +9 -0
  93. package/dist/builders/fireworksai.d.ts.map +1 -0
  94. package/dist/builders/fireworksai.js +57 -0
  95. package/dist/builders/fireworksai.js.map +1 -0
  96. package/dist/builders/index.d.ts +13 -0
  97. package/dist/builders/index.d.ts.map +1 -0
  98. package/dist/builders/index.js +31 -0
  99. package/dist/builders/index.js.map +1 -0
  100. package/dist/builders/openai.d.ts +8 -0
  101. package/dist/builders/openai.d.ts.map +1 -0
  102. package/dist/builders/openai.js +87 -0
  103. package/dist/builders/openai.js.map +1 -0
  104. package/dist/builders/types.d.ts +54 -0
  105. package/dist/builders/types.d.ts.map +1 -0
  106. package/dist/builders/types.js +211 -0
  107. package/dist/builders/types.js.map +1 -0
  108. package/dist/connectors/index.d.ts.map +1 -1
  109. package/dist/connectors/index.js +3 -2
  110. package/dist/connectors/index.js.map +1 -1
  111. package/dist/connectors/registry.d.ts +2 -1
  112. package/dist/connectors/registry.d.ts.map +1 -1
  113. package/dist/connectors/registry.js +65 -96
  114. package/dist/connectors/registry.js.map +1 -1
  115. package/dist/connectors/types.d.ts.map +1 -1
  116. package/dist/customizer/Customizer.d.ts +57 -0
  117. package/dist/customizer/Customizer.d.ts.map +1 -0
  118. package/dist/customizer/Customizer.js +124 -0
  119. package/dist/customizer/Customizer.js.map +1 -0
  120. package/dist/customizer/index.d.ts.map +1 -0
  121. package/dist/customizer/index.js +9 -0
  122. package/dist/customizer/index.js.map +1 -0
  123. package/dist/files.d.ts +17 -0
  124. package/dist/files.d.ts.map +1 -1
  125. package/dist/files.js +75 -1
  126. package/dist/files.js.map +1 -1
  127. package/dist/index.d.ts.map +1 -1
  128. package/dist/index.js +1 -0
  129. package/dist/index.js.map +1 -1
  130. package/dist/init.d.ts +10 -6
  131. package/dist/init.d.ts.map +1 -1
  132. package/dist/init.js +97 -86
  133. package/dist/init.js.map +1 -1
  134. package/dist/migrations.d.ts.map +1 -1
  135. package/dist/migrations.js +142 -145
  136. package/dist/migrations.js.map +1 -1
  137. package/dist/models/anthropic.d.ts +24 -0
  138. package/dist/models/anthropic.d.ts.map +1 -0
  139. package/dist/models/anthropic.js +103 -0
  140. package/dist/models/anthropic.js.map +1 -0
  141. package/dist/models/chainOfThought.d.ts.map +1 -0
  142. package/dist/models/chainOfThought.js +45 -0
  143. package/dist/models/chainOfThought.js.map +1 -0
  144. package/dist/models/fireworksai.d.ts.map +1 -0
  145. package/dist/models/fireworksai.js +141 -0
  146. package/dist/models/fireworksai.js.map +1 -0
  147. package/dist/models/index.d.ts +7 -1
  148. package/dist/models/index.d.ts.map +1 -1
  149. package/dist/models/index.js +20 -1
  150. package/dist/models/index.js.map +1 -1
  151. package/dist/models/logCompletePrompt.d.ts.map +1 -0
  152. package/dist/models/logCompletePrompt.js +23 -0
  153. package/dist/models/logCompletePrompt.js.map +1 -0
  154. package/dist/models/openai.d.ts +24 -0
  155. package/dist/models/openai.d.ts.map +1 -0
  156. package/dist/models/openai.js +101 -0
  157. package/dist/models/openai.js.map +1 -0
  158. package/dist/models/providers.d.ts.map +1 -1
  159. package/dist/models/providers.js +12 -4
  160. package/dist/models/providers.js.map +1 -1
  161. package/dist/models/types.d.ts +53 -2
  162. package/dist/models/types.d.ts.map +1 -1
  163. package/dist/models/types.js +21 -0
  164. package/dist/models/types.js.map +1 -1
  165. package/dist/models/utils.d.ts.map +1 -0
  166. package/dist/models/utils.js +21 -0
  167. package/dist/models/utils.js.map +1 -0
  168. package/dist/pages.d.ts +30 -7
  169. package/dist/pages.d.ts.map +1 -1
  170. package/dist/pages.js +177 -55
  171. package/dist/pages.js.map +1 -1
  172. package/dist/scripts.d.ts.map +1 -1
  173. package/dist/scripts.js +4 -3
  174. package/dist/scripts.js.map +1 -1
  175. package/dist/service/createCompletePrompt.d.ts.map +1 -1
  176. package/dist/service/createCompletePrompt.js +9 -6
  177. package/dist/service/createCompletePrompt.js.map +1 -1
  178. package/dist/service/generateImage.d.ts.map +1 -1
  179. package/dist/service/generateImage.js +3 -3
  180. package/dist/service/generateImage.js.map +1 -1
  181. package/dist/service/server.d.ts.map +1 -1
  182. package/dist/service/server.js +39 -7
  183. package/dist/service/server.js.map +1 -1
  184. package/dist/service/transformPage.d.ts +47 -18
  185. package/dist/service/transformPage.d.ts.map +1 -1
  186. package/dist/service/transformPage.js +559 -270
  187. package/dist/service/transformPage.js.map +1 -1
  188. package/dist/service/useAgentRoutes.d.ts +5 -0
  189. package/dist/service/useAgentRoutes.d.ts.map +1 -0
  190. package/dist/service/useAgentRoutes.js +392 -0
  191. package/dist/service/useAgentRoutes.js.map +1 -0
  192. package/dist/service/useApiRoutes.d.ts.map +1 -1
  193. package/dist/service/useApiRoutes.js +380 -138
  194. package/dist/service/useApiRoutes.js.map +1 -1
  195. package/dist/service/useConnectorRoutes.d.ts.map +1 -1
  196. package/dist/service/useConnectorRoutes.js +20 -9
  197. package/dist/service/useConnectorRoutes.js.map +1 -1
  198. package/dist/service/useFileRoutes.d.ts +4 -0
  199. package/dist/service/useFileRoutes.d.ts.map +1 -0
  200. package/dist/service/useFileRoutes.js +122 -0
  201. package/dist/service/useFileRoutes.js.map +1 -0
  202. package/dist/service/usePageRoutes.d.ts.map +1 -1
  203. package/dist/service/usePageRoutes.js +660 -68
  204. package/dist/service/usePageRoutes.js.map +1 -1
  205. package/dist/service/useSharedDataRoutes.d.ts +4 -0
  206. package/dist/service/useSharedDataRoutes.d.ts.map +1 -0
  207. package/dist/service/useSharedDataRoutes.js +104 -0
  208. package/dist/service/useSharedDataRoutes.js.map +1 -0
  209. package/dist/service/useSharedFileRoutes.d.ts +4 -0
  210. package/dist/service/useSharedFileRoutes.d.ts.map +1 -0
  211. package/dist/service/useSharedFileRoutes.js +121 -0
  212. package/dist/service/useSharedFileRoutes.js.map +1 -0
  213. package/dist/settings.d.ts +3 -1
  214. package/dist/settings.d.ts.map +1 -1
  215. package/dist/settings.js +5 -8
  216. package/dist/settings.js.map +1 -1
  217. package/dist/synthos-cli.d.ts.map +1 -1
  218. package/dist/synthos-cli.js +4 -3
  219. package/dist/synthos-cli.js.map +1 -1
  220. package/dist/themes.d.ts +15 -0
  221. package/dist/themes.d.ts.map +1 -1
  222. package/dist/themes.js +106 -20
  223. package/dist/themes.js.map +1 -1
  224. package/migration-rules/v1-to-v2.md +193 -0
  225. package/migration-rules/v2-to-v3.md +481 -0
  226. package/package.json +15 -11
  227. package/required-pages/builder/page.html +43 -0
  228. package/required-pages/builder/page.json +10 -0
  229. package/required-pages/pages/page.html +924 -0
  230. package/required-pages/pages/page.json +10 -0
  231. package/required-pages/settings/page.html +1753 -0
  232. package/required-pages/settings/page.json +10 -0
  233. package/required-pages/synthos_apis/page.html +846 -0
  234. package/required-pages/synthos_apis/page.json +10 -0
  235. package/required-pages/{synthos_scripts.html → synthos_scripts/page.html} +13 -11
  236. package/required-pages/synthos_scripts/page.json +10 -0
  237. package/service-connectors/airtable/connector.json +27 -0
  238. package/service-connectors/alpha-vantage/connector.json +26 -0
  239. package/service-connectors/brave-search/connector.json +26 -0
  240. package/service-connectors/cloudinary/connector.json +27 -0
  241. package/service-connectors/deepl/connector.json +28 -0
  242. package/service-connectors/elevenlabs/connector.json +30 -0
  243. package/service-connectors/giphy/connector.json +27 -0
  244. package/service-connectors/github/connector.json +29 -0
  245. package/service-connectors/huggingface/connector.json +27 -0
  246. package/service-connectors/imgur/connector.json +29 -0
  247. package/service-connectors/instagram/connector.json +43 -0
  248. package/service-connectors/jira/connector.json +28 -0
  249. package/service-connectors/mapbox/connector.json +26 -0
  250. package/service-connectors/nasa/connector.json +27 -0
  251. package/service-connectors/newsapi/connector.json +27 -0
  252. package/service-connectors/notion/connector.json +28 -0
  253. package/service-connectors/open-exchange-rates/connector.json +27 -0
  254. package/service-connectors/openweathermap/connector.json +26 -0
  255. package/service-connectors/pexels/connector.json +27 -0
  256. package/service-connectors/resend/connector.json +29 -0
  257. package/service-connectors/rss2json/connector.json +27 -0
  258. package/service-connectors/sendgrid/connector.json +27 -0
  259. package/service-connectors/spoonacular/connector.json +28 -0
  260. package/service-connectors/stability-ai/connector.json +27 -0
  261. package/service-connectors/twilio/connector.json +28 -0
  262. package/service-connectors/unsplash/connector.json +27 -0
  263. package/service-connectors/wolfram-alpha/connector.json +26 -0
  264. package/service-connectors/youtube-data/connector.json +30 -0
  265. package/src/agents/a2a/a2aProvider.ts +110 -0
  266. package/src/agents/discovery.ts +74 -0
  267. package/src/agents/index.ts +6 -0
  268. package/src/agents/openclaw/gatewayManager.ts +570 -0
  269. package/src/agents/openclaw/openclawProvider.ts +259 -0
  270. package/src/agents/openclaw/sshTunnelManager.ts +393 -0
  271. package/src/agents/types.ts +82 -0
  272. package/src/builders/anthropic.ts +283 -0
  273. package/src/builders/fireworksai.ts +59 -0
  274. package/src/builders/index.ts +33 -0
  275. package/src/builders/openai.ts +89 -0
  276. package/src/builders/types.ts +261 -0
  277. package/src/connectors/index.ts +3 -1
  278. package/src/connectors/registry.ts +40 -96
  279. package/src/connectors/types.ts +25 -0
  280. package/src/customizer/Customizer.ts +151 -0
  281. package/src/customizer/index.ts +5 -0
  282. package/src/files.ts +71 -0
  283. package/src/index.ts +2 -1
  284. package/src/init.ts +138 -97
  285. package/src/migrations.ts +148 -145
  286. package/src/models/anthropic.ts +119 -0
  287. package/src/models/chainOfThought.ts +56 -0
  288. package/src/models/fireworksai.ts +143 -0
  289. package/src/models/index.ts +7 -1
  290. package/src/models/logCompletePrompt.ts +25 -0
  291. package/src/models/openai.ts +110 -0
  292. package/src/models/providers.ts +12 -3
  293. package/src/models/types.ts +97 -2
  294. package/src/models/utils.ts +16 -0
  295. package/src/pages.ts +176 -54
  296. package/src/scripts.ts +2 -2
  297. package/src/service/createCompletePrompt.ts +3 -1
  298. package/src/service/generateImage.ts +2 -2
  299. package/src/service/server.ts +39 -8
  300. package/src/service/transformPage.ts +605 -301
  301. package/src/service/useAgentRoutes.ts +428 -0
  302. package/src/service/useApiRoutes.ts +309 -45
  303. package/src/service/useConnectorRoutes.ts +21 -10
  304. package/src/service/useFileRoutes.ts +127 -0
  305. package/src/service/usePageRoutes.ts +736 -75
  306. package/src/service/useSharedDataRoutes.ts +106 -0
  307. package/src/service/useSharedFileRoutes.ts +126 -0
  308. package/src/settings.ts +8 -10
  309. package/src/synthos-cli.ts +4 -3
  310. package/src/themes.ts +103 -20
  311. package/static-files/favicon.svg +12 -0
  312. package/static-files/fluentlm-instructions.llmd +868 -0
  313. package/static-files/fluentlm-instructions.md +1595 -0
  314. package/static-files/fluentlm.css +4844 -0
  315. package/static-files/fluentlm.js +3602 -0
  316. package/static-files/fluentlm.min.css +1 -0
  317. package/static-files/fluentlm.min.js +1 -0
  318. package/static-files/helpers.v3.js +304 -0
  319. package/static-files/page.v3.js +1290 -0
  320. package/static-files/recommended-frameworks.llmd +81 -0
  321. package/static-files/recommended-frameworks.md +137 -0
  322. package/static-files/retro-game.js +877 -0
  323. package/static-files/shell.css +797 -0
  324. package/static-files/theme-dark.css +169 -0
  325. package/static-files/theme-light.css +169 -0
  326. package/tests/anthropic.spec.ts +84 -0
  327. package/tests/builders.spec.ts +139 -0
  328. package/tests/chainOfThought.spec.ts +108 -0
  329. package/tests/ensureScripts.spec.ts +82 -0
  330. package/tests/files.spec.ts +233 -0
  331. package/tests/fireworksai.spec.ts +92 -0
  332. package/tests/logCompletePrompt.spec.ts +74 -0
  333. package/tests/migrations.spec.ts +79 -1
  334. package/tests/openai.spec.ts +71 -0
  335. package/tests/pages.spec.ts +226 -1
  336. package/tests/providers.spec.ts +144 -0
  337. package/tests/scripts.spec.ts +209 -0
  338. package/tests/transformPage.spec.ts +456 -0
  339. package/tests/types.spec.ts +23 -0
  340. package/default-pages/app_builder.html +0 -40
  341. package/default-pages/app_builder.json +0 -1
  342. package/default-pages/json_tools.json +0 -1
  343. package/default-pages/my_notes.html +0 -33
  344. package/default-pages/neon_asteroids.html +0 -77
  345. package/default-pages/sidebar_builder.json +0 -1
  346. package/default-pages/solar_tutorial.json +0 -1
  347. package/default-pages/two-panel_builder.json +0 -1
  348. package/dist/connectors/index.d.ts +0 -3
  349. package/dist/connectors/types.d.ts +0 -61
  350. package/dist/index.d.ts +0 -7
  351. package/dist/migrations.d.ts +0 -11
  352. package/dist/models/providers.d.ts +0 -7
  353. package/dist/scripts.d.ts +0 -14
  354. package/dist/service/createCompletePrompt.d.ts +0 -5
  355. package/dist/service/debugLog.d.ts +0 -11
  356. package/dist/service/generateImage.d.ts +0 -32
  357. package/dist/service/index.d.ts +0 -8
  358. package/dist/service/modelInstructions.d.ts +0 -7
  359. package/dist/service/requiresSettings.d.ts +0 -3
  360. package/dist/service/server.d.ts +0 -4
  361. package/dist/service/useApiRoutes.d.ts +0 -4
  362. package/dist/service/useConnectorRoutes.d.ts +0 -4
  363. package/dist/service/useDataRoutes.d.ts +0 -4
  364. package/dist/service/usePageRoutes.d.ts +0 -5
  365. package/dist/synthos-cli.d.ts +0 -2
  366. package/images/home.png +0 -0
  367. package/images/page-management.png +0 -0
  368. package/images/settings.png +0 -0
  369. package/images/synthos-square.png +0 -0
  370. package/page-scripts/helpers-v2.js +0 -121
  371. package/page-scripts/page-v2.js +0 -615
  372. package/required-pages/builder.html +0 -74
  373. package/required-pages/builder.json +0 -1
  374. package/required-pages/pages.html +0 -196
  375. package/required-pages/pages.json +0 -1
  376. package/required-pages/settings.html +0 -841
  377. package/required-pages/settings.json +0 -1
  378. package/required-pages/synthos_apis.html +0 -272
  379. package/required-pages/synthos_apis.json +0 -1
  380. package/required-pages/synthos_scripts.json +0 -1
@@ -0,0 +1,1363 @@
1
+ <!DOCTYPE html><html lang="en"><head>
2
+ <meta charset="UTF-8">
3
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
4
+ <title>SynthOS - ElevenLabs Effects Studio</title>
5
+ <script id="theme-info" src="/api/theme-info.js" data-locked="true"></script>
6
+ <link id="theme-css" rel="stylesheet" href="/api/theme.css" data-locked="true">
7
+ <style>
8
+ /* ---- Master / Detail layout ---- */
9
+ .sfx-app { display: flex; height: 100%; width: 100%; overflow: hidden; }
10
+ .sfx-sidebar { width: 320px; min-width: 320px; background: var(--defaultStateBackground); border-right: 2px solid var(--neutralLight); display: flex; flex-direction: column; }
11
+ .sfx-sidebar-header { padding: 16px; border-bottom: 1px solid var(--neutralLight); display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; }
12
+ .sfx-sidebar-actions { padding: 10px 16px; border-bottom: 1px solid var(--neutralLight); display: flex; flex-direction: column; gap: 8px; }
13
+ .sfx-list { flex: 1; overflow-y: auto; padding: 8px; padding-bottom: 16px; }
14
+ .sfx-sidebar-footer { padding: 12px 16px; border-top: 1px solid var(--neutralLight); font-size: 12px; color: var(--bodySubtext); }
15
+ .sfx-main { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; background: var(--bodyBackground); }
16
+ .sfx-instructions { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 40px; }
17
+ .sfx-detail { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
18
+ .sfx-detail-header { display: flex; align-items: center; gap: 12px; padding: 20px 20px 12px; flex-shrink: 0; border-bottom: 1px solid var(--neutralLight); }
19
+ .sfx-detail-header input { font-size: 22px; font-weight: 600; flex: 1; }
20
+ .sfx-detail-body { flex: 1; overflow-y: auto; padding: 16px 20px; display: flex; flex-direction: column; gap: 16px; }
21
+ .sfx-detail-footer { flex-shrink: 0; padding: 12px 20px; border-top: 1px solid var(--neutralLight); }
22
+ .sfx-section { padding: 16px; background: var(--defaultStateBackground); border-radius: 8px; border: 1px solid var(--neutralLight); }
23
+ .sfx-section h3 { color: var(--themePrimary); font-weight: 600; margin: 0 0 12px; font-size: 15px; }
24
+ .sfx-actions { display: flex; gap: 12px; justify-content: flex-end; }
25
+ .save-toast { opacity: 0; transform: translateY(4px); transition: opacity .3s, transform .3s; pointer-events: none; }
26
+ .save-toast.show { opacity: 1; transform: translateY(0); pointer-events: auto; }
27
+
28
+ /* ---- Slider rows ---- */
29
+ .slider-row { margin-bottom: 12px; }
30
+ .slider-row label { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 13px; color: var(--bodyText); }
31
+ .slider-row label span { color: var(--themePrimary); font-weight: 600; }
32
+ .slider-row input[type="range"] { width: 100%; height: 6px; border-radius: 3px; background: var(--neutralLight); -webkit-appearance: none; appearance: none; cursor: pointer; }
33
+ .slider-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--themePrimary); cursor: pointer; }
34
+ .slider-row input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: var(--themePrimary); cursor: pointer; border: none; }
35
+
36
+ /* ---- Effects grid ---- */
37
+ .sfx-effects-grid { display: grid; grid-template-columns: 1fr; gap: 6px; padding: 8px; }
38
+ .sfx-grid-item { display: flex; flex-direction: column; padding: 10px; border-radius: 8px; border: 1px solid var(--neutralLight); background: var(--defaultStateBackground); cursor: pointer; gap: 4px; transition: border-color 0.15s, background 0.15s; position: relative; overflow: visible; }
39
+ .sfx-grid-item:hover { border-color: var(--themePrimary); background: var(--defaultHoverBackground); }
40
+ .sfx-grid-item--selected { border-color: var(--themePrimary); background: var(--themeLighterAlt); box-shadow: inset 0 0 0 1px var(--themePrimary); }
41
+ .sfx-grid-item-name { font-size: 13px; font-weight: 600; color: var(--bodyText); overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; }
42
+ .sfx-grid-item-meta { font-size: 10px; color: var(--bodySubtext); }
43
+ .sfx-grid-item-badges { display: flex; gap: 4px; align-items: center; margin-top: 2px; }
44
+
45
+ /* ---- Loop badge ---- */
46
+ .effect-loop-badge { display: inline-block; padding: 1px 6px; background: var(--successBackground); border: 1px solid var(--successText); border-radius: 4px; font-size: 10px; color: var(--successText); flex-shrink: 0; }
47
+
48
+ /* ---- Playing indicator ---- */
49
+ .sfx-playing-indicator { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--successText); margin-left: 6px; animation: sfx-pulse 1s infinite; vertical-align: middle; }
50
+ @keyframes sfx-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
51
+
52
+ /* ---- Waveform ---- */
53
+ .waveform-container { height: 50px; display: flex; align-items: center; justify-content: center; gap: 3px; margin-bottom: 10px; }
54
+ .waveform-bar { width: 4px; background: var(--themePrimary); border-radius: 2px; transition: height 0.1s ease; }
55
+
56
+
57
+ /* ---- Loop settings ---- */
58
+ .loop-settings { padding: 12px; background: var(--defaultStateBackground); border-radius: 8px; border: 1px solid var(--neutralLight); margin-top: 10px; }
59
+ .loop-settings h4 { color: var(--themePrimary); font-size: 13px; margin: 0 0 10px; }
60
+ </style>
61
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/14.1.1/marked.min.js"></script>
62
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
63
+ </head>
64
+ <body>
65
+ <div class="shell-toolbar" data-locked="true">
66
+ <button class="shell-toolbar-btn" id="builderToggle" aria-label="Page Builder" data-locked="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M7 18.5H6.2c-1.77 0-3.2-1.43-3.2-3.2V7.7C3 5.93 4.43 4.5 6.2 4.5h11.6c1.77 0 3.2 1.43 3.2 3.2v7.6c0 1.77-1.43 3.2-3.2 3.2H12l-4.2 3.2c-.5.38-1.2.02-1.2-.6V18.5Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><circle cx="8.5" cy="11.5" r="1" fill="currentColor"/><circle cx="12" cy="11.5" r="1" fill="currentColor"/><circle cx="15.5" cy="11.5" r="1" fill="currentColor"/></svg></button>
67
+ <button class="shell-toolbar-btn" id="pagesBtn" aria-label="View All Pages" data-locked="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none"><rect x="3" y="3" width="11" height="12" rx="1.5" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M6 7.5h5M6 10h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><rect x="18" y="3" width="11" height="12" rx="1.5" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M21 7.5h5M21 10h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><rect x="3" y="18" width="11" height="12" rx="1.5" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M6 22.5h5M6 25h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><rect x="18" y="18" width="11" height="12" rx="1.5" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M21 22.5h5M21 25h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
68
+ <button class="shell-toolbar-btn" id="saveBtn" aria-label="Save Page" data-locked="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M17 21v-8H7v8" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M7 3v5h8" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg></button>
69
+ <div class="shell-toolbar-spacer" data-locked="true"></div>
70
+ <button class="shell-toolbar-btn" id="settingsBtn" aria-label="Settings" data-locked="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" stroke="currentColor" stroke-width="1.8"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
71
+ </div>
72
+ <div class="chat-panel" data-locked="true">
73
+ <div class="chat-header" data-locked="true"><span>Page Builder</span><button class="chat-header-close" id="builderClose" aria-label="Close builder" data-locked="true">&times;</button></div>
74
+ <div class="chat-messages" id="chatMessages" data-locked="true">
75
+ <div class="chat-message">
76
+ <p><strong>SynthOS:</strong> Welcome to the ElevenLabs Effects Studio! Create, manage, and loop sound effects. Make sure the ElevenLabs connector is configured in Settings &gt; Connectors.</p>
77
+ </div>
78
+ </div> <form action="/" method="POST" id="chatForm" data-locked="true">
79
+ <textarea class="chat-input" id="chatInput" name="message" rows="2" placeholder="Type a message..." data-locked="true"></textarea>
80
+ </form>
81
+ </div>
82
+ <div class="viewer-panel full-viewer" id="viewerPanel" style="justify-content: flex-start; align-items: stretch;">
83
+ <div class="sfx-app">
84
+ <!-- ======== SIDEBAR ======== -->
85
+ <div class="sfx-sidebar">
86
+ <div class="sfx-sidebar-header">
87
+ <span class="flm-text flm-text--large flm-text--bold">Effects Studio</span>
88
+ </div>
89
+
90
+ <!-- Project picker -->
91
+ <div class="sfx-sidebar-actions">
92
+ <label class="flm-text flm-text--small flm-text--secondary" style="margin-bottom:2px;">Project</label>
93
+ <select class="flm-textfield-input" id="projectSelect"></select>
94
+ <div style="display:flex;gap:6px;">
95
+ <button class="flm-button flm-button--primary" id="scanPageBtn" style="flex:1;">Scan</button>
96
+ <button class="flm-button" id="addEffectBtn" data-icon="Add" style="flex:1;">Add Effect</button>
97
+ <button class="flm-button" id="applyBtn" style="flex:1;">Apply</button>
98
+ </div>
99
+ </div>
100
+
101
+ <!-- Scan status -->
102
+ <div id="scanStatus" class="flm-messagebar flm-messagebar--info" style="display:none;margin:8px 16px 0;font-size:11px;"></div>
103
+
104
+ <!-- Effects grid for selected project -->
105
+ <div class="sfx-list" id="effectsList"></div>
106
+ </div>
107
+
108
+ <!-- ======== MAIN PANEL ======== -->
109
+ <div class="sfx-main">
110
+ <!-- Connector status -->
111
+ <div id="connectorStatus" style="padding:12px 20px 0;"></div>
112
+
113
+ <!-- Empty state / instructions -->
114
+ <div class="sfx-instructions" id="sfxInstructions">
115
+ <i class="flm-icon flm-icon--large" data-icon="MusicInCollection" style="font-size:64px;opacity:0.6;margin-bottom:20px;"></i>
116
+ <span class="flm-text flm-text--xLarge flm-text--bold flm-text--block" style="margin-bottom:12px;">Effects Studio</span>
117
+ <span class="flm-text flm-text--secondary flm-text--block" style="max-width:320px;line-height:1.5;">Select an effect from the sidebar to edit it, or click "Add Effect" to create a new sound effect.</span>
118
+ </div>
119
+
120
+ <!-- Detail editor -->
121
+ <div class="sfx-detail" id="sfxDetail" style="display:none;">
122
+ <!-- Header: name input -->
123
+ <div class="sfx-detail-header">
124
+ <div class="flm-textfield" style="flex:1;">
125
+ <input class="flm-textfield-input" id="effectName" placeholder="Effect name...">
126
+ </div>
127
+ <span class="flm-text flm-text--small flm-text--secondary" id="editorSubtitle"></span>
128
+ </div>
129
+
130
+ <!-- Scrollable body -->
131
+ <div class="sfx-detail-body">
132
+ <!-- AI instruction bar -->
133
+ <div style="display:flex;gap:8px;">
134
+ <div class="flm-textfield" style="flex:1;">
135
+ <input class="flm-textfield-input" id="aiInstruction" placeholder="Describe the effect you want, or a change to make...">
136
+ </div>
137
+ <button class="flm-button flm-button--primary" id="aiInstructBtn" style="flex-shrink:0;white-space:nowrap;">Update</button>
138
+ </div>
139
+ <div id="statusMessage" class="flm-messagebar" style="display:none;"></div>
140
+
141
+ <!-- Sound Description -->
142
+ <div class="sfx-section">
143
+ <h3>Sound Description</h3>
144
+ <div class="flm-textfield">
145
+ <textarea class="flm-textfield-input" id="effectPrompt" rows="3" placeholder="Describe the sound effect..."></textarea>
146
+ </div>
147
+ </div>
148
+
149
+ <!-- Usage Instructions -->
150
+ <div class="sfx-section">
151
+ <h3>Usage</h3>
152
+ <div class="flm-textfield">
153
+ <textarea class="flm-textfield-input" id="effectUsage" rows="2" placeholder="How should this effect be used? e.g. 'Play on bullet fire', 'Ambient background loop during gameplay'..."></textarea>
154
+ </div>
155
+ </div>
156
+
157
+ <!-- Generation Settings -->
158
+ <div class="sfx-section">
159
+ <h3>Generation Settings</h3>
160
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
161
+ <div class="slider-row"><label>Duration <span id="durationValue">Auto</span></label><input type="range" id="duration" min="0" max="22" value="0" step="0.5"></div>
162
+ <div class="slider-row"><label>Prompt Influence <span id="influenceValue">30%</span></label><input type="range" id="influence" min="0" max="100" value="30"></div>
163
+ </div>
164
+ </div>
165
+
166
+ <!-- Loop Settings -->
167
+ <div class="sfx-section">
168
+ <h3>Loop Settings</h3>
169
+ <label class="flm-toggle flm-toggle--inline">
170
+ <span class="flm-toggle-label">Enable looping</span>
171
+ <input type="checkbox" class="flm-toggle-input" id="loopEnabled">
172
+ <span class="flm-toggle-track"><span class="flm-toggle-thumb"></span></span>
173
+ <span class="flm-toggle-state" data-on="On" data-off="Off"></span>
174
+ </label>
175
+ <div id="loopOptions" style="display:none;margin-top:10px;">
176
+ <label class="flm-toggle flm-toggle--inline" style="margin-bottom:10px;">
177
+ <span class="flm-toggle-label">Ambient</span>
178
+ <input type="checkbox" class="flm-toggle-input" id="ambientEnabled">
179
+ <span class="flm-toggle-track"><span class="flm-toggle-thumb"></span></span>
180
+ <span class="flm-toggle-state" data-on="On" data-off="Off"></span>
181
+ </label>
182
+ <p id="ambientHint" class="flm-text flm-text--small flm-text--secondary" style="margin-bottom:10px;display:none;">Gapless looping via Web Audio API — ideal for background ambience.</p>
183
+ <div id="delayOptions">
184
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
185
+ <div class="slider-row"><label>Min Delay <span id="minDelayValue">0s</span></label><input type="range" id="minDelay" min="0" max="10" value="0" step="0.5"></div>
186
+ <div class="slider-row"><label>Max Delay <span id="maxDelayValue">0s</span></label><input type="range" id="maxDelay" min="0" max="10" value="0" step="0.5"></div>
187
+ </div>
188
+ <div class="slider-row"><label>Repeat Count <span id="repeatValue">Infinite</span></label><input type="range" id="repeatCount" min="0" max="20" value="0"></div>
189
+ <p class="flm-text flm-text--small flm-text--secondary">Repeat 0 = infinite loop</p>
190
+ </div>
191
+ </div>
192
+ </div>
193
+
194
+ <!-- Audio Preview -->
195
+ <div class="sfx-section" id="audioPreviewSection">
196
+ <h3>Audio Preview</h3>
197
+ <div style="display:flex;gap:8px;margin-bottom:12px;">
198
+ <button class="flm-button" id="generateBtn" style="flex:1;padding:12px;">Generate New</button>
199
+ <button class="flm-button flm-button--primary" id="playCurrentBtn" disabled style="flex:1;padding:12px;">Play Current</button>
200
+ </div>
201
+ <div id="audioSection" style="display:none;">
202
+ <div class="waveform-container" id="waveform"></div>
203
+ <audio id="audioPlayer" controls style="width:100%;"></audio>
204
+ <div id="historySection" style="display:none;margin-top:8px;">
205
+ <label class="flm-text flm-text--small flm-text--secondary">History</label>
206
+ <select class="flm-dropdown-select" id="historySelect" style="width:100%;margin-top:4px;"></select>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ </div>
211
+
212
+ <!-- Footer: save toast + delete / save -->
213
+ <div class="sfx-detail-footer">
214
+ <div id="saveToast" class="flm-messagebar flm-messagebar--success save-toast" style="margin-bottom:8px;"></div>
215
+ <div class="sfx-actions">
216
+ <button class="flm-button" id="deleteEffectBtn" data-icon="Delete" style="color:var(--errorText);">Delete</button>
217
+ <button class="flm-button" id="saveEffectBtn" data-icon="Save">Save</button>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ </div>
222
+ </div>
223
+
224
+ <!-- Delete confirm dialog -->
225
+ <div id="deleteConfirmDialog" class="flm-dialog-overlay" data-light-dismiss>
226
+ <div class="flm-dialog" style="max-width:400px;width:90%;">
227
+ <div class="flm-dialog-header"><h2 class="flm-dialog-title">Delete Effect</h2></div>
228
+ <div class="flm-dialog-body"><p class="flm-text">Are you sure you want to delete this effect? This cannot be undone.</p></div>
229
+ <div class="flm-dialog-footer">
230
+ <div style="flex:1;"></div>
231
+ <button class="flm-button" id="deleteConfirmCancel">Cancel</button>
232
+ <button class="flm-button" id="deleteConfirmOk" style="color:var(--errorText);">Delete</button>
233
+ </div>
234
+ </div>
235
+ </div>
236
+
237
+ <div id="loadingOverlay" class="loading-overlay"><div class="spinner"></div></div>
238
+ </div>
239
+ <div id="instructions" style="display:none;" data-locked="true"></div>
240
+ <div id="thoughts" style="display:none;" data-locked="true"></div>
241
+ <script id="page-helpers" src="/api/page-helpers.js?v=3" data-locked="true"></script>
242
+ <script id="page-script" src="/api/page-script.js?v=3" data-locked="true"></script>
243
+ <script>
244
+ // ============ STATE ============
245
+ let effects = [];
246
+ let currentId = null; // selected effect in sidebar
247
+ let currentBlob = null;
248
+ let currentProject = ''; // currently selected project (page name)
249
+ let availablePages = []; // { name, title } objects
250
+ let isDirty = false;
251
+ let scannedPages = new Set(); // pages that have been scanned
252
+
253
+ const $ = id => document.getElementById(id);
254
+ const audioPlayer = $('audioPlayer');
255
+
256
+ // ============ LOOP MANAGER ============
257
+ class LoopManager {
258
+ constructor() {
259
+ this.activeLoops = new Map();
260
+ this._audioCtx = null;
261
+ }
262
+
263
+ _getAudioCtx() {
264
+ if (!this._audioCtx) this._audioCtx = new (window.AudioContext || window.webkitAudioContext)();
265
+ return this._audioCtx;
266
+ }
267
+
268
+ isPlaying(effectId) { return this.activeLoops.has(effectId); }
269
+
270
+ async start(effect) {
271
+ if (this.isPlaying(effect.id)) { this.stop(effect.id); return; }
272
+ if (!effect.audioUrl) { showStatus('No audio generated for this effect', 'error'); return; }
273
+
274
+ const loop = effect.loop || {};
275
+ const minDelay = (loop.minDelay || 0) * 1000;
276
+ const maxDelay = (loop.maxDelay || 0) * 1000;
277
+ const maxRepeats = loop.repeat || 0;
278
+ const useWebAudio = minDelay === 0 && maxDelay === 0 && maxRepeats === 0;
279
+
280
+ if (useWebAudio) {
281
+ // Web Audio API path — truly gapless looping
282
+ try {
283
+ const ctx = this._getAudioCtx();
284
+ if (ctx.state === 'suspended') await ctx.resume();
285
+ const response = await fetch(effect.audioUrl);
286
+ const arrayBuf = await response.arrayBuffer();
287
+ const audioBuffer = await ctx.decodeAudioData(arrayBuf);
288
+ const source = ctx.createBufferSource();
289
+ source.buffer = audioBuffer;
290
+ source.loop = true;
291
+ const gain = ctx.createGain();
292
+ gain.gain.value = 1;
293
+ source.connect(gain);
294
+ gain.connect(ctx.destination);
295
+ source.start(0);
296
+ this.activeLoops.set(effect.id, { effect, webAudio: true, source, gain, ctx });
297
+ } catch (err) {
298
+ console.error('Gapless playback failed:', err);
299
+ showStatus('Playback failed: ' + err.message, 'error');
300
+ return;
301
+ }
302
+ } else {
303
+ // HTML Audio path — delay-based loops
304
+ const audio = new Audio(effect.audioUrl);
305
+ audio.preload = 'auto';
306
+ const loopState = { effect, audio, timeoutId: null, playCount: 0 };
307
+
308
+ this.activeLoops.set(effect.id, loopState);
309
+ loopState.playCount = 1;
310
+ audio.play().catch(err => { console.error('Audio play failed:', err); this.stop(effect.id); });
311
+
312
+ audio.onended = () => {
313
+ if (!this.activeLoops.has(effect.id)) return;
314
+ loopState.playCount++;
315
+ if (maxRepeats > 0 && loopState.playCount > maxRepeats) { this.stop(effect.id); return; }
316
+ const delay = minDelay + Math.random() * (maxDelay - minDelay);
317
+ loopState.timeoutId = setTimeout(() => {
318
+ if (!this.activeLoops.has(effect.id)) return;
319
+ audio.currentTime = 0;
320
+ audio.play().catch(() => this.stop(effect.id));
321
+ }, delay);
322
+ };
323
+ }
324
+
325
+ renderList();
326
+ }
327
+
328
+ stop(effectId) {
329
+ const state = this.activeLoops.get(effectId);
330
+ if (!state) return;
331
+
332
+ if (state.webAudio) {
333
+ // Web Audio cleanup
334
+ try { state.source.stop(); } catch (e) {}
335
+ try { state.source.disconnect(); } catch (e) {}
336
+ try { state.gain.disconnect(); } catch (e) {}
337
+ } else {
338
+ // HTML Audio cleanup
339
+ if (state.timeoutId) clearTimeout(state.timeoutId);
340
+ if (state.audio) {
341
+ state.audio.loop = false;
342
+ state.audio.pause();
343
+ state.audio.currentTime = 0;
344
+ }
345
+ }
346
+
347
+ this.activeLoops.delete(effectId);
348
+ renderList();
349
+ }
350
+
351
+ stopAll() { Array.from(this.activeLoops.keys()).forEach(id => this.stop(id)); }
352
+
353
+ getActiveLoops() {
354
+ return Array.from(this.activeLoops.entries()).map(([id, state]) => ({
355
+ id, name: state.effect.name, playCount: state.playCount || 0, maxRepeats: state.effect.loop?.repeat || 0
356
+ }));
357
+ }
358
+ }
359
+
360
+ const loopManager = new LoopManager();
361
+
362
+ // ============ INIT ============
363
+ window.addEventListener('load', async () => {
364
+ await checkConnector();
365
+ await loadData();
366
+ await loadPages();
367
+ // Default to neon_asteroids
368
+ if (availablePages.find(p => p.name === 'neon_asteroids')) {
369
+ currentProject = 'neon_asteroids';
370
+ } else if (availablePages.length > 0) {
371
+ currentProject = availablePages[0].name;
372
+ }
373
+ $('projectSelect').value = currentProject;
374
+ if (currentProject) await importFromPageFiles(currentProject);
375
+ renderList();
376
+ });
377
+
378
+ // ============ CONNECTOR CHECK ============
379
+ async function checkConnector() {
380
+ try {
381
+ const connectors = await synthos.connectors.list({ id: 'elevenlabs' });
382
+ const el = Array.isArray(connectors) ? connectors.find(c => c.id === 'elevenlabs') : null;
383
+ if (!el || !el.configured) {
384
+ $('connectorStatus').innerHTML = '<div class="flm-messagebar flm-messagebar--warning">ElevenLabs connector not configured. Go to <a href="/settings" class="flm-link">Settings</a> &gt; Connectors to set up your API key.</div>';
385
+ }
386
+ } catch (e) {
387
+ $('connectorStatus').innerHTML = '<div class="flm-messagebar flm-messagebar--warning">Could not check connector status.</div>';
388
+ }
389
+ }
390
+
391
+ // ============ DATA PERSISTENCE (shared table) ============
392
+ async function loadData() {
393
+ try {
394
+ const rows = await synthos.shared.data.list('elevenlabs_effects');
395
+ const items = Array.isArray(rows) ? rows : rows?.items || [];
396
+ effects = [];
397
+ for (const row of items) {
398
+ if (row.id !== '_selections') {
399
+ effects.push(row);
400
+ }
401
+ }
402
+ } catch (e) { /* first run, no data yet */ }
403
+ }
404
+
405
+ async function saveEffect(effectData) {
406
+ await synthos.shared.data.save('elevenlabs_effects', effectData);
407
+ }
408
+
409
+ async function removeEffect(id) {
410
+ await synthos.shared.data.remove('elevenlabs_effects', id);
411
+ }
412
+
413
+ // ============ PROJECTS (PAGES) ============
414
+ async function loadPages() {
415
+ try {
416
+ const pages = await synthos.pages.list();
417
+ availablePages = (pages || []).map(p => ({ name: p.name, title: p.title || p.name }));
418
+ const select = $('projectSelect');
419
+ const prev = select.value;
420
+ select.innerHTML = '';
421
+ availablePages.forEach(p => {
422
+ const opt = document.createElement('option');
423
+ opt.value = p.name;
424
+ opt.textContent = p.title;
425
+ select.appendChild(opt);
426
+ });
427
+ if (prev && availablePages.find(p => p.name === prev)) select.value = prev;
428
+ } catch (e) {}
429
+ }
430
+
431
+ $('projectSelect').addEventListener('change', async (e) => {
432
+ currentProject = e.target.value;
433
+ await importFromPageFiles(currentProject);
434
+ showInstructions();
435
+ renderList();
436
+ updateApplyBtn();
437
+ updateScanBtn();
438
+ });
439
+
440
+ // ============ IMPORT FROM PAGE FILES ============
441
+ // When selecting a project, check its files/ for effects.json and import any
442
+ // effects that aren't already tracked in shared data.
443
+ async function importFromPageFiles(pageName) {
444
+ try {
445
+ const res = await fetch('/api/files/' + encodeURIComponent(pageName) + '/effects.json');
446
+ if (!res.ok) return; // no manifest — nothing to import
447
+ const manifest = await res.json();
448
+ if (!manifest?.effects?.length) return;
449
+
450
+ const existing = getProjectEffects();
451
+ const existingNames = new Set(existing.map(e => e.name));
452
+ let imported = 0;
453
+
454
+ for (const me of manifest.effects) {
455
+ if (existingNames.has(me.name)) continue; // already tracked
456
+ const newId = 'eff_' + Date.now() + '_' + imported;
457
+ const audioUrl = '/api/files/' + encodeURIComponent(pageName) + '/' + encodeURIComponent(me.file);
458
+ const effectData = {
459
+ id: newId,
460
+ name: me.name,
461
+ prompt: '',
462
+ usage: '',
463
+ duration: me.duration || 0,
464
+ influence: me.influence || 30,
465
+ loop: {
466
+ enabled: me.loop?.enabled || false,
467
+ ambient: me.loop?.ambient || false,
468
+ minDelay: me.loop?.minDelay || 0,
469
+ maxDelay: me.loop?.maxDelay || 0,
470
+ repeat: me.loop?.repeat || 0
471
+ },
472
+ audioUrl,
473
+ audioHistory: [{ url: audioUrl, timestamp: Date.now(), prompt: 'Imported from page files' }],
474
+ targetPage: pageName
475
+ };
476
+ effects.push(effectData);
477
+ await saveEffect(effectData);
478
+ imported++;
479
+ }
480
+ if (imported > 0) renderList();
481
+ } catch (e) { /* ignore fetch errors */ }
482
+ }
483
+
484
+ // ============ RENDER SIDEBAR LIST ============
485
+ function getAudioPage(url) {
486
+ if (!url) return null;
487
+ const m = url.match(/^\/api\/files\/([^/]+)\//);
488
+ return m ? decodeURIComponent(m[1]) : null;
489
+ }
490
+
491
+ function getProjectEffects() {
492
+ return effects.filter(e => {
493
+ const page = e.targetPage || getAudioPage(e.audioUrl) || '';
494
+ return page === currentProject;
495
+ });
496
+ }
497
+
498
+ function renderList() {
499
+ const list = $('effectsList');
500
+ const projectEffects = getProjectEffects();
501
+
502
+ if (projectEffects.length === 0) {
503
+ list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--bodySubtext);font-size:13px;">No effects for this project.<br>Click "Add Effect" or "Scan Page" to get started.</div>';
504
+ list.className = 'sfx-list';
505
+ return;
506
+ }
507
+
508
+ list.innerHTML = '';
509
+ list.className = 'sfx-list sfx-effects-grid';
510
+
511
+ projectEffects.forEach(e => list.appendChild(createEffectItem(e)));
512
+ }
513
+
514
+ function createEffectItem(e) {
515
+ const item = document.createElement('div');
516
+ const isSelected = currentId === e.id;
517
+ item.className = 'sfx-grid-item' + (isSelected ? ' sfx-grid-item--selected' : '');
518
+
519
+ // Name
520
+ const nameEl = document.createElement('div');
521
+ nameEl.className = 'sfx-grid-item-name';
522
+ nameEl.textContent = e.name || 'Untitled';
523
+ item.appendChild(nameEl);
524
+
525
+ // Meta
526
+ const durText = e.duration == 0 ? 'Auto' : e.duration + 's';
527
+ const metaEl = document.createElement('div');
528
+ metaEl.className = 'sfx-grid-item-meta';
529
+ metaEl.textContent = durText + ' \u2022 ' + (e.influence ?? 30) + '%';
530
+ item.appendChild(metaEl);
531
+
532
+ // Badges
533
+ const badges = document.createElement('div');
534
+ badges.className = 'sfx-grid-item-badges';
535
+ if (e.loop?.ambient) {
536
+ badges.innerHTML += '<span class="effect-loop-badge" style="background:var(--themeLighterAlt);border-color:var(--themePrimary);color:var(--themePrimary);">Ambient</span>';
537
+ } else if (e.loop?.enabled) {
538
+ badges.innerHTML += '<span class="effect-loop-badge">Loop</span>';
539
+ }
540
+ if (loopManager.isPlaying(e.id)) {
541
+ badges.innerHTML += '<span class="sfx-playing-indicator" title="Playing"></span>';
542
+ }
543
+ if (e.audioUrl) {
544
+ badges.innerHTML += '<span style="font-size:10px;color:var(--successText);">\u266B</span>';
545
+ }
546
+ if (badges.childNodes.length > 0) item.appendChild(badges);
547
+
548
+ item.addEventListener('click', () => selectEffect(e.id));
549
+ return item;
550
+ }
551
+
552
+ // ============ SELECT EFFECT (show detail) ============
553
+ function selectEffect(id) {
554
+ currentId = id;
555
+ const e = effects.find(x => x.id === id);
556
+ if (!e) return;
557
+
558
+ $('sfxInstructions').style.display = 'none';
559
+ $('sfxDetail').style.display = '';
560
+
561
+ $('effectName').value = e.name || '';
562
+ $('effectPrompt').value = e.prompt || '';
563
+ $('effectUsage').value = e.usage || '';
564
+ $('duration').value = e.duration ?? 0;
565
+ $('durationValue').textContent = e.duration == 0 ? 'Auto' : e.duration + 's';
566
+ $('influence').value = e.influence ?? 30;
567
+ $('influenceValue').textContent = (e.influence ?? 30) + '%';
568
+ $('loopEnabled').checked = e.loop?.enabled || false;
569
+ $('loopOptions').style.display = e.loop?.enabled ? 'block' : 'none';
570
+ $('ambientEnabled').checked = e.loop?.ambient || false;
571
+ syncAmbientUI();
572
+ $('minDelay').value = e.loop?.minDelay || 0;
573
+ $('minDelayValue').textContent = (e.loop?.minDelay || 0) + 's';
574
+ $('maxDelay').value = e.loop?.maxDelay || 0;
575
+ $('maxDelayValue').textContent = (e.loop?.maxDelay || 0) + 's';
576
+ $('repeatCount').value = e.loop?.repeat || 0;
577
+ $('repeatValue').textContent = (e.loop?.repeat || 0) == 0 ? 'Infinite' : e.loop?.repeat;
578
+
579
+ // Audio preview
580
+ currentBlob = null;
581
+ if (e.audioUrl) {
582
+ audioPlayer.src = e.audioUrl;
583
+ audioPlayer.loop = e.loop?.enabled || false;
584
+ $('audioSection').style.display = '';
585
+ $('playCurrentBtn').disabled = false;
586
+ createWaveform();
587
+ } else {
588
+ $('audioSection').style.display = 'none';
589
+ $('playCurrentBtn').disabled = true;
590
+ }
591
+ populateHistory(e);
592
+ hideStatus();
593
+
594
+ // Subtitle
595
+ $('editorSubtitle').textContent = e._isNew ? 'New Effect' : '';
596
+
597
+ isDirty = false;
598
+ updateSaveBtn();
599
+ renderList();
600
+ }
601
+
602
+ // ============ SHOW INSTRUCTIONS (deselect) ============
603
+ function showInstructions() {
604
+ currentId = null;
605
+ $('sfxInstructions').style.display = '';
606
+ $('sfxDetail').style.display = 'none';
607
+ renderList();
608
+ }
609
+
610
+ // ============ DIRTY TRACKING ============
611
+ function markDirty() { isDirty = true; updateSaveBtn(); }
612
+ function updateSaveBtn() {
613
+ const btn = $('saveEffectBtn');
614
+ if (isDirty) btn.classList.add('flm-button--primary');
615
+ else btn.classList.remove('flm-button--primary');
616
+ }
617
+
618
+ // ============ APPLY BUTTON STATE ============
619
+ function updateApplyBtn() {
620
+ const btn = $('applyBtn');
621
+ const hasWork = getProjectEffects().some(e => e.audioUrl);
622
+ if (hasWork) {
623
+ btn.classList.add('flm-button--primary');
624
+ } else {
625
+ btn.classList.remove('flm-button--primary');
626
+ }
627
+ }
628
+
629
+ function updateScanBtn() {
630
+ const btn = $('scanPageBtn');
631
+ if (scannedPages.has(currentProject)) {
632
+ btn.classList.remove('flm-button--primary');
633
+ } else {
634
+ btn.classList.add('flm-button--primary');
635
+ }
636
+ }
637
+ function showSaveToast() {
638
+ const t = $('saveToast');
639
+ t.textContent = 'Effect saved';
640
+ t.classList.add('show');
641
+ setTimeout(() => t.classList.remove('show'), 2500);
642
+ }
643
+
644
+ // Wire up dirty tracking on inputs
645
+ $('effectName').addEventListener('input', markDirty);
646
+ $('effectPrompt').addEventListener('input', markDirty);
647
+ $('effectUsage').addEventListener('input', markDirty);
648
+ $('duration').addEventListener('input', () => { $('durationValue').textContent = $('duration').value == 0 ? 'Auto' : $('duration').value + 's'; markDirty(); });
649
+ $('influence').addEventListener('input', () => { $('influenceValue').textContent = $('influence').value + '%'; markDirty(); });
650
+ $('loopEnabled').addEventListener('change', () => { $('loopOptions').style.display = $('loopEnabled').checked ? 'block' : 'none'; audioPlayer.loop = $('loopEnabled').checked; syncAmbientUI(); markDirty(); });
651
+ $('ambientEnabled').addEventListener('change', () => { syncAmbientUI(); markDirty(); });
652
+ $('minDelay').addEventListener('input', () => { $('minDelayValue').textContent = $('minDelay').value + 's'; markDirty(); });
653
+ $('maxDelay').addEventListener('input', () => { $('maxDelayValue').textContent = $('maxDelay').value + 's'; markDirty(); });
654
+ $('repeatCount').addEventListener('input', () => { $('repeatValue').textContent = $('repeatCount').value == 0 ? 'Infinite' : $('repeatCount').value; markDirty(); });
655
+
656
+ function syncAmbientUI() {
657
+ const ambient = $('ambientEnabled').checked;
658
+ $('delayOptions').style.display = ambient ? 'none' : '';
659
+ $('ambientHint').style.display = ambient ? '' : 'none';
660
+ }
661
+
662
+ // ============ AI INSTRUCTION ============
663
+ async function handleAiInstruction() {
664
+ const instruction = $('aiInstruction').value.trim();
665
+ if (!instruction) return;
666
+ if (!currentProject) { showStatus('Select a project first', 'error'); return; }
667
+
668
+ const btn = $('aiInstructBtn');
669
+ btn.disabled = true;
670
+ btn.textContent = 'Thinking...';
671
+ $('aiInstruction').disabled = true;
672
+
673
+ // Gather current effect state (if editing an existing effect)
674
+ const current = currentId ? effects.find(e => e.id === currentId) : null;
675
+ const currentState = current ? {
676
+ name: $('effectName').value.trim(),
677
+ description: $('effectPrompt').value.trim(),
678
+ usage: $('effectUsage').value.trim(),
679
+ duration: parseFloat($('duration').value),
680
+ influence: parseInt($('influence').value),
681
+ loop: {
682
+ enabled: $('loopEnabled').checked,
683
+ ambient: $('ambientEnabled').checked,
684
+ minDelay: parseFloat($('minDelay').value),
685
+ maxDelay: parseFloat($('maxDelay').value),
686
+ repeat: parseInt($('repeatCount').value)
687
+ }
688
+ } : null;
689
+
690
+ const prompt = `You are helping configure a sound effect for the ElevenLabs sound generation API. ${currentState ? 'The user has an existing effect and wants to modify it.' : 'The user wants to create a new effect.'}
691
+
692
+ ${currentState ? `Current effect state:
693
+ ${JSON.stringify(currentState, null, 2)}` : 'No existing effect — create from scratch.'}
694
+
695
+ User instruction: "${instruction}"
696
+
697
+ Return ONLY a JSON code block with a single effect object. No other text.
698
+ The object must have these fields:
699
+ - "name": short effect name (2-4 words, use underscores e.g. "Laser_Fire")
700
+ - "description": detailed sound description suitable for ElevenLabs sound generation API (1-2 sentences)
701
+ - "usage": how this effect should be used (e.g. "Play when the player fires a bullet")
702
+ - "duration": number 0-22 (0 = auto, or specific seconds)
703
+ - "influence": number 0-100 (prompt influence percentage, default 30)
704
+ - "loop": { "enabled": boolean, "ambient": boolean (true for gapless background loops), "minDelay": number 0-10, "maxDelay": number 0-10, "repeat": number 0-20 (0=infinite) }
705
+
706
+ ${currentState ? 'Preserve any fields the user did not ask to change.' : 'Fill in sensible defaults based on the user description.'}`;
707
+
708
+ try {
709
+ const result = await synthos.pages.ask(currentProject, prompt);
710
+ const text = typeof result === 'string' ? result : result?.answer || result?.response || JSON.stringify(result);
711
+
712
+ const fenceMatch = text.match(/```json?\s*([\s\S]*?)```/);
713
+ const jsonStr = fenceMatch ? fenceMatch[1].trim() : text.trim();
714
+
715
+ let parsed;
716
+ try { parsed = JSON.parse(jsonStr); } catch (e) { throw new Error('Could not parse AI response as JSON'); }
717
+
718
+ // If response is an array, take the first element
719
+ if (Array.isArray(parsed)) parsed = parsed[0];
720
+
721
+ // Apply fields to the form
722
+ if (parsed.name) $('effectName').value = parsed.name;
723
+ if (parsed.description) $('effectPrompt').value = parsed.description;
724
+ if (parsed.usage !== undefined) $('effectUsage').value = parsed.usage || '';
725
+ if (parsed.duration !== undefined) {
726
+ $('duration').value = Math.max(0, Math.min(22, Number(parsed.duration) || 0));
727
+ $('durationValue').textContent = $('duration').value == 0 ? 'Auto' : $('duration').value + 's';
728
+ }
729
+ if (parsed.influence !== undefined) {
730
+ $('influence').value = Math.max(0, Math.min(100, Math.round(Number(parsed.influence) || 30)));
731
+ $('influenceValue').textContent = $('influence').value + '%';
732
+ }
733
+ if (parsed.loop) {
734
+ $('loopEnabled').checked = Boolean(parsed.loop.enabled);
735
+ $('loopOptions').style.display = parsed.loop.enabled ? 'block' : 'none';
736
+ $('ambientEnabled').checked = Boolean(parsed.loop.ambient);
737
+ syncAmbientUI();
738
+ $('minDelay').value = Math.max(0, Math.min(10, Number(parsed.loop.minDelay) || 0));
739
+ $('minDelayValue').textContent = $('minDelay').value + 's';
740
+ $('maxDelay').value = Math.max(0, Math.min(10, Number(parsed.loop.maxDelay) || 0));
741
+ $('maxDelayValue').textContent = $('maxDelay').value + 's';
742
+ $('repeatCount').value = Math.max(0, Math.min(20, Number(parsed.loop.repeat) || 0));
743
+ $('repeatValue').textContent = $('repeatCount').value == 0 ? 'Infinite' : $('repeatCount').value;
744
+ audioPlayer.loop = parsed.loop.enabled;
745
+ }
746
+
747
+ // If no effect is selected, create one
748
+ if (!currentId) {
749
+ const newId = 'eff_' + Date.now();
750
+ const newEffect = {
751
+ id: newId, name: '', prompt: '', usage: '', duration: 0, influence: 30,
752
+ loop: { enabled: false, ambient: false, minDelay: 0, maxDelay: 0, repeat: 0 },
753
+ audioUrl: null, audioHistory: [], targetPage: currentProject, _isNew: true
754
+ };
755
+ effects.push(newEffect);
756
+ currentId = newId;
757
+ $('sfxInstructions').style.display = 'none';
758
+ $('sfxDetail').style.display = '';
759
+ $('editorSubtitle').textContent = 'New Effect';
760
+ }
761
+
762
+ markDirty();
763
+ showStatus('Effect updated from AI instruction', 'success');
764
+ $('aiInstruction').value = '';
765
+ } catch (e) {
766
+ showStatus('AI instruction failed: ' + e.message, 'error');
767
+ } finally {
768
+ btn.disabled = false;
769
+ btn.textContent = 'Update';
770
+ $('aiInstruction').disabled = false;
771
+ }
772
+ }
773
+
774
+ $('aiInstructBtn').addEventListener('click', handleAiInstruction);
775
+ $('aiInstruction').addEventListener('keydown', (e) => {
776
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleAiInstruction(); }
777
+ });
778
+
779
+ // ============ ADD EFFECT ============
780
+ $('addEffectBtn').addEventListener('click', () => {
781
+ if (!currentProject) { showStatus('Select a project first', 'error'); return; }
782
+ const newId = 'eff_' + Date.now();
783
+ const newEffect = {
784
+ id: newId,
785
+ name: '',
786
+ prompt: '',
787
+ usage: '',
788
+ duration: 0,
789
+ influence: 30,
790
+ loop: { enabled: false, ambient: false, minDelay: 0, maxDelay: 0, repeat: 0 },
791
+ audioUrl: null,
792
+ audioHistory: [],
793
+ targetPage: currentProject,
794
+ _isNew: true
795
+ };
796
+ effects.push(newEffect);
797
+ selectEffect(newId);
798
+ isDirty = true;
799
+ updateSaveBtn();
800
+ });
801
+
802
+ // ============ SAVE EFFECT ============
803
+ $('saveEffectBtn').addEventListener('click', handleSaveEffect);
804
+
805
+ async function handleSaveEffect() {
806
+ if (!currentId) return;
807
+ const name = $('effectName').value.trim();
808
+ if (!name) { showStatus('Enter an effect name', 'error'); return; }
809
+ if (!$('effectPrompt').value.trim()) { showStatus('Enter a sound description', 'error'); return; }
810
+
811
+ const existing = effects.find(e => e.id === currentId);
812
+ let audioUrl = existing?.audioUrl || null;
813
+
814
+ // Save audio blob to the project page's file storage (only for blobs not yet saved by generate)
815
+ const savePage = currentProject;
816
+ if (currentBlob && savePage) {
817
+ try {
818
+ const filename = name.replace(/[^a-zA-Z0-9_-]/g, '_') + '.mp3';
819
+ await fetch('/api/files/' + encodeURIComponent(savePage), {
820
+ method: 'POST',
821
+ headers: { 'x-filename': filename },
822
+ body: currentBlob
823
+ });
824
+ audioUrl = '/api/files/' + encodeURIComponent(savePage) + '/' + encodeURIComponent(filename);
825
+ } catch (e) {
826
+ showStatus('Failed to save audio file: ' + e.message, 'error');
827
+ return;
828
+ }
829
+ } else if (currentBlob && !savePage) {
830
+ audioUrl = URL.createObjectURL(currentBlob);
831
+ }
832
+
833
+ const effectData = {
834
+ id: currentId,
835
+ name,
836
+ prompt: $('effectPrompt').value.trim(),
837
+ usage: $('effectUsage').value.trim(),
838
+ duration: parseFloat($('duration').value),
839
+ influence: parseInt($('influence').value),
840
+ loop: {
841
+ enabled: $('loopEnabled').checked,
842
+ ambient: $('ambientEnabled').checked,
843
+ minDelay: parseFloat($('minDelay').value),
844
+ maxDelay: parseFloat($('maxDelay').value),
845
+ repeat: parseInt($('repeatCount').value)
846
+ },
847
+ audioUrl,
848
+ audioHistory: existing?.audioHistory || [],
849
+ targetPage: savePage
850
+ };
851
+
852
+ const idx = effects.findIndex(e => e.id === currentId);
853
+ if (idx >= 0) effects[idx] = effectData;
854
+ else effects.push(effectData);
855
+
856
+ await saveEffect(effectData);
857
+ isDirty = false;
858
+ updateSaveBtn();
859
+ showSaveToast();
860
+ updateApplyBtn();
861
+ renderList();
862
+ }
863
+
864
+ // ============ DELETE EFFECT ============
865
+ $('deleteEffectBtn').addEventListener('click', () => {
866
+ if (currentId) $('deleteConfirmDialog').classList.add('flm-dialog-overlay--open');
867
+ });
868
+ $('deleteConfirmCancel').addEventListener('click', () => {
869
+ $('deleteConfirmDialog').classList.remove('flm-dialog-overlay--open');
870
+ });
871
+ $('deleteConfirmOk').addEventListener('click', async () => {
872
+ $('deleteConfirmDialog').classList.remove('flm-dialog-overlay--open');
873
+ if (!currentId) return;
874
+ loopManager.stop(currentId);
875
+ effects = effects.filter(e => e.id !== currentId);
876
+ await removeEffect(currentId);
877
+ updateApplyBtn();
878
+ showInstructions();
879
+ });
880
+
881
+ // ============ AUDIO GENERATION (reusable) ============
882
+ async function generateAudioForEffect(effect) {
883
+ const promptText = effect.prompt;
884
+ if (!promptText) throw new Error('No sound description');
885
+
886
+ const body = { text: promptText, prompt_influence: (effect.influence || 30) / 100 };
887
+ if (effect.duration > 0) body.duration_seconds = effect.duration;
888
+
889
+ const res = await fetch('/api/connectors', {
890
+ method: 'POST',
891
+ headers: { 'Content-Type': 'application/json' },
892
+ body: JSON.stringify({
893
+ connector: 'elevenlabs',
894
+ method: 'POST',
895
+ path: '/v1/sound-generation',
896
+ headers: { 'Accept': 'audio/mpeg' },
897
+ body: body
898
+ })
899
+ });
900
+
901
+ console.log('[EffectsStudio] Connector response status:', res.status, res.statusText);
902
+ if (!res.ok) {
903
+ const errText = await res.text();
904
+ console.error('[EffectsStudio] Connector error response:', errText);
905
+ let errMsg = 'Generation failed';
906
+ try { const errJson = JSON.parse(errText); errMsg = errJson.detail?.message || errJson.detail || errJson.error || errMsg; } catch (e) {}
907
+ throw new Error(errMsg);
908
+ }
909
+
910
+ const blob = await res.blob();
911
+ console.log('[EffectsStudio] Audio blob received:', blob.size, 'bytes, type:', blob.type);
912
+ const timestamp = Date.now();
913
+ const savePage = effect.targetPage || currentProject;
914
+ let audioUrl;
915
+
916
+ if (savePage) {
917
+ const effectName = (effect.name || 'effect').replace(/[^a-zA-Z0-9_-]/g, '_');
918
+ const filename = effectName + '_' + timestamp + '.mp3';
919
+ await fetch('/api/files/' + encodeURIComponent(savePage), {
920
+ method: 'POST',
921
+ headers: { 'x-filename': filename },
922
+ body: blob
923
+ });
924
+ audioUrl = '/api/files/' + encodeURIComponent(savePage) + '/' + encodeURIComponent(filename);
925
+ } else {
926
+ audioUrl = URL.createObjectURL(blob);
927
+ }
928
+
929
+ // Update history on the effect
930
+ if (!effect.audioHistory) effect.audioHistory = [];
931
+ effect.audioHistory.unshift({ url: audioUrl, timestamp, prompt: promptText });
932
+ if (effect.audioHistory.length > 10) effect.audioHistory = effect.audioHistory.slice(0, 10);
933
+ effect.audioUrl = audioUrl;
934
+ await saveEffect(effect);
935
+
936
+ return { blob, audioUrl };
937
+ }
938
+
939
+ // ============ GENERATE PREVIEW (UI wrapper) ============
940
+ $('generateBtn').addEventListener('click', generatePreview);
941
+
942
+ async function generatePreview() {
943
+ const prompt = $('effectPrompt').value.trim();
944
+ if (!prompt) { showStatus('Enter a sound description', 'error'); return; }
945
+
946
+ const genBtn = $('generateBtn');
947
+ genBtn.disabled = true;
948
+ genBtn.textContent = 'Generating...';
949
+ showStatus('Generating sound effect...', 'info');
950
+
951
+ try {
952
+ // Build a temporary effect object from the current editor state
953
+ const eff = effects.find(x => x.id === currentId);
954
+ if (!eff) throw new Error('No effect selected');
955
+
956
+ // Sync editor values into the effect before generating
957
+ eff.name = $('effectName').value.trim() || eff.name;
958
+ eff.prompt = prompt;
959
+ eff.duration = parseFloat($('duration').value);
960
+ eff.influence = parseInt($('influence').value);
961
+
962
+ const { blob, audioUrl } = await generateAudioForEffect(eff);
963
+ currentBlob = blob;
964
+
965
+ populateHistory(eff);
966
+ audioPlayer.src = audioUrl;
967
+ audioPlayer.loop = $('loopEnabled').checked;
968
+ $('audioSection').style.display = '';
969
+ $('playCurrentBtn').disabled = false;
970
+ createWaveform();
971
+ hideStatus();
972
+ isDirty = false;
973
+ updateSaveBtn();
974
+ updateApplyBtn();
975
+ audioPlayer.play();
976
+ } catch (e) {
977
+ showStatus('Error: ' + e.message, 'error');
978
+ } finally {
979
+ genBtn.disabled = false;
980
+ genBtn.textContent = 'Generate New';
981
+ }
982
+ }
983
+
984
+ // ============ PLAY CURRENT BUTTON ============
985
+ $('playCurrentBtn').addEventListener('click', () => {
986
+ audioPlayer.currentTime = 0;
987
+ audioPlayer.loop = $('loopEnabled').checked;
988
+ audioPlayer.play();
989
+ });
990
+
991
+ // ============ AUDIO HISTORY ============
992
+ function populateHistory(effect) {
993
+ const history = effect.audioHistory || [];
994
+ const section = $('historySection');
995
+ const select = $('historySelect');
996
+ if (history.length < 2) {
997
+ section.style.display = 'none';
998
+ return;
999
+ }
1000
+ section.style.display = '';
1001
+ select.innerHTML = '';
1002
+ history.forEach((entry, i) => {
1003
+ const opt = document.createElement('option');
1004
+ opt.value = i;
1005
+ opt.textContent = relativeTime(entry.timestamp) + ' \u2014 ' + truncate(entry.prompt, 40);
1006
+ if (entry.url === effect.audioUrl) opt.selected = true;
1007
+ select.appendChild(opt);
1008
+ });
1009
+ }
1010
+
1011
+ $('historySelect').addEventListener('change', () => {
1012
+ if (!currentId) return;
1013
+ const e = effects.find(x => x.id === currentId);
1014
+ if (!e || !e.audioHistory) return;
1015
+ const entry = e.audioHistory[parseInt($('historySelect').value)];
1016
+ if (!entry) return;
1017
+ e.audioUrl = entry.url;
1018
+ audioPlayer.src = entry.url;
1019
+ audioPlayer.loop = $('loopEnabled').checked;
1020
+ $('playCurrentBtn').disabled = false;
1021
+ createWaveform();
1022
+ markDirty();
1023
+ });
1024
+
1025
+ function relativeTime(ts) {
1026
+ const diff = Date.now() - ts;
1027
+ if (diff < 60000) return 'just now';
1028
+ if (diff < 3600000) return Math.floor(diff / 60000) + ' min ago';
1029
+ if (diff < 86400000) return Math.floor(diff / 3600000) + ' hr ago';
1030
+ return Math.floor(diff / 86400000) + ' d ago';
1031
+ }
1032
+
1033
+ function truncate(str, len) {
1034
+ if (!str) return '';
1035
+ return str.length <= len ? str : str.slice(0, len) + '\u2026';
1036
+ }
1037
+
1038
+ // ============ WAVEFORM ============
1039
+ function createWaveform() {
1040
+ const container = $('waveform');
1041
+ container.innerHTML = '';
1042
+ for (let i = 0; i < 40; i++) {
1043
+ const bar = document.createElement('div');
1044
+ bar.className = 'waveform-bar';
1045
+ bar.style.height = '10px';
1046
+ container.appendChild(bar);
1047
+ }
1048
+ audioPlayer.addEventListener('play', animateWaveform);
1049
+ audioPlayer.addEventListener('pause', stopWaveform);
1050
+ audioPlayer.addEventListener('ended', stopWaveform);
1051
+ }
1052
+ let waveformInterval;
1053
+ function animateWaveform() {
1054
+ const bars = $('waveform').querySelectorAll('.waveform-bar');
1055
+ waveformInterval = setInterval(() => { bars.forEach(b => { b.style.height = Math.random() * 40 + 10 + 'px'; }); }, 100);
1056
+ }
1057
+ function stopWaveform() {
1058
+ clearInterval(waveformInterval);
1059
+ $('waveform').querySelectorAll('.waveform-bar').forEach(b => { b.style.height = '10px'; });
1060
+ }
1061
+
1062
+ // ============ STATUS HELPERS ============
1063
+ function showStatus(msg, type) {
1064
+ const el = $('statusMessage');
1065
+ const typeClass = type === 'error' ? 'flm-messagebar--error' : type === 'success' ? 'flm-messagebar--success' : 'flm-messagebar--info';
1066
+ el.className = 'flm-messagebar ' + typeClass;
1067
+ el.textContent = msg;
1068
+ el.style.display = '';
1069
+ }
1070
+ function hideStatus() { $('statusMessage').style.display = 'none'; }
1071
+
1072
+ // ============ UTIL ============
1073
+ function escHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
1074
+
1075
+ // ============ SCAN PAGE FOR EFFECTS ============
1076
+ const SCAN_PROMPT = `Analyze this page's HTML and suggest 3-8 sound effects that would enhance the user experience. Return ONLY a JSON code block with an array of effect objects. No other text.
1077
+
1078
+ Each effect object must have:
1079
+ - "name": short effect name (2-4 words, use underscores e.g. "Laser_Fire")
1080
+ - "description": detailed sound description suitable for ElevenLabs sound generation API (1-2 sentences)
1081
+ - "usage": how this effect should be integrated into the page (e.g. "Play when the player fires a bullet", "Loop as ambient background during gameplay")
1082
+ - "duration": number 0-22 (0 = auto, or specific seconds)
1083
+ - "influence": number 0-100 (prompt influence percentage, default 30)
1084
+ - "loop": { "enabled": boolean, "ambient": boolean (true for gapless background loops), "minDelay": number 0-10, "maxDelay": number 0-10, "repeat": number 0-20 (0=infinite) }
1085
+ - "rationale": brief explanation of why this effect fits the page
1086
+
1087
+ Consider: UI interactions (clicks, hovers, transitions), ambient background sounds, game events, notifications, and thematic atmosphere.
1088
+
1089
+ Example response:
1090
+ \`\`\`json
1091
+ [
1092
+ {
1093
+ "name": "Button_Click",
1094
+ "description": "A soft, satisfying click sound like a modern UI button press",
1095
+ "usage": "Play when any .btn element is clicked",
1096
+ "duration": 0.5,
1097
+ "influence": 40,
1098
+ "loop": { "enabled": false, "minDelay": 0, "maxDelay": 0, "repeat": 0 },
1099
+ "rationale": "Page has interactive buttons that would benefit from audio feedback"
1100
+ }
1101
+ ]
1102
+ \`\`\``;
1103
+
1104
+ $('scanPageBtn').addEventListener('click', () => {
1105
+ if (!currentProject) {
1106
+ showScanStatus('Select a project first.', 'error');
1107
+ return;
1108
+ }
1109
+ doScan(currentProject);
1110
+ });
1111
+
1112
+ // ---- Scan logic (auto-accepts all) ----
1113
+ async function doScan(pageName) {
1114
+ const btn = $('scanPageBtn');
1115
+ btn.disabled = true;
1116
+ btn.textContent = 'Scanning...';
1117
+ $('addEffectBtn').disabled = true;
1118
+ $('applyBtn').disabled = true;
1119
+ $('projectSelect').disabled = true;
1120
+ showScanStatus('Scanning page — analyzing HTML for sound effect opportunities...', 'info');
1121
+
1122
+ try {
1123
+ const result = await synthos.pages.ask(pageName, SCAN_PROMPT);
1124
+ const text = typeof result === 'string' ? result : result?.answer || result?.response || JSON.stringify(result);
1125
+
1126
+ console.log('[EffectsStudio] Raw scan response:', text);
1127
+ const fenceMatch = text.match(/```json?\s*([\s\S]*?)```/);
1128
+ const jsonStr = fenceMatch ? fenceMatch[1].trim() : text.trim();
1129
+ console.log('[EffectsStudio] Extracted JSON string:', jsonStr);
1130
+
1131
+ let parsed;
1132
+ try {
1133
+ parsed = JSON.parse(jsonStr);
1134
+ } catch (e) {
1135
+ console.error('[EffectsStudio] JSON parse error:', e, '\nInput was:', jsonStr);
1136
+ throw new Error('Could not parse AI response as JSON');
1137
+ }
1138
+
1139
+ console.log('[EffectsStudio] Parsed suggestions:', parsed);
1140
+ if (!Array.isArray(parsed) || parsed.length === 0) {
1141
+ throw new Error('AI returned no suggestions');
1142
+ }
1143
+
1144
+ // Auto-accept all suggestions as effects
1145
+ const newEffects = [];
1146
+ let addedCount = 0;
1147
+ for (const s of parsed) {
1148
+ const newId = 'eff_' + Date.now() + '_' + addedCount;
1149
+ const newEffect = {
1150
+ id: newId,
1151
+ name: String(s.name || 'Unnamed Effect').slice(0, 50),
1152
+ prompt: String(s.description || '').slice(0, 300),
1153
+ usage: String(s.usage || '').slice(0, 300),
1154
+ duration: Math.max(0, Math.min(22, Number(s.duration) || 0)),
1155
+ influence: Math.max(0, Math.min(100, Math.round(Number(s.influence) || 30))),
1156
+ loop: {
1157
+ enabled: Boolean(s.loop?.enabled),
1158
+ ambient: Boolean(s.loop?.ambient),
1159
+ minDelay: Math.max(0, Math.min(10, Number(s.loop?.minDelay) || 0)),
1160
+ maxDelay: Math.max(0, Math.min(10, Number(s.loop?.maxDelay) || 0)),
1161
+ repeat: Math.max(0, Math.min(20, Math.round(Number(s.loop?.repeat) || 0)))
1162
+ },
1163
+ audioUrl: null,
1164
+ audioHistory: [],
1165
+ targetPage: currentProject
1166
+ };
1167
+ effects.push(newEffect);
1168
+ try {
1169
+ await saveEffect(newEffect);
1170
+ console.log('[EffectsStudio] Saved effect:', newEffect.id, newEffect.name);
1171
+ } catch (saveErr) {
1172
+ console.error('[EffectsStudio] Failed to save effect:', newEffect.id, newEffect.name, saveErr);
1173
+ }
1174
+ newEffects.push(newEffect);
1175
+ addedCount++;
1176
+ }
1177
+
1178
+ showScanStatus(`Found ${addedCount} effect${addedCount === 1 ? '' : 's'} — generating audio files...`, 'info');
1179
+ renderList();
1180
+
1181
+ // Auto-generate audio for each new effect sequentially with delay to avoid rate limits
1182
+ let genCount = 0;
1183
+ let genFailed = 0;
1184
+ for (let i = 0; i < newEffects.length; i++) {
1185
+ const eff = newEffects[i];
1186
+ // Pause between requests to avoid ElevenLabs rate limiting
1187
+ if (i > 0) await new Promise(r => setTimeout(r, 1500));
1188
+ try {
1189
+ showScanStatus(`Generating ${genCount + 1} of ${newEffects.length}: "${eff.name}"...`, 'info');
1190
+ console.log('[EffectsStudio] Generating audio for:', eff.name, 'prompt:', eff.prompt, 'duration:', eff.duration, 'influence:', eff.influence);
1191
+ await generateAudioForEffect(eff);
1192
+ console.log('[EffectsStudio] Audio generated OK:', eff.name, 'url:', eff.audioUrl);
1193
+ genCount++;
1194
+ } catch (e) {
1195
+ console.error('[EffectsStudio] Failed to generate audio for "' + eff.name + '":', e.message, e);
1196
+ genFailed++;
1197
+ }
1198
+ renderList();
1199
+ }
1200
+
1201
+ let statusMsg = `Done — ${genCount} of ${addedCount} audio file${genCount === 1 ? '' : 's'} generated`;
1202
+ if (genFailed > 0) statusMsg += `, ${genFailed} failed`;
1203
+ showScanStatus(statusMsg, genFailed > 0 ? 'warning' : 'success');
1204
+ scannedPages.add(pageName);
1205
+ updateApplyBtn();
1206
+ updateScanBtn();
1207
+ renderList();
1208
+ } catch (e) {
1209
+ console.error('[EffectsStudio] Scan failed:', e.message, e);
1210
+ showScanStatus('Scan failed: ' + e.message, 'error');
1211
+ } finally {
1212
+ btn.disabled = false;
1213
+ btn.textContent = 'Scan';
1214
+ $('addEffectBtn').disabled = false;
1215
+ $('applyBtn').disabled = false;
1216
+ $('projectSelect').disabled = false;
1217
+ }
1218
+ }
1219
+
1220
+ // ============ APPLY EFFECTS ============
1221
+ $('applyBtn').addEventListener('click', applyEffects);
1222
+
1223
+ async function applyEffects() {
1224
+ if (!currentProject) { showScanStatus('Select a project first.', 'error'); return; }
1225
+
1226
+ const projectEffects = getProjectEffects().filter(e => e.audioUrl);
1227
+ if (projectEffects.length === 0) {
1228
+ showScanStatus('No effects with audio to apply. Generate audio first.', 'error');
1229
+ return;
1230
+ }
1231
+
1232
+ const btn = $('applyBtn');
1233
+ btn.disabled = true;
1234
+ btn.textContent = 'Applying...';
1235
+ $('addEffectBtn').disabled = true;
1236
+ $('scanPageBtn').disabled = true;
1237
+ $('projectSelect').disabled = true;
1238
+
1239
+ try {
1240
+ // Build effects.json manifest
1241
+ const manifest = {
1242
+ effects: projectEffects.map(e => {
1243
+ // Extract filename from audioUrl
1244
+ const urlParts = (e.audioUrl || '').split('/');
1245
+ const file = decodeURIComponent(urlParts[urlParts.length - 1] || '');
1246
+ return {
1247
+ name: (e.name || 'Untitled').replace(/[^a-zA-Z0-9_-]/g, '_'),
1248
+ file: file,
1249
+ loop: {
1250
+ enabled: e.loop?.enabled || false,
1251
+ ambient: e.loop?.ambient || false,
1252
+ minDelay: e.loop?.minDelay || 0,
1253
+ maxDelay: e.loop?.maxDelay || 0,
1254
+ repeat: e.loop?.repeat || 0
1255
+ },
1256
+ duration: e.duration || 0,
1257
+ influence: e.influence || 30
1258
+ };
1259
+ })
1260
+ };
1261
+
1262
+ // Save effects.json to the target page's file storage
1263
+ // Use octet-stream to prevent Express global JSON parser from consuming the body
1264
+ const manifestBlob = new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/octet-stream' });
1265
+ await fetch('/api/files/' + encodeURIComponent(currentProject), {
1266
+ method: 'POST',
1267
+ headers: { 'x-filename': 'effects.json' },
1268
+ body: manifestBlob
1269
+ });
1270
+
1271
+ // Save per-effect JSON files
1272
+ for (const e of projectEffects) {
1273
+ const effectMeta = {
1274
+ name: (e.name || 'Untitled').replace(/[^a-zA-Z0-9_-]/g, '_'),
1275
+ prompt: e.prompt || '',
1276
+ usage: e.usage || '',
1277
+ duration: e.duration || 0,
1278
+ influence: e.influence || 30,
1279
+ loop: {
1280
+ enabled: e.loop?.enabled || false,
1281
+ ambient: e.loop?.ambient || false,
1282
+ minDelay: e.loop?.minDelay || 0,
1283
+ maxDelay: e.loop?.maxDelay || 0,
1284
+ repeat: e.loop?.repeat || 0
1285
+ }
1286
+ };
1287
+ const metaBlob = new Blob([JSON.stringify(effectMeta, null, 2)], { type: 'application/octet-stream' });
1288
+ const metaFilename = effectMeta.name + '.json';
1289
+ await fetch('/api/files/' + encodeURIComponent(currentProject), {
1290
+ method: 'POST',
1291
+ headers: { 'x-filename': metaFilename },
1292
+ body: metaBlob
1293
+ });
1294
+ }
1295
+
1296
+ // Build integration instructions and send to the target page
1297
+ showScanStatus('Sending integration instructions to page...', 'info');
1298
+ const effectsInstructions = projectEffects.map(e => {
1299
+ const safeName = (e.name || 'Untitled').replace(/[^a-zA-Z0-9_-]/g, '_');
1300
+ const urlParts = (e.audioUrl || '').split('/');
1301
+ const file = decodeURIComponent(urlParts[urlParts.length - 1] || '');
1302
+ const loopInfo = e.loop?.ambient ? 'ambient (gapless loop)' : e.loop?.enabled ? `loop (delay ${e.loop.minDelay}-${e.loop.maxDelay}s)` : 'one-shot';
1303
+ return `- **${safeName}**: ${e.usage || e.prompt || 'No description'} [${loopInfo}] — file: \`${file}\``;
1304
+ }).join('\n');
1305
+
1306
+ const integrationMessage = `# Sound Effects Integration
1307
+
1308
+ An effects.json manifest and audio files have been saved to this page's file storage.
1309
+
1310
+ ## Loading sound effects
1311
+
1312
+ Add this script tag to the page (if not already present):
1313
+ \`\`\`html
1314
+ <` + `script src="/static/retro-game.js"></` + `script>
1315
+ \`\`\`
1316
+
1317
+ Then initialize and load:
1318
+ \`\`\`javascript
1319
+ const sfx = RetroGame.createSoundEffects();
1320
+ await sfx.load();
1321
+ \`\`\`
1322
+
1323
+ ## Available effects and how to use them:
1324
+ ${effectsInstructions}
1325
+
1326
+ ## Playing effects
1327
+ \`\`\`javascript
1328
+ sfx.play('Effect_Name'); // play by name
1329
+ sfx.stop('Effect_Name'); // stop a looping/ambient effect
1330
+ sfx.stopAll(); // stop everything
1331
+ \`\`\`
1332
+
1333
+ Please integrate these sound effects into the page according to the usage instructions above. Add the loading code and trigger each effect at the appropriate points described.`;
1334
+
1335
+ await fetch('/' + encodeURIComponent(currentProject), {
1336
+ method: 'POST',
1337
+ headers: { 'Content-Type': 'application/json' },
1338
+ body: JSON.stringify({ message: integrationMessage })
1339
+ });
1340
+
1341
+ const pageTitle = availablePages.find(p => p.name === currentProject)?.title || currentProject;
1342
+ showScanStatus(`Applied ${projectEffects.length} effect${projectEffects.length === 1 ? '' : 's'} to ${pageTitle}`, 'success');
1343
+ updateApplyBtn();
1344
+ } catch (e) {
1345
+ showScanStatus('Apply failed: ' + e.message, 'error');
1346
+ } finally {
1347
+ btn.disabled = false;
1348
+ btn.textContent = 'Apply';
1349
+ $('addEffectBtn').disabled = false;
1350
+ $('scanPageBtn').disabled = false;
1351
+ $('projectSelect').disabled = false;
1352
+ }
1353
+ }
1354
+
1355
+ function showScanStatus(msg, type) {
1356
+ const el = $('scanStatus');
1357
+ const typeMap = { error: 'flm-messagebar--error', success: 'flm-messagebar--success', warning: 'flm-messagebar--warning', info: 'flm-messagebar--info' };
1358
+ el.className = 'flm-messagebar ' + (typeMap[type] || typeMap.info);
1359
+ el.textContent = msg;
1360
+ el.style.display = '';
1361
+ }
1362
+ </script>
1363
+ </body></html>