Spaces:
Sleeping
Sleeping
ShebMichel commited on
Commit ·
2ba8d81
1
Parent(s): a6d8ed5
Fix autoplay: use inline HTML (no iframe) + sync to Gradio autoplay audio element
Browse files- app.py +66 -60
- 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 |
-
|
| 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(
|
| 63 |
-
"""Return the robot viewer in idle state."""
|
| 64 |
-
return
|
| 65 |
-
<iframe id="robot-frame" srcdoc='{ROBOT_HTML.replace(chr(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]
|
| 72 |
-
"""Return robot viewer
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 98 |
-
|
| 99 |
<script>
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
}}
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
-
return
|
| 118 |
-
<iframe id="robot-frame" srcdoc='{modified_html.replace(chr(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 |
-
|
| 154 |
-
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
<!
|
| 2 |
-
<
|
| 3 |
-
<head>
|
| 4 |
<style>
|
| 5 |
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 6 |
-
|
| 7 |
-
#
|
| 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 |
-
#
|
| 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 |
-
#
|
|
|
|
| 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 |
-
#
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 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 |
-
.
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
font-size:
|
| 77 |
-
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
#
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
padding:
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
#
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 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 |
-
.
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 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 |
-
|
| 182 |
-
<
|
| 183 |
-
<div id="
|
| 184 |
-
|
| 185 |
-
<
|
| 186 |
-
<div id="
|
| 187 |
-
<div id="
|
| 188 |
-
<div id="
|
| 189 |
-
|
| 190 |
-
<span class="
|
| 191 |
-
<span class="speaker-text" id="sub-text">Waiting for episode...</span>
|
| 192 |
</div>
|
| 193 |
</div>
|
| 194 |
-
<div id="
|
| 195 |
-
<button id="
|
| 196 |
-
<div id="
|
| 197 |
-
<span id="
|
| 198 |
</div>
|
| 199 |
-
<div id="
|
| 200 |
<span>📜 Transcript</span>
|
| 201 |
-
<span
|
| 202 |
</div>
|
| 203 |
-
<div id="
|
|
|
|
| 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 |
-
|
| 217 |
-
const
|
| 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(
|
| 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,
|
| 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
|
| 233 |
-
|
| 234 |
-
|
| 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 |
-
|
| 242 |
-
|
| 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
|
| 253 |
-
const
|
| 254 |
-
const
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
const
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
const
|
| 262 |
-
|
| 263 |
-
|
| 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 |
-
|
| 287 |
-
|
| 288 |
}
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
const
|
| 292 |
-
const
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
const
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 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 (
|
| 367 |
-
else if (
|
| 368 |
-
else {
|
| 369 |
renderer.render(scene, camera);
|
| 370 |
}
|
| 371 |
animate();
|
| 372 |
|
| 373 |
-
// --- Resize ---
|
| 374 |
function onResize() {
|
| 375 |
-
const w =
|
| 376 |
-
|
| 377 |
-
camera.updateProjectionMatrix();
|
| 378 |
-
renderer.setSize(w, h);
|
| 379 |
}
|
| 380 |
window.addEventListener('resize', onResize);
|
| 381 |
-
new ResizeObserver(onResize).observe(
|
| 382 |
|
| 383 |
-
// --- Audio
|
| 384 |
-
let
|
| 385 |
let timeline = [];
|
| 386 |
-
let
|
| 387 |
-
let
|
| 388 |
-
|
| 389 |
-
const playBtn = document.getElementById('
|
| 390 |
-
const
|
| 391 |
-
const
|
| 392 |
-
const
|
| 393 |
-
const
|
| 394 |
-
const
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
transcriptToggle.classList.toggle('collapsed', !transcriptExpanded);
|
| 402 |
});
|
| 403 |
|
| 404 |
-
// Play/pause
|
| 405 |
playBtn.addEventListener('click', () => {
|
| 406 |
-
if (!
|
| 407 |
-
if (
|
| 408 |
-
else
|
| 409 |
});
|
| 410 |
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
const pct = (e.clientX - rect.left) / rect.width;
|
| 416 |
-
audioEl.currentTime = pct * totalDuration;
|
| 417 |
});
|
| 418 |
|
| 419 |
-
function
|
| 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
|
| 426 |
-
if (!
|
| 427 |
-
const
|
|
|
|
| 428 |
|
| 429 |
-
//
|
| 430 |
-
|
| 431 |
-
progressFill.style.width = pct + '%';
|
| 432 |
-
timeDisplay.textContent = `${formatTime(elapsed)} / ${formatTime(totalDuration)}`;
|
| 433 |
|
| 434 |
-
//
|
| 435 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
for (let i = 0; i < timeline.length; i++) {
|
| 437 |
-
if (
|
| 438 |
-
newActive = i;
|
| 439 |
-
break;
|
| 440 |
-
}
|
| 441 |
}
|
| 442 |
-
|
| 443 |
-
if (
|
| 444 |
-
|
| 445 |
-
if (
|
| 446 |
-
const
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 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 |
-
|
| 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 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 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 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
document.getElementById('
|
| 542 |
-
document.getElementById('
|
|
|
|
| 543 |
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
|
|
|
|
|
|
| 550 |
}
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 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 |
-
|
| 585 |
-
|
| 586 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
},
|
| 588 |
|
| 589 |
-
idle(
|
| 590 |
-
|
| 591 |
-
document.getElementById('sub-
|
| 592 |
-
document.getElementById('sub-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
|
|
|
| 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>
|
|
|
|
|
|