J Z commited on
Commit
d06f609
·
verified ·
1 Parent(s): 9d5d0ce

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +310 -59
app.py CHANGED
@@ -35,11 +35,64 @@ _float_lock = threading.Lock()
35
  _sessions = {}
36
 
37
 
 
 
 
38
  @app.on_event("startup")
39
  async def startup():
 
40
  await download_videos()
41
  if FLOAT_ENABLED:
42
  await init_float()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
 
45
  async def init_float():
@@ -96,10 +149,83 @@ async def init_float():
96
  "wav2vec_model_path": str(w2v), "audio2emotion_path": str(emo),
97
  })
98
  logger.info("[STARTUP] ✓ FLOAT ready!")
 
 
 
 
99
  except Exception as e:
100
  logger.error(f"[STARTUP] FLOAT init failed: {e}", exc_info=True)
101
 
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  async def download_videos():
104
  from huggingface_hub import HfApi, hf_hub_download
105
  import shutil
@@ -182,6 +308,16 @@ async def chat(request: Request):
182
 
183
  logger.info(f"[{_ts()}] [CHAT] User: {user_text}")
184
 
 
 
 
 
 
 
 
 
 
 
185
  # 1. LLM (single call)
186
  t1 = _time.time()
187
  from llm import generate_darwin_response
@@ -265,7 +401,7 @@ async def chat_next(request: Request):
265
 
266
  def _generate_chunk(sentence, idx, total):
267
  """Synchronous TTS + FLOAT generation (runs in thread pool)."""
268
- logger.info(f"[{_ts()}] [CHUNK {idx+1}/{total}] {sentence[:60]}...")
269
  t0 = _time.time()
270
 
271
  # 1. TTS
@@ -320,6 +456,14 @@ async def reset_chat():
320
  return JSONResponse({"status": "ok"})
321
 
322
 
 
 
 
 
 
 
 
 
323
  @app.get("/", response_class=HTMLResponse)
324
  async def index():
325
  return HTML_PAGE
@@ -339,7 +483,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
339
  .video-section{position:relative;width:100%;min-height:300px;max-height:45vh;background:#000;border-radius:12px;overflow:hidden;flex-shrink:0;display:flex;align-items:center;justify-content:center}
340
  .video-container{position:relative;width:100%;height:100%}
341
  .video-container video{width:100%;height:100%;object-fit:contain;display:block}
342
- #videoA,#videoB{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:contain;transition:opacity .25s ease}
343
  #videoA{z-index:1;opacity:1}#videoB{z-index:0;opacity:0}
344
  .video-status{position:absolute;bottom:8px;right:12px;font-size:.7rem;color:rgba(255,255,255,.4);background:rgba(0,0,0,.5);padding:2px 8px;border-radius:4px;z-index:10}
345
  .chat-section{flex:1;display:flex;flex-direction:column;min-height:0;background:rgba(15,23,42,.6);border:1px solid rgba(59,130,246,.15);border-radius:12px;overflow:hidden}
@@ -387,51 +531,98 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
387
  // ======================================================================
388
  // STATE
389
  // ======================================================================
390
- let videos=[], currentVideo='A', isTransitioning=false, isProcessing=false, lastPlayed=null, msgCounter=0;
391
  const videoA=document.getElementById('videoA'), videoB=document.getElementById('videoB');
392
  const videoStatus=document.getElementById('videoStatus'), chatLog=document.getElementById('chatLog');
393
  const chatInput=document.getElementById('chatInput'), btnSend=document.getElementById('btnSend');
394
  const btnReset=document.getElementById('btnReset');
395
 
396
  // ======================================================================
397
- // VIDEO: Crossfade
398
  // ======================================================================
399
  function getActiveVideo(){return currentVideo==='A'?videoA:videoB}
400
  function getInactiveVideo(){return currentVideo==='A'?videoB:videoA}
401
 
402
- function crossfadeTo(url, muted){
 
403
  return new Promise((resolve)=>{
404
- if(isTransitioning){ resolve(); return; }
405
- isTransitioning=true;
406
- const prev=getActiveVideo(), next=getInactiveVideo();
407
- next.muted=(muted!==false);
408
- next.src=url; next.load();
409
- next.onloadeddata=function(){
410
- next.style.zIndex='2'; prev.style.zIndex='1'; next.style.opacity='0';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  next.play().then(()=>{
412
  next.style.opacity='1';
 
413
  setTimeout(()=>{
414
- prev.style.opacity='0'; prev.pause(); prev.src='';
415
- prev.style.zIndex='0'; next.style.zIndex='1';
 
 
 
416
  currentVideo=currentVideo==='A'?'B':'A';
417
- isTransitioning=false;
418
  resolve();
419
- },300);
420
- }).catch(()=>{
421
- next.muted=true;
422
- next.play().then(()=>{
423
- next.style.opacity='1';
424
- setTimeout(()=>{
425
- prev.style.opacity='0'; prev.pause(); prev.src='';
426
- prev.style.zIndex='0'; next.style.zIndex='1';
427
- currentVideo=currentVideo==='A'?'B':'A';
428
- isTransitioning=false;
429
- resolve();
430
- },300);
431
- }).catch(()=>{ isTransitioning=false; resolve(); });
432
- });
433
- };
434
- next.onerror=function(){ isTransitioning=false; resolve(); };
435
  });
436
  }
437
 
@@ -450,16 +641,15 @@ function playNextIdle(){
450
  }
451
 
452
  // Idle loop: when video ends and not speaking, play next idle
453
- videoA.addEventListener('ended',()=>{ if(currentVideo==='A'&&!isProcessing) playNextIdle(); });
454
- videoB.addEventListener('ended',()=>{ if(currentVideo==='B'&&!isProcessing) playNextIdle(); });
455
 
456
  // ======================================================================
457
- // VIDEO: Wait for current lipsync video to end
458
  // ======================================================================
459
  function waitForVideoEnd(){
460
  return new Promise((resolve)=>{
461
  const active=getActiveVideo();
462
- // If video is very short or already ended, resolve quickly
463
  if(active.ended || active.paused || !active.src){
464
  resolve(); return;
465
  }
@@ -518,6 +708,9 @@ async function sendMessage(){
518
 
519
  videoStatus.textContent='generating 1/'+totalChunks+'...';
520
 
 
 
 
521
  // Helper to fetch a chunk from the server
522
  async function fetchChunk(sid){
523
  const r=await fetch('/api/chat/next',{
@@ -528,41 +721,74 @@ async function sendMessage(){
528
  return await r.json();
529
  }
530
 
531
- // Step 2: Prefetch first chunk
532
- let nextChunkPromise=fetchChunk(sessionId);
533
 
534
  for(let i=0; i<totalChunks; i++){
535
- videoStatus.textContent='waiting '+(i+1)+'/'+totalChunks+'...';
536
-
537
- // Wait for the current chunk to be ready
538
- const chunk=await nextChunkPromise;
539
  if(!chunk || chunk.done) break;
540
 
541
- // Immediately start prefetching the NEXT chunk (runs in parallel with playback)
542
- if(i+1 < totalChunks){
543
- videoStatus.textContent='speaking '+(i+1)+'/'+totalChunks+' (prefetching '+(i+2)+')';
544
- nextChunkPromise=fetchChunk(sessionId);
545
- }
546
 
547
- // Play audio
548
- let audioPromise=null;
 
 
 
549
  if(chunk.audio_url){
550
  const audio=new Audio(chunk.audio_url);
551
- audioPromise=new Promise((resolve)=>{
552
  audio.onended=resolve;
553
  audio.onerror=resolve;
554
- audio.play().catch(()=>resolve());
 
 
 
555
  });
556
  }
557
 
558
- // Play lipsync video
559
  if(chunk.lipsync_video_url){
560
- await crossfadeTo(chunk.lipsync_video_url, true);
561
- // Wait for this lipsync video to finish before next chunk
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
562
  await waitForVideoEnd();
563
- } else if(audioPromise){
564
- // No lipsync, just wait for audio to finish
565
- await audioPromise;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  }
567
  }
568
 
@@ -575,7 +801,7 @@ async function sendMessage(){
575
  typing.contentEl.classList.remove('typing-dots');
576
  typing.contentEl.textContent='Error: '+err.message;
577
  }
578
- isProcessing=false; btnSend.disabled=false; chatInput.focus();
579
  }
580
 
581
  btnSend.addEventListener('click', sendMessage);
@@ -596,8 +822,33 @@ async function init(){
596
  if(!data.videos.length){ videoStatus.textContent='Downloading...'; setTimeout(init,3000); return; }
597
  videos=data.videos;
598
  videoStatus.textContent=videos.length+' idle clips loaded';
599
- playNextIdle();
600
- addMessage('bot','Good day! I am Charles Darwin, er, recreated in this curious digital form. Do feel free to ask me anything, umm, about the natural world or otherwise.');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
601
  }catch(e){ videoStatus.textContent='Error: '+e.message; }
602
  }
603
  init();
 
35
  _sessions = {}
36
 
37
 
38
+ GREETING_DATA = None # Will hold {audio_url, lipsync_video_url, text} after startup
39
+
40
+
41
  @app.on_event("startup")
42
  async def startup():
43
+ global GREETING_DATA
44
  await download_videos()
45
  if FLOAT_ENABLED:
46
  await init_float()
47
+ # Generate a greeting to warm up FLOAT and play on first load
48
+ await generate_greeting()
49
+
50
+
51
+ async def generate_greeting():
52
+ """Generate TTS + lipsync for a greeting message to warm up the model."""
53
+ global GREETING_DATA
54
+ import asyncio
55
+
56
+ greeting_text = "Good day! I am Charles Darwin, er, recreated in this curious digital form. Do feel free to ask me anything, umm, about the natural world."
57
+
58
+ logger.info(f"[{_ts()}] [GREETING] Generating warmup greeting...")
59
+ t0 = _time.time()
60
+
61
+ try:
62
+ from tts import generate_audio
63
+ audio_path = generate_audio(greeting_text)
64
+ audio_url = None
65
+ audio_duration = 0
66
+
67
+ if audio_path and os.path.exists(audio_path):
68
+ audio_url = f"/api/audio/{os.path.basename(audio_path)}"
69
+ try:
70
+ r = subprocess.run(["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
71
+ "-of", "csv=p=0", audio_path], capture_output=True, text=True, check=True)
72
+ audio_duration = float(r.stdout.strip())
73
+ except Exception:
74
+ audio_duration = 5.0
75
+
76
+ lipsync_video_url = None
77
+ if FLOAT_ENABLED and audio_path and os.path.exists(audio_path):
78
+ from float_lipsync import get_lipsync
79
+ lipsync = get_lipsync()
80
+ if lipsync.ready:
81
+ lp = lipsync.generate(audio_path)
82
+ if lp and os.path.exists(lp):
83
+ lipsync_video_url = f"/api/stream/{os.path.basename(lp)}"
84
+
85
+ GREETING_DATA = {
86
+ "text": greeting_text,
87
+ "audio_url": audio_url,
88
+ "audio_duration": audio_duration,
89
+ "lipsync_video_url": lipsync_video_url,
90
+ }
91
+ logger.info(f"[{_ts()}] [GREETING] Ready in {_time.time()-t0:.2f}s (lipsync: {lipsync_video_url is not None})")
92
+
93
+ except Exception as e:
94
+ logger.error(f"[{_ts()}] [GREETING] Failed: {e}", exc_info=True)
95
+ GREETING_DATA = {"text": greeting_text, "audio_url": None, "audio_duration": 0, "lipsync_video_url": None}
96
 
97
 
98
  async def init_float():
 
149
  "wav2vec_model_path": str(w2v), "audio2emotion_path": str(emo),
150
  })
151
  logger.info("[STARTUP] ✓ FLOAT ready!")
152
+
153
+ # Warmup: generate a greeting to prime GPU kernels
154
+ await _generate_warmup_greeting()
155
+
156
  except Exception as e:
157
  logger.error(f"[STARTUP] FLOAT init failed: {e}", exc_info=True)
158
 
159
 
160
+ # Warmup greeting data (set during startup, served to frontend)
161
+ _warmup_greeting = None
162
+
163
+ GREETING_TEXT = "Good day! I am Charles Darwin, er, recreated in this curious digital form. Do feel free to ask me anything, umm, about the natural world or otherwise."
164
+
165
+ async def _generate_warmup_greeting():
166
+ """Generate TTS + lipsync for a greeting to warm up the GPU."""
167
+ global _warmup_greeting
168
+ import asyncio
169
+ logger.info("[WARMUP] Generating greeting TTS + lipsync...")
170
+ t0 = _time.time()
171
+
172
+ try:
173
+ result = await asyncio.to_thread(_generate_warmup_sync)
174
+ _warmup_greeting = result
175
+ logger.info(f"[WARMUP] ✓ Greeting ready in {_time.time()-t0:.2f}s")
176
+ except Exception as e:
177
+ logger.error(f"[WARMUP] Failed: {e}", exc_info=True)
178
+ _warmup_greeting = None
179
+
180
+
181
+ def _generate_warmup_sync():
182
+ """Synchronous warmup generation."""
183
+ from tts import generate_audio
184
+
185
+ t1 = _time.time()
186
+ audio_path = generate_audio(GREETING_TEXT)
187
+ t2 = _time.time()
188
+ logger.info(f"[WARMUP] TTS: {t2-t1:.2f}s")
189
+
190
+ audio_url = None
191
+ audio_duration = 0
192
+ if audio_path and os.path.exists(audio_path):
193
+ audio_url = f"/api/audio/{os.path.basename(audio_path)}"
194
+ try:
195
+ r = subprocess.run(["ffprobe","-v","quiet","-show_entries","format=duration",
196
+ "-of","csv=p=0", audio_path], capture_output=True, text=True, check=True)
197
+ audio_duration = float(r.stdout.strip())
198
+ except Exception:
199
+ audio_duration = 5.0
200
+
201
+ lipsync_video_url = None
202
+ if FLOAT_ENABLED and audio_path and os.path.exists(audio_path):
203
+ from float_lipsync import get_lipsync
204
+ lipsync = get_lipsync()
205
+ if lipsync.ready:
206
+ t3 = _time.time()
207
+ lp = lipsync.generate(audio_path)
208
+ t4 = _time.time()
209
+ logger.info(f"[WARMUP] FLOAT: {t4-t3:.2f}s")
210
+ if lp and os.path.exists(lp):
211
+ lipsync_video_url = f"/api/stream/{os.path.basename(lp)}"
212
+
213
+ return {
214
+ "text": GREETING_TEXT,
215
+ "audio_url": audio_url,
216
+ "audio_duration": audio_duration,
217
+ "lipsync_video_url": lipsync_video_url,
218
+ }
219
+
220
+
221
+ @app.get("/api/greeting")
222
+ async def get_greeting():
223
+ """Return the pre-generated warmup greeting (or null if not ready)."""
224
+ if _warmup_greeting:
225
+ return JSONResponse(_warmup_greeting)
226
+ return JSONResponse({"text": GREETING_TEXT, "audio_url": None, "lipsync_video_url": None})
227
+
228
+
229
  async def download_videos():
230
  from huggingface_hub import HfApi, hf_hub_download
231
  import shutil
 
308
 
309
  logger.info(f"[{_ts()}] [CHAT] User: {user_text}")
310
 
311
+ # Reset FLOAT reference to original for new message
312
+ if FLOAT_ENABLED:
313
+ try:
314
+ from float_lipsync import get_lipsync
315
+ lipsync = get_lipsync()
316
+ if lipsync.ready:
317
+ lipsync.reset_reference()
318
+ except Exception:
319
+ pass
320
+
321
  # 1. LLM (single call)
322
  t1 = _time.time()
323
  from llm import generate_darwin_response
 
401
 
402
  def _generate_chunk(sentence, idx, total):
403
  """Synchronous TTS + FLOAT generation (runs in thread pool)."""
404
+ logger.info(f"[{_ts()}] [CHUNK {idx+1}/{total}] ({len(sentence.split())}w {len(sentence)}c) {sentence[:60]}...")
405
  t0 = _time.time()
406
 
407
  # 1. TTS
 
456
  return JSONResponse({"status": "ok"})
457
 
458
 
459
+ @app.get("/api/greeting")
460
+ async def get_greeting():
461
+ """Return the pre-generated greeting (audio + lipsync video)."""
462
+ if GREETING_DATA:
463
+ return JSONResponse(GREETING_DATA)
464
+ return JSONResponse({"text": None})
465
+
466
+
467
  @app.get("/", response_class=HTMLResponse)
468
  async def index():
469
  return HTML_PAGE
 
483
  .video-section{position:relative;width:100%;min-height:300px;max-height:45vh;background:#000;border-radius:12px;overflow:hidden;flex-shrink:0;display:flex;align-items:center;justify-content:center}
484
  .video-container{position:relative;width:100%;height:100%}
485
  .video-container video{width:100%;height:100%;object-fit:contain;display:block}
486
+ #videoA,#videoB{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:contain;transition:opacity .175s ease}
487
  #videoA{z-index:1;opacity:1}#videoB{z-index:0;opacity:0}
488
  .video-status{position:absolute;bottom:8px;right:12px;font-size:.7rem;color:rgba(255,255,255,.4);background:rgba(0,0,0,.5);padding:2px 8px;border-radius:4px;z-index:10}
489
  .chat-section{flex:1;display:flex;flex-direction:column;min-height:0;background:rgba(15,23,42,.6);border:1px solid rgba(59,130,246,.15);border-radius:12px;overflow:hidden}
 
531
  // ======================================================================
532
  // STATE
533
  // ======================================================================
534
+ let videos=[], currentVideo='A', isTransitioning=false, isProcessing=false, isSpeaking=false, lastPlayed=null, msgCounter=0;
535
  const videoA=document.getElementById('videoA'), videoB=document.getElementById('videoB');
536
  const videoStatus=document.getElementById('videoStatus'), chatLog=document.getElementById('chatLog');
537
  const chatInput=document.getElementById('chatInput'), btnSend=document.getElementById('btnSend');
538
  const btnReset=document.getElementById('btnReset');
539
 
540
  // ======================================================================
541
+ // VIDEO: Seamless crossfade with preloading
542
  // ======================================================================
543
  function getActiveVideo(){return currentVideo==='A'?videoA:videoB}
544
  function getInactiveVideo(){return currentVideo==='A'?videoB:videoA}
545
 
546
+ // Preload a video into the inactive element (doesn't play yet)
547
+ function preloadVideo(url){
548
  return new Promise((resolve)=>{
549
+ const next=getInactiveVideo();
550
+ next.muted=true;
551
+ next.preload='auto';
552
+ next.src=url;
553
+ next.load();
554
+ next.oncanplaythrough=function(){
555
+ next.oncanplaythrough=null;
556
+ console.log('['+new Date().toISOString().substr(11,12)+'] preloaded: '+url.split('/').pop());
557
+ resolve(true);
558
+ };
559
+ next.onerror=function(){
560
+ next.onerror=null;
561
+ console.warn('[VIDEO] Preload failed:', url);
562
+ resolve(false);
563
+ };
564
+ // Timeout fallback — don't wait forever
565
+ setTimeout(()=>resolve(true), 3000);
566
+ });
567
+ }
568
+
569
+ // Swap: the inactive element is already loaded, just play and crossfade
570
+ function swapToPreloaded(){
571
+ return new Promise((resolve)=>{
572
+ const prev=getActiveVideo();
573
+ const next=getInactiveVideo();
574
+
575
+ // If nothing preloaded, just resolve
576
+ if(!next.src || next.src===''){
577
+ resolve(); return;
578
+ }
579
+
580
+ next.style.zIndex='2';
581
+ prev.style.zIndex='1';
582
+ next.style.opacity='0';
583
+
584
+ next.play().then(()=>{
585
+ // Instant opacity swap (no slow fade — speed over beauty)
586
+ next.style.opacity='1';
587
+ prev.style.opacity='0';
588
+
589
+ // Clean up old video after brief overlap
590
+ setTimeout(()=>{
591
+ prev.pause();
592
+ prev.removeAttribute('src');
593
+ prev.load(); // reset
594
+ prev.style.zIndex='0';
595
+ next.style.zIndex='1';
596
+ currentVideo=currentVideo==='A'?'B':'A';
597
+ resolve();
598
+ }, 50);
599
+ }).catch(()=>{
600
+ next.muted=true;
601
  next.play().then(()=>{
602
  next.style.opacity='1';
603
+ prev.style.opacity='0';
604
  setTimeout(()=>{
605
+ prev.pause();
606
+ prev.removeAttribute('src');
607
+ prev.load();
608
+ prev.style.zIndex='0';
609
+ next.style.zIndex='1';
610
  currentVideo=currentVideo==='A'?'B':'A';
 
611
  resolve();
612
+ }, 50);
613
+ }).catch(()=>{ resolve(); });
614
+ });
615
+ });
616
+ }
617
+
618
+ // Full crossfade (load + play) for idle videos and first chunk
619
+ function crossfadeTo(url, muted){
620
+ return new Promise(async (resolve)=>{
621
+ await preloadVideo(url);
622
+ const next=getInactiveVideo();
623
+ next.muted=(muted!==false);
624
+ await swapToPreloaded();
625
+ resolve();
 
 
626
  });
627
  }
628
 
 
641
  }
642
 
643
  // Idle loop: when video ends and not speaking, play next idle
644
+ videoA.addEventListener('ended',()=>{ if(currentVideo==='A'&&!isSpeaking) playNextIdle(); });
645
+ videoB.addEventListener('ended',()=>{ if(currentVideo==='B'&&!isSpeaking) playNextIdle(); });
646
 
647
  // ======================================================================
648
+ // VIDEO: Wait for current video to end
649
  // ======================================================================
650
  function waitForVideoEnd(){
651
  return new Promise((resolve)=>{
652
  const active=getActiveVideo();
 
653
  if(active.ended || active.paused || !active.src){
654
  resolve(); return;
655
  }
 
708
 
709
  videoStatus.textContent='generating 1/'+totalChunks+'...';
710
 
711
+ // Idle videos keep playing while we wait for chunks
712
+ // isSpeaking stays false until we actually play a lipsync video
713
+
714
  // Helper to fetch a chunk from the server
715
  async function fetchChunk(sid){
716
  const r=await fetch('/api/chat/next',{
 
721
  return await r.json();
722
  }
723
 
724
+ // Step 2: Fetch and play chunks with preloading
725
+ let nextChunk = await fetchChunk(sessionId);
726
 
727
  for(let i=0; i<totalChunks; i++){
728
+ const chunk = nextChunk;
 
 
 
729
  if(!chunk || chunk.done) break;
730
 
731
+ console.log('['+new Date().toISOString().substr(11,12)+'] chunk '+(i+1)+' ready');
732
+ videoStatus.textContent='playing '+(i+1)+'/'+totalChunks;
 
 
 
733
 
734
+ // Start fetching next chunk data from server (TTS+FLOAT generation)
735
+ let nextChunkPromise = (i+1 < totalChunks) ? fetchChunk(sessionId) : null;
736
+
737
+ // Play audio and get a promise for when it ends
738
+ let audioDonePromise = null;
739
  if(chunk.audio_url){
740
  const audio=new Audio(chunk.audio_url);
741
+ audioDonePromise = new Promise((resolve)=>{
742
  audio.onended=resolve;
743
  audio.onerror=resolve;
744
+ // Fallback timeout in case ended event doesn't fire
745
+ const fallback = setTimeout(resolve, (chunk.audio_duration||5)*1000 + 500);
746
+ audio.onended=()=>{ clearTimeout(fallback); resolve(); };
747
+ audio.play().catch(()=>{ clearTimeout(fallback); resolve(); });
748
  });
749
  }
750
 
 
751
  if(chunk.lipsync_video_url){
752
+ // --- HAS LIPSYNC VIDEO ---
753
+ isSpeaking=true; // stop idle loop for entire response
754
+ if(i===0){
755
+ await crossfadeTo(chunk.lipsync_video_url, true);
756
+ } else {
757
+ await swapToPreloaded();
758
+ }
759
+ console.log('['+new Date().toISOString().substr(11,12)+'] chunk '+(i+1)+' video playing');
760
+
761
+ // While video plays, wait for next chunk data and preload its video
762
+ if(nextChunkPromise){
763
+ nextChunkPromise.then((nc)=>{
764
+ if(nc && !nc.done && nc.lipsync_video_url){
765
+ console.log('['+new Date().toISOString().substr(11,12)+'] preloading chunk '+(i+2));
766
+ preloadVideo(nc.lipsync_video_url);
767
+ }
768
+ });
769
+ }
770
+
771
  await waitForVideoEnd();
772
+ console.log('['+new Date().toISOString().substr(11,12)+'] chunk '+(i+1)+' video ended');
773
+ // isSpeaking stays true — no idle between chunks
774
+
775
+ } else {
776
+ // --- AUDIO ONLY (no lipsync) ---
777
+ console.log('['+new Date().toISOString().substr(11,12)+'] chunk '+(i+1)+' audio only');
778
+ if(audioDonePromise){
779
+ await audioDonePromise;
780
+ } else if(chunk.audio_duration > 0){
781
+ await new Promise(r=>setTimeout(r, chunk.audio_duration*1000));
782
+ } else {
783
+ await new Promise(r=>setTimeout(r, 2000));
784
+ }
785
+ }
786
+
787
+ // Get the next chunk (should already be resolved)
788
+ if(nextChunkPromise){
789
+ nextChunk = await nextChunkPromise;
790
+ } else {
791
+ nextChunk = null;
792
  }
793
  }
794
 
 
801
  typing.contentEl.classList.remove('typing-dots');
802
  typing.contentEl.textContent='Error: '+err.message;
803
  }
804
+ isProcessing=false; isSpeaking=false; btnSend.disabled=false; chatInput.focus();
805
  }
806
 
807
  btnSend.addEventListener('click', sendMessage);
 
822
  if(!data.videos.length){ videoStatus.textContent='Downloading...'; setTimeout(init,3000); return; }
823
  videos=data.videos;
824
  videoStatus.textContent=videos.length+' idle clips loaded';
825
+
826
+ // Check for pre-generated greeting
827
+ const greetRes=await fetch('/api/greeting');
828
+ const greeting=await greetRes.json();
829
+
830
+ if(greeting.lipsync_video_url){
831
+ // Play greeting lipsync video first
832
+ videoStatus.textContent='greeting...';
833
+ addMessage('bot', greeting.text);
834
+
835
+ // Play audio
836
+ if(greeting.audio_url){
837
+ const audio=new Audio(greeting.audio_url);
838
+ audio.play().catch(e=>console.warn('[AUDIO]',e));
839
+ }
840
+
841
+ // Play lipsync video
842
+ await crossfadeTo(greeting.lipsync_video_url, true);
843
+ await waitForVideoEnd();
844
+
845
+ // Then start idle loop
846
+ playNextIdle();
847
+ } else {
848
+ // No greeting video, just show text and start idle
849
+ addMessage('bot', greeting.text || 'Good day! I am Charles Darwin, er, recreated in this curious digital form. Do feel free to ask me anything, umm, about the natural world.');
850
+ playNextIdle();
851
+ }
852
  }catch(e){ videoStatus.textContent='Error: '+e.message; }
853
  }
854
  init();