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.
- package/README.md +215 -65
- package/default-pages/application/page.html +42 -0
- package/default-pages/application/page.json +10 -0
- package/default-pages/elevenlabs_effects_studio/page.html +1363 -0
- package/default-pages/elevenlabs_effects_studio/page.json +11 -0
- package/default-pages/elevenlabs_voice_studio/page.html +801 -0
- package/default-pages/elevenlabs_voice_studio/page.json +11 -0
- package/default-pages/{json_tools.html → json_tools/page.html} +13 -11
- package/default-pages/json_tools/page.json +10 -0
- package/default-pages/my_notes/notes/a1b2c3d4-e5f6-7890-abcd-ef1234567890.json +5 -0
- package/default-pages/my_notes/page.html +132 -0
- package/default-pages/{my_notes.json → my_notes/page.json} +2 -2
- package/default-pages/neon_asteroids/files/Ambient_Space.mp3 +0 -0
- package/default-pages/neon_asteroids/files/Ambient_Space2.mp3 +0 -0
- package/default-pages/neon_asteroids/files/Ambient_Space3.mp3 +0 -0
- package/default-pages/neon_asteroids/files/Asteroid_Explosion.mp3 +0 -0
- package/default-pages/neon_asteroids/files/Hyperspace_Jump.mp3 +0 -0
- package/default-pages/neon_asteroids/files/Laser_Fire.mp3 +0 -0
- package/default-pages/neon_asteroids/files/Menu_Navigate.mp3 +0 -0
- package/default-pages/neon_asteroids/files/Power_Up_Collect.mp3 +0 -0
- package/default-pages/neon_asteroids/files/Saucer_Alert.mp3 +0 -0
- package/default-pages/neon_asteroids/files/Ship_Thrust.mp3 +0 -0
- package/default-pages/neon_asteroids/files/effects.json +74 -0
- package/default-pages/neon_asteroids/page.html +1822 -0
- package/default-pages/{neon_asteroids.json → neon_asteroids/page.json} +3 -3
- package/default-pages/oregon_trail/page.html +323 -0
- package/default-pages/oregon_trail/page.json +12 -0
- package/default-pages/retro_game_starter/page.html +1308 -0
- package/default-pages/retro_game_starter/page.json +12 -0
- package/default-pages/{sidebar_builder.html → sidebar_page/page.html} +12 -10
- package/default-pages/sidebar_page/page.json +10 -0
- package/default-pages/{solar_explorer.html → solar_explorer/page.html} +24 -29
- package/default-pages/{solar_explorer.json → solar_explorer/page.json} +4 -4
- package/default-pages/{solar_tutorial.html → solar_tutorial/page.html} +12 -10
- package/default-pages/solar_tutorial/page.json +10 -0
- package/default-pages/{two-panel_builder.html → two-panel_page/page.html} +13 -11
- package/default-pages/two-panel_page/page.json +10 -0
- package/default-pages/us_map/page.html +193 -0
- package/default-pages/us_map/page.json +12 -0
- package/default-pages/us_map_1850/page.html +326 -0
- package/default-pages/us_map_1850/page.json +12 -0
- package/default-pages/western_cities_1850/page.html +527 -0
- package/default-pages/western_cities_1850/page.json +12 -0
- package/default-themes/aurora-dawn.json +19 -0
- package/default-themes/aurora-dawn.v3.css +198 -0
- package/default-themes/aurora-dusk.json +19 -0
- package/default-themes/aurora-dusk.v3.css +200 -0
- package/default-themes/cosmos-dawn.json +19 -0
- package/default-themes/cosmos-dawn.v3.css +198 -0
- package/default-themes/cosmos-dusk.json +19 -0
- package/default-themes/cosmos-dusk.v3.css +200 -0
- package/default-themes/high-contrast-dark.json +19 -0
- package/default-themes/high-contrast-dark.v3.css +200 -0
- package/default-themes/high-contrast-light.json +19 -0
- package/default-themes/high-contrast-light.v3.css +198 -0
- package/default-themes/{nebula-dawn.css → nebula-dawn.v2.css} +134 -0
- package/default-themes/nebula-dawn.v3.css +199 -0
- package/default-themes/{nebula-dusk.css → nebula-dusk.v2.css} +128 -0
- package/default-themes/nebula-dusk.v3.css +201 -0
- package/default-themes/solar-flare-dawn.json +19 -0
- package/default-themes/solar-flare-dawn.v3.css +198 -0
- package/default-themes/solar-flare-dusk.json +19 -0
- package/default-themes/solar-flare-dusk.v3.css +200 -0
- package/dist/agents/a2a/a2aProvider.d.ts.map +1 -0
- package/dist/agents/a2a/a2aProvider.js +126 -0
- package/dist/agents/a2a/a2aProvider.js.map +1 -0
- package/dist/agents/discovery.d.ts.map +1 -0
- package/dist/agents/discovery.js +52 -0
- package/dist/agents/discovery.js.map +1 -0
- package/dist/agents/index.d.ts +7 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/index.js +20 -0
- package/dist/agents/index.js.map +1 -0
- package/dist/agents/openclaw/gatewayManager.d.ts +117 -0
- package/dist/agents/openclaw/gatewayManager.d.ts.map +1 -0
- package/dist/agents/openclaw/gatewayManager.js +486 -0
- package/dist/agents/openclaw/gatewayManager.js.map +1 -0
- package/dist/agents/openclaw/openclawProvider.d.ts.map +1 -0
- package/dist/agents/openclaw/openclawProvider.js +237 -0
- package/dist/agents/openclaw/openclawProvider.js.map +1 -0
- package/dist/agents/openclaw/sshTunnelManager.d.ts +25 -0
- package/dist/agents/openclaw/sshTunnelManager.d.ts.map +1 -0
- package/dist/agents/openclaw/sshTunnelManager.js +359 -0
- package/dist/agents/openclaw/sshTunnelManager.js.map +1 -0
- package/dist/agents/types.d.ts.map +1 -0
- package/dist/agents/types.js +6 -0
- package/dist/agents/types.js.map +1 -0
- package/dist/builders/anthropic.d.ts +31 -0
- package/dist/builders/anthropic.d.ts.map +1 -0
- package/dist/builders/anthropic.js +227 -0
- package/dist/builders/anthropic.js.map +1 -0
- package/dist/builders/fireworksai.d.ts +9 -0
- package/dist/builders/fireworksai.d.ts.map +1 -0
- package/dist/builders/fireworksai.js +57 -0
- package/dist/builders/fireworksai.js.map +1 -0
- package/dist/builders/index.d.ts +13 -0
- package/dist/builders/index.d.ts.map +1 -0
- package/dist/builders/index.js +31 -0
- package/dist/builders/index.js.map +1 -0
- package/dist/builders/openai.d.ts +8 -0
- package/dist/builders/openai.d.ts.map +1 -0
- package/dist/builders/openai.js +87 -0
- package/dist/builders/openai.js.map +1 -0
- package/dist/builders/types.d.ts +54 -0
- package/dist/builders/types.d.ts.map +1 -0
- package/dist/builders/types.js +211 -0
- package/dist/builders/types.js.map +1 -0
- package/dist/connectors/index.d.ts.map +1 -1
- package/dist/connectors/index.js +3 -2
- package/dist/connectors/index.js.map +1 -1
- package/dist/connectors/registry.d.ts +2 -1
- package/dist/connectors/registry.d.ts.map +1 -1
- package/dist/connectors/registry.js +65 -96
- package/dist/connectors/registry.js.map +1 -1
- package/dist/connectors/types.d.ts.map +1 -1
- package/dist/customizer/Customizer.d.ts +57 -0
- package/dist/customizer/Customizer.d.ts.map +1 -0
- package/dist/customizer/Customizer.js +124 -0
- package/dist/customizer/Customizer.js.map +1 -0
- package/dist/customizer/index.d.ts.map +1 -0
- package/dist/customizer/index.js +9 -0
- package/dist/customizer/index.js.map +1 -0
- package/dist/files.d.ts +17 -0
- package/dist/files.d.ts.map +1 -1
- package/dist/files.js +75 -1
- package/dist/files.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +10 -6
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +97 -86
- package/dist/init.js.map +1 -1
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +142 -145
- package/dist/migrations.js.map +1 -1
- package/dist/models/anthropic.d.ts +24 -0
- package/dist/models/anthropic.d.ts.map +1 -0
- package/dist/models/anthropic.js +103 -0
- package/dist/models/anthropic.js.map +1 -0
- package/dist/models/chainOfThought.d.ts.map +1 -0
- package/dist/models/chainOfThought.js +45 -0
- package/dist/models/chainOfThought.js.map +1 -0
- package/dist/models/fireworksai.d.ts.map +1 -0
- package/dist/models/fireworksai.js +141 -0
- package/dist/models/fireworksai.js.map +1 -0
- package/dist/models/index.d.ts +7 -1
- package/dist/models/index.d.ts.map +1 -1
- package/dist/models/index.js +20 -1
- package/dist/models/index.js.map +1 -1
- package/dist/models/logCompletePrompt.d.ts.map +1 -0
- package/dist/models/logCompletePrompt.js +23 -0
- package/dist/models/logCompletePrompt.js.map +1 -0
- package/dist/models/openai.d.ts +24 -0
- package/dist/models/openai.d.ts.map +1 -0
- package/dist/models/openai.js +101 -0
- package/dist/models/openai.js.map +1 -0
- package/dist/models/providers.d.ts.map +1 -1
- package/dist/models/providers.js +12 -4
- package/dist/models/providers.js.map +1 -1
- package/dist/models/types.d.ts +53 -2
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js +21 -0
- package/dist/models/types.js.map +1 -1
- package/dist/models/utils.d.ts.map +1 -0
- package/dist/models/utils.js +21 -0
- package/dist/models/utils.js.map +1 -0
- package/dist/pages.d.ts +30 -7
- package/dist/pages.d.ts.map +1 -1
- package/dist/pages.js +177 -55
- package/dist/pages.js.map +1 -1
- package/dist/scripts.d.ts.map +1 -1
- package/dist/scripts.js +4 -3
- package/dist/scripts.js.map +1 -1
- package/dist/service/createCompletePrompt.d.ts.map +1 -1
- package/dist/service/createCompletePrompt.js +9 -6
- package/dist/service/createCompletePrompt.js.map +1 -1
- package/dist/service/generateImage.d.ts.map +1 -1
- package/dist/service/generateImage.js +3 -3
- package/dist/service/generateImage.js.map +1 -1
- package/dist/service/server.d.ts.map +1 -1
- package/dist/service/server.js +39 -7
- package/dist/service/server.js.map +1 -1
- package/dist/service/transformPage.d.ts +47 -18
- package/dist/service/transformPage.d.ts.map +1 -1
- package/dist/service/transformPage.js +559 -270
- package/dist/service/transformPage.js.map +1 -1
- package/dist/service/useAgentRoutes.d.ts +5 -0
- package/dist/service/useAgentRoutes.d.ts.map +1 -0
- package/dist/service/useAgentRoutes.js +392 -0
- package/dist/service/useAgentRoutes.js.map +1 -0
- package/dist/service/useApiRoutes.d.ts.map +1 -1
- package/dist/service/useApiRoutes.js +380 -138
- package/dist/service/useApiRoutes.js.map +1 -1
- package/dist/service/useConnectorRoutes.d.ts.map +1 -1
- package/dist/service/useConnectorRoutes.js +20 -9
- package/dist/service/useConnectorRoutes.js.map +1 -1
- package/dist/service/useFileRoutes.d.ts +4 -0
- package/dist/service/useFileRoutes.d.ts.map +1 -0
- package/dist/service/useFileRoutes.js +122 -0
- package/dist/service/useFileRoutes.js.map +1 -0
- package/dist/service/usePageRoutes.d.ts.map +1 -1
- package/dist/service/usePageRoutes.js +660 -68
- package/dist/service/usePageRoutes.js.map +1 -1
- package/dist/service/useSharedDataRoutes.d.ts +4 -0
- package/dist/service/useSharedDataRoutes.d.ts.map +1 -0
- package/dist/service/useSharedDataRoutes.js +104 -0
- package/dist/service/useSharedDataRoutes.js.map +1 -0
- package/dist/service/useSharedFileRoutes.d.ts +4 -0
- package/dist/service/useSharedFileRoutes.d.ts.map +1 -0
- package/dist/service/useSharedFileRoutes.js +121 -0
- package/dist/service/useSharedFileRoutes.js.map +1 -0
- package/dist/settings.d.ts +3 -1
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +5 -8
- package/dist/settings.js.map +1 -1
- package/dist/synthos-cli.d.ts.map +1 -1
- package/dist/synthos-cli.js +4 -3
- package/dist/synthos-cli.js.map +1 -1
- package/dist/themes.d.ts +15 -0
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +106 -20
- package/dist/themes.js.map +1 -1
- package/migration-rules/v1-to-v2.md +193 -0
- package/migration-rules/v2-to-v3.md +481 -0
- package/package.json +15 -11
- package/required-pages/builder/page.html +43 -0
- package/required-pages/builder/page.json +10 -0
- package/required-pages/pages/page.html +924 -0
- package/required-pages/pages/page.json +10 -0
- package/required-pages/settings/page.html +1753 -0
- package/required-pages/settings/page.json +10 -0
- package/required-pages/synthos_apis/page.html +846 -0
- package/required-pages/synthos_apis/page.json +10 -0
- package/required-pages/{synthos_scripts.html → synthos_scripts/page.html} +13 -11
- package/required-pages/synthos_scripts/page.json +10 -0
- package/service-connectors/airtable/connector.json +27 -0
- package/service-connectors/alpha-vantage/connector.json +26 -0
- package/service-connectors/brave-search/connector.json +26 -0
- package/service-connectors/cloudinary/connector.json +27 -0
- package/service-connectors/deepl/connector.json +28 -0
- package/service-connectors/elevenlabs/connector.json +30 -0
- package/service-connectors/giphy/connector.json +27 -0
- package/service-connectors/github/connector.json +29 -0
- package/service-connectors/huggingface/connector.json +27 -0
- package/service-connectors/imgur/connector.json +29 -0
- package/service-connectors/instagram/connector.json +43 -0
- package/service-connectors/jira/connector.json +28 -0
- package/service-connectors/mapbox/connector.json +26 -0
- package/service-connectors/nasa/connector.json +27 -0
- package/service-connectors/newsapi/connector.json +27 -0
- package/service-connectors/notion/connector.json +28 -0
- package/service-connectors/open-exchange-rates/connector.json +27 -0
- package/service-connectors/openweathermap/connector.json +26 -0
- package/service-connectors/pexels/connector.json +27 -0
- package/service-connectors/resend/connector.json +29 -0
- package/service-connectors/rss2json/connector.json +27 -0
- package/service-connectors/sendgrid/connector.json +27 -0
- package/service-connectors/spoonacular/connector.json +28 -0
- package/service-connectors/stability-ai/connector.json +27 -0
- package/service-connectors/twilio/connector.json +28 -0
- package/service-connectors/unsplash/connector.json +27 -0
- package/service-connectors/wolfram-alpha/connector.json +26 -0
- package/service-connectors/youtube-data/connector.json +30 -0
- package/src/agents/a2a/a2aProvider.ts +110 -0
- package/src/agents/discovery.ts +74 -0
- package/src/agents/index.ts +6 -0
- package/src/agents/openclaw/gatewayManager.ts +570 -0
- package/src/agents/openclaw/openclawProvider.ts +259 -0
- package/src/agents/openclaw/sshTunnelManager.ts +393 -0
- package/src/agents/types.ts +82 -0
- package/src/builders/anthropic.ts +283 -0
- package/src/builders/fireworksai.ts +59 -0
- package/src/builders/index.ts +33 -0
- package/src/builders/openai.ts +89 -0
- package/src/builders/types.ts +261 -0
- package/src/connectors/index.ts +3 -1
- package/src/connectors/registry.ts +40 -96
- package/src/connectors/types.ts +25 -0
- package/src/customizer/Customizer.ts +151 -0
- package/src/customizer/index.ts +5 -0
- package/src/files.ts +71 -0
- package/src/index.ts +2 -1
- package/src/init.ts +138 -97
- package/src/migrations.ts +148 -145
- package/src/models/anthropic.ts +119 -0
- package/src/models/chainOfThought.ts +56 -0
- package/src/models/fireworksai.ts +143 -0
- package/src/models/index.ts +7 -1
- package/src/models/logCompletePrompt.ts +25 -0
- package/src/models/openai.ts +110 -0
- package/src/models/providers.ts +12 -3
- package/src/models/types.ts +97 -2
- package/src/models/utils.ts +16 -0
- package/src/pages.ts +176 -54
- package/src/scripts.ts +2 -2
- package/src/service/createCompletePrompt.ts +3 -1
- package/src/service/generateImage.ts +2 -2
- package/src/service/server.ts +39 -8
- package/src/service/transformPage.ts +605 -301
- package/src/service/useAgentRoutes.ts +428 -0
- package/src/service/useApiRoutes.ts +309 -45
- package/src/service/useConnectorRoutes.ts +21 -10
- package/src/service/useFileRoutes.ts +127 -0
- package/src/service/usePageRoutes.ts +736 -75
- package/src/service/useSharedDataRoutes.ts +106 -0
- package/src/service/useSharedFileRoutes.ts +126 -0
- package/src/settings.ts +8 -10
- package/src/synthos-cli.ts +4 -3
- package/src/themes.ts +103 -20
- package/static-files/favicon.svg +12 -0
- package/static-files/fluentlm-instructions.llmd +868 -0
- package/static-files/fluentlm-instructions.md +1595 -0
- package/static-files/fluentlm.css +4844 -0
- package/static-files/fluentlm.js +3602 -0
- package/static-files/fluentlm.min.css +1 -0
- package/static-files/fluentlm.min.js +1 -0
- package/static-files/helpers.v3.js +304 -0
- package/static-files/page.v3.js +1290 -0
- package/static-files/recommended-frameworks.llmd +81 -0
- package/static-files/recommended-frameworks.md +137 -0
- package/static-files/retro-game.js +877 -0
- package/static-files/shell.css +797 -0
- package/static-files/theme-dark.css +169 -0
- package/static-files/theme-light.css +169 -0
- package/tests/anthropic.spec.ts +84 -0
- package/tests/builders.spec.ts +139 -0
- package/tests/chainOfThought.spec.ts +108 -0
- package/tests/ensureScripts.spec.ts +82 -0
- package/tests/files.spec.ts +233 -0
- package/tests/fireworksai.spec.ts +92 -0
- package/tests/logCompletePrompt.spec.ts +74 -0
- package/tests/migrations.spec.ts +79 -1
- package/tests/openai.spec.ts +71 -0
- package/tests/pages.spec.ts +226 -1
- package/tests/providers.spec.ts +144 -0
- package/tests/scripts.spec.ts +209 -0
- package/tests/transformPage.spec.ts +456 -0
- package/tests/types.spec.ts +23 -0
- package/default-pages/app_builder.html +0 -40
- package/default-pages/app_builder.json +0 -1
- package/default-pages/json_tools.json +0 -1
- package/default-pages/my_notes.html +0 -33
- package/default-pages/neon_asteroids.html +0 -77
- package/default-pages/sidebar_builder.json +0 -1
- package/default-pages/solar_tutorial.json +0 -1
- package/default-pages/two-panel_builder.json +0 -1
- package/dist/connectors/index.d.ts +0 -3
- package/dist/connectors/types.d.ts +0 -61
- package/dist/index.d.ts +0 -7
- package/dist/migrations.d.ts +0 -11
- package/dist/models/providers.d.ts +0 -7
- package/dist/scripts.d.ts +0 -14
- package/dist/service/createCompletePrompt.d.ts +0 -5
- package/dist/service/debugLog.d.ts +0 -11
- package/dist/service/generateImage.d.ts +0 -32
- package/dist/service/index.d.ts +0 -8
- package/dist/service/modelInstructions.d.ts +0 -7
- package/dist/service/requiresSettings.d.ts +0 -3
- package/dist/service/server.d.ts +0 -4
- package/dist/service/useApiRoutes.d.ts +0 -4
- package/dist/service/useConnectorRoutes.d.ts +0 -4
- package/dist/service/useDataRoutes.d.ts +0 -4
- package/dist/service/usePageRoutes.d.ts +0 -5
- package/dist/synthos-cli.d.ts +0 -2
- package/images/home.png +0 -0
- package/images/page-management.png +0 -0
- package/images/settings.png +0 -0
- package/images/synthos-square.png +0 -0
- package/page-scripts/helpers-v2.js +0 -121
- package/page-scripts/page-v2.js +0 -615
- package/required-pages/builder.html +0 -74
- package/required-pages/builder.json +0 -1
- package/required-pages/pages.html +0 -196
- package/required-pages/pages.json +0 -1
- package/required-pages/settings.html +0 -841
- package/required-pages/settings.json +0 -1
- package/required-pages/synthos_apis.html +0 -272
- package/required-pages/synthos_apis.json +0 -1
- 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">×</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 > 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> > 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>
|