Spaces:
Running on Zero
Running on Zero
Commit ·
cadb025
1
Parent(s): 022bba5
Smooth live audio streaming
Browse files- README.md +2 -0
- 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 >=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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=
|
| 600 |
-
concurrency_limit=
|
|
|
|
| 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 |
|