AgentNewTwo commited on
Commit
cadb025
·
1 Parent(s): 022bba5

Smooth live audio streaming

Browse files
Files changed (2) hide show
  1. README.md +2 -0
  2. live_app.py +53 -10
README.md CHANGED
@@ -41,6 +41,8 @@ session. Context is held only in volatile worker memory and disappears when the
41
  This is near-full-duplex rather than mathematically simultaneous model inference: LFM is turn-based, so visitor
42
  speech cancels the active reply before the new utterance is processed. Gradio streaming audio is used instead of
43
  FastRTC/WebRTC because the earlier ZeroGPU WebRTC transport never passed physical media flow.
 
 
44
 
45
  ## Privacy and safety
46
 
 
41
  This is near-full-duplex rather than mathematically simultaneous model inference: LFM is turn-based, so visitor
42
  speech cancels the active reply before the new utterance is processed. Gradio streaming audio is used instead of
43
  FastRTC/WebRTC because the earlier ZeroGPU WebRTC transport never passed physical media flow.
44
+ Lyra's native 80 ms decoder frames are grouped into 320 ms playback packets to reduce HLS segment churn, while
45
+ the microphone uploads every 500 ms with background progress indicators hidden.
46
 
47
  ## Privacy and safety
48
 
live_app.py CHANGED
@@ -24,6 +24,9 @@ MODEL_REVISION = "c362a0625dfe45aa588dce5f0ada28a7e5707628"
24
  DEVICE = "cuda"
25
  OUTPUT_SAMPLE_RATE = 24_000
26
  STREAM_PRIMER_SAMPLES = OUTPUT_SAMPLE_RATE // 4
 
 
 
27
  SESSION_SECONDS = 90
28
  MAX_NEW_TOKENS = 320
29
  MAX_TURNS = 8
@@ -272,7 +275,7 @@ def drain_queue(input_queue):
272
  return chunks
273
 
274
 
275
- def session_metrics(phase, started, turns, counters, first_reply, interruptions, model_seconds):
276
  return json.dumps(
277
  {
278
  "phase": phase,
@@ -283,6 +286,10 @@ def session_metrics(phase, started, turns, counters, first_reply, interruptions,
283
  "microphone_chunks_received": int(counters.get("received_chunks", 0)),
284
  "microphone_chunks_dropped": int(counters.get("dropped_chunks", 0)),
285
  "model_generation_seconds": round(model_seconds, 3),
 
 
 
 
286
  "gpu_peak_gib": round(torch.cuda.max_memory_allocated() / (1024**3), 3),
287
  "transport": "Gradio streaming audio; no WebRTC/ICE/TURN",
288
  "mode": "interruptible near-full-duplex LFM2.5-Audio",
@@ -335,6 +342,7 @@ def run_live_session(scene, request: gr.Request):
335
  interruptions = 0
336
  first_reply = None
337
  model_seconds = 0.0
 
338
  ready_event.set()
339
  live_started = time.perf_counter()
340
  last_heartbeat = 0.0
@@ -345,12 +353,12 @@ def run_live_session(scene, request: gr.Request):
345
  sample_rate, audio = input_queue.get(timeout=0.1)
346
  except queue.Empty:
347
  now = time.perf_counter()
348
- if now - last_heartbeat >= 0.75:
349
  yield (
350
  gr.skip(),
351
  "**Listening.** Speak naturally; Lyra replies after a short pause.",
352
  gr.skip(),
353
- session_metrics("listening", started, turns, counters, first_reply, interruptions, model_seconds),
354
  )
355
  last_heartbeat = now
356
  continue
@@ -388,6 +396,8 @@ def run_live_session(scene, request: gr.Request):
388
  text_tokens = []
389
  audio_tokens = []
390
  modalities = []
 
 
391
  response_started = time.perf_counter()
392
  interrupted = False
393
  with torch.inference_mode(), mimi.streaming(1):
@@ -430,7 +440,16 @@ def run_live_session(scene, request: gr.Request):
430
  waveform = mimi.decode(token[None, :, None])[0]
431
  pcm = np.clip(waveform.detach().float().cpu().numpy(), -1.0, 1.0)
432
  pcm = (pcm * 32767.0).astype(np.int16).reshape(-1)
433
- if first_reply is None and np.sqrt(np.mean((pcm.astype(np.float32) / 32768.0) ** 2)) > 0.003:
 
 
 
 
 
 
 
 
 
434
  first_reply = time.perf_counter() - response_started
435
  current_text = (
436
  processor.text.decode(torch.cat(text_tokens)).removesuffix("<|text_end|>").strip()
@@ -438,12 +457,30 @@ def run_live_session(scene, request: gr.Request):
438
  else ""
439
  )
440
  yield (
441
- (OUTPUT_SAMPLE_RATE, pcm),
442
  "**Lyra is speaking.** You can interrupt by speaking clearly.",
443
  render_transcript(entries) + (f"\n\n**Current Lyra reply:** {safe_text(current_text)}" if current_text else ""),
444
- session_metrics("speaking", started, turns, counters, first_reply, interruptions, model_seconds),
445
  )
446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  model_seconds += time.perf_counter() - response_started
448
  response_text = (
449
  processor.text.decode(torch.cat(text_tokens)).removesuffix("<|text_end|>").strip()
@@ -473,7 +510,7 @@ def run_live_session(scene, request: gr.Request):
473
  gr.skip(),
474
  "**Listening.** Speak naturally; Lyra replies after a short pause.",
475
  render_transcript(entries),
476
- session_metrics("listening", started, turns, counters, first_reply, interruptions, model_seconds),
477
  )
478
 
479
  ready_event.clear()
@@ -483,7 +520,7 @@ def run_live_session(scene, request: gr.Request):
483
  gr.skip(),
484
  final_status,
485
  render_transcript(entries),
486
- session_metrics("complete", started, turns, counters, first_reply, interruptions, model_seconds),
487
  )
488
 
489
 
@@ -570,6 +607,7 @@ with gr.Blocks(title="RoleForge Live Voice NPC", theme=gr.themes.Soft(), css=CSS
570
  new_live_session,
571
  outputs=[status, metrics, transcript],
572
  queue=False,
 
573
  api_name="new_live_session",
574
  )
575
  run_event = start_event.then(
@@ -577,18 +615,21 @@ with gr.Blocks(title="RoleForge Live Voice NPC", theme=gr.themes.Soft(), css=CSS
577
  inputs=[scene],
578
  outputs=[reply_audio, status, transcript, metrics],
579
  concurrency_limit=1,
 
580
  api_name="run_live_session",
581
  )
582
  run_event.then(
583
  finish_live_microphone,
584
  outputs=[microphone],
585
  queue=False,
 
586
  api_name="finish_live_microphone",
587
  )
588
  microphone.stop_recording(
589
  request_live_stop,
590
  outputs=[status],
591
  queue=False,
 
592
  api_name="request_live_stop",
593
  )
594
  microphone.stream(
@@ -596,14 +637,16 @@ with gr.Blocks(title="RoleForge Live Voice NPC", theme=gr.themes.Soft(), css=CSS
596
  inputs=[microphone],
597
  outputs=[],
598
  queue=False,
599
- stream_every=0.25,
600
- concurrency_limit=8,
 
601
  api_name="ingest_audio_chunk",
602
  )
603
  stop_btn.click(
604
  stop_live_session_button,
605
  outputs=[status, microphone],
606
  queue=False,
 
607
  api_name="stop_live_session",
608
  )
609
 
 
24
  DEVICE = "cuda"
25
  OUTPUT_SAMPLE_RATE = 24_000
26
  STREAM_PRIMER_SAMPLES = OUTPUT_SAMPLE_RATE // 4
27
+ OUTPUT_PACKET_SAMPLES = 7_680
28
+ MICROPHONE_STREAM_SECONDS = 0.5
29
+ STATUS_HEARTBEAT_SECONDS = 2.0
30
  SESSION_SECONDS = 90
31
  MAX_NEW_TOKENS = 320
32
  MAX_TURNS = 8
 
275
  return chunks
276
 
277
 
278
+ def session_metrics(phase, started, turns, counters, first_reply, interruptions, model_seconds, playback):
279
  return json.dumps(
280
  {
281
  "phase": phase,
 
286
  "microphone_chunks_received": int(counters.get("received_chunks", 0)),
287
  "microphone_chunks_dropped": int(counters.get("dropped_chunks", 0)),
288
  "model_generation_seconds": round(model_seconds, 3),
289
+ "reply_packets_emitted": playback["packets"],
290
+ "reply_audio_emitted_seconds": round(playback["samples"] / OUTPUT_SAMPLE_RATE, 3),
291
+ "reply_packet_target_seconds": round(OUTPUT_PACKET_SAMPLES / OUTPUT_SAMPLE_RATE, 3),
292
+ "microphone_stream_interval_seconds": MICROPHONE_STREAM_SECONDS,
293
  "gpu_peak_gib": round(torch.cuda.max_memory_allocated() / (1024**3), 3),
294
  "transport": "Gradio streaming audio; no WebRTC/ICE/TURN",
295
  "mode": "interruptible near-full-duplex LFM2.5-Audio",
 
342
  interruptions = 0
343
  first_reply = None
344
  model_seconds = 0.0
345
+ playback = {"packets": 0, "samples": 0}
346
  ready_event.set()
347
  live_started = time.perf_counter()
348
  last_heartbeat = 0.0
 
353
  sample_rate, audio = input_queue.get(timeout=0.1)
354
  except queue.Empty:
355
  now = time.perf_counter()
356
+ if now - last_heartbeat >= STATUS_HEARTBEAT_SECONDS:
357
  yield (
358
  gr.skip(),
359
  "**Listening.** Speak naturally; Lyra replies after a short pause.",
360
  gr.skip(),
361
+ session_metrics("listening", started, turns, counters, first_reply, interruptions, model_seconds, playback),
362
  )
363
  last_heartbeat = now
364
  continue
 
396
  text_tokens = []
397
  audio_tokens = []
398
  modalities = []
399
+ pcm_frames = []
400
+ pcm_samples = 0
401
  response_started = time.perf_counter()
402
  interrupted = False
403
  with torch.inference_mode(), mimi.streaming(1):
 
440
  waveform = mimi.decode(token[None, :, None])[0]
441
  pcm = np.clip(waveform.detach().float().cpu().numpy(), -1.0, 1.0)
442
  pcm = (pcm * 32767.0).astype(np.int16).reshape(-1)
443
+ pcm_frames.append(pcm)
444
+ pcm_samples += pcm.size
445
+ if pcm_samples < OUTPUT_PACKET_SAMPLES:
446
+ continue
447
+ packet = np.concatenate(pcm_frames)
448
+ pcm_frames = []
449
+ pcm_samples = 0
450
+ playback["packets"] += 1
451
+ playback["samples"] += packet.size
452
+ if first_reply is None and np.sqrt(np.mean((packet.astype(np.float32) / 32768.0) ** 2)) > 0.003:
453
  first_reply = time.perf_counter() - response_started
454
  current_text = (
455
  processor.text.decode(torch.cat(text_tokens)).removesuffix("<|text_end|>").strip()
 
457
  else ""
458
  )
459
  yield (
460
+ (OUTPUT_SAMPLE_RATE, packet),
461
  "**Lyra is speaking.** You can interrupt by speaking clearly.",
462
  render_transcript(entries) + (f"\n\n**Current Lyra reply:** {safe_text(current_text)}" if current_text else ""),
463
+ session_metrics("speaking", started, turns, counters, first_reply, interruptions, model_seconds, playback),
464
  )
465
 
466
+ if pcm_frames and not interrupted:
467
+ packet = np.concatenate(pcm_frames)
468
+ playback["packets"] += 1
469
+ playback["samples"] += packet.size
470
+ if first_reply is None and np.sqrt(np.mean((packet.astype(np.float32) / 32768.0) ** 2)) > 0.003:
471
+ first_reply = time.perf_counter() - response_started
472
+ current_text = (
473
+ processor.text.decode(torch.cat(text_tokens)).removesuffix("<|text_end|>").strip()
474
+ if text_tokens
475
+ else ""
476
+ )
477
+ yield (
478
+ (OUTPUT_SAMPLE_RATE, packet),
479
+ "**Lyra is speaking.** You can interrupt by speaking clearly.",
480
+ render_transcript(entries) + (f"\n\n**Current Lyra reply:** {safe_text(current_text)}" if current_text else ""),
481
+ session_metrics("speaking", started, turns, counters, first_reply, interruptions, model_seconds, playback),
482
+ )
483
+
484
  model_seconds += time.perf_counter() - response_started
485
  response_text = (
486
  processor.text.decode(torch.cat(text_tokens)).removesuffix("<|text_end|>").strip()
 
510
  gr.skip(),
511
  "**Listening.** Speak naturally; Lyra replies after a short pause.",
512
  render_transcript(entries),
513
+ session_metrics("listening", started, turns, counters, first_reply, interruptions, model_seconds, playback),
514
  )
515
 
516
  ready_event.clear()
 
520
  gr.skip(),
521
  final_status,
522
  render_transcript(entries),
523
+ session_metrics("complete", started, turns, counters, first_reply, interruptions, model_seconds, playback),
524
  )
525
 
526
 
 
607
  new_live_session,
608
  outputs=[status, metrics, transcript],
609
  queue=False,
610
+ show_progress="hidden",
611
  api_name="new_live_session",
612
  )
613
  run_event = start_event.then(
 
615
  inputs=[scene],
616
  outputs=[reply_audio, status, transcript, metrics],
617
  concurrency_limit=1,
618
+ show_progress="hidden",
619
  api_name="run_live_session",
620
  )
621
  run_event.then(
622
  finish_live_microphone,
623
  outputs=[microphone],
624
  queue=False,
625
+ show_progress="hidden",
626
  api_name="finish_live_microphone",
627
  )
628
  microphone.stop_recording(
629
  request_live_stop,
630
  outputs=[status],
631
  queue=False,
632
+ show_progress="hidden",
633
  api_name="request_live_stop",
634
  )
635
  microphone.stream(
 
637
  inputs=[microphone],
638
  outputs=[],
639
  queue=False,
640
+ stream_every=MICROPHONE_STREAM_SECONDS,
641
+ concurrency_limit=4,
642
+ show_progress="hidden",
643
  api_name="ingest_audio_chunk",
644
  )
645
  stop_btn.click(
646
  stop_live_session_button,
647
  outputs=[status, microphone],
648
  queue=False,
649
+ show_progress="hidden",
650
  api_name="stop_live_session",
651
  )
652