JasonZhai-9936 Claude Opus 5.5 commited on
Commit
abc34f2
Β·
1 Parent(s): 8388ff5

Add per-turn timeline logging: every stage of a reply (LLM, per-chunk TTS, feature encode, speech render, WS delivery, client audio start/end) logs in order as [T<turn> +<secs>] so sync/latency problems can be read straight from a copied log

Browse files
Files changed (3) hide show
  1. app.py +65 -21
  2. float_streamer.py +45 -16
  3. timeline.py +41 -0
app.py CHANGED
@@ -16,6 +16,8 @@ import wave
16
  import cv2
17
  import asyncio
18
 
 
 
19
  logging.basicConfig(level=logging.INFO)
20
  logger = logging.getLogger(__name__)
21
 
@@ -296,7 +298,7 @@ async def generate_greeting():
296
  from float_streamer import get_streamer
297
  streamer = get_streamer()
298
  if streamer.ready:
299
- await asyncio.to_thread(streamer.inject_speech, audio_path, audio_url)
300
  logger.info(f"[{_ts()}] [GREETING] Injected into streamer")
301
  except Exception as e:
302
  logger.warning(f"[GREETING] Streamer inject failed: {e}")
@@ -841,6 +843,7 @@ async def interrupt():
841
  (the client already stops audio playback on its own the instant this fires).
842
  """
843
  global _active_tail_task, _generation_id
 
844
  # Invalidate in-flight chunks immediately, at the moment of interrupt rather
845
  # than waiting for the eventual new message β€” closes the race where a chunk's
846
  # TTS finishes and gets injected between the interrupt and the next message.
@@ -857,6 +860,25 @@ async def interrupt():
857
  return JSONResponse({"status": "ok"})
858
 
859
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
860
  @app.get("/api/greeting")
861
  async def get_greeting():
862
  if GREETING_DATA:
@@ -892,6 +914,10 @@ async def ws_video(websocket: WebSocket):
892
  if isinstance(item, dict):
893
  # Audio trigger β€” send instantly, skip the 25fps delay
894
  await websocket.send_json(item)
 
 
 
 
895
  continue
896
  elif item is not None:
897
  # Video frame
@@ -984,7 +1010,7 @@ async def ws_mic(websocket: WebSocket):
984
  logger.info(f"[{_ts()}] [WS-MIC] Client disconnected")
985
 
986
 
987
- async def _chat_stream_process_chunk(idx: int, total: int, sentence: str, streamer, gen_id: int):
988
  """Synthesize TTS for one chunk and inject it into the streamer. Returns (audio_url, duration).
989
 
990
  gen_id guards against a race with barge-in/new-message cancellation: asyncio
@@ -996,6 +1022,8 @@ async def _chat_stream_process_chunk(idx: int, total: int, sentence: str, stream
996
  instead of being spoken after the user already cut the avatar off.
997
  """
998
  from tts import generate_audio
 
 
999
  t_a = _time.time()
1000
  audio_path = await asyncio.to_thread(generate_audio, sentence)
1001
  t_b = _time.time()
@@ -1005,40 +1033,42 @@ async def _chat_stream_process_chunk(idx: int, total: int, sentence: str, stream
1005
  if audio_path and os.path.exists(audio_path):
1006
  audio_url = f"/api/audio/{os.path.basename(audio_path)}"
1007
  audio_duration = _audio_duration(audio_path, sentence)
1008
- logger.info(
1009
- f"[{_ts()}] [CHAT-STREAM] Chunk {idx+1}/{total} TTS: {t_b-t_a:.2f}s | {audio_duration:.1f}s audio"
1010
- )
1011
 
1012
  if gen_id != _generation_id:
1013
- logger.info(f"[{_ts()}] [CHAT-STREAM] Chunk {idx+1}/{total} stale (interrupted mid-flight) β€” discarding")
1014
  return audio_url, audio_duration
1015
 
1016
  if streamer.ready and audio_path:
1017
  await asyncio.to_thread(
1018
  streamer.inject_speech, audio_path, audio_url,
1019
  clear_buffer=(idx == 0), is_last=(idx == total - 1),
 
1020
  )
1021
  elif streamer.ready and idx == total - 1:
1022
  # TTS failed for the final chunk β€” inject_speech (and therefore the
1023
  # is_last=True marker) never runs. Signal speech_end anyway so the
1024
  # client's mic doesn't stay muted forever waiting for a response
1025
  # that already partially played but will never formally "end".
1026
- logger.warning(f"[{_ts()}] [CHAT-STREAM] Chunk {idx+1}/{total} TTS failed β€” signaling speech_end anyway")
1027
  await asyncio.to_thread(streamer.signal_speech_end)
1028
 
1029
  return audio_url, audio_duration
1030
 
1031
 
1032
- async def _chat_stream_tail(sentences, total, streamer, gen_id: int):
1033
  """Synthesize + inject chunks 2..N in the background while chunk 1 plays."""
1034
  for i in range(1, total):
1035
  try:
1036
- await _chat_stream_process_chunk(i, total, sentences[i], streamer, gen_id)
1037
  except asyncio.CancelledError:
1038
- logger.info(f"[{_ts()}] [CHAT-STREAM] Tail cancelled (barge-in) at chunk {i+1}/{total}")
1039
  raise
1040
  except Exception as e:
1041
  logger.error(f"[{_ts()}] [CHAT-STREAM] Chunk {i+1}/{total} failed: {e}")
 
1042
 
1043
 
1044
  @app.post("/api/chat/stream")
@@ -1066,7 +1096,7 @@ async def chat_stream(request: Request):
1066
  _generation_id += 1
1067
  my_gen = _generation_id
1068
 
1069
- logger.info(f"[{_ts()}] [CHAT-STREAM] User: {user_text}")
1070
 
1071
  # 1. LLM β€” run in worker thread so the event loop stays free to send WS frames
1072
  t1 = _time.time()
@@ -1075,8 +1105,7 @@ async def chat_stream(request: Request):
1075
  reply_text = llm_result['text']
1076
  clean_text = llm_result.get('clean_text', reply_text)
1077
  t2 = _time.time()
1078
- logger.info(f"[{_ts()}] [CHAT-STREAM] Reply: {clean_text[:80]}...")
1079
- logger.info(f"[{_ts()}] [CHAT-STREAM] LLM: {t2-t1:.2f}s")
1080
 
1081
  # 2. Split into short chunks (~12 words each) so the first chunk's TTS
1082
  # call is small and fast instead of synthesizing the entire reply.
@@ -1084,19 +1113,20 @@ async def chat_stream(request: Request):
1084
  if not sentences:
1085
  sentences = [reply_text]
1086
  total = len(sentences)
 
1087
 
1088
  from float_streamer import get_streamer
1089
  streamer = get_streamer()
1090
 
1091
  # 3. First chunk β€” awaited inline so speech is already queued by the time
1092
  # this handler returns.
1093
- audio_url, audio_duration = await _chat_stream_process_chunk(0, total, sentences[0], streamer, my_gen)
1094
 
1095
  # 4. Remaining chunks β€” generated in the background while chunk 1 plays.
1096
  if total > 1:
1097
- _active_tail_task = asyncio.create_task(_chat_stream_tail(sentences, total, streamer, my_gen))
1098
 
1099
- logger.info(f"[{_ts()}] [CHAT-STREAM] Total (first chunk ready): {_time.time()-t0:.2f}s")
1100
 
1101
  return JSONResponse({
1102
  "text": clean_text,
@@ -1295,7 +1325,7 @@ function connectWebSocket(){
1295
  if(typeof e.data==='string'){
1296
  const msg=JSON.parse(e.data);
1297
  if(msg.type==='speech_start' && msg.audio_url){
1298
- enqueueAudio(msg.audio_url);
1299
  } else if(msg.type==='speech_end'){
1300
  handleSpeechEnd();
1301
  }
@@ -1336,16 +1366,30 @@ function waitForAvatarDone(){
1336
  return new Promise(resolve=>doneResolvers.push(resolve));
1337
  }
1338
 
 
 
 
 
 
 
1339
  function playNextAudio(){
1340
  if(audioPlaying || audioQueue.length===0) return;
1341
  audioPlaying=true;
1342
- const url=audioQueue.shift();
1343
- const audio=new Audio(url);
 
1344
  currentAudioEl=audio;
1345
  audio.addEventListener('canplaythrough', ()=>{
1346
  audio.play().catch(err=>console.warn('[AUDIO]',err));
1347
  }, {once:true});
 
 
 
 
 
 
1348
  audio.addEventListener('ended', ()=>{
 
1349
  audioPlaying=false;
1350
  if(currentAudioEl===audio) currentAudioEl=null;
1351
  if(audioQueue.length>0) playNextAudio();
@@ -1354,10 +1398,10 @@ function playNextAudio(){
1354
  audio.load();
1355
  }
1356
 
1357
- function enqueueAudio(url){
1358
  expectingAudio=false; avatarSpeaking=true;
1359
  updateMicIndicator();
1360
- audioQueue.push(url);
1361
  playNextAudio();
1362
  }
1363
 
 
16
  import cv2
17
  import asyncio
18
 
19
+ import timeline
20
+
21
  logging.basicConfig(level=logging.INFO)
22
  logger = logging.getLogger(__name__)
23
 
 
298
  from float_streamer import get_streamer
299
  streamer = get_streamer()
300
  if streamer.ready:
301
+ await asyncio.to_thread(streamer.inject_speech, audio_path, audio_url, tag="greeting")
302
  logger.info(f"[{_ts()}] [GREETING] Injected into streamer")
303
  except Exception as e:
304
  logger.warning(f"[GREETING] Streamer inject failed: {e}")
 
843
  (the client already stops audio playback on its own the instant this fires).
844
  """
845
  global _active_tail_task, _generation_id
846
+ timeline.mark("BARGE-IN: client sent /api/interrupt")
847
  # Invalidate in-flight chunks immediately, at the moment of interrupt rather
848
  # than waiting for the eventual new message β€” closes the race where a chunk's
849
  # TTS finishes and gets injected between the interrupt and the next message.
 
860
  return JSONResponse({"status": "ok"})
861
 
862
 
863
+ @app.post("/api/timing")
864
+ async def client_timing(request: Request):
865
+ """Client-side playback milestones, logged into the same turn timeline so
866
+ one copied log shows when audio actually started relative to its video."""
867
+ try:
868
+ b = await request.json()
869
+ except Exception:
870
+ return JSONResponse({"status": "bad"})
871
+ tag, turn, ev = b.get("tag"), b.get("turn"), b.get("event")
872
+ if ev == "audio_play":
873
+ timeline.mark(
874
+ f"{tag} CLIENT audio playing {b.get('since_start_ms', 0):.0f}ms after its speech_start arrived "
875
+ f"(waited {b.get('queued_ms', 0):.0f}ms for previous clip + {b.get('load_ms', 0):.0f}ms loading) "
876
+ f"= how far audio lags its video", turn)
877
+ elif ev == "audio_end":
878
+ timeline.mark(f"{tag} CLIENT audio ended (played {b.get('played_ms', 0)/1000:.2f}s)", turn)
879
+ return JSONResponse({"status": "ok"})
880
+
881
+
882
  @app.get("/api/greeting")
883
  async def get_greeting():
884
  if GREETING_DATA:
 
914
  if isinstance(item, dict):
915
  # Audio trigger β€” send instantly, skip the 25fps delay
916
  await websocket.send_json(item)
917
+ if item.get("type") == "speech_start":
918
+ timeline.mark(f"{item.get('tag')} speech_start sent to client β€” its video frames follow now", item.get("turn"))
919
+ elif item.get("type") == "speech_end":
920
+ timeline.mark("speech_end sent to client", item.get("turn"))
921
  continue
922
  elif item is not None:
923
  # Video frame
 
1010
  logger.info(f"[{_ts()}] [WS-MIC] Client disconnected")
1011
 
1012
 
1013
+ async def _chat_stream_process_chunk(idx: int, total: int, sentence: str, streamer, gen_id: int, turn: int):
1014
  """Synthesize TTS for one chunk and inject it into the streamer. Returns (audio_url, duration).
1015
 
1016
  gen_id guards against a race with barge-in/new-message cancellation: asyncio
 
1022
  instead of being spoken after the user already cut the avatar off.
1023
  """
1024
  from tts import generate_audio
1025
+ tag = f"c{idx+1}/{total}"
1026
+ timeline.mark(f"{tag} TTS start: \"{sentence[:60]}\"", turn)
1027
  t_a = _time.time()
1028
  audio_path = await asyncio.to_thread(generate_audio, sentence)
1029
  t_b = _time.time()
 
1033
  if audio_path and os.path.exists(audio_path):
1034
  audio_url = f"/api/audio/{os.path.basename(audio_path)}"
1035
  audio_duration = _audio_duration(audio_path, sentence)
1036
+ timeline.mark(
1037
+ f"{tag} TTS done in {t_b-t_a:.2f}s ({audio_duration:.1f}s audio)" if audio_path
1038
+ else f"{tag} TTS FAILED after {t_b-t_a:.2f}s", turn)
1039
 
1040
  if gen_id != _generation_id:
1041
+ timeline.mark(f"{tag} stale (interrupted mid-flight) β€” discarding", turn)
1042
  return audio_url, audio_duration
1043
 
1044
  if streamer.ready and audio_path:
1045
  await asyncio.to_thread(
1046
  streamer.inject_speech, audio_path, audio_url,
1047
  clear_buffer=(idx == 0), is_last=(idx == total - 1),
1048
+ tag=tag, turn=turn,
1049
  )
1050
  elif streamer.ready and idx == total - 1:
1051
  # TTS failed for the final chunk β€” inject_speech (and therefore the
1052
  # is_last=True marker) never runs. Signal speech_end anyway so the
1053
  # client's mic doesn't stay muted forever waiting for a response
1054
  # that already partially played but will never formally "end".
1055
+ timeline.mark(f"{tag} was the final chunk β€” signaling speech_end anyway", turn)
1056
  await asyncio.to_thread(streamer.signal_speech_end)
1057
 
1058
  return audio_url, audio_duration
1059
 
1060
 
1061
+ async def _chat_stream_tail(sentences, total, streamer, gen_id: int, turn: int):
1062
  """Synthesize + inject chunks 2..N in the background while chunk 1 plays."""
1063
  for i in range(1, total):
1064
  try:
1065
+ await _chat_stream_process_chunk(i, total, sentences[i], streamer, gen_id, turn)
1066
  except asyncio.CancelledError:
1067
+ timeline.mark(f"tail cancelled (barge-in) at c{i+1}/{total}", turn)
1068
  raise
1069
  except Exception as e:
1070
  logger.error(f"[{_ts()}] [CHAT-STREAM] Chunk {i+1}/{total} failed: {e}")
1071
+ timeline.mark(f"c{i+1}/{total} FAILED: {e}", turn)
1072
 
1073
 
1074
  @app.post("/api/chat/stream")
 
1096
  _generation_id += 1
1097
  my_gen = _generation_id
1098
 
1099
+ turn = timeline.begin(f"user: \"{user_text[:80]}\"")
1100
 
1101
  # 1. LLM β€” run in worker thread so the event loop stays free to send WS frames
1102
  t1 = _time.time()
 
1105
  reply_text = llm_result['text']
1106
  clean_text = llm_result.get('clean_text', reply_text)
1107
  t2 = _time.time()
1108
+ timeline.mark(f"LLM done in {t2-t1:.2f}s: \"{clean_text[:80]}...\"", turn)
 
1109
 
1110
  # 2. Split into short chunks (~12 words each) so the first chunk's TTS
1111
  # call is small and fast instead of synthesizing the entire reply.
 
1113
  if not sentences:
1114
  sentences = [reply_text]
1115
  total = len(sentences)
1116
+ timeline.mark(f"reply split into {total} chunk(s)", turn)
1117
 
1118
  from float_streamer import get_streamer
1119
  streamer = get_streamer()
1120
 
1121
  # 3. First chunk β€” awaited inline so speech is already queued by the time
1122
  # this handler returns.
1123
+ audio_url, audio_duration = await _chat_stream_process_chunk(0, total, sentences[0], streamer, my_gen, turn)
1124
 
1125
  # 4. Remaining chunks β€” generated in the background while chunk 1 plays.
1126
  if total > 1:
1127
+ _active_tail_task = asyncio.create_task(_chat_stream_tail(sentences, total, streamer, my_gen, turn))
1128
 
1129
+ timeline.mark(f"HTTP response sent (first chunk queued) β€” {_time.time()-t0:.2f}s total", turn)
1130
 
1131
  return JSONResponse({
1132
  "text": clean_text,
 
1325
  if(typeof e.data==='string'){
1326
  const msg=JSON.parse(e.data);
1327
  if(msg.type==='speech_start' && msg.audio_url){
1328
+ enqueueAudio(msg.audio_url, msg);
1329
  } else if(msg.type==='speech_end'){
1330
  handleSpeechEnd();
1331
  }
 
1366
  return new Promise(resolve=>doneResolvers.push(resolve));
1367
  }
1368
 
1369
+ // Report playback milestones into the server's per-turn timeline log.
1370
+ function reportTiming(data){
1371
+ fetch('/api/timing',{method:'POST',headers:{'Content-Type':'application/json'},
1372
+ body:JSON.stringify(data),keepalive:true}).catch(()=>{});
1373
+ }
1374
+
1375
  function playNextAudio(){
1376
  if(audioPlaying || audioQueue.length===0) return;
1377
  audioPlaying=true;
1378
+ const item=audioQueue.shift();
1379
+ const loadAt=performance.now();
1380
+ const audio=new Audio(item.url);
1381
  currentAudioEl=audio;
1382
  audio.addEventListener('canplaythrough', ()=>{
1383
  audio.play().catch(err=>console.warn('[AUDIO]',err));
1384
  }, {once:true});
1385
+ let playAt=0;
1386
+ audio.addEventListener('playing', ()=>{
1387
+ playAt=performance.now();
1388
+ reportTiming({event:'audio_play', tag:item.tag, turn:item.turn,
1389
+ since_start_ms:playAt-item.recvAt, queued_ms:loadAt-item.recvAt, load_ms:playAt-loadAt});
1390
+ }, {once:true});
1391
  audio.addEventListener('ended', ()=>{
1392
+ reportTiming({event:'audio_end', tag:item.tag, turn:item.turn, played_ms:playAt?performance.now()-playAt:0});
1393
  audioPlaying=false;
1394
  if(currentAudioEl===audio) currentAudioEl=null;
1395
  if(audioQueue.length>0) playNextAudio();
 
1398
  audio.load();
1399
  }
1400
 
1401
+ function enqueueAudio(url, msg){
1402
  expectingAudio=false; avatarSpeaking=true;
1403
  updateMicIndicator();
1404
+ audioQueue.push({url, tag:msg&&msg.tag, turn:msg&&msg.turn, recvAt:performance.now()});
1405
  playNextAudio();
1406
  }
1407
 
float_streamer.py CHANGED
@@ -20,6 +20,8 @@ import torch.nn.functional as F
20
  import cv2
21
  import librosa
22
 
 
 
23
  logger = logging.getLogger(__name__)
24
 
25
  FLOAT_REPO_PATH = "/app/float_repo"
@@ -121,12 +123,16 @@ class FloatStreamer:
121
  self._running = False
122
  if self._thread: self._thread.join(timeout=5)
123
 
124
- def inject_speech(self, audio_path: str, audio_url: str = None, clear_buffer: bool = True, is_last: bool = True):
 
 
 
125
  t0 = time.time()
126
 
127
  if clear_buffer:
 
128
  self.drain_buffer()
129
- logger.info("[STREAMER] Buffer flushed for immediate speech playback")
130
 
131
  speech_array, sr = librosa.load(audio_path, sr=self.sampling_rate)
132
 
@@ -154,19 +160,23 @@ class FloatStreamer:
154
  chunk_data = {
155
  "wa": wa_chunk, "we": we, "chunk_index": i,
156
  "total_chunks": num_chunks, "actual_frames": end_frame - start_frame,
 
157
  }
158
 
159
  if i == 0:
160
  chunk_data["speech_start"] = {
161
  "type": "speech_start", "audio_url": audio_url,
162
  "duration": len(speech_array) / self.sampling_rate, "num_chunks": num_chunks,
 
163
  }
164
  self._speech_queue.put(chunk_data)
165
 
166
  if is_last:
167
- self._speech_queue.put({"type": "speech_end"})
168
-
169
- logger.info(f"[STREAMER] Speech injected: {T} frames, {num_chunks} chunks, is_last={is_last}")
 
 
170
 
171
  def get_frame(self, timeout=0.1):
172
  try: return self._frame_buffer.get(timeout=timeout)
@@ -185,9 +195,11 @@ class FloatStreamer:
185
  if speech_data and speech_data.get("type") == "speech_end":
186
  self._is_speaking = False
187
  self._idle_cfg_scale = self.opt.a_cfg_scale
188
- logger.info("[STREAMER] Speech ended, transitioning to idle")
 
 
189
  try:
190
- self._frame_buffer.put({"type": "speech_end"}, timeout=2.0)
191
  except queue.Full:
192
  logger.warning("[STREAMER] Frame buffer full, dropping speech_end event")
193
  continue
@@ -200,7 +212,9 @@ class FloatStreamer:
200
  wa=speech_data["wa"], we=speech_data["we"],
201
  actual_frames=speech_data.get("actual_frames", self.frames_per_chunk),
202
  is_speech=True, nfe=5,
203
- speech_start_event=speech_start_event
 
 
204
  )
205
 
206
  else:
@@ -253,7 +267,7 @@ class FloatStreamer:
253
  )
254
 
255
  @torch.no_grad()
256
- def _generate_chunk(self, wa, we, actual_frames=None, a_cfg_scale=None, e_cfg_scale=None, is_speech=False, nfe=None, speech_start_event=None, dynamic_dampening=None):
257
  if actual_frames is None: actual_frames = self.frames_per_chunk
258
  if a_cfg_scale is None: a_cfg_scale = self.opt.a_cfg_scale
259
  if e_cfg_scale is None: e_cfg_scale = self.opt.e_cfg_scale
@@ -323,6 +337,12 @@ class FloatStreamer:
323
  frames_pushed = 0
324
 
325
  if speech_start_event:
 
 
 
 
 
 
326
  try:
327
  self._frame_buffer.put(speech_start_event, timeout=2.0)
328
  except queue.Full:
@@ -356,12 +376,21 @@ class FloatStreamer:
356
 
357
  t_dec_done = time.time()
358
 
359
- # Idle chunks render back-to-back forever β€” keep them out of INFO so
360
- # copied logs stay readable; speech chunks are the ones worth seeing.
361
- (logger.info if is_speech else logger.debug)(
362
- f"[STREAMER] Chunk rendered | speech={is_speech} | frames={frames_pushed} | "
363
- f"ode={t_ode:.2f}s | encode={t_dec_done-t_dec:.2f}s | total={t_dec_done-t0:.2f}s"
364
- )
 
 
 
 
 
 
 
 
 
365
 
366
  def drain_buffer(self):
367
  while not self._frame_buffer.empty():
@@ -379,7 +408,7 @@ class FloatStreamer:
379
  self.drain_buffer()
380
  self._is_speaking = False
381
  self._idle_cfg_scale = self.opt.a_cfg_scale
382
- logger.info("[STREAMER] Interrupted β€” speech dropped, transitioning to idle")
383
 
384
  def signal_speech_end(self):
385
  """Tell the generation loop speech is over with no audio to inject β€” used
 
20
  import cv2
21
  import librosa
22
 
23
+ import timeline
24
+
25
  logger = logging.getLogger(__name__)
26
 
27
  FLOAT_REPO_PATH = "/app/float_repo"
 
123
  self._running = False
124
  if self._thread: self._thread.join(timeout=5)
125
 
126
+ def inject_speech(self, audio_path: str, audio_url: str = None, clear_buffer: bool = True, is_last: bool = True,
127
+ tag: str = None, turn: int = None):
128
+ """tag ("c2/4") and turn label this TTS chunk's timeline entries all the
129
+ way through render, WS delivery and client playback."""
130
  t0 = time.time()
131
 
132
  if clear_buffer:
133
+ flushed = self._frame_buffer.qsize()
134
  self.drain_buffer()
135
+ timeline.mark(f"{tag} flushed {flushed} buffered idle frames so speech plays immediately", turn)
136
 
137
  speech_array, sr = librosa.load(audio_path, sr=self.sampling_rate)
138
 
 
160
  chunk_data = {
161
  "wa": wa_chunk, "we": we, "chunk_index": i,
162
  "total_chunks": num_chunks, "actual_frames": end_frame - start_frame,
163
+ "tag": tag, "turn": turn,
164
  }
165
 
166
  if i == 0:
167
  chunk_data["speech_start"] = {
168
  "type": "speech_start", "audio_url": audio_url,
169
  "duration": len(speech_array) / self.sampling_rate, "num_chunks": num_chunks,
170
+ "tag": tag, "turn": turn,
171
  }
172
  self._speech_queue.put(chunk_data)
173
 
174
  if is_last:
175
+ self._speech_queue.put({"type": "speech_end", "turn": turn})
176
+
177
+ timeline.mark(
178
+ f"{tag} audio features encoded in {time.time()-t0:.2f}s β†’ {num_chunks} render chunk(s) "
179
+ f"({T} frames) queued{' + speech_end' if is_last else ''}", turn)
180
 
181
  def get_frame(self, timeout=0.1):
182
  try: return self._frame_buffer.get(timeout=timeout)
 
195
  if speech_data and speech_data.get("type") == "speech_end":
196
  self._is_speaking = False
197
  self._idle_cfg_scale = self.opt.a_cfg_scale
198
+ timeline.mark(
199
+ f"speech_end queued behind {self._frame_buffer.qsize()} buffered frames β€” back to idle",
200
+ speech_data.get("turn"))
201
  try:
202
+ self._frame_buffer.put({"type": "speech_end", "turn": speech_data.get("turn")}, timeout=2.0)
203
  except queue.Full:
204
  logger.warning("[STREAMER] Frame buffer full, dropping speech_end event")
205
  continue
 
212
  wa=speech_data["wa"], we=speech_data["we"],
213
  actual_frames=speech_data.get("actual_frames", self.frames_per_chunk),
214
  is_speech=True, nfe=5,
215
+ speech_start_event=speech_start_event,
216
+ tag=f"{speech_data.get('tag')} render {speech_data['chunk_index']+1}/{speech_data['total_chunks']}",
217
+ turn=speech_data.get("turn"),
218
  )
219
 
220
  else:
 
267
  )
268
 
269
  @torch.no_grad()
270
+ def _generate_chunk(self, wa, we, actual_frames=None, a_cfg_scale=None, e_cfg_scale=None, is_speech=False, nfe=None, speech_start_event=None, dynamic_dampening=None, tag=None, turn=None):
271
  if actual_frames is None: actual_frames = self.frames_per_chunk
272
  if a_cfg_scale is None: a_cfg_scale = self.opt.a_cfg_scale
273
  if e_cfg_scale is None: e_cfg_scale = self.opt.e_cfg_scale
 
337
  frames_pushed = 0
338
 
339
  if speech_start_event:
340
+ # Frames already buffered ahead of speech_start play before this
341
+ # chunk's audio is even requested β€” that's added reply latency.
342
+ ahead = self._frame_buffer.qsize()
343
+ timeline.mark(
344
+ f"{speech_start_event.get('tag')} speech_start queued behind {ahead} buffered frames "
345
+ f"(~{ahead / self.fps:.2f}s of video before it)", turn)
346
  try:
347
  self._frame_buffer.put(speech_start_event, timeout=2.0)
348
  except queue.Full:
 
376
 
377
  t_dec_done = time.time()
378
 
379
+ if is_speech:
380
+ # Rendering slower than the video it produces means the frame buffer
381
+ # drains and video falls behind the audio.
382
+ video_s = frames_pushed / self.fps
383
+ took = t_dec_done - t0
384
+ slow = f" ⚠ SLOWER THAN REAL TIME ({took/video_s:.2f}x)" if video_s and took > video_s else ""
385
+ timeline.mark(
386
+ f"{tag} rendered {frames_pushed} frames ({video_s:.2f}s video) in {took:.2f}s "
387
+ f"[ode {t_ode:.2f}s, decode {t_dec_done-t_dec:.2f}s]{slow}", turn)
388
+ else:
389
+ # Idle chunks render back-to-back forever β€” keep them out of INFO.
390
+ logger.debug(
391
+ f"[STREAMER] Idle chunk rendered | frames={frames_pushed} | "
392
+ f"ode={t_ode:.2f}s | encode={t_dec_done-t_dec:.2f}s | total={t_dec_done-t0:.2f}s"
393
+ )
394
 
395
  def drain_buffer(self):
396
  while not self._frame_buffer.empty():
 
408
  self.drain_buffer()
409
  self._is_speaking = False
410
  self._idle_cfg_scale = self.opt.a_cfg_scale
411
+ timeline.mark("streamer interrupted β€” queued speech dropped, back to idle")
412
 
413
  def signal_speech_end(self):
414
  """Tell the generation loop speech is over with no audio to inject β€” used
timeline.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Per-turn timeline logging. Every stage of one reply β€” LLM, TTS per chunk,
3
+ feature encoding, rendering, WS delivery, client audio playback β€” logs as
4
+
5
+ [T3 +1.42s] c2/4 TTS done in 0.38s (2.9s audio)
6
+
7
+ where T3 is the turn number and +1.42s is time since the user's message
8
+ arrived, so a copied log reads top-to-bottom as an ordered breakdown of where
9
+ the time went. Shared by app.py (request side) and float_streamer.py
10
+ (render thread), hence its own module.
11
+ """
12
+ import logging
13
+ import time
14
+
15
+ logger = logging.getLogger("timeline")
16
+
17
+ _turn = 0
18
+ _t0 = time.time()
19
+
20
+
21
+ def begin(label: str) -> int:
22
+ """Start a new turn; later marks are timed relative to this moment."""
23
+ global _turn, _t0
24
+ _turn += 1
25
+ _t0 = time.time()
26
+ logger.info(f"[T{_turn} + 0.00s] ===== TURN START: {label}")
27
+ return _turn
28
+
29
+
30
+ def current() -> int:
31
+ return _turn
32
+
33
+
34
+ def mark(msg: str, turn: int = None):
35
+ """Log one stage of the current turn. A mark tagged with an older turn
36
+ (e.g. a late client report after barge-in) is labelled as such, since its
37
+ offset is measured against the newer turn's start."""
38
+ if turn is not None and turn != _turn:
39
+ logger.info(f"[T{turn} (late, now T{_turn})] {msg}")
40
+ return
41
+ logger.info(f"[T{_turn} +{time.time() - _t0:6.2f}s] {msg}")