ShebMichel commited on
Commit
2ba8d81
·
1 Parent(s): a6d8ed5

Fix autoplay: use inline HTML (no iframe) + sync to Gradio autoplay audio element

Browse files
Files changed (2) hide show
  1. app.py +66 -60
  2. robot_viewer.html +245 -501
app.py CHANGED
@@ -1,6 +1,5 @@
1
  """GeoTalk: AI-Generated Geoscience Debate Podcast"""
2
 
3
- import base64
4
  import json
5
  from pathlib import Path
6
 
@@ -18,7 +17,7 @@ from backend import (
18
 
19
  # --- Load the robot viewer HTML template ---
20
  ROBOT_HTML_PATH = Path(__file__).parent / "robot_viewer.html"
21
- ROBOT_HTML = ROBOT_HTML_PATH.read_text(encoding="utf-8")
22
 
23
  # --- PROMPTS ---
24
 
@@ -59,17 +58,16 @@ Example: [{"speaker": "Prof. Hawkins", "line": "Now Sam, look at this beautiful
59
  # --- Robot HTML Helpers ---
60
 
61
 
62
- def get_robot_idle_html(message="Waiting for episode..."):
63
- """Return the robot viewer in idle state."""
64
- return f"""<div style="width:100%;border-radius:12px;overflow:hidden;">
65
- <iframe id="robot-frame" srcdoc='{ROBOT_HTML.replace(chr(39), "&#39;")}'
66
- style="width:100%;height:520px;border:none;border-radius:12px;"
67
- sandbox="allow-scripts allow-same-origin" allow="autoplay"></iframe>
68
- </div>"""
69
 
70
 
71
- def get_robot_live_html(results: list[dict], audio_path: str) -> str:
72
- """Return robot viewer HTML with audio embedded and auto-play on load."""
 
 
 
73
  # Build timeline data
74
  timeline_data = []
75
  speakers_seen = {}
@@ -87,38 +85,44 @@ def get_robot_live_html(results: list[dict], audio_path: str) -> str:
87
  speaker_a = speaker_names[0] if len(speaker_names) > 0 else "Speaker A"
88
  speaker_b = speaker_names[1] if len(speaker_names) > 1 else "Speaker B"
89
 
90
- # Encode audio as base64 data URI for embedding in iframe
91
- with open(audio_path, "rb") as f:
92
- audio_b64 = base64.b64encode(f.read()).decode("utf-8")
93
- audio_data_uri = f"data:audio/mpeg;base64,{audio_b64}"
94
-
95
  js_data = json.dumps(timeline_data)
96
 
97
- # Inject auto-start script
98
- auto_start_script = f"""
99
  <script>
100
- window.addEventListener('load', function() {{
101
- setTimeout(function() {{
102
- if (window.GeoTalkRobots) {{
103
- window.GeoTalkRobots.start(
104
- {js_data},
105
- "{speaker_a}",
106
- "{speaker_b}",
107
- "{audio_data_uri}"
108
- );
 
 
 
 
 
 
 
 
 
 
 
109
  }}
110
- }}, 600);
111
- }});
112
- </script>
113
- </body>"""
114
-
115
- modified_html = ROBOT_HTML.replace("</body>", auto_start_script)
 
 
 
 
116
 
117
- return f"""<div style="width:100%;border-radius:12px;overflow:hidden;">
118
- <iframe id="robot-frame" srcdoc='{modified_html.replace(chr(39), "&#39;")}'
119
- style="width:100%;height:520px;border:none;border-radius:12px;"
120
- sandbox="allow-scripts allow-same-origin" allow="autoplay"></iframe>
121
- </div>"""
122
 
123
 
124
  # --- TAB FUNCTIONS ---
@@ -126,9 +130,9 @@ window.addEventListener('load', function() {{
126
 
127
  def run_core_talk(image):
128
  if image is None:
129
- return "Please upload a core or thin section image.", get_robot_idle_html()
130
 
131
- yield "🔬 Analyzing core image with AI vision...", get_robot_idle_html()
132
 
133
  description = describe_image(
134
  image,
@@ -137,29 +141,28 @@ def run_core_talk(image):
137
  "(laminations, fractures, fossils), color variations, and any diagenetic features.",
138
  )
139
 
140
- yield f"📝 Generating debate script...\n\n*Image analysis:* {description[:200]}...", get_robot_idle_html()
141
 
142
  script = generate_debate_script(
143
  CORE_TALK_SYSTEM,
144
  f"The two petrographers are examining a core sample. Here is an AI-generated description of what they see:\n\n{description}\n\nWrite their debate about the interpretation.",
145
  )
146
 
147
- yield "🎙️ Generating audio narration...", get_robot_idle_html()
148
 
149
  results = generate_audio_for_script(script, "core_talk")
150
  audio_path = combine_audio_files(results)
151
  transcript = format_transcript(results)
152
 
153
- # Build the live robot viewer with embedded audio
154
- robot_html = get_robot_live_html(results, audio_path)
155
- yield transcript, robot_html
156
 
157
 
158
  def run_log_doctor(las_file):
159
  if las_file is None:
160
- return "Please upload a LAS file.", get_robot_idle_html()
161
 
162
- yield "📊 Parsing LAS file...", get_robot_idle_html()
163
 
164
  las = lasio.read(las_file.name)
165
 
@@ -177,28 +180,28 @@ def run_log_doctor(las_file):
177
 
178
  curve_summary = "\n".join(summary_lines)
179
 
180
- yield f"📋 Log summary:\n{curve_summary}\n\n🩺 Generating diagnosis...", get_robot_idle_html()
181
 
182
  script = generate_debate_script(
183
  LOG_DOCTOR_SYSTEM,
184
  f"The two log doctors are examining this well log data:\n\n{curve_summary}\n\nWrite their medical-style consultation about what the logs reveal about the formation.",
185
  )
186
 
187
- yield "🎙️ Generating audio narration...", get_robot_idle_html()
188
 
189
  results = generate_audio_for_script(script, "log_doctor")
190
  audio_path = combine_audio_files(results)
191
  transcript = format_transcript(results)
192
 
193
- robot_html = get_robot_live_html(results, audio_path)
194
- yield transcript, robot_html
195
 
196
 
197
  def run_field_trip(image):
198
  if image is None:
199
- return "Please upload an outcrop photo.", get_robot_idle_html()
200
 
201
- yield "🏔️ Analyzing outcrop with AI vision...", get_robot_idle_html()
202
 
203
  description = describe_image(
204
  image,
@@ -207,21 +210,21 @@ def run_field_trip(image):
207
  "weathering patterns, vegetation clues, scale indicators, and overall geological setting.",
208
  )
209
 
210
- yield f"📝 Generating field trip narration...\n\n*Outcrop analysis:* {description[:200]}...", get_robot_idle_html()
211
 
212
  script = generate_debate_script(
213
  FIELD_TRIP_SYSTEM,
214
  f"Prof. Hawkins and Sam are standing in front of this outcrop. Here is an AI description of what they see:\n\n{description}\n\nWrite their field trip conversation.",
215
  )
216
 
217
- yield "🎙️ Generating audio narration...", get_robot_idle_html()
218
 
219
  results = generate_audio_for_script(script, "field_trip")
220
  audio_path = combine_audio_files(results)
221
  transcript = format_transcript(results)
222
 
223
- robot_html = get_robot_live_html(results, audio_path)
224
- yield transcript, robot_html
225
 
226
 
227
  # --- GRADIO UI ---
@@ -242,7 +245,8 @@ Upload geological data and watch AI geoscientists debate the interpretation live
242
  core_status = gr.Markdown(label="Status")
243
  with gr.Column(scale=2):
244
  core_robots = gr.HTML(value=get_robot_idle_html(), label="🤖 GeoTalk Live")
245
- core_btn.click(run_core_talk, inputs=core_image, outputs=[core_status, core_robots])
 
246
 
247
  with gr.Tab("🩺 Log Doctor"):
248
  gr.Markdown("*Two log analysts diagnose your formation like doctors diagnosing a patient.*")
@@ -253,7 +257,8 @@ Upload geological data and watch AI geoscientists debate the interpretation live
253
  log_status = gr.Markdown(label="Status")
254
  with gr.Column(scale=2):
255
  log_robots = gr.HTML(value=get_robot_idle_html(), label="🤖 GeoTalk Live")
256
- log_btn.click(run_log_doctor, inputs=las_input, outputs=[log_status, log_robots])
 
257
 
258
  with gr.Tab("🏕️ Field Trip FM"):
259
  gr.Markdown("*A professor and student narrate a virtual field trip to your outcrop.*")
@@ -264,7 +269,8 @@ Upload geological data and watch AI geoscientists debate the interpretation live
264
  field_status = gr.Markdown(label="Status")
265
  with gr.Column(scale=2):
266
  field_robots = gr.HTML(value=get_robot_idle_html(), label="🤖 GeoTalk Live")
267
- field_btn.click(run_field_trip, inputs=outcrop_image, outputs=[field_status, field_robots])
 
268
 
269
  gr.Markdown(
270
  "---\n*Powered by Llama 3.2 Vision + Llama 3.1 + Edge-TTS on Hugging Face 🤗*\n"
 
1
  """GeoTalk: AI-Generated Geoscience Debate Podcast"""
2
 
 
3
  import json
4
  from pathlib import Path
5
 
 
17
 
18
  # --- Load the robot viewer HTML template ---
19
  ROBOT_HTML_PATH = Path(__file__).parent / "robot_viewer.html"
20
+ ROBOT_HTML_TEMPLATE = ROBOT_HTML_PATH.read_text(encoding="utf-8")
21
 
22
  # --- PROMPTS ---
23
 
 
58
  # --- Robot HTML Helpers ---
59
 
60
 
61
+ def get_robot_idle_html():
62
+ """Return the robot viewer in idle state (inline, no iframe)."""
63
+ return ROBOT_HTML_TEMPLATE
 
 
 
 
64
 
65
 
66
+ def get_robot_live_html(results: list[dict]) -> str:
67
+ """Return robot viewer with timeline data and audio-sync script.
68
+ The audio comes from Gradio's gr.Audio component (autoplay=True).
69
+ Our JS hooks into that audio element to drive the robot animations.
70
+ """
71
  # Build timeline data
72
  timeline_data = []
73
  speakers_seen = {}
 
85
  speaker_a = speaker_names[0] if len(speaker_names) > 0 else "Speaker A"
86
  speaker_b = speaker_names[1] if len(speaker_names) > 1 else "Speaker B"
87
 
 
 
 
 
 
88
  js_data = json.dumps(timeline_data)
89
 
90
+ # This script finds Gradio's audio element and hooks into it
91
+ sync_script = f"""
92
  <script>
93
+ (function initGeoTalk() {{
94
+ // Wait for our GeoTalkRobots API to be ready
95
+ if (!window.GeoTalkRobots) {{
96
+ setTimeout(initGeoTalk, 200);
97
+ return;
98
+ }}
99
+
100
+ // Load timeline and speakers into the viewer
101
+ window.GeoTalkRobots.loadTimeline({js_data}, "{speaker_a}", "{speaker_b}");
102
+
103
+ // Find Gradio's audio element and sync to it
104
+ function findAndSync() {{
105
+ // Gradio renders audio in a <audio> tag inside the component
106
+ const audioEls = document.querySelectorAll('audio');
107
+ let targetAudio = null;
108
+ for (const a of audioEls) {{
109
+ if (a.src && a.src.length > 0) {{
110
+ targetAudio = a;
111
+ break;
112
+ }}
113
  }}
114
+ if (targetAudio) {{
115
+ window.GeoTalkRobots.syncToAudio(targetAudio);
116
+ }} else {{
117
+ // Audio not rendered yet, retry
118
+ setTimeout(findAndSync, 300);
119
+ }}
120
+ }}
121
+ findAndSync();
122
+ }})();
123
+ </script>"""
124
 
125
+ return ROBOT_HTML_TEMPLATE + sync_script
 
 
 
 
126
 
127
 
128
  # --- TAB FUNCTIONS ---
 
130
 
131
  def run_core_talk(image):
132
  if image is None:
133
+ return None, "Please upload a core or thin section image.", get_robot_idle_html()
134
 
135
+ yield None, "🔬 Analyzing core image with AI vision...", get_robot_idle_html()
136
 
137
  description = describe_image(
138
  image,
 
141
  "(laminations, fractures, fossils), color variations, and any diagenetic features.",
142
  )
143
 
144
+ yield None, f"📝 Generating debate script...\n\n*Image analysis:* {description[:200]}...", get_robot_idle_html()
145
 
146
  script = generate_debate_script(
147
  CORE_TALK_SYSTEM,
148
  f"The two petrographers are examining a core sample. Here is an AI-generated description of what they see:\n\n{description}\n\nWrite their debate about the interpretation.",
149
  )
150
 
151
+ yield None, "🎙️ Generating audio narration...", get_robot_idle_html()
152
 
153
  results = generate_audio_for_script(script, "core_talk")
154
  audio_path = combine_audio_files(results)
155
  transcript = format_transcript(results)
156
 
157
+ robot_html = get_robot_live_html(results)
158
+ yield audio_path, transcript, robot_html
 
159
 
160
 
161
  def run_log_doctor(las_file):
162
  if las_file is None:
163
+ return None, "Please upload a LAS file.", get_robot_idle_html()
164
 
165
+ yield None, "📊 Parsing LAS file...", get_robot_idle_html()
166
 
167
  las = lasio.read(las_file.name)
168
 
 
180
 
181
  curve_summary = "\n".join(summary_lines)
182
 
183
+ yield None, f"📋 Log summary:\n{curve_summary}\n\n🩺 Generating diagnosis...", get_robot_idle_html()
184
 
185
  script = generate_debate_script(
186
  LOG_DOCTOR_SYSTEM,
187
  f"The two log doctors are examining this well log data:\n\n{curve_summary}\n\nWrite their medical-style consultation about what the logs reveal about the formation.",
188
  )
189
 
190
+ yield None, "🎙️ Generating audio narration...", get_robot_idle_html()
191
 
192
  results = generate_audio_for_script(script, "log_doctor")
193
  audio_path = combine_audio_files(results)
194
  transcript = format_transcript(results)
195
 
196
+ robot_html = get_robot_live_html(results)
197
+ yield audio_path, transcript, robot_html
198
 
199
 
200
  def run_field_trip(image):
201
  if image is None:
202
+ return None, "Please upload an outcrop photo.", get_robot_idle_html()
203
 
204
+ yield None, "🏔️ Analyzing outcrop with AI vision...", get_robot_idle_html()
205
 
206
  description = describe_image(
207
  image,
 
210
  "weathering patterns, vegetation clues, scale indicators, and overall geological setting.",
211
  )
212
 
213
+ yield None, f"📝 Generating field trip narration...\n\n*Outcrop analysis:* {description[:200]}...", get_robot_idle_html()
214
 
215
  script = generate_debate_script(
216
  FIELD_TRIP_SYSTEM,
217
  f"Prof. Hawkins and Sam are standing in front of this outcrop. Here is an AI description of what they see:\n\n{description}\n\nWrite their field trip conversation.",
218
  )
219
 
220
+ yield None, "🎙️ Generating audio narration...", get_robot_idle_html()
221
 
222
  results = generate_audio_for_script(script, "field_trip")
223
  audio_path = combine_audio_files(results)
224
  transcript = format_transcript(results)
225
 
226
+ robot_html = get_robot_live_html(results)
227
+ yield audio_path, transcript, robot_html
228
 
229
 
230
  # --- GRADIO UI ---
 
245
  core_status = gr.Markdown(label="Status")
246
  with gr.Column(scale=2):
247
  core_robots = gr.HTML(value=get_robot_idle_html(), label="🤖 GeoTalk Live")
248
+ core_audio = gr.Audio(label="Episode Audio", type="filepath", autoplay=True)
249
+ core_btn.click(run_core_talk, inputs=core_image, outputs=[core_audio, core_status, core_robots])
250
 
251
  with gr.Tab("🩺 Log Doctor"):
252
  gr.Markdown("*Two log analysts diagnose your formation like doctors diagnosing a patient.*")
 
257
  log_status = gr.Markdown(label="Status")
258
  with gr.Column(scale=2):
259
  log_robots = gr.HTML(value=get_robot_idle_html(), label="🤖 GeoTalk Live")
260
+ log_audio = gr.Audio(label="Episode Audio", type="filepath", autoplay=True)
261
+ log_btn.click(run_log_doctor, inputs=las_input, outputs=[log_audio, log_status, log_robots])
262
 
263
  with gr.Tab("🏕️ Field Trip FM"):
264
  gr.Markdown("*A professor and student narrate a virtual field trip to your outcrop.*")
 
269
  field_status = gr.Markdown(label="Status")
270
  with gr.Column(scale=2):
271
  field_robots = gr.HTML(value=get_robot_idle_html(), label="🤖 GeoTalk Live")
272
+ field_audio = gr.Audio(label="Episode Audio", type="filepath", autoplay=True)
273
+ field_btn.click(run_field_trip, inputs=outcrop_image, outputs=[field_audio, field_status, field_robots])
274
 
275
  gr.Markdown(
276
  "---\n*Powered by Llama 3.2 Vision + Llama 3.1 + Edge-TTS on Hugging Face 🤗*\n"
robot_viewer.html CHANGED
@@ -1,30 +1,27 @@
1
- <!DOCTYPE html>
2
- <html>
3
- <head>
4
  <style>
5
- * { margin: 0; padding: 0; box-sizing: border-box; }
6
- body { background: transparent; overflow: hidden; font-family: 'Segoe UI', system-ui, sans-serif; }
7
- #main-container {
8
  width: 100%;
9
  height: 520px;
10
- position: relative;
11
- border-radius: 12px;
12
- overflow: hidden;
13
  display: flex;
14
  flex-direction: column;
 
 
15
  }
16
- #scene-container {
17
  width: 100%;
18
  height: 320px;
19
  position: relative;
20
  flex-shrink: 0;
21
  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
22
  }
23
- #subtitle-bar {
 
24
  position: absolute;
25
- bottom: 0;
26
- left: 0;
27
- right: 0;
28
  background: rgba(0,0,0,0.75);
29
  backdrop-filter: blur(8px);
30
  color: #fff;
@@ -36,569 +33,316 @@ body { background: transparent; overflow: hidden; font-family: 'Segoe UI', syste
36
  align-items: center;
37
  gap: 8px;
38
  }
39
- #subtitle-bar .speaker-name {
40
- font-weight: 700;
41
- color: #4fc3f7;
42
- white-space: nowrap;
43
- flex-shrink: 0;
44
- }
45
- #subtitle-bar .speaker-text {
46
- color: #e0e0e0;
47
- }
48
- .speaker-indicator {
49
- position: absolute;
50
- top: 10px;
51
- padding: 5px 12px;
52
- border-radius: 16px;
53
- font-size: 11px;
54
- font-weight: 600;
55
- color: #fff;
56
  backdrop-filter: blur(4px);
57
  transition: opacity 0.3s, transform 0.3s;
58
  opacity: 0.5;
59
  }
60
- .speaker-indicator.active {
61
- opacity: 1;
62
- transform: scale(1.08);
63
- }
64
- #speaker-left { left: 10px; background: rgba(76, 175, 80, 0.85); }
65
- #speaker-right { right: 10px; background: rgba(156, 39, 176, 0.85); }
66
-
67
- #status-badge {
68
- position: absolute;
69
- top: 10px;
70
- left: 50%;
71
- transform: translateX(-50%);
72
- padding: 4px 12px;
73
- border-radius: 12px;
74
- background: rgba(255,255,255,0.15);
75
- backdrop-filter: blur(4px);
76
- font-size: 11px;
77
- color: rgba(255,255,255,0.8);
78
- }
79
-
80
- /* --- Controls bar --- */
81
- #controls-bar {
82
- display: flex;
83
- align-items: center;
84
- gap: 10px;
85
- padding: 8px 14px;
86
- background: #1e1e2e;
87
- border-top: 1px solid rgba(255,255,255,0.08);
88
- flex-shrink: 0;
89
- }
90
- #play-btn {
91
- width: 32px; height: 32px;
92
- border-radius: 50%;
93
- border: none;
94
- background: #4fc3f7;
95
- color: #111;
96
- font-size: 14px;
97
- cursor: pointer;
98
- display: flex; align-items: center; justify-content: center;
99
- flex-shrink: 0;
100
- }
101
- #play-btn:hover { background: #81d4fa; }
102
- #progress-bar {
103
- flex: 1;
104
- height: 4px;
105
- background: rgba(255,255,255,0.15);
106
- border-radius: 2px;
107
- cursor: pointer;
108
- position: relative;
109
- }
110
- #progress-fill {
111
- height: 100%;
112
- background: #4fc3f7;
113
- border-radius: 2px;
114
- width: 0%;
115
- transition: width 0.3s linear;
116
- }
117
- #time-display {
118
- font-size: 11px;
119
- color: rgba(255,255,255,0.6);
120
- white-space: nowrap;
121
- flex-shrink: 0;
122
- }
123
-
124
- /* --- Transcript log --- */
125
- #transcript-panel {
126
- flex: 1;
127
- overflow-y: auto;
128
- background: #12121e;
129
- padding: 10px 14px;
130
- min-height: 0;
131
- }
132
- #transcript-panel::-webkit-scrollbar { width: 6px; }
133
- #transcript-panel::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
134
- #transcript-toggle {
135
- display: flex;
136
- align-items: center;
137
- justify-content: space-between;
138
- padding: 6px 14px;
139
- background: #16162a;
140
- cursor: pointer;
141
- user-select: none;
142
- border-top: 1px solid rgba(255,255,255,0.05);
143
- flex-shrink: 0;
144
- }
145
- #transcript-toggle span {
146
- font-size: 12px;
147
- color: rgba(255,255,255,0.7);
148
- font-weight: 600;
149
- }
150
- #transcript-toggle .arrow {
151
- transition: transform 0.2s;
152
- color: rgba(255,255,255,0.5);
153
- }
154
- #transcript-toggle.collapsed .arrow { transform: rotate(180deg); }
155
-
156
- .transcript-line {
157
- padding: 6px 0;
158
- font-size: 12px;
159
- line-height: 1.5;
160
  color: rgba(255,255,255,0.4);
161
  border-bottom: 1px solid rgba(255,255,255,0.03);
162
  transition: color 0.3s, background 0.3s;
163
  }
164
- .transcript-line.active {
165
- color: #fff;
166
- background: rgba(79, 195, 247, 0.08);
167
- border-radius: 6px;
168
- padding: 6px 8px;
169
- }
170
- .transcript-line.past {
171
- color: rgba(255,255,255,0.6);
172
- }
173
- .transcript-line .tl-speaker {
174
- font-weight: 700;
175
- color: #4fc3f7;
176
- }
177
- .transcript-line.speaker-b .tl-speaker {
178
- color: #ce93d8;
179
- }
180
  </style>
181
- </head>
182
- <body>
183
- <div id="main-container">
184
- <div id="scene-container">
185
- <canvas id="robot-canvas"></canvas>
186
- <div id="speaker-left" class="speaker-indicator">Speaker A</div>
187
- <div id="speaker-right" class="speaker-indicator">Speaker B</div>
188
- <div id="status-badge">🎙️ GeoTalk Live</div>
189
- <div id="subtitle-bar">
190
- <span class="speaker-name" id="sub-speaker"></span>
191
- <span class="speaker-text" id="sub-text">Waiting for episode...</span>
192
  </div>
193
  </div>
194
- <div id="controls-bar">
195
- <button id="play-btn">▶</button>
196
- <div id="progress-bar"><div id="progress-fill"></div></div>
197
- <span id="time-display">0:00 / 0:00</span>
198
  </div>
199
- <div id="transcript-toggle">
200
  <span>📜 Transcript</span>
201
- <span class="arrow">▼</span>
202
  </div>
203
- <div id="transcript-panel"></div>
 
204
  </div>
205
 
206
- <script type="importmap">
207
- {
208
- "imports": {
209
- "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
210
- }
211
- }
212
- </script>
213
  <script type="module">
214
- import * as THREE from 'three';
215
 
216
- // --- Scene Setup ---
217
- const sceneContainer = document.getElementById('scene-container');
218
- const canvas = document.getElementById('robot-canvas');
219
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
220
- renderer.setPixelRatio(window.devicePixelRatio);
221
- renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight);
222
  renderer.shadowMap.enabled = true;
223
- renderer.shadowMap.type = THREE.PCFSoftShadowMap;
224
 
225
  const scene = new THREE.Scene();
226
- const camera = new THREE.PerspectiveCamera(45, sceneContainer.clientWidth / sceneContainer.clientHeight, 0.1, 100);
227
  camera.position.set(0, 1.5, 5);
228
  camera.lookAt(0, 0.8, 0);
229
 
230
- // --- Lighting ---
231
  scene.add(new THREE.AmbientLight(0x404060, 0.8));
232
- const keyLight = new THREE.DirectionalLight(0xffffff, 1.2);
233
- keyLight.position.set(3, 5, 4);
234
- keyLight.castShadow = true;
235
- scene.add(keyLight);
236
- const fillLight = new THREE.DirectionalLight(0x4fc3f7, 0.4);
237
- fillLight.position.set(-3, 2, -2);
238
- scene.add(fillLight);
239
- scene.add(new THREE.PointLight(0xff6f61, 0.6, 10).translateY(3).translateZ(-3));
240
 
241
- // --- Floor ---
242
- const floor = new THREE.Mesh(
243
- new THREE.CircleGeometry(4, 64),
244
- new THREE.MeshStandardMaterial({ color: 0x1a1a2e, roughness: 0.8 })
245
- );
246
- floor.rotation.x = -Math.PI / 2;
247
- floor.receiveShadow = true;
248
- scene.add(floor);
249
 
250
- // --- Robot Builder ---
251
  function createRobot(color, x) {
252
- const group = new THREE.Group();
253
- const mainColor = new THREE.Color(color);
254
- const darkColor = mainColor.clone().multiplyScalar(0.5);
255
-
256
- const bodyMat = new THREE.MeshStandardMaterial({ color: mainColor, roughness: 0.3, metalness: 0.6 });
257
- const body = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.35, 0.9, 16), bodyMat);
258
- body.position.y = 0.7; body.castShadow = true;
259
- group.add(body);
260
-
261
- const ring = new THREE.Mesh(
262
- new THREE.TorusGeometry(0.32, 0.03, 8, 32),
263
- new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.2, metalness: 0.8 })
264
- );
265
- ring.position.y = 0.9; ring.rotation.x = Math.PI / 2;
266
- group.add(ring);
267
-
268
- const neck = new THREE.Mesh(
269
- new THREE.CylinderGeometry(0.08, 0.12, 0.15, 12),
270
- new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.4, metalness: 0.7 })
271
- );
272
- neck.position.y = 1.22;
273
- group.add(neck);
274
-
275
- const head = new THREE.Mesh(
276
- new THREE.SphereGeometry(0.28, 24, 24),
277
- new THREE.MeshStandardMaterial({ color: mainColor, roughness: 0.3, metalness: 0.5 })
278
- );
279
- head.position.y = 1.5; head.castShadow = true;
280
- group.add(head);
281
-
282
- // Eyes
283
- const eyeMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.1, metalness: 0.9 });
284
- const eyeRingMat = new THREE.MeshStandardMaterial({ color: 0xcccccc, metalness: 0.9, roughness: 0.2 });
285
  for (const ex of [-0.1, 0.1]) {
286
- group.add(Object.assign(new THREE.Mesh(new THREE.SphereGeometry(0.07, 16, 16), eyeMat), { position: new THREE.Vector3(ex, 1.52, 0.24) }));
287
- group.add(Object.assign(new THREE.Mesh(new THREE.TorusGeometry(0.07, 0.015, 8, 16), eyeRingMat), { position: new THREE.Vector3(ex, 1.52, 0.24) }));
288
  }
289
-
290
- // Antennas
291
- const antMat = new THREE.MeshStandardMaterial({ color: 0x666666, metalness: 0.8, roughness: 0.3 });
292
- const leftAnt = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.02, 0.3, 8), antMat);
293
- leftAnt.position.set(-0.12, 1.85, 0); leftAnt.rotation.z = 0.2;
294
- group.add(leftAnt);
295
- const rightAnt = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.02, 0.3, 8), antMat);
296
- rightAnt.position.set(0.12, 1.85, 0); rightAnt.rotation.z = -0.2;
297
- group.add(rightAnt);
298
-
299
- // Antenna tips
300
- const tipMat = new THREE.MeshStandardMaterial({ color: mainColor, emissive: mainColor, emissiveIntensity: 0.5, roughness: 0.2 });
301
- const leftTip = new THREE.Mesh(new THREE.SphereGeometry(0.035, 12, 12), tipMat);
302
- leftTip.position.set(-0.15, 2.0, 0);
303
- group.add(leftTip);
304
- const rightTip = new THREE.Mesh(new THREE.SphereGeometry(0.035, 12, 12), tipMat);
305
- rightTip.position.set(0.15, 2.0, 0);
306
- group.add(rightTip);
307
-
308
- // Base
309
- const base = new THREE.Mesh(
310
- new THREE.CylinderGeometry(0.35, 0.4, 0.15, 16),
311
- new THREE.MeshStandardMaterial({ color: darkColor, roughness: 0.4, metalness: 0.6 })
312
- );
313
- base.position.y = 0.15; base.castShadow = true;
314
- group.add(base);
315
-
316
- // Speaker grille
317
- group.add(Object.assign(
318
- new THREE.Mesh(new THREE.CircleGeometry(0.12, 24), new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.8, side: THREE.DoubleSide })),
319
- { position: new THREE.Vector3(0, 0.6, 0.35) }
320
- ));
321
-
322
- group.position.x = x;
323
- group.userData = { head, leftAnt, rightAnt, leftTip, rightTip, tipMat, body, talkPhase: 0 };
324
- scene.add(group);
325
- return group;
326
- }
327
-
328
- const robotA = createRobot(0x4caf50, -1.2);
329
- const robotB = createRobot(0x9c27b0, 1.2);
330
- robotA.rotation.y = 0.3;
331
- robotB.rotation.y = -0.3;
332
-
333
- // --- Animation ---
334
- let currentSpeaker = -1;
335
-
336
- function idleAnim(robot, time, offset) {
337
- const d = robot.userData;
338
- robot.position.y = Math.sin(time * 0.8 + offset) * 0.02;
339
- d.leftAnt.rotation.z = 0.2 + Math.sin(time * 1.2 + offset) * 0.05;
340
- d.rightAnt.rotation.z = -0.2 + Math.sin(time * 1.2 + offset + 1) * 0.05;
341
- d.tipMat.emissiveIntensity = 0.3 + Math.sin(time * 2) * 0.1;
342
- }
343
-
344
- function talkAnim(robot, time) {
345
- const d = robot.userData;
346
- d.talkPhase += 0.15;
347
- d.head.position.y = 1.5 + Math.sin(d.talkPhase * 3) * 0.02;
348
- d.head.rotation.x = Math.sin(d.talkPhase * 2) * 0.05;
349
- d.head.rotation.z = Math.sin(d.talkPhase * 1.5) * 0.03;
350
- d.leftAnt.rotation.z = 0.2 + Math.sin(d.talkPhase * 4) * 0.15;
351
- d.rightAnt.rotation.z = -0.2 + Math.sin(d.talkPhase * 4 + 1) * 0.15;
352
- d.tipMat.emissiveIntensity = 0.5 + Math.sin(d.talkPhase * 5) * 0.4;
353
- robot.rotation.x = Math.sin(d.talkPhase * 1.2) * 0.03;
354
- }
355
-
356
- function listenAnim(robot, time) {
357
- const d = robot.userData;
358
- d.head.rotation.z = Math.sin(time * 0.5) * 0.08;
359
- d.head.rotation.x = -0.05;
360
- d.tipMat.emissiveIntensity = 0.3;
361
- }
362
 
363
  function animate() {
364
  requestAnimationFrame(animate);
365
  const t = performance.now() / 1000;
366
- if (currentSpeaker === 0) { talkAnim(robotA, t); listenAnim(robotB, t); }
367
- else if (currentSpeaker === 1) { talkAnim(robotB, t); listenAnim(robotA, t); }
368
- else { idleAnim(robotA, t, 0); idleAnim(robotB, t, Math.PI); }
369
  renderer.render(scene, camera);
370
  }
371
  animate();
372
 
373
- // --- Resize ---
374
  function onResize() {
375
- const w = sceneContainer.clientWidth, h = sceneContainer.clientHeight;
376
- camera.aspect = w / h;
377
- camera.updateProjectionMatrix();
378
- renderer.setSize(w, h);
379
  }
380
  window.addEventListener('resize', onResize);
381
- new ResizeObserver(onResize).observe(sceneContainer);
382
 
383
- // --- Audio + Timeline Engine ---
384
- let audioEl = null;
385
  let timeline = [];
386
- let totalDuration = 0;
387
- let activeLineIdx = -1;
388
-
389
- const playBtn = document.getElementById('play-btn');
390
- const progressFill = document.getElementById('progress-fill');
391
- const progressBar = document.getElementById('progress-bar');
392
- const timeDisplay = document.getElementById('time-display');
393
- const transcriptPanel = document.getElementById('transcript-panel');
394
- const transcriptToggle = document.getElementById('transcript-toggle');
395
-
396
- // Transcript toggle
397
- let transcriptExpanded = true;
398
- transcriptToggle.addEventListener('click', () => {
399
- transcriptExpanded = !transcriptExpanded;
400
- transcriptPanel.style.display = transcriptExpanded ? 'block' : 'none';
401
- transcriptToggle.classList.toggle('collapsed', !transcriptExpanded);
402
  });
403
 
404
- // Play/pause
405
  playBtn.addEventListener('click', () => {
406
- if (!audioEl) return;
407
- if (audioEl.paused) { audioEl.play(); playBtn.textContent = '⏸'; }
408
- else { audioEl.pause(); playBtn.textContent = '▶'; }
409
  });
410
 
411
- // Progress bar seek
412
- progressBar.addEventListener('click', (e) => {
413
- if (!audioEl || !totalDuration) return;
414
- const rect = progressBar.getBoundingClientRect();
415
- const pct = (e.clientX - rect.left) / rect.width;
416
- audioEl.currentTime = pct * totalDuration;
417
  });
418
 
419
- function formatTime(sec) {
420
- const m = Math.floor(sec / 60);
421
- const s = Math.floor(sec % 60);
422
- return `${m}:${s.toString().padStart(2, '0')}`;
423
- }
424
 
425
- function updatePlayback() {
426
- if (!audioEl || audioEl.paused) return;
427
- const elapsed = audioEl.currentTime;
 
428
 
429
- // Progress
430
- const pct = totalDuration > 0 ? (elapsed / totalDuration) * 100 : 0;
431
- progressFill.style.width = pct + '%';
432
- timeDisplay.textContent = `${formatTime(elapsed)} / ${formatTime(totalDuration)}`;
433
 
434
- // Find active line
435
- let newActive = -1;
 
 
 
 
436
  for (let i = 0; i < timeline.length; i++) {
437
- if (elapsed >= timeline[i].startTime && elapsed < timeline[i].startTime + timeline[i].duration) {
438
- newActive = i;
439
- break;
440
- }
441
  }
442
-
443
- if (newActive !== activeLineIdx) {
444
- activeLineIdx = newActive;
445
- if (newActive >= 0) {
446
- const item = timeline[newActive];
447
- currentSpeaker = item.speakerIdx;
448
- updateSubtitle(item.speaker, item.line);
449
- updateTranscriptHighlight(newActive);
450
- updateSpeakerIndicators(item.speakerIdx);
 
 
 
 
451
  }
452
  }
453
-
454
- requestAnimationFrame(updatePlayback);
455
- }
456
-
457
- function updateSubtitle(speaker, line) {
458
- document.getElementById('sub-speaker').textContent = speaker + ':';
459
- document.getElementById('sub-text').textContent = line;
460
- }
461
 
462
- function updateSpeakerIndicators(idx) {
463
- document.getElementById('speaker-left').classList.toggle('active', idx === 0);
464
- document.getElementById('speaker-right').classList.toggle('active', idx === 1);
465
- }
466
-
467
- function updateTranscriptHighlight(idx) {
468
- const lines = transcriptPanel.querySelectorAll('.transcript-line');
469
- lines.forEach((el, i) => {
470
- el.classList.toggle('active', i === idx);
471
- el.classList.toggle('past', i < idx);
472
- });
473
- // Auto-scroll to active line
474
- if (lines[idx]) {
475
- lines[idx].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
476
  }
477
- }
478
 
479
- function buildTranscript(data) {
480
- transcriptPanel.innerHTML = '';
481
- data.forEach((item, i) => {
482
- const div = document.createElement('div');
483
- div.className = 'transcript-line' + (item.speakerIdx === 1 ? ' speaker-b' : '');
484
- div.innerHTML = `<span class="tl-speaker">${item.speaker}:</span> ${item.line}`;
485
- transcriptPanel.appendChild(div);
486
- });
487
  }
488
 
489
- // --- Click-to-play overlay (fallback when autoplay is blocked) ---
490
- let clickOverlay = null;
491
-
492
- function showClickOverlay() {
493
- if (clickOverlay) return;
494
- clickOverlay = document.createElement('div');
495
- clickOverlay.id = 'click-overlay';
496
- clickOverlay.style.cssText = `
497
- position:absolute; top:0; left:0; right:0; bottom:0; z-index:100;
498
- background:rgba(0,0,0,0.6); backdrop-filter:blur(4px);
499
- display:flex; flex-direction:column; align-items:center; justify-content:center;
500
- cursor:pointer; border-radius:12px;
501
- `;
502
- clickOverlay.innerHTML = `
503
- <div style="width:70px;height:70px;border-radius:50%;background:#4fc3f7;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 20px rgba(79,195,247,0.5);">
504
- <span style="font-size:32px;margin-left:4px;">▶</span>
505
- </div>
506
- <div style="color:#fff;font-size:14px;margin-top:14px;font-weight:600;">Click to start GeoTalk Live</div>
507
- <div style="color:rgba(255,255,255,0.6);font-size:12px;margin-top:4px;">Episode ready</div>
508
- `;
509
- clickOverlay.addEventListener('click', () => {
510
- if (audioEl) {
511
- audioEl.play();
512
- }
513
- hideClickOverlay();
514
- }, { once: true });
515
-
516
- const mainContainer = document.getElementById('main-container');
517
- mainContainer.style.position = 'relative';
518
- mainContainer.appendChild(clickOverlay);
519
-
520
- document.getElementById('sub-text').textContent = '🎙️ Episode ready — click to play!';
521
- }
522
-
523
- function hideClickOverlay() {
524
- if (clickOverlay) {
525
- clickOverlay.remove();
526
- clickOverlay = null;
527
- }
528
  }
529
 
530
  // --- Public API ---
531
  window.GeoTalkRobots = {
532
- /**
533
- * Load episode data and auto-play.
534
- * @param {Array} data - [{speaker, line, duration, speakerIdx}]
535
- * @param {string} speakerAName
536
- * @param {string} speakerBName
537
- * @param {string} audioUrl - URL/data URI of the combined audio
538
- */
539
- start(data, speakerAName, speakerBName, audioUrl) {
540
- // Set speaker labels
541
- document.getElementById('speaker-left').textContent = speakerAName || 'Speaker A';
542
- document.getElementById('speaker-right').textContent = speakerBName || 'Speaker B';
 
543
 
544
- // Build timeline
545
- timeline = [];
546
- let cumTime = 0;
547
- for (const item of data) {
548
- timeline.push({ ...item, startTime: cumTime });
549
- cumTime += item.duration;
 
 
550
  }
551
- totalDuration = cumTime;
552
- timeDisplay.textContent = `0:00 / ${formatTime(totalDuration)}`;
553
-
554
- // Build transcript log
555
- buildTranscript(data);
556
-
557
- // Create audio element
558
- if (audioEl) { audioEl.pause(); audioEl.remove(); }
559
- audioEl = new Audio(audioUrl);
560
- audioEl.addEventListener('play', () => { playBtn.textContent = '⏸'; requestAnimationFrame(updatePlayback); });
561
- audioEl.addEventListener('pause', () => { playBtn.textContent = '▶'; });
562
- audioEl.addEventListener('ended', () => {
563
- playBtn.textContent = '▶';
564
- currentSpeaker = -1;
565
- document.getElementById('sub-text').textContent = '✅ Episode complete!';
566
- document.getElementById('sub-speaker').textContent = '';
567
- updateSpeakerIndicators(-1);
568
  });
569
-
570
- // Attempt auto-play; if blocked, show click-to-play overlay
571
- function tryPlay() {
572
- audioEl.play().then(() => {
573
- // Success — hide overlay if shown
574
- hideClickOverlay();
575
- }).catch(() => {
576
- // Autoplay blocked — show click-to-play overlay
577
- showClickOverlay();
578
- });
579
- }
580
-
581
- tryPlay();
582
  },
583
 
584
- stop() {
585
- if (audioEl) { audioEl.pause(); }
586
- currentSpeaker = -1;
 
 
 
 
 
587
  },
588
 
589
- idle(message) {
590
- currentSpeaker = -1;
591
- document.getElementById('sub-speaker').textContent = '';
592
- document.getElementById('sub-text').textContent = message || 'Waiting for episode...';
593
- updateSpeakerIndicators(-1);
594
- progressFill.style.width = '0%';
595
- timeDisplay.textContent = '0:00 / 0:00';
596
- transcriptPanel.innerHTML = `<div style="color:rgba(255,255,255,0.4);font-size:12px;padding:10px;">${message || 'Transcript will appear here...'}</div>`;
 
597
  }
598
  };
599
 
600
- // Initial state
601
  window.GeoTalkRobots.idle();
602
  </script>
603
- </body>
604
- </html>
 
1
+ <!-- GeoTalk Robot Viewer - Inline Component -->
2
+ <div id="geotalk-viewer" style="width:100%;border-radius:12px;overflow:hidden;position:relative;">
 
3
  <style>
4
+ #geotalk-viewer * { margin: 0; padding: 0; box-sizing: border-box; }
5
+ #geotalk-viewer { font-family: 'Segoe UI', system-ui, sans-serif; }
6
+ #gt-main {
7
  width: 100%;
8
  height: 520px;
 
 
 
9
  display: flex;
10
  flex-direction: column;
11
+ border-radius: 12px;
12
+ overflow: hidden;
13
  }
14
+ #gt-scene {
15
  width: 100%;
16
  height: 320px;
17
  position: relative;
18
  flex-shrink: 0;
19
  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
20
  }
21
+ #gt-scene canvas { display: block; width: 100%; height: 100%; }
22
+ #gt-subtitle {
23
  position: absolute;
24
+ bottom: 0; left: 0; right: 0;
 
 
25
  background: rgba(0,0,0,0.75);
26
  backdrop-filter: blur(8px);
27
  color: #fff;
 
33
  align-items: center;
34
  gap: 8px;
35
  }
36
+ #gt-subtitle .gt-spk { font-weight: 700; color: #4fc3f7; white-space: nowrap; flex-shrink: 0; }
37
+ #gt-subtitle .gt-txt { color: #e0e0e0; }
38
+ .gt-ind {
39
+ position: absolute; top: 10px;
40
+ padding: 5px 12px; border-radius: 16px;
41
+ font-size: 11px; font-weight: 600; color: #fff;
 
 
 
 
 
 
 
 
 
 
 
42
  backdrop-filter: blur(4px);
43
  transition: opacity 0.3s, transform 0.3s;
44
  opacity: 0.5;
45
  }
46
+ .gt-ind.active { opacity: 1; transform: scale(1.08); }
47
+ #gt-ind-l { left: 10px; background: rgba(76, 175, 80, 0.85); }
48
+ #gt-ind-r { right: 10px; background: rgba(156, 39, 176, 0.85); }
49
+ #gt-badge {
50
+ position: absolute; top: 10px; left: 50%; transform: translateX(-50%);
51
+ padding: 4px 12px; border-radius: 12px;
52
+ background: rgba(255,255,255,0.15); backdrop-filter: blur(4px);
53
+ font-size: 11px; color: rgba(255,255,255,0.8);
54
+ }
55
+ #gt-controls {
56
+ display: flex; align-items: center; gap: 10px;
57
+ padding: 8px 14px; background: #1e1e2e;
58
+ border-top: 1px solid rgba(255,255,255,0.08); flex-shrink: 0;
59
+ }
60
+ #gt-play {
61
+ width: 36px; height: 36px; border-radius: 50%; border: none;
62
+ background: #4fc3f7; color: #111; font-size: 16px;
63
+ cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
64
+ }
65
+ #gt-play:hover { background: #81d4fa; }
66
+ #gt-prog { flex: 1; height: 6px; background: rgba(255,255,255,0.15); border-radius: 3px; cursor: pointer; }
67
+ #gt-prog-fill { height: 100%; background: #4fc3f7; border-radius: 3px; width: 0%; transition: width 0.2s linear; }
68
+ #gt-time { font-size: 11px; color: rgba(255,255,255,0.6); white-space: nowrap; flex-shrink: 0; }
69
+ #gt-tog {
70
+ display: flex; align-items: center; justify-content: space-between;
71
+ padding: 6px 14px; background: #16162a; cursor: pointer; user-select: none;
72
+ border-top: 1px solid rgba(255,255,255,0.05); flex-shrink: 0;
73
+ }
74
+ #gt-tog span { font-size: 12px; color: rgba(255,255,255,0.7); font-weight: 600; }
75
+ #gt-log {
76
+ flex: 1; overflow-y: auto; background: #12121e; padding: 10px 14px; min-height: 0;
77
+ }
78
+ #gt-log::-webkit-scrollbar { width: 6px; }
79
+ #gt-log::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
80
+ .gt-line {
81
+ padding: 6px 0; font-size: 12px; line-height: 1.5;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  color: rgba(255,255,255,0.4);
83
  border-bottom: 1px solid rgba(255,255,255,0.03);
84
  transition: color 0.3s, background 0.3s;
85
  }
86
+ .gt-line.active { color: #fff; background: rgba(79,195,247,0.08); border-radius: 6px; padding: 6px 8px; }
87
+ .gt-line.past { color: rgba(255,255,255,0.6); }
88
+ .gt-line .gt-ls { font-weight: 700; color: #4fc3f7; }
89
+ .gt-line.spkb .gt-ls { color: #ce93d8; }
 
 
 
 
 
 
 
 
 
 
 
 
90
  </style>
91
+
92
+ <div id="gt-main">
93
+ <div id="gt-scene">
94
+ <canvas id="gt-canvas"></canvas>
95
+ <div id="gt-ind-l" class="gt-ind">Speaker A</div>
96
+ <div id="gt-ind-r" class="gt-ind">Speaker B</div>
97
+ <div id="gt-badge">🎙️ GeoTalk Live</div>
98
+ <div id="gt-subtitle">
99
+ <span class="gt-spk" id="gt-sub-spk"></span>
100
+ <span class="gt-txt" id="gt-sub-txt">Waiting for episode...</span>
 
101
  </div>
102
  </div>
103
+ <div id="gt-controls">
104
+ <button id="gt-play">▶</button>
105
+ <div id="gt-prog"><div id="gt-prog-fill"></div></div>
106
+ <span id="gt-time">0:00 / 0:00</span>
107
  </div>
108
+ <div id="gt-tog">
109
  <span>📜 Transcript</span>
110
+ <span id="gt-arrow">▼</span>
111
  </div>
112
+ <div id="gt-log"></div>
113
+ </div>
114
  </div>
115
 
 
 
 
 
 
 
 
116
  <script type="module">
117
+ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';
118
 
119
+ const sceneEl = document.getElementById('gt-scene');
120
+ const canvas = document.getElementById('gt-canvas');
 
121
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
122
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
123
+ renderer.setSize(sceneEl.clientWidth, sceneEl.clientHeight);
124
  renderer.shadowMap.enabled = true;
 
125
 
126
  const scene = new THREE.Scene();
127
+ const camera = new THREE.PerspectiveCamera(45, sceneEl.clientWidth / sceneEl.clientHeight, 0.1, 100);
128
  camera.position.set(0, 1.5, 5);
129
  camera.lookAt(0, 0.8, 0);
130
 
 
131
  scene.add(new THREE.AmbientLight(0x404060, 0.8));
132
+ const kl = new THREE.DirectionalLight(0xffffff, 1.2); kl.position.set(3, 5, 4); kl.castShadow = true; scene.add(kl);
133
+ scene.add(Object.assign(new THREE.DirectionalLight(0x4fc3f7, 0.4), { position: new THREE.Vector3(-3, 2, -2) }));
134
+ scene.add(Object.assign(new THREE.PointLight(0xff6f61, 0.6, 10), { position: new THREE.Vector3(0, 3, -3) }));
 
 
 
 
 
135
 
136
+ const floor = new THREE.Mesh(new THREE.CircleGeometry(4, 64), new THREE.MeshStandardMaterial({ color: 0x1a1a2e, roughness: 0.8 }));
137
+ floor.rotation.x = -Math.PI / 2; floor.receiveShadow = true; scene.add(floor);
 
 
 
 
 
 
138
 
 
139
  function createRobot(color, x) {
140
+ const g = new THREE.Group();
141
+ const mc = new THREE.Color(color), dc = mc.clone().multiplyScalar(0.5);
142
+ const bm = new THREE.MeshStandardMaterial({ color: mc, roughness: 0.3, metalness: 0.6 });
143
+ const body = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.35, 0.9, 16), bm);
144
+ body.position.y = 0.7; body.castShadow = true; g.add(body);
145
+ const ring = new THREE.Mesh(new THREE.TorusGeometry(0.32, 0.03, 8, 32), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.2, metalness: 0.8 }));
146
+ ring.position.y = 0.9; ring.rotation.x = Math.PI/2; g.add(ring);
147
+ const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.12, 0.15, 12), new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.4, metalness: 0.7 }));
148
+ neck.position.y = 1.22; g.add(neck);
149
+ const head = new THREE.Mesh(new THREE.SphereGeometry(0.28, 24, 24), new THREE.MeshStandardMaterial({ color: mc, roughness: 0.3, metalness: 0.5 }));
150
+ head.position.y = 1.5; head.castShadow = true; g.add(head);
151
+ const em = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.1, metalness: 0.9 });
152
+ const erm = new THREE.MeshStandardMaterial({ color: 0xcccccc, metalness: 0.9, roughness: 0.2 });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  for (const ex of [-0.1, 0.1]) {
154
+ const eye = new THREE.Mesh(new THREE.SphereGeometry(0.07, 16, 16), em); eye.position.set(ex, 1.52, 0.24); g.add(eye);
155
+ const eyeR = new THREE.Mesh(new THREE.TorusGeometry(0.07, 0.015, 8, 16), erm); eyeR.position.set(ex, 1.52, 0.24); g.add(eyeR);
156
  }
157
+ const am = new THREE.MeshStandardMaterial({ color: 0x666666, metalness: 0.8, roughness: 0.3 });
158
+ const la = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.02, 0.3, 8), am); la.position.set(-0.12, 1.85, 0); la.rotation.z = 0.2; g.add(la);
159
+ const ra = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.02, 0.3, 8), am); ra.position.set(0.12, 1.85, 0); ra.rotation.z = -0.2; g.add(ra);
160
+ const tm = new THREE.MeshStandardMaterial({ color: mc, emissive: mc, emissiveIntensity: 0.5, roughness: 0.2 });
161
+ const lt = new THREE.Mesh(new THREE.SphereGeometry(0.035, 12, 12), tm); lt.position.set(-0.15, 2.0, 0); g.add(lt);
162
+ const rt = new THREE.Mesh(new THREE.SphereGeometry(0.035, 12, 12), tm); rt.position.set(0.15, 2.0, 0); g.add(rt);
163
+ const base = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.4, 0.15, 16), new THREE.MeshStandardMaterial({ color: dc, roughness: 0.4, metalness: 0.6 }));
164
+ base.position.y = 0.15; base.castShadow = true; g.add(base);
165
+ const spk = new THREE.Mesh(new THREE.CircleGeometry(0.12, 24), new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.8, side: THREE.DoubleSide }));
166
+ spk.position.set(0, 0.6, 0.35); g.add(spk);
167
+ g.position.x = x;
168
+ g.userData = { head, la, ra, tm, talkPhase: 0 };
169
+ scene.add(g);
170
+ return g;
171
+ }
172
+
173
+ const rA = createRobot(0x4caf50, -1.2);
174
+ const rB = createRobot(0x9c27b0, 1.2);
175
+ rA.rotation.y = 0.3; rB.rotation.y = -0.3;
176
+
177
+ let curSpk = -1;
178
+ function idleA(r, t, o) { const d = r.userData; r.position.y = Math.sin(t*0.8+o)*0.02; d.la.rotation.z = 0.2+Math.sin(t*1.2+o)*0.05; d.ra.rotation.z = -0.2+Math.sin(t*1.2+o+1)*0.05; d.tm.emissiveIntensity = 0.3+Math.sin(t*2)*0.1; }
179
+ function talkA(r) { const d = r.userData; d.talkPhase += 0.15; d.head.position.y = 1.5+Math.sin(d.talkPhase*3)*0.02; d.head.rotation.x = Math.sin(d.talkPhase*2)*0.05; d.head.rotation.z = Math.sin(d.talkPhase*1.5)*0.03; d.la.rotation.z = 0.2+Math.sin(d.talkPhase*4)*0.15; d.ra.rotation.z = -0.2+Math.sin(d.talkPhase*4+1)*0.15; d.tm.emissiveIntensity = 0.5+Math.sin(d.talkPhase*5)*0.4; r.rotation.x = Math.sin(d.talkPhase*1.2)*0.03; }
180
+ function lisA(r, t) { const d = r.userData; d.head.rotation.z = Math.sin(t*0.5)*0.08; d.head.rotation.x = -0.05; d.tm.emissiveIntensity = 0.3; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
  function animate() {
183
  requestAnimationFrame(animate);
184
  const t = performance.now() / 1000;
185
+ if (curSpk === 0) { talkA(rA); lisA(rB, t); }
186
+ else if (curSpk === 1) { talkA(rB); lisA(rA, t); }
187
+ else { idleA(rA, t, 0); idleA(rB, t, Math.PI); }
188
  renderer.render(scene, camera);
189
  }
190
  animate();
191
 
 
192
  function onResize() {
193
+ const w = sceneEl.clientWidth, h = sceneEl.clientHeight;
194
+ if (w === 0 || h === 0) return;
195
+ camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h);
 
196
  }
197
  window.addEventListener('resize', onResize);
198
+ new ResizeObserver(onResize).observe(sceneEl);
199
 
200
+ // --- Audio Sync Engine ---
201
+ let syncedAudio = null;
202
  let timeline = [];
203
+ let totalDur = 0;
204
+ let activeIdx = -1;
205
+
206
+ const playBtn = document.getElementById('gt-play');
207
+ const progFill = document.getElementById('gt-prog-fill');
208
+ const progBar = document.getElementById('gt-prog');
209
+ const timeDisp = document.getElementById('gt-time');
210
+ const logPanel = document.getElementById('gt-log');
211
+ const togEl = document.getElementById('gt-tog');
212
+
213
+ let logOpen = true;
214
+ togEl.addEventListener('click', () => {
215
+ logOpen = !logOpen;
216
+ logPanel.style.display = logOpen ? 'block' : 'none';
217
+ document.getElementById('gt-arrow').textContent = logOpen ? '' : '';
 
218
  });
219
 
220
+ // Play/pause controls the synced Gradio audio
221
  playBtn.addEventListener('click', () => {
222
+ if (!syncedAudio) return;
223
+ if (syncedAudio.paused) syncedAudio.play();
224
+ else syncedAudio.pause();
225
  });
226
 
227
+ progBar.addEventListener('click', (e) => {
228
+ if (!syncedAudio || !totalDur) return;
229
+ const r = progBar.getBoundingClientRect();
230
+ syncedAudio.currentTime = ((e.clientX - r.left) / r.width) * totalDur;
 
 
231
  });
232
 
233
+ function fmt(s) { return Math.floor(s/60) + ':' + String(Math.floor(s%60)).padStart(2,'0'); }
 
 
 
 
234
 
235
+ function tick() {
236
+ if (!syncedAudio) return;
237
+ const el = syncedAudio.currentTime;
238
+ const dur = syncedAudio.duration || totalDur;
239
 
240
+ // Update button state
241
+ playBtn.textContent = syncedAudio.paused ? '▶' : '⏸';
 
 
242
 
243
+ // Progress
244
+ progFill.style.width = (dur > 0 ? (el/dur)*100 : 0) + '%';
245
+ timeDisp.textContent = `${fmt(el)} / ${fmt(dur)}`;
246
+
247
+ // Find active line by current time
248
+ let ni = -1;
249
  for (let i = 0; i < timeline.length; i++) {
250
+ if (el >= timeline[i].st && el < timeline[i].st + timeline[i].dur) { ni = i; break; }
 
 
 
251
  }
252
+
253
+ if (ni !== activeIdx) {
254
+ activeIdx = ni;
255
+ if (ni >= 0) {
256
+ const it = timeline[ni];
257
+ curSpk = it.speakerIdx;
258
+ document.getElementById('gt-sub-spk').textContent = it.speaker + ':';
259
+ document.getElementById('gt-sub-txt').textContent = it.line;
260
+ document.getElementById('gt-ind-l').classList.toggle('active', it.speakerIdx === 0);
261
+ document.getElementById('gt-ind-r').classList.toggle('active', it.speakerIdx === 1);
262
+ const lines = logPanel.querySelectorAll('.gt-line');
263
+ lines.forEach((el, i) => { el.classList.toggle('active', i === ni); el.classList.toggle('past', i < ni); });
264
+ if (lines[ni]) lines[ni].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
265
  }
266
  }
 
 
 
 
 
 
 
 
267
 
268
+ // Check if ended
269
+ if (syncedAudio.ended) {
270
+ curSpk = -1;
271
+ playBtn.textContent = '▶';
272
+ document.getElementById('gt-sub-spk').textContent = '';
273
+ document.getElementById('gt-sub-txt').textContent = '✅ Episode complete!';
274
+ document.getElementById('gt-ind-l').classList.remove('active');
275
+ document.getElementById('gt-ind-r').classList.remove('active');
 
 
 
 
 
 
276
  }
 
277
 
278
+ requestAnimationFrame(tick);
 
 
 
 
 
 
 
279
  }
280
 
281
+ function buildLog(data) {
282
+ logPanel.innerHTML = '';
283
+ data.forEach((it) => {
284
+ const d = document.createElement('div');
285
+ d.className = 'gt-line' + (it.speakerIdx === 1 ? ' spkb' : '');
286
+ d.innerHTML = `<span class="gt-ls">${it.speaker}:</span> ${it.line}`;
287
+ logPanel.appendChild(d);
288
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  }
290
 
291
  // --- Public API ---
292
  window.GeoTalkRobots = {
293
+ /** Load timeline data and set speaker labels */
294
+ loadTimeline(data, spkA, spkB) {
295
+ document.getElementById('gt-ind-l').textContent = spkA || 'Speaker A';
296
+ document.getElementById('gt-ind-r').textContent = spkB || 'Speaker B';
297
+ timeline = []; let cum = 0;
298
+ for (const it of data) { timeline.push({ ...it, st: cum, dur: it.duration }); cum += it.duration; }
299
+ totalDur = cum;
300
+ timeDisp.textContent = `0:00 / ${fmt(totalDur)}`;
301
+ buildLog(data);
302
+ document.getElementById('gt-sub-spk').textContent = '';
303
+ document.getElementById('gt-sub-txt').textContent = '🎙️ Episode loaded — playing...';
304
+ },
305
 
306
+ /** Sync robot animations to an existing audio element (e.g. Gradio's) */
307
+ syncToAudio(audioElement) {
308
+ syncedAudio = audioElement;
309
+ // Start the tick loop
310
+ requestAnimationFrame(tick);
311
+ // If audio is already playing, update UI
312
+ if (!audioElement.paused) {
313
+ playBtn.textContent = '⏸';
314
  }
315
+ // Listen for play/pause events
316
+ audioElement.addEventListener('play', () => { playBtn.textContent = '⏸'; requestAnimationFrame(tick); });
317
+ audioElement.addEventListener('pause', () => { playBtn.textContent = '▶'; });
318
+ audioElement.addEventListener('ended', () => {
319
+ curSpk = -1; playBtn.textContent = '▶';
320
+ document.getElementById('gt-sub-spk').textContent = '';
321
+ document.getElementById('gt-sub-txt').textContent = '✅ Episode complete!';
 
 
 
 
 
 
 
 
 
 
322
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  },
324
 
325
+ /** Convenience: start with own audio (standalone mode) */
326
+ start(data, spkA, spkB, audioUrl) {
327
+ this.loadTimeline(data, spkA, spkB);
328
+ const audio = new Audio(audioUrl);
329
+ this.syncToAudio(audio);
330
+ audio.play().catch(() => {
331
+ document.getElementById('gt-sub-txt').textContent = '▶ Press play to start';
332
+ });
333
  },
334
 
335
+ idle(msg) {
336
+ curSpk = -1;
337
+ document.getElementById('gt-sub-spk').textContent = '';
338
+ document.getElementById('gt-sub-txt').textContent = msg || 'Waiting for episode...';
339
+ document.getElementById('gt-ind-l').classList.remove('active');
340
+ document.getElementById('gt-ind-r').classList.remove('active');
341
+ progFill.style.width = '0%';
342
+ timeDisp.textContent = '0:00 / 0:00';
343
+ logPanel.innerHTML = `<div style="color:rgba(255,255,255,0.4);font-size:12px;padding:10px;">${msg || 'Transcript will appear here...'}</div>`;
344
  }
345
  };
346
 
 
347
  window.GeoTalkRobots.idle();
348
  </script>