emotion-avatar-server / emo_code /liveTryHeliumFrontendDequeStaticPoseFP32FM_ws_binary_AHAudioPace_EmoText.py
Download emo_code/liveTryHeliumFrontendDequeStaticPoseFP32FM_ws_binary_AHAudioPace_EmoText.py from sadmN/emotion-avatar-server: direct link, hf CLI and curl.
- Browser
- Download file 175 kB
-
https://huggingface.co/sadmN/emotion-avatar-server/resolve/main/emo_code/liveTryHeliumFrontendDequeStaticPoseFP32FM_ws_binary_AHAudioPace_EmoText.py
- Command line
-
hf download hf://sadmN/emotion-avatar-server/emo_code/liveTryHeliumFrontendDequeStaticPoseFP32FM_ws_binary_AHAudioPace_EmoText.py
-
curl -L -o liveTryHeliumFrontendDequeStaticPoseFP32FM_ws_binary_AHAudioPace_EmoText.py https://huggingface.co/sadmN/emotion-avatar-server/resolve/main/emo_code/liveTryHeliumFrontendDequeStaticPoseFP32FM_ws_binary_AHAudioPace_EmoText.py
175 kB
| """PersonaPlex + IMTalker AJ server with split audio/video WebSockets. | |
| Raw PersonaPlex audio is never stored in or cleared with the video queue. | |
| Assistant Opus audio stays on /ws/conversation, while JPEG video frames are | |
| sent through /ws/video?session_id=... to avoid websocket head-of-line blocking. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import asyncio | |
| import base64 | |
| import concurrent.futures | |
| import contextlib | |
| import faulthandler | |
| import json | |
| import os | |
| import queue | |
| import signal | |
| faulthandler.enable() | |
| try: | |
| faulthandler.register(signal.SIGUSR1, all_threads=True) | |
| except Exception: | |
| pass | |
| import ws_av_binary_codec as _wsbin | |
| import sys | |
| import threading | |
| import time | |
| import types | |
| import uuid | |
| from pathlib import Path | |
| from typing import Optional | |
| import cv2 | |
| import numpy as np | |
| import sphn | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| import torchaudio | |
| import torchvision.transforms as T | |
| from fastapi import FastAPI, WebSocket, WebSocketDisconnect | |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from PIL import Image | |
| from transformers import Wav2Vec2FeatureExtractor | |
| ROOT = Path(__file__).resolve().parent | |
| if str(ROOT) not in sys.path: | |
| sys.path.insert(0, str(ROOT)) | |
| from generator.FM import FMGenerator | |
| from generator.train_lora import apply_lora_to_model | |
| from generator.helium_w2v_frontend_adapter import HeliumToWav2VecFrontendAdapter | |
| from generator.unitalk_wav2vec_adapter import UniTalkLastLayerLiveAdapter | |
| from generator.options.base_options import BaseOptions | |
| from generator.wav2vec2 import Wav2VecModel | |
| if os.environ.get("IMTALKER_CACHED_ENGINE", "0").strip().lower() in { | |
| "1", | |
| "true", | |
| "yes", | |
| "on", | |
| }: | |
| from liveTry_cached import MoshiOnlyEngine | |
| else: | |
| from liveTry import MoshiOnlyEngine | |
| from renderer.models import IMTRenderer | |
| # --------------------------------------------------------------------------- | |
| # Constants | |
| # --------------------------------------------------------------------------- | |
| TARGET_SR = 24_000 # Mimi sample rate (24kHz) | |
| VIDEO_FPS = 25 # IMTalker frame rate | |
| MIMI_FRAME_SIZE = 1_920 # samples per Mimi frame (80ms @ 24kHz) | |
| MAIN_CODEBOOKS = 8 # codebooks used for Helium input embeddings | |
| PREBUFFER_CHUNKS = 0 # produce this many chunks before sender starts pacing | |
| WAV2VEC_SR = 16_000 | |
| class PlasticityProjectionHead(nn.Module): | |
| def __init__(self) -> None: | |
| super().__init__() | |
| self.input_ln = nn.LayerNorm(4096) | |
| self.net = nn.Sequential( | |
| nn.Linear(4096, 1024), | |
| nn.LayerNorm(1024), | |
| nn.GELU(), | |
| nn.Dropout(0.1), | |
| nn.Linear(1024, 768), | |
| nn.LayerNorm(768), | |
| nn.GELU(), | |
| nn.Dropout(0.1), | |
| nn.Linear(768, 768), | |
| nn.LayerNorm(768), | |
| nn.GELU(), | |
| ) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| return self.net(self.input_ln(x)) | |
| class PlasticityUpsampler(nn.Module): | |
| def __init__(self) -> None: | |
| super().__init__() | |
| self.up = nn.ConvTranspose1d(768, 768, kernel_size=4, stride=4) | |
| def forward(self, low: torch.Tensor, target_len: int) -> torch.Tensor: | |
| y = self.up(low.transpose(1, 2).contiguous()) | |
| if y.shape[-1] != int(target_len): | |
| y = F.interpolate(y, size=int(target_len), mode="linear", align_corners=False) | |
| return y.transpose(1, 2).contiguous() | |
| class PlasticityCausalBlock(nn.Module): | |
| def __init__(self) -> None: | |
| super().__init__() | |
| self.norm1 = nn.LayerNorm(768) | |
| self.attn = nn.MultiheadAttention( | |
| embed_dim=768, | |
| num_heads=12, | |
| dropout=0.15, | |
| batch_first=True, | |
| ) | |
| self.drop1 = nn.Dropout(0.1) | |
| self.norm2 = nn.LayerNorm(768) | |
| self.ff = nn.Sequential( | |
| nn.Linear(768, 2048), | |
| nn.GELU(), | |
| nn.Dropout(0.1), | |
| nn.Linear(2048, 768), | |
| ) | |
| self.drop2 = nn.Dropout(0.1) | |
| def forward(self, x: torch.Tensor, mask: torch.Tensor) -> torch.Tensor: | |
| h = self.norm1(x) | |
| attn, _ = self.attn(h, h, h, attn_mask=mask, need_weights=False) | |
| x = x + self.drop1(attn) | |
| h = self.norm2(x) | |
| x = x + self.drop2(self.ff(h)) | |
| return x | |
| class PlasticityCausalTransformer(nn.Module): | |
| def __init__(self, max_len: int = 2048) -> None: | |
| super().__init__() | |
| self.blocks = nn.ModuleList([PlasticityCausalBlock() for _ in range(8)]) | |
| self.norm = nn.LayerNorm(768) | |
| mask = torch.triu(torch.ones(max_len, max_len, dtype=torch.bool), diagonal=1) | |
| self.register_buffer("causal_mask", mask, persistent=False) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| mask = self.causal_mask[: x.shape[1], : x.shape[1]].to(device=x.device) | |
| for block in self.blocks: | |
| x = block(x, mask) | |
| return self.norm(x) | |
| class StudioNativeLiveAdapter(nn.Module): | |
| """Frontend fp32 adapter live wrapper. | |
| Training contract: | |
| raw 12.5Hz Helium -> Wav2Vec2 projected frontend [T50, 768] | |
| live contract: | |
| projected frontend -> frozen Wav2Vec2 encoder -> final hidden -> IMTalker audio_projection. | |
| """ | |
| def __init__(self, wav2vec_model_path: str, num_layers: int, dropout: float = 0.0) -> None: | |
| super().__init__() | |
| self.model = HeliumToWav2VecFrontendAdapter(num_layers=int(num_layers), dropout=float(dropout)) | |
| self.wav2vec = Wav2VecModel.from_pretrained(wav2vec_model_path, local_files_only=True).eval().float() | |
| for param in self.wav2vec.parameters(): | |
| param.requires_grad_(False) | |
| def load_state_dict(self, state_dict, strict: bool = True): # type: ignore[override] | |
| return self.model.load_state_dict(state_dict, strict=strict) | |
| def forward_single(self, source: torch.Tensor, target_len: int) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: | |
| src = source.unsqueeze(0).contiguous() | |
| target_len = int(target_len) | |
| frontend_len = max(1, target_len * 2) | |
| frontend50 = self.model(src.float(), target_len=frontend_len).float() | |
| final50 = self.wav2vec.encode_from_projected_frontend(frontend50).last_hidden_state.float() | |
| final25 = F.interpolate( | |
| final50.transpose(1, 2), | |
| size=target_len, | |
| mode="linear", | |
| align_corners=False, | |
| ).transpose(1, 2)[0].float().contiguous() | |
| return frontend50[0].float().contiguous(), final50[0].float().contiguous(), final25 | |
| class MoshiOnlyEngineWithHidden(MoshiOnlyEngine): | |
| """Moshi reply engine that also returns the main LM hidden for each generated step. | |
| Layer[-2] is exposed as a native LMGen output so Moshi can keep CUDA graph | |
| replay enabled. We do not use Python forward hooks in this path. | |
| """ | |
| def __init__(self, *args, capture_layer: int = -2, **kwargs) -> None: | |
| self.tf_capture_layer = int(capture_layer) | |
| super().__init__(*args, **kwargs) | |
| self._install_graph_hidden_capture() | |
| def _install_graph_hidden_capture(self) -> None: | |
| lm_model = self.lm | |
| lm_gen = self.lm_gen | |
| if hasattr(lm_gen, "prepare_step_input") and hasattr(lm_gen, "process_transformer_output"): | |
| def personaplex_step_with_hidden( | |
| self_gen, | |
| input_tokens: torch.Tensor = None, | |
| moshi_tokens: torch.Tensor = None, | |
| text_token: torch.Tensor = None, | |
| depformer_replace_tokens: torch.Tensor | None = None, | |
| ): | |
| prepared = self_gen.prepare_step_input(input_tokens, moshi_tokens, text_token) | |
| if prepared is None: | |
| return None | |
| input_, provided_, target_, model_input_position, target_position = prepared | |
| state = self_gen._streaming_state | |
| transformer_out, text_logits = state.graphed_main(input_) | |
| output = self_gen.process_transformer_output( | |
| transformer_out, | |
| text_logits, | |
| provided_, | |
| target_, | |
| model_input_position, | |
| target_position, | |
| ) | |
| return output, transformer_out, transformer_out | |
| lm_gen._step = types.MethodType(personaplex_step_with_hidden, lm_gen) | |
| lm_gen.streaming_forever(1) | |
| self._warmup_runtime() | |
| print("[liveTryPlasticity] installed PersonaPlex graphed hidden capture", flush=True) | |
| return | |
| from moshi.models.lm import scatter_with_mask_ | |
| from moshi.modules.transformer import create_sin_embedding | |
| from moshi.utils.sampling import sample_token | |
| capture_layer = int(self.tf_capture_layer) % len(lm_model.transformer.layers) | |
| old_state = getattr(lm_gen, "_streaming_state", None) | |
| if old_state is not None: | |
| with contextlib.suppress(Exception): | |
| old_state.__exit__(None, None, None) | |
| with contextlib.suppress(Exception): | |
| lm_gen._stop_streaming() | |
| def forward_text_with_layer(self_lm, sequence, sum_condition=None, cross_attention_src=None): | |
| B, K, S = sequence.shape | |
| assert K == self_lm.num_codebooks, (K, self_lm.num_codebooks) | |
| input_sequence = sequence | |
| input_ = None | |
| for cb_index in range(self_lm.num_audio_codebooks): | |
| audio_emb = self_lm.emb[cb_index](input_sequence[:, cb_index + self_lm.audio_offset]) | |
| input_ = audio_emb if input_ is None else input_ + audio_emb | |
| text_emb = self_lm.text_emb(input_sequence[:, 0]) | |
| input_ = text_emb if input_ is None else input_ + text_emb | |
| if sum_condition is not None: | |
| input_ = input_ + sum_condition.to(input_) | |
| if cross_attention_src is not None: | |
| cross_attention_src = cross_attention_src.to(input_) | |
| transformer = self_lm.transformer | |
| _, T, C = input_.shape | |
| dtype_input = input_.dtype | |
| state = transformer._streaming_state | |
| if state is None: | |
| offsets = torch.zeros(1, dtype=torch.long, device=input_.device) | |
| else: | |
| offsets = state.offsets | |
| x = input_ | |
| if transformer.positional_embedding in {"sin", "sin_rope"}: | |
| positions = torch.arange(T, device=x.device).view(1, -1, 1) | |
| positions = positions + offsets.view(-1, 1, 1) | |
| pos_emb = create_sin_embedding(positions, C, max_period=transformer.max_period, dtype=x.dtype) | |
| x = x + transformer.positional_scale * pos_emb | |
| captured = x | |
| for idx, layer in enumerate(transformer.layers): | |
| x = layer(x, cross_attention_src=cross_attention_src) | |
| if idx == capture_layer: | |
| captured = x | |
| if state is not None: | |
| state.offsets[:] = torch.where(state.exec_mask, state.offsets + T, state.offsets) | |
| transformer_out = x.to(dtype_input) | |
| layer_hidden = captured.to(dtype_input) | |
| if self_lm.out_norm: | |
| transformer_out = self_lm.out_norm(transformer_out) | |
| text_logits = self_lm.text_linear(transformer_out) | |
| text_logits = text_logits[:, None] | |
| return transformer_out, text_logits, layer_hidden | |
| def step_with_layer(self_gen, input_tokens: torch.Tensor, depformer_replace_tokens: torch.Tensor | None = None): | |
| state = self_gen._streaming_state | |
| if state is None: | |
| raise RuntimeError("You should wrap those calls with a `with lm_gen.streaming(): ...`.") | |
| lm_model_local = self_gen.lm_model | |
| assert input_tokens.dim() == 3, "Shape should be [B, K, T]." | |
| B, Ki, S = input_tokens.shape | |
| assert B == state.batch_size, f"Got a batch size {B}, expected {state.batch_size}" | |
| assert S == 1, "Only support being given steps one by one." | |
| needed_tokens = lm_model_local.num_codebooks - lm_model_local.dep_q - 1 | |
| assert Ki >= needed_tokens, f"We expect {needed_tokens} tokens from the user stream, got {Ki}." | |
| if Ki > needed_tokens: | |
| input_tokens = input_tokens[:, :needed_tokens, :] | |
| CT = state.cache.shape[2] | |
| delays = self_gen.delays_cuda[lm_model_local.dep_q + 1:] | |
| write_positions = (state.offsets[:, None, None] + delays[:, None]) % CT | |
| scatter_with_mask_(state.cache[:, lm_model_local.dep_q + 1:], -1, write_positions, input_tokens, state.exec_mask[:, None, None]) | |
| is_init = state.offsets[:, None, None] <= self_gen.delays_cuda[:, None] | |
| is_init |= ~state.exec_mask[:, None, None] | |
| positions = (state.offsets % CT)[:, None, None].expand_as(is_init) | |
| input_ = state.cache.gather(dim=2, index=positions) | |
| input_ = torch.where(is_init, state.initial, input_) | |
| if self_gen.check: | |
| assert not (input_ == lm_model_local.ungenerated_token_id).any(), (state.offsets, input_) | |
| assert (input_[:, lm_model_local.audio_offset:] <= lm_model_local.card).all(), input_ | |
| assert (input_[:, :1] <= lm_model_local.text_card).all() | |
| zero = torch.full((1,), lm_model_local.zero_token_id, dtype=torch.long, device=input_.device) | |
| if self_gen.cfg_coef != 1.: | |
| if state.cfg_is_masked_until is not None: | |
| limit = self_gen.delays_cuda[:, None] + state.cfg_is_masked_until.view(-1, 1, 1) | |
| is_zeroed = state.offsets[:, None, None] <= limit | |
| masked = torch.where(is_zeroed & ~is_init, zero, input_) | |
| input_ = torch.cat([input_, masked], dim=0) | |
| else: | |
| input_ = input_.repeat(2, 1, 1) | |
| if self_gen.cfg_is_no_text: | |
| input_[B:, :1] = torch.where(~is_init[:, :1], zero, input_[B:, :1]) | |
| transformer_out, text_logits, layer_hidden = state.graphed_main(input_, state.condition_sum, state.condition_cross) | |
| if self_gen.cfg_coef != 1.: | |
| logits, logits_null = text_logits.chunk(2) | |
| if self_gen.cfg_is_no_text: | |
| text_logits = logits | |
| layer_hidden = layer_hidden[:B] | |
| else: | |
| text_logits = logits_null + (logits - logits_null) * self_gen.cfg_coef | |
| layer_hidden = layer_hidden[:B] | |
| if self_gen.on_text_logits_hook: | |
| self_gen.on_text_logits_hook(text_logits) | |
| text_token = sample_token(text_logits.float(), self_gen.use_sampling, self_gen.temp_text, self_gen.top_k_text) | |
| assert text_token.dim() == 3, text_token.shape | |
| assert text_token.shape[2] == 1 | |
| assert text_token.shape[1] == 1, "Only one text stream supported." | |
| text_token = text_token[:, 0, 0] | |
| if self_gen.on_text_hook is not None: | |
| self_gen.on_text_hook(text_token) | |
| if state.graphed_depth is None: | |
| audio_tokens = None | |
| else: | |
| if depformer_replace_tokens is None: | |
| audio_tokens = state.graphed_depth(text_token, transformer_out) | |
| else: | |
| assert depformer_replace_tokens.dim() == 3 | |
| audio_tokens = depformer_replace_tokens.squeeze(-1) | |
| if self_gen.on_audio_hook is not None: | |
| self_gen.on_audio_hook(audio_tokens) | |
| state.offsets = torch.where(state.exec_mask, state.offsets + 1, state.offsets) | |
| state.offset_cpu += 1 | |
| positions = (state.offsets % CT)[:, None, None] | |
| scatter_with_mask_(state.cache[:, :1], -1, positions, text_token[:, None, None], state.exec_mask[:, None, None]) | |
| if audio_tokens is not None: | |
| audio_tokens = audio_tokens[:, :, None] | |
| scatter_with_mask_(state.cache[:, 1: lm_model_local.dep_q + 1, :], -1, positions.expand_as(audio_tokens), audio_tokens, state.exec_mask[:, None, None]) | |
| if not self_gen.support_out_of_sync and state.offset_cpu <= self_gen.max_delay: | |
| return None | |
| gen_delays_cuda = self_gen.delays_cuda[: lm_model_local.dep_q + 1] | |
| index = (state.offsets[:, None, None] - self_gen.max_delay + gen_delays_cuda[:, None]) % CT | |
| out = state.cache.gather(dim=2, index=index) | |
| mask = (state.offsets <= self_gen.max_delay) | ~state.exec_mask | |
| out[mask, :, :] = lm_model_local.ungenerated_token_id | |
| return out, transformer_out, layer_hidden | |
| lm_model.forward_text = types.MethodType(forward_text_with_layer, lm_model) | |
| lm_gen._step = types.MethodType(step_with_layer, lm_gen) | |
| lm_gen.streaming_forever(1) | |
| self._warmup_runtime() | |
| print(f"[liveTryPlasticity] installed graphed layer capture layer={self.tf_capture_layer}", flush=True) | |
| def _step(self, pcm24: np.ndarray) -> dict: | |
| self.step += 1 | |
| t0 = time.perf_counter() | |
| chunk = torch.from_numpy(pcm24).to(self.device, dtype=torch.float32)[None, None] | |
| t_encode0 = time.perf_counter() | |
| codes = self.mimi.encode(chunk) | |
| t_encode1 = time.perf_counter() | |
| if self.skip_first: | |
| self.mimi.reset_streaming() | |
| self.skip_first = False | |
| t_lm0 = time.perf_counter() | |
| lm_out = self.lm_gen._step(codes[:, :, :1]) | |
| t_lm1 = time.perf_counter() | |
| tokens = None | |
| helium_hidden = None | |
| if lm_out is not None: | |
| if not (isinstance(lm_out, tuple) and len(lm_out) == 3): | |
| raise RuntimeError(f"Moshi graph layer[-2] contract failure: got {type(lm_out)} len={len(lm_out) if isinstance(lm_out, tuple) else 'n/a'}") | |
| tokens, _transformer_out, layer_hidden = lm_out | |
| helium_hidden = layer_hidden[:1, -1:].detach().float().cpu() | |
| token = -1 | |
| token_piece = "" | |
| decode_ms = 0.0 | |
| reply_codes = None | |
| if tokens is None: | |
| reply_pcm = np.zeros(MIMI_FRAME_SIZE, dtype=np.float32) | |
| else: | |
| token = int(tokens[0, 0, 0].detach().item()) | |
| token_piece = self.decode_piece(token) | |
| if token_piece: | |
| self.audio_text += token_piece | |
| reply_codes = tokens[:, 1:].detach().to(device="cpu", dtype=torch.int16) | |
| t_decode0 = time.perf_counter() | |
| reply = self.mimi.decode(tokens[:, 1:]) | |
| reply_pcm = reply[0, 0].detach().float().cpu().numpy() | |
| decode_ms = 1000.0 * (time.perf_counter() - t_decode0) | |
| if reply_pcm.shape[0] < MIMI_FRAME_SIZE: | |
| reply_pcm = np.pad(reply_pcm, (0, MIMI_FRAME_SIZE - reply_pcm.shape[0])) | |
| elif reply_pcm.shape[0] > MIMI_FRAME_SIZE: | |
| reply_pcm = reply_pcm[:MIMI_FRAME_SIZE] | |
| reply_rms = float(np.sqrt(np.mean(np.square(reply_pcm, dtype=np.float32)))) | |
| reply_peak = float(np.max(np.abs(reply_pcm))) if reply_pcm.size else 0.0 | |
| input_rms = float(np.sqrt(np.mean(np.square(pcm24, dtype=np.float32)))) | |
| encode_ms = 1000.0 * (t_encode1 - t_encode0) | |
| lm_ms = 1000.0 * (t_lm1 - t_lm0) | |
| total_ms = 1000.0 * (time.perf_counter() - t0) | |
| reply_i16 = np.clip(reply_pcm, -1.0, 1.0) | |
| reply_i16 = (reply_i16 * 32767.0).astype(np.int16) | |
| audio_b64 = base64.b64encode(reply_i16.tobytes()).decode("ascii") | |
| print( | |
| "[liveTryStudio] moshi " | |
| f"step={self.step} token={token} piece={token_piece!r} " | |
| f"in_rms={input_rms:.5f} reply_rms={reply_rms:.5f} peak={reply_peak:.3f} " | |
| f"hidden={helium_hidden is not None} " | |
| f"encode={encode_ms:.1f}ms lm={lm_ms:.1f}ms decode={decode_ms:.1f}ms total={total_ms:.1f}ms", | |
| flush=True, | |
| ) | |
| return { | |
| "step": int(self.step), | |
| "sample_rate": TARGET_SR, | |
| "reply_i16_b64": audio_b64, | |
| "reply_rms": reply_rms, | |
| "reply_peak": reply_peak, | |
| "input_rms": input_rms, | |
| "token": token, | |
| "piece": token_piece, | |
| "sampled_text": self.sampled_text, | |
| "audio_text": self.audio_text, | |
| "encode_ms": encode_ms, | |
| "lm_ms": lm_ms, | |
| "decode_ms": decode_ms, | |
| "total_ms": total_ms, | |
| "helium_hidden": helium_hidden, | |
| "reply_codes": reply_codes, | |
| } | |
| def process_ready_steps_limited(self, max_steps: int) -> list[dict]: | |
| """Process a bounded number of Mimi frames. | |
| The base MoshiOnlyEngine drains the entire input buffer before returning. | |
| In live mode that is dangerous: mic audio can accumulate while Moshi is | |
| loading, then avatar frames do not reach the sender until the backlog is | |
| fully processed. Bounded draining keeps the producer/sender interleaved. | |
| """ | |
| events: list[dict] = [] | |
| for _ in range(max(1, int(max_steps))): | |
| if self.input_buffer.shape[0] < MIMI_FRAME_SIZE: | |
| break | |
| pcm = self.input_buffer[:MIMI_FRAME_SIZE].copy() | |
| self.input_buffer = self.input_buffer[MIMI_FRAME_SIZE:].copy() | |
| events.append(self._step(pcm)) | |
| return events | |
| # --------------------------------------------------------------------------- | |
| # Utilities | |
| # --------------------------------------------------------------------------- | |
| def _sync_cuda() -> None: | |
| if torch.cuda.is_available(): | |
| torch.cuda.synchronize() | |
| def _ms(t0: float) -> float: | |
| return 1000.0 * (time.perf_counter() - t0) | |
| def encode_jpeg_b64(frame_rgb: np.ndarray, quality: int) -> str: | |
| frame_bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR) | |
| ok, enc = cv2.imencode(".jpg", frame_bgr, [int(cv2.IMWRITE_JPEG_QUALITY), int(quality)]) | |
| if not ok: | |
| raise RuntimeError("JPEG encode failed") | |
| return base64.b64encode(enc.tobytes()).decode("ascii") | |
| def encode_jpeg_bytes(frame_rgb: np.ndarray, quality: int) -> bytes: | |
| frame_bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR) | |
| ok, enc = cv2.imencode(".jpg", frame_bgr, [int(cv2.IMWRITE_JPEG_QUALITY), int(quality)]) | |
| if not ok: | |
| raise RuntimeError("JPEG encode failed") | |
| return enc.tobytes() | |
| def _pcm_f32_to_i16_b64(pcm: np.ndarray) -> str: | |
| arr = np.clip(np.asarray(pcm, dtype=np.float32), -1.0, 1.0) | |
| return base64.b64encode((arr * 32767.0).astype(np.int16).tobytes()).decode("ascii") | |
| def _pcm_f32_to_i16_bytes(pcm: np.ndarray) -> bytes: | |
| arr = np.clip(np.asarray(pcm, dtype=np.float32), -1.0, 1.0) | |
| return (arr * 32767.0).astype(np.int16).tobytes() | |
| def split_audio_into_frame_slices(pcm: np.ndarray, fps: float) -> list[np.ndarray]: | |
| frame_samples = int(round(TARGET_SR / float(fps))) | |
| arr = np.asarray(pcm, dtype=np.float32) | |
| n_frames = max(0, int(round(arr.shape[0] / frame_samples))) | |
| if n_frames == 0: | |
| return [] | |
| total = n_frames * frame_samples | |
| if arr.shape[0] < total: | |
| arr = np.pad(arr, (0, total - arr.shape[0])) | |
| elif arr.shape[0] > total: | |
| arr = arr[:total] | |
| return [arr[i * frame_samples:(i + 1) * frame_samples].copy() for i in range(n_frames)] | |
| def load_audio_24k(path: str) -> np.ndarray: | |
| # torchaudio 2.11 has no built-in decoders (needs torchcodec) -> use soundfile, | |
| # with an ffmpeg fallback for containers soundfile cannot read. | |
| import soundfile as sf | |
| try: | |
| wav, sr = sf.read(path, dtype="float32", always_2d=True) | |
| except Exception: | |
| import subprocess, tempfile | |
| with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: | |
| tmp = f.name | |
| subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", path, "-ac", "1", | |
| "-ar", str(TARGET_SR), "-f", "wav", tmp], check=True) | |
| wav, sr = sf.read(tmp, dtype="float32", always_2d=True) | |
| os.remove(tmp) | |
| wav = wav.mean(axis=1) | |
| if sr != TARGET_SR: | |
| import librosa | |
| wav = librosa.resample(wav, orig_sr=sr, target_sr=TARGET_SR) | |
| return np.asarray(wav, dtype=np.float32) | |
| def load_ref_image(path: str | Path, device: torch.device, dtype: torch.dtype) -> torch.Tensor: | |
| img = Image.open(path).convert("RGB").resize((512, 512), Image.LANCZOS) | |
| return T.ToTensor()(img).unsqueeze(0).to(device=device, dtype=dtype) | |
| # --------------------------------------------------------------------------- | |
| # FM + Renderer weight loading (identical to liveTryFM.py) | |
| # --------------------------------------------------------------------------- | |
| def _clean_generator_state(ckpt: dict) -> dict: | |
| raw = ckpt.get("ema_state_dict") or ckpt.get("state_dict", ckpt.get("model", ckpt)) | |
| if isinstance(raw, dict) and "model" in raw and isinstance(raw["model"], dict): | |
| raw = raw["model"] | |
| return {k.replace("model.", "", 1) if k.startswith("model.") else k: v for k, v in raw.items()} | |
| def _load_fm(args: argparse.Namespace, device: torch.device) -> FMGenerator: | |
| t_total = time.perf_counter() | |
| fm = FMGenerator(args).to(device).eval() | |
| ckpt = torch.load(args.generator_path, map_location="cpu") | |
| cleaned = _clean_generator_state(ckpt) | |
| missing, unexpected = fm.load_state_dict(cleaned, strict=False) | |
| print( | |
| f"[liveTryHeliumFM][FM] base loaded missing={len(missing)} unexpected={len(unexpected)}", | |
| flush=True, | |
| ) | |
| lora_path = str(getattr(args, "lora_generator_path", "") or "") | |
| if lora_path: | |
| apply_lora_to_model( | |
| fm, | |
| rank=int(getattr(args, "lora_rank", 64) or 64), | |
| alpha=float(getattr(args, "lora_alpha", 128) or 128), | |
| dropout=float(getattr(args, "lora_dropout", 0.05)), | |
| include_pose_lora=not bool(getattr(args, "no_lora_pose_projection", False)), | |
| include_audio_lora=not bool(getattr(args, "no_lora_audio_projection", False)), | |
| only_pose_lora=bool(getattr(args, "only_lora_pose_projection", False)), | |
| ) | |
| lora_ckpt = torch.load(lora_path, map_location="cpu") | |
| lora_cleaned = _clean_generator_state(lora_ckpt) | |
| missing_lora, unexpected_lora = fm.load_state_dict(lora_cleaned, strict=False) | |
| lora_keys = sum(1 for key in lora_cleaned if "lora_" in key) | |
| print( | |
| f"[liveTryHeliumFM][FM] lora loaded path={lora_path} " | |
| f"lora_keys={lora_keys} missing={len(missing_lora)} unexpected={len(unexpected_lora)}", | |
| flush=True, | |
| ) | |
| fm.to(device).eval() | |
| _sync_cuda() | |
| print(f"[liveTryHeliumFM][FM] loaded in {_ms(t_total):.0f}ms", flush=True) | |
| return fm | |
| def _load_renderer(args: argparse.Namespace, device: torch.device, dtype: torch.dtype) -> IMTRenderer: | |
| t_total = time.perf_counter() | |
| renderer = IMTRenderer(args).to(device).eval() | |
| ckpt = torch.load(args.renderer_path, map_location="cpu") | |
| raw = ckpt.get("state_dict", ckpt.get("model", ckpt)) | |
| cleaned = {k.replace("gen.", "", 1).replace("model.", "", 1): v for k, v in raw.items()} | |
| missing, unexpected = renderer.load_state_dict(cleaned, strict=False) | |
| renderer = renderer.to(dtype=dtype) | |
| _sync_cuda() | |
| if getattr(args, "compile_renderer", False): | |
| def _fused_render(motion_latent, g_r, m_r, f_r): | |
| ta_c = renderer.adapt(motion_latent, g_r) | |
| m_c = renderer.latent_token_decoder(ta_c) | |
| frames = renderer.decode(m_c, m_r, f_r) | |
| return frames | |
| renderer._fused_render = torch.compile(_fused_render) | |
| print( | |
| f"[liveTryHeliumFM][renderer] loaded in {_ms(t_total):.0f}ms " | |
| f"missing={len(missing)} unexpected={len(unexpected)}", | |
| flush=True, | |
| ) | |
| return renderer | |
| # --------------------------------------------------------------------------- | |
| # Helium extractor (chunk-local, batch LM) | |
| # --------------------------------------------------------------------------- | |
| class HeliumExtractor: | |
| """Prefix-growing raw Helium + global interpolation. | |
| This matches the best Stage 3 diagnostic: | |
| audio prefix -> raw Helium -> append only new raw steps | |
| -> one global interpolation over accumulated raw Helium | |
| -> emit last target_frames | |
| """ | |
| def __init__( | |
| self, | |
| helium_mimi: "MimiModel", | |
| helium_lm: "LMModel", | |
| device: torch.device, | |
| ) -> None: | |
| self.helium_mimi = helium_mimi | |
| self.helium_lm = helium_lm | |
| self.device = device | |
| self._lock = threading.Lock() | |
| self._prefix_pcm = np.empty(0, dtype=np.float32) | |
| self._raw_parts: list[torch.Tensor] = [] | |
| self._prev_raw_len = 0 | |
| self._emitted_frames = 0 | |
| def reset(self) -> None: | |
| with self._lock: | |
| self._prefix_pcm = np.empty(0, dtype=np.float32) | |
| self._raw_parts = [] | |
| self._prev_raw_len = 0 | |
| self._emitted_frames = 0 | |
| def _extract_raw(self, pcm_np: np.ndarray) -> torch.Tensor: | |
| wav = torch.from_numpy(np.asarray(pcm_np, dtype=np.float32)).to(self.device, dtype=torch.float32)[None, None] | |
| codes = self.helium_mimi.encode(wav) | |
| codes = codes[:, :MAIN_CODEBOOKS, :].detach() | |
| batch_size, n_q, total_steps = codes.shape | |
| dtype = next(self.helium_lm.parameters()).dtype | |
| input_emb = torch.zeros( | |
| batch_size, total_steps, self.helium_lm.dim, device=self.device, dtype=dtype | |
| ) | |
| for q in range(n_q): | |
| input_emb = input_emb + self.helium_lm.emb[q](codes[:, q].long()) | |
| padding_ids = torch.full( | |
| (batch_size, total_steps), | |
| self.helium_lm.existing_text_padding_id, | |
| dtype=torch.long, | |
| device=self.device, | |
| ) | |
| input_emb = input_emb + self.helium_lm.text_emb(padding_ids) | |
| if getattr(self.helium_lm.transformer, "_streaming_state", None) is not None: | |
| raise RuntimeError("helium_lm must stay in batch mode (non-streaming)") | |
| captured: list[torch.Tensor] = [] | |
| def _hook(_mod, _inp, out): | |
| captured.append(out.detach()) | |
| handle = self.helium_lm.transformer.layers[-2].register_forward_hook(_hook) | |
| try: | |
| self.helium_lm.transformer(input_emb) | |
| finally: | |
| handle.remove() | |
| if len(captured) != 1: | |
| raise RuntimeError(f"Helium hook captured {len(captured)} tensors; expected 1") | |
| return captured[0].squeeze(0).float().contiguous() # [T_raw, 4096] | |
| def extract_raw_chunk(self, pcm_np: np.ndarray) -> torch.Tensor: | |
| """Return only the new raw 12.5Hz Helium steps for one new audio chunk.""" | |
| pcm = np.asarray(pcm_np, dtype=np.float32) | |
| if pcm.ndim != 1 or pcm.size == 0: | |
| raise RuntimeError("HeliumExtractor.extract_raw_chunk expects non-empty 1D PCM") | |
| with self._lock: | |
| self._prefix_pcm = np.concatenate([self._prefix_pcm, pcm], axis=0) | |
| raw_prefix = self._extract_raw(self._prefix_pcm) | |
| new_raw = raw_prefix[self._prev_raw_len:] | |
| if int(new_raw.shape[0]) == 0 and int(raw_prefix.shape[0]) > 0: | |
| new_raw = raw_prefix[-1:] | |
| self._raw_parts.append(new_raw.cpu()) | |
| self._prev_raw_len = int(raw_prefix.shape[0]) | |
| return new_raw.contiguous() | |
| def extract_exact_chunk_from_prefix( | |
| self, | |
| pcm_prefix: np.ndarray, | |
| chunk_start_frame: int, | |
| target_frames: int, | |
| ) -> torch.Tensor: | |
| """Return the raw 12.5Hz Helium slice for a video-frame window. | |
| This path is used by file-mode lookahead. It must return raw Helium | |
| steps so the studio adapter remains the only temporal upsampler. | |
| """ | |
| pcm = np.asarray(pcm_prefix, dtype=np.float32) | |
| if pcm.ndim != 1 or pcm.size == 0: | |
| raise RuntimeError("HeliumExtractor.extract_exact_chunk_from_prefix expects non-empty 1D PCM") | |
| with self._lock: | |
| raw_prefix = self._extract_raw(pcm) | |
| start_frame = int(chunk_start_frame) | |
| end_frame = start_frame + int(target_frames) | |
| start_raw = int(round(start_frame * 0.5)) | |
| end_raw = int(round(end_frame * 0.5)) | |
| start_raw = max(0, min(start_raw, int(raw_prefix.shape[0]))) | |
| end_raw = max(start_raw + 1, min(end_raw, int(raw_prefix.shape[0]))) | |
| if end_raw > int(raw_prefix.shape[0]): | |
| raise RuntimeError( | |
| f"Requested raw slice [{start_raw}, {end_raw}) exceeds prefix Helium length {raw_prefix.shape[0]}" | |
| ) | |
| return raw_prefix[start_raw:end_raw].contiguous() | |
| # --------------------------------------------------------------------------- | |
| # Main engine | |
| # --------------------------------------------------------------------------- | |
| class LiveHeliumFMEngine: | |
| """Helium extraction + FM + renderer, session-stateful.""" | |
| def __init__(self, args: argparse.Namespace) -> None: | |
| if args.tf32: | |
| torch.backends.cuda.matmul.allow_tf32 = True | |
| torch.backends.cudnn.allow_tf32 = True | |
| torch.set_float32_matmul_precision("high") | |
| self.args = args | |
| self.device = torch.device(args.device) | |
| renderer_precision = str( | |
| getattr(args, "renderer_precision", "fp32") | |
| ).lower() | |
| self.dtype = { | |
| "fp32": torch.float32, | |
| "fp16": torch.float16, | |
| "bf16": torch.bfloat16, | |
| }[renderer_precision] | |
| self.fps = float(args.fps) | |
| self.audio_chunk_sec = float(getattr(args, "audio_chunk_sec", 0.96)) | |
| self.audio_chunk_samples = int(round(self.audio_chunk_sec * TARGET_SR)) | |
| self.fm_chunk_frames = max(1, int(getattr(args, "fm_chunk_frames", 24))) | |
| self.live_sliding_window = bool(getattr(args, "enable_live_sliding_window", False)) | |
| self.slide_past_frames = max(0, int(getattr(args, "slide_past_frames", 10))) | |
| self.slide_future_frames = max(0, int(getattr(args, "slide_future_frames", 3))) | |
| self.render_sub_batch = max(1, int(args.render_sub_batch)) | |
| self.jpeg_quality = int(args.jpeg_quality) | |
| # emotion conditioning (audio + semantic, 80 ms streaming state) | |
| self.emo = LiveEmotionState(args) | |
| self.e_cfg_scale = float(getattr(args, "e_cfg_scale", 3.0)) | |
| trained_window = int(round(float(args.wav2vec_sec) * self.fps)) | |
| if self.fm_chunk_frames != trained_window: | |
| print( | |
| f"[liveTryHeliumFM] WARNING fm_chunk_frames={self.fm_chunk_frames} " | |
| f"but wav2vec_sec*fps={trained_window}", | |
| flush=True, | |
| ) | |
| if self.live_sliding_window: | |
| print( | |
| f"[liveTryHeliumFM][typeAC] IMTalker lookahead enabled " | |
| f"past={self.slide_past_frames}f current={self.fm_chunk_frames}f " | |
| f"future={self.slide_future_frames}f", | |
| flush=True, | |
| ) | |
| t_total = time.perf_counter() | |
| # FM + renderer | |
| self.fm = _load_fm(args, self.device) | |
| self.renderer = _load_renderer(args, self.device, self.dtype) | |
| # Adapter: either the six-layer projected-frontend model or UniTalk's | |
| # 12-layer model with only its final layer passed to IMTalker. | |
| t_adapter = time.perf_counter() | |
| if bool(int(getattr(args, "audio_direct", 0))): | |
| self.studio_adapter = None | |
| print("[liveTryHeliumFM] audio_direct: Helium->wav2vec adapter skipped", | |
| flush=True) | |
| elif args.adapter_type == "unitalk_last_layer": | |
| self.studio_adapter = UniTalkLastLayerLiveAdapter( | |
| args.wav2vec_model_path, | |
| args.adapter_dropout, | |
| ).to(self.device).float().eval() | |
| else: | |
| self.studio_adapter = StudioNativeLiveAdapter( | |
| args.wav2vec_model_path, | |
| args.adapter_num_layers, | |
| args.adapter_dropout, | |
| ).to(self.device).float().eval() | |
| if self.studio_adapter is not None: | |
| payload = torch.load(args.adapter_path, map_location="cpu") | |
| if isinstance(payload, dict) and args.adapter_type == "frontend": | |
| saved_args = payload.get("args", {}) | |
| if saved_args and int(saved_args.get("num_layers", args.adapter_num_layers)) != int(args.adapter_num_layers): | |
| print( | |
| f"[liveTryHeliumFrontendFM] WARNING checkpoint num_layers={saved_args.get('num_layers')} " | |
| f"but CLI adapter_num_layers={args.adapter_num_layers}", | |
| flush=True, | |
| ) | |
| state = payload.get("adapter", payload.get("model", payload)) | |
| else: | |
| state = payload | |
| self.studio_adapter.load_state_dict(state, strict=True) | |
| _sync_cuda() | |
| print( | |
| f"[liveTryHeliumFrontendFM][adapter] type={args.adapter_type} loaded in {_ms(t_adapter):.0f}ms " | |
| f"path={args.adapter_path}", | |
| flush=True, | |
| ) | |
| # Raw HF Wav2Vec2 target path used during Helium adapter training. | |
| t_w2v = time.perf_counter() | |
| self.wav2vec_feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained( | |
| args.wav2vec_model_path, | |
| local_files_only=True, | |
| ) | |
| self.wav2vec_model = self.studio_adapter.wav2vec if self.studio_adapter is not None else None | |
| _sync_cuda() | |
| print( | |
| f"[liveTryHeliumStudioFM][wav2vec] loaded in {_ms(t_w2v):.0f}ms " | |
| f"path={args.wav2vec_model_path}", | |
| flush=True, | |
| ) | |
| # Reference image: pre-compute identity + motion-ref features once | |
| ref_tensor = load_ref_image(args.ref_path, self.device, self.dtype) | |
| with torch.no_grad(): | |
| self.f_r, self.g_r = self.renderer.dense_feature_encoder(ref_tensor) | |
| self.ref_x = self.renderer.latent_token_encoder(ref_tensor).to(dtype=torch.float32) | |
| ta_r = self.renderer.adapt(self.ref_x.to(dtype=self.dtype), self.g_r) | |
| self.m_r = self.renderer.latent_token_decoder(ta_r) | |
| _sync_cuda() | |
| self.eye_blink_enabled = bool(getattr(args, "enable_eye_blink_composite", False)) | |
| self._blink_maps: tuple[torch.Tensor, ...] | None = None | |
| self._eye_masks: tuple[torch.Tensor, ...] | None = None | |
| self._render_frame_cursor: int = 0 | |
| if self.eye_blink_enabled: | |
| self._init_eye_blink_composite() | |
| # Moshi models for Helium extraction | |
| self._init_moshi(args) | |
| # Optional local audio file (simulate-live mode) | |
| self.audio_pcm: np.ndarray | None = None | |
| if getattr(args, "audio_path", "") and Path(args.audio_path).is_file(): | |
| self.audio_pcm = load_audio_24k(args.audio_path) | |
| print( | |
| f"[liveTryHeliumFM] audio_path loaded: {self.audio_pcm.shape[0]/TARGET_SR:.2f}s " | |
| f"chunk={self.audio_chunk_sec:.3f}s/{self.audio_chunk_samples} samples " | |
| f"fm_chunk={self.fm_chunk_frames}f", | |
| flush=True, | |
| ) | |
| # Shared noise tensor (pre-generated, indexed by absolute frame position) | |
| self.noise_buf: torch.Tensor | None = None | |
| if getattr(args, "shared_noise", False): | |
| max_frames = int(getattr(args, "noise_max_frames", 5000)) | |
| gen = torch.Generator(device=self.device) | |
| gen.manual_seed(int(getattr(args, "noise_seed", 1234))) | |
| self.noise_buf = torch.randn( | |
| 1, max_frames, int(args.dim_w), device=self.device, generator=gen | |
| ) | |
| print(f"[liveTryHeliumFM] shared noise buf: {tuple(self.noise_buf.shape)}", flush=True) | |
| # Per-session state (reset on each new client) | |
| self.stream_state: dict | None = None | |
| self.abs_frame: int = 0 | |
| self.helium_context_tail: torch.Tensor | None = None | |
| self.helium_deque_size: int = 100 | |
| self.helium_deque: torch.Tensor | None = None | |
| self.helium_deque_filled: int = 0 | |
| self.silence_helium_seed: torch.Tensor | None = None | |
| silence_helium_path = str( | |
| getattr(args, "silence_helium_path", "") or "" | |
| ) | |
| if silence_helium_path: | |
| payload = torch.load(silence_helium_path, map_location="cpu") | |
| seed = ( | |
| payload.get("silence_helium_mean") | |
| if isinstance(payload, dict) | |
| else payload | |
| ) | |
| if not isinstance(seed, torch.Tensor) or seed.numel() != 4096: | |
| raise RuntimeError( | |
| f"Invalid silence Helium seed: {silence_helium_path}" | |
| ) | |
| self.silence_helium_seed = seed.reshape(1, 4096).to( | |
| device=self.device, dtype=torch.float32 | |
| ) | |
| print( | |
| f"[liveTryHeliumFM] silence Helium seed loaded: " | |
| f"{silence_helium_path}", | |
| flush=True, | |
| ) | |
| self._pcm_accum: np.ndarray = np.empty(0, dtype=np.float32) | |
| self.dump_motion = bool(getattr(args, "dump_motion", False)) | |
| self.dump_dir = Path(getattr(args, "dump_dir", ROOT / "live_try_dumps")) | |
| self._session_motion_parts: list[torch.Tensor] = [] | |
| self._session_helium_parts: list[torch.Tensor] = [] | |
| self._session_adapter_50_parts: list[torch.Tensor] = [] | |
| self._session_adapter_25_parts: list[torch.Tensor] = [] | |
| self._session_projected_audio_parts: list[torch.Tensor] = [] | |
| self._session_audio_parts: list[np.ndarray] = [] | |
| self._session_live_token_parts: list[torch.Tensor] = [] | |
| self._session_chunk_rows: list[dict] = [] | |
| self._session_reply_events: list[dict] = [] | |
| self._session_started_wall: float = time.time() | |
| # JPEG encoding thread pool (CPU-only work, parallelizable) | |
| self._jpeg_pool = concurrent.futures.ThreadPoolExecutor( | |
| max_workers=4, thread_name_prefix="jpeg" | |
| ) | |
| # Warmup | |
| self._warmup() | |
| print( | |
| f"[liveTryHeliumFM] ready — total startup {_ms(t_total):.0f}ms " | |
| f"fm_chunk={self.fm_chunk_frames} render_sub={self.render_sub_batch} " | |
| f"dtype={self.dtype}", | |
| flush=True, | |
| ) | |
| def _init_moshi(self, args: argparse.Namespace) -> None: | |
| if bool(int(getattr(args, "audio_direct", 0))): | |
| self.extractor = None | |
| print("[liveTryHeliumFM] audio_direct: Helium extractor skipped " | |
| "(reply PCM -> IMTalker wav2vec)", flush=True) | |
| return | |
| if bool(getattr(args, "direct_reply_hidden", False)) and bool(getattr(args, "enable_moshi_reply", False)): | |
| self.extractor = None | |
| print("[liveTryHeliumStudioFM] using direct Moshi reply hidden; batch Helium extractor skipped", flush=True) | |
| return | |
| from generate_helium import load_mimi_and_lm | |
| t0 = time.perf_counter() | |
| helium_mimi, helium_lm, _ = load_mimi_and_lm(args) | |
| helium_mimi.eval() | |
| helium_lm.eval() | |
| print( | |
| f"[liveTryHeliumFM] Moshi loaded in {_ms(t0):.0f}ms " | |
| f"dtype={next(helium_lm.parameters()).dtype}", | |
| flush=True, | |
| ) | |
| self.extractor = HeliumExtractor(helium_mimi, helium_lm, self.device) | |
| def reset_session(self) -> None: | |
| """Call when a new WebSocket client connects or sends 'start'.""" | |
| self.stream_state = None | |
| self.abs_frame = 0 | |
| self._render_frame_cursor = 0 | |
| if getattr(self, "emo", None) is not None: | |
| self.emo.reset() | |
| self.helium_context_tail = None | |
| self.helium_deque = None | |
| self.helium_deque_filled = 0 | |
| self._pcm_accum = np.empty(0, dtype=np.float32) | |
| if self.extractor is not None: | |
| self.extractor.reset() | |
| self._session_motion_parts = [] | |
| self._session_helium_parts = [] | |
| self._session_adapter_50_parts = [] | |
| self._session_adapter_25_parts = [] | |
| self._session_projected_audio_parts = [] | |
| self._session_audio_parts = [] | |
| self._session_live_token_parts = [] | |
| self._session_chunk_rows = [] | |
| self._session_reply_events = [] | |
| self._session_started_wall = time.time() | |
| def _warmup(self) -> None: | |
| dummy_pcm = np.zeros(self.audio_chunk_samples, dtype=np.float32) | |
| t0 = time.perf_counter() | |
| if self.extractor is None: | |
| raw_steps = max(1, int(round(self.fm_chunk_frames * 12.5 / float(self.fps)))) | |
| if self.silence_helium_seed is not None: | |
| dummy_helium = self.silence_helium_seed.expand( | |
| raw_steps, -1 | |
| ).contiguous() | |
| else: | |
| dummy_helium = torch.zeros( | |
| raw_steps, 4096, device=self.device, dtype=torch.float32 | |
| ) | |
| print(f"[liveTryHeliumStudioFM][warmup] raw_helium=skipped direct_hidden raw_steps={raw_steps}", flush=True) | |
| else: | |
| dummy_helium = self.extractor.extract_raw_chunk(dummy_pcm) | |
| _sync_cuda() | |
| print(f"[liveTryHeliumStudioFM][warmup] raw_helium={_ms(t0):.0f}ms", flush=True) | |
| self.extractor.reset() | |
| t0 = time.perf_counter() | |
| motion, _info = self._sample_motion_from_helium(dummy_helium, self.fm_chunk_frames) | |
| _sync_cuda() | |
| print(f"[liveTryHeliumFM][warmup] fm={_ms(t0):.0f}ms motion={tuple(motion.shape)}", flush=True) | |
| self.stream_state = None | |
| self.abs_frame = 0 | |
| self._render_frame_cursor = 0 | |
| self.helium_context_tail = None | |
| self.helium_deque = None | |
| self.helium_deque_filled = 0 | |
| t0 = time.perf_counter() | |
| dummy_motion = torch.zeros(self.render_sub_batch, 32, device=self.device, dtype=self.dtype) | |
| _frames, _timings = self._render_motion(dummy_motion) | |
| _sync_cuda() | |
| print(f"[liveTryHeliumFM][warmup] renderer={_ms(t0):.0f}ms", flush=True) | |
| self._render_frame_cursor = 0 | |
| # Warmup JPEG pool | |
| t0 = time.perf_counter() | |
| dummy_np = np.zeros((512, 512, 3), dtype=np.uint8) | |
| _ = encode_jpeg_b64(dummy_np, self.jpeg_quality) | |
| print(f"[liveTryHeliumFM][warmup] jpeg={_ms(t0):.0f}ms", flush=True) | |
| self.stream_state = None | |
| def feed_pcm(self, pcm_s16le_bytes: bytes) -> Optional[tuple[torch.Tensor, dict, np.ndarray]]: | |
| pcm = np.frombuffer(pcm_s16le_bytes, dtype=np.int16).astype(np.float32) / 32768.0 | |
| return self.feed_pcm_f32(pcm) | |
| def feed_pcm_f32(self, pcm_f32: np.ndarray) -> Optional[tuple[torch.Tensor, dict, np.ndarray]]: | |
| pcm = np.asarray(pcm_f32, dtype=np.float32) | |
| self._pcm_accum = np.concatenate([self._pcm_accum, pcm]) | |
| if self._pcm_accum.shape[0] < self.audio_chunk_samples: | |
| return None | |
| chunk = self._pcm_accum[:self.audio_chunk_samples].copy() | |
| self._pcm_accum = self._pcm_accum[self.audio_chunk_samples:] | |
| motion, info = self._process_pcm_chunk(chunk, self.fm_chunk_frames) | |
| self._record_session_chunk(chunk, motion, info) | |
| return motion, info, chunk | |
| def _process_pcm_chunk(self, pcm_chunk: np.ndarray, target_frames: int) -> tuple[torch.Tensor, dict]: | |
| timings: dict = {} | |
| target_frames = max(1, min(int(target_frames), self.fm_chunk_frames)) | |
| pcm_chunk = np.asarray(pcm_chunk, dtype=np.float32) | |
| # ---- SILENCE must be STILL (closed mouth) ------------------------- | |
| # All-zero audio is out-of-distribution for wav2vec (it normalises per | |
| # call), and the generator answered digital silence with a WIDE-OPEN | |
| # mouth. The drain lead-in and every pause therefore showed the avatar | |
| # "talking" with no audio. Render the reference latent instead: the | |
| # head stays still and the mouth stays shut. | |
| rms = float(np.sqrt(np.mean(np.square(pcm_chunk)))) if pcm_chunk.size else 0.0 | |
| if bool(int(getattr(self.args, "idle_still", 1))) and rms < float( | |
| getattr(self.args, "idle_rms_threshold", 0.004)): | |
| self.pending_audio = pcm_chunk | |
| idle = self.ref_x.reshape(1, -1).to(self.device, dtype=torch.float32) | |
| motion = idle.expand(int(target_frames), -1).clone() | |
| tail = getattr(self, "_last_motion_tail", None) | |
| cf = int(getattr(self.args, "idle_crossfade_frames", 10)) | |
| if tail is not None and cf > 0 and tail.shape[0] > 0: | |
| k = min(cf, int(target_frames), int(tail.shape[0])) | |
| w = torch.linspace(0.0, 1.0, k + 2, device=motion.device, | |
| dtype=motion.dtype)[1:-1].view(-1, 1) | |
| motion[:k] = (1.0 - w) * tail[-k:].to(motion.device, motion.dtype) + w * motion[:k] | |
| self._last_motion_tail = motion[-min(10, int(target_frames)):].detach().clone() | |
| self.abs_frame += int(target_frames) | |
| timings.update({ | |
| "frames": int(target_frames), | |
| "abs_start": int(self.abs_frame) - int(target_frames), | |
| "helium_ms": 0.0, | |
| "fm_ms": 0.0, | |
| "idle": 1, | |
| }) | |
| return motion, timings | |
| t0 = time.perf_counter() | |
| if self.extractor is None: | |
| helium = None | |
| timings["helium_ms"] = 0.0 | |
| else: | |
| helium = self.extractor.extract_raw_chunk(pcm_chunk) | |
| timings["helium_ms"] = _ms(t0) | |
| self.pending_audio = np.asarray(pcm_chunk, dtype=np.float32) | |
| motion, fm_info = self._sample_motion_from_helium(helium, target_frames) | |
| self._last_motion_tail = motion[-min(10, int(motion.shape[0])):].detach().clone() | |
| timings.update(fm_info) | |
| return motion, timings | |
| def _emo_conditioning(self, n_frames: int) -> dict: | |
| """Per-frame emotion conditioning for the next chunk. | |
| All of the schedule logic lives in LiveEmotionState, which reproduces | |
| the per-frame schedule used by the offline conversation renders. | |
| """ | |
| emo = getattr(self, "emo", None) | |
| if emo is None or not getattr(emo, "enable", False): | |
| return {} | |
| return emo.conditioning(int(n_frames)) or {} | |
| def _sample_motion_from_helium(self, helium: torch.Tensor, target_frames: int) -> tuple[torch.Tensor, dict]: | |
| timings: dict = {} | |
| # ---- direct-audio path (our IMTalker checkpoint): reply PCM -> wav2vec | |
| # -> generator. Skips Helium + the Helium->wav2vec adapter entirely. | |
| if bool(getattr(self.args, "audio_direct", False)): | |
| import torchaudio as _ta | |
| pending = getattr(self, "pending_audio", None) | |
| if pending is None: | |
| pending = np.zeros(int(self.audio_chunk_samples), dtype=np.float32) | |
| a24 = torch.from_numpy(np.asarray(pending, dtype=np.float32)).view(1, -1) | |
| a16 = _ta.functional.resample(a24, 24000, 16000) # (1, T) for wav2vec | |
| _prev_tail = getattr(self, "_prev_a_tail", None) | |
| # 3 s of LEFT CONTEXT for the wav2vec encoder (see FM.sample): | |
| # features from isolated chunks do not match the training features. | |
| _hist = getattr(self, "_fm_audio_hist", None) | |
| if _hist is not None and _hist.numel(): | |
| _ctx = torch.cat([_hist, a16], dim=-1) | |
| else: | |
| _ctx = a16 | |
| if _ctx.shape[-1] > 16000 * 5: | |
| _ctx = _ctx[:, -16000 * 5:] | |
| # Encode the left context (better wav2vec features) but feed the | |
| # generator ONLY the frames of THIS chunk: the generator is trained | |
| # on 50-frame (2 s) clips. Passing the full 5 s context made it | |
| # generate 125 frames, and the caller kept the FIRST 50 -> motion | |
| # paired with audio ~3 s in the past ("mouth does not match"). | |
| _tc = int(round(_ctx.shape[-1] * 25.0 / 16000.0)) | |
| _feat = self.fm.audio_encoder.inference( | |
| _ctx.to(self.device), seq_len=_tc) | |
| if _feat.ndim == 3: | |
| _feat = _feat[0] | |
| _tf = int(target_frames) | |
| _feat = _feat[-_tf:] if _feat.shape[0] > _tf else _feat | |
| if _feat.shape[0] < _tf: | |
| _feat = torch.cat( | |
| [_feat, _feat[-1:].expand(_tf - _feat.shape[0], -1)], dim=0) | |
| data: dict = {"a": a16, "a_feat": _feat[None], "ref_x": self.ref_x, | |
| "gen_frames": _tf} | |
| if _prev_tail is not None: | |
| data["prev_a_wav"] = _prev_tail | |
| try: | |
| self._prev_a_tail = a16[:, -6400:].detach().clone() | |
| self._fm_audio_hist = _ctx[:, -(16000 * 3):].detach().clone() | |
| except Exception: | |
| self._prev_a_tail = None | |
| self._fm_audio_hist = None | |
| emo_cond = self._emo_conditioning(int(target_frames)) | |
| if emo_cond: | |
| data.update(emo_cond) | |
| timings["emo_cls"] = int(emo_cond["_cls"]) | |
| timings["emo_inten"] = round(float(emo_cond["_inten"]), 3) | |
| emo_cond.pop("_cls", None) | |
| emo_cond.pop("_inten", None) | |
| t0 = time.perf_counter() | |
| _call = dict( | |
| a_cfg_scale=float(self.args.a_cfg_scale), | |
| e_cfg_scale=float(getattr(self, "e_cfg_scale", 3.0)), | |
| nfe=int(self.args.nfe), | |
| ) | |
| try: | |
| motion, self.stream_state = self.fm.sample( | |
| data, stream_state=self.stream_state, return_state=True, **_call | |
| ) | |
| except RuntimeError as _e: | |
| # The frozen transformer's prev-context path has a shape bug for | |
| # some chunk sizes; fall back to an independent chunk (same as the | |
| # offline render) instead of dropping the whole stream. | |
| print(f"[audio_direct] state carry failed ({_e}); retry cold", flush=True) | |
| self.stream_state = None | |
| motion = self.fm.sample(data, **_call) | |
| timings["fm_ms"] = _ms(t0) | |
| timings["audio_direct"] = 1 | |
| motion = motion.squeeze(0).detach() | |
| if motion.shape[0] > target_frames: | |
| motion = motion[-target_frames:] | |
| else: | |
| motion = motion[:target_frames] | |
| if int(motion.shape[0]) < target_frames: | |
| pad = motion[-1:].expand(target_frames - motion.shape[0], -1) | |
| motion = torch.cat([motion, pad], 0) | |
| timings["frames"] = int(motion.shape[0]) | |
| timings["abs_start"] = self.abs_frame | |
| self.abs_frame += timings["frames"] | |
| timings["helium_ms"] = 0.0 | |
| return motion, timings | |
| t_adapter = time.perf_counter() | |
| helium = helium.to(self.device, dtype=torch.float32).contiguous() | |
| target_frames = int(target_frames) | |
| current_steps = int(helium.shape[0]) | |
| deque_size = int(getattr(self, "helium_deque_size", 100)) | |
| if self.helium_deque is None: | |
| if self.silence_helium_seed is not None: | |
| self.helium_deque = self.silence_helium_seed.expand( | |
| deque_size, -1 | |
| ).clone() | |
| else: | |
| self.helium_deque = torch.zeros( | |
| deque_size, | |
| helium.shape[1], | |
| device=self.device, | |
| dtype=torch.float32, | |
| ) | |
| self.helium_deque_filled = 0 | |
| if current_steps >= deque_size: | |
| self.helium_deque = helium[-deque_size:].detach().clone() | |
| self.helium_deque_filled = deque_size | |
| else: | |
| self.helium_deque = torch.cat([self.helium_deque[current_steps:], helium], dim=0).contiguous() | |
| self.helium_deque_filled = min(deque_size, int(self.helium_deque_filled) + current_steps) | |
| adapter_window_mode = str( | |
| getattr(self.args, "adapter_window_mode", "tail") | |
| ) | |
| if adapter_window_mode == "lookahead": | |
| # Match training: process the full 8-second window at 50 Hz, | |
| # emit .96 seconds, and retain .48 seconds as future context. | |
| future_steps = int(getattr(self.args, "adapter_future_steps", 6)) | |
| target_len_50_full = deque_size * 4 | |
| _baseline, _cnn, feat_50_full = self.studio_adapter.forward_single( | |
| self.helium_deque, target_len_50_full | |
| ) | |
| if self.live_sliding_window: | |
| # Type AC: keep the Type A Helium deque + adapter path, but | |
| # give IMTalker/FM a small [past + current + future] feature | |
| # window and emit only current frames. This places lookahead in | |
| # IMTalker instead of discarding future context inside adapter. | |
| past_25 = int(self.slide_past_frames) | |
| future_25 = int(self.slide_future_frames) | |
| current_25 = int(target_frames) | |
| past_50 = past_25 * 2 | |
| future_50 = future_25 * 2 | |
| current_50 = current_25 * 2 | |
| full_len_50 = int(feat_50_full.shape[0]) | |
| current_end_50 = full_len_50 - future_50 | |
| current_start_50 = current_end_50 - current_50 | |
| window_start_50 = current_start_50 - past_50 | |
| window_end_50 = current_end_50 + future_50 | |
| if window_start_50 < 0 or current_start_50 < 0 or window_end_50 > full_len_50: | |
| raise RuntimeError( | |
| "Sliding adapter window is out of range: " | |
| f"full={full_len_50} start={window_start_50} " | |
| f"current_start={current_start_50} end={window_end_50}" | |
| ) | |
| feat_50 = feat_50_full[window_start_50:window_end_50].contiguous() | |
| feat_25 = F.interpolate( | |
| feat_50.T.unsqueeze(0), | |
| size=past_25 + current_25 + future_25, | |
| mode="linear", | |
| align_corners=False, | |
| ).squeeze(0).T.contiguous() | |
| else: | |
| emitted_frames_50 = max(1, current_steps * 4) | |
| future_frames_50 = max(0, future_steps * 4) | |
| segment_end = int(feat_50_full.shape[0]) - future_frames_50 | |
| segment_start = segment_end - emitted_frames_50 | |
| if segment_start < 0: | |
| raise RuntimeError( | |
| "Look-ahead adapter output is shorter than its emit/future region" | |
| ) | |
| feat_50 = feat_50_full[segment_start:segment_end].contiguous() | |
| feat_25 = F.interpolate( | |
| feat_50.T.unsqueeze(0), | |
| size=target_frames, | |
| mode="linear", | |
| align_corners=False, | |
| ).squeeze(0).T.contiguous() | |
| else: | |
| # Legacy behavior: predict at 25 Hz and emit the newest tail. | |
| target_len_25_full = deque_size * 2 | |
| _baseline, _cnn, feat_25_full = self.studio_adapter.forward_single( | |
| self.helium_deque, target_len_25_full | |
| ) | |
| fresh_frames = max(1, current_steps * 2) | |
| if int(feat_25_full.shape[0]) < fresh_frames: | |
| raise RuntimeError( | |
| f"Deque adapter output too short: got " | |
| f"{feat_25_full.shape[0]}, need {fresh_frames}" | |
| ) | |
| feat_25 = feat_25_full[-fresh_frames:].contiguous() | |
| feat_50 = feat_25 | |
| if (not self.live_sliding_window) and int(feat_25.shape[0]) != target_frames: | |
| feat_25 = F.interpolate( | |
| feat_25.T.unsqueeze(0), | |
| size=target_frames, | |
| mode="linear", | |
| align_corners=False, | |
| ).squeeze(0).T.contiguous() | |
| projected_a = self.fm._project_audio(feat_25.unsqueeze(0).float()) | |
| timings["adapter_ms"] = _ms(t_adapter) | |
| timings["helium_ms"] = timings["adapter_ms"] | |
| timings["helium_deque_filled"] = int(self.helium_deque_filled) | |
| data: dict = {"a_feat": feat_25.unsqueeze(0).float(), "ref_x": self.ref_x} | |
| emo_cond = self._emo_conditioning(int(target_frames)) | |
| if emo_cond: | |
| data.update(emo_cond) | |
| timings["emo_cls"] = int(emo_cond["_cls"]) | |
| timings["emo_inten"] = round(float(emo_cond["_inten"]), 3) | |
| emo_cond.pop("_cls", None) | |
| emo_cond.pop("_inten", None) | |
| if self.noise_buf is not None: | |
| if self.live_sliding_window: | |
| noise_start = max(0, self.abs_frame - int(self.slide_past_frames)) | |
| noise_end = noise_start + int(feat_25.shape[0]) | |
| else: | |
| noise_start = self.abs_frame | |
| noise_end = self.abs_frame + target_frames | |
| data["noise_init"] = self.noise_buf[:, noise_start:noise_end] | |
| t_fm = time.perf_counter() | |
| if self.live_sliding_window: | |
| motion = self.fm.sample( | |
| data, | |
| a_cfg_scale=float(self.args.a_cfg_scale), | |
| e_cfg_scale=float(getattr(self, "e_cfg_scale", 3.0)), | |
| nfe=int(self.args.nfe), | |
| ) | |
| else: | |
| motion, self.stream_state = self.fm.sample( | |
| data, | |
| a_cfg_scale=float(self.args.a_cfg_scale), | |
| e_cfg_scale=float(getattr(self, "e_cfg_scale", 3.0)), | |
| nfe=int(self.args.nfe), | |
| stream_state=self.stream_state, | |
| return_state=True, | |
| ) | |
| timings["fm_ms"] = _ms(t_fm) | |
| motion = motion.squeeze(0).detach() | |
| if self.live_sliding_window: | |
| start = int(self.slide_past_frames) | |
| motion = motion[start:start + target_frames] | |
| else: | |
| motion = motion[:target_frames] | |
| ref_blend = float(getattr(self.args, "motion_ref_blend", 0.0) or 0.0) | |
| if ref_blend > 0.0: | |
| ref_motion = self.ref_x.detach().float() | |
| if ref_motion.ndim == 3: | |
| ref_motion = ref_motion[0] | |
| if ref_motion.ndim == 1: | |
| ref_motion = ref_motion.unsqueeze(0) | |
| if int(ref_motion.shape[0]) == 1: | |
| ref_motion = ref_motion.expand(int(motion.shape[0]), -1) | |
| elif int(ref_motion.shape[0]) != int(motion.shape[0]): | |
| ref_motion = F.interpolate( | |
| ref_motion.T.unsqueeze(0), | |
| size=int(motion.shape[0]), | |
| mode="linear", | |
| align_corners=False, | |
| ).squeeze(0).T | |
| ref_motion = ref_motion.to(device=motion.device, dtype=motion.dtype) | |
| blend = max(0.0, min(1.0, ref_blend)) | |
| motion = motion.mul(1.0 - blend).add(ref_motion, alpha=blend) | |
| timings["helium_feat"] = helium.detach().cpu() | |
| timings["adapter_feat_50"] = feat_50.detach().cpu() | |
| timings["adapter_feat_25"] = feat_25.detach().cpu() | |
| timings["projected_audio"] = projected_a.squeeze(0).detach().cpu() | |
| timings["frames"] = int(motion.shape[0]) | |
| timings["abs_start"] = self.abs_frame | |
| self.abs_frame += timings["frames"] | |
| return motion, timings | |
| def _record_session_chunk(self, pcm_chunk: np.ndarray, motion: torch.Tensor, info: dict) -> None: | |
| self._session_audio_parts.append(np.asarray(pcm_chunk, dtype=np.float32).copy()) | |
| self._session_motion_parts.append(motion.detach().float().cpu().clone()) | |
| helium_feat = info.get("helium_feat") | |
| if isinstance(helium_feat, torch.Tensor): | |
| self._session_helium_parts.append(helium_feat.float().cpu().clone()) | |
| adapter_feat_50 = info.get("adapter_feat_50") | |
| if isinstance(adapter_feat_50, torch.Tensor): | |
| self._session_adapter_50_parts.append(adapter_feat_50.float().cpu().clone()) | |
| adapter_feat_25 = info.get("adapter_feat_25") | |
| if isinstance(adapter_feat_25, torch.Tensor): | |
| self._session_adapter_25_parts.append(adapter_feat_25.float().cpu().clone()) | |
| projected_audio = info.get("projected_audio") | |
| if isinstance(projected_audio, torch.Tensor): | |
| self._session_projected_audio_parts.append(projected_audio.float().cpu().clone()) | |
| self._session_chunk_rows.append({ | |
| "chunk": len(self._session_chunk_rows) + 1, | |
| "abs_start": int(info.get("abs_start", 0)), | |
| "frames": int(info.get("frames", int(motion.shape[0]))), | |
| "samples": int(len(pcm_chunk)), | |
| "helium_ms": float(info.get("helium_ms", 0.0)), | |
| "fm_ms": float(info.get("fm_ms", 0.0)), | |
| }) | |
| def _extract_wav2vec_raw_50hz(self, audio_24k: np.ndarray) -> torch.Tensor: | |
| arr = np.asarray(audio_24k, dtype=np.float32) | |
| if arr.ndim != 1 or arr.size == 0: | |
| return torch.empty((0, 768), dtype=torch.float32) | |
| wav = torch.from_numpy(arr).view(1, -1) | |
| wav16 = torchaudio.functional.resample(wav, TARGET_SR, WAV2VEC_SR).squeeze(0).contiguous().numpy() | |
| inputs = self.wav2vec_feature_extractor( | |
| wav16, | |
| sampling_rate=WAV2VEC_SR, | |
| return_tensors="pt", | |
| padding=True, | |
| ) | |
| kwargs = { | |
| "input_values": inputs.input_values.to(self.device), | |
| } | |
| if getattr(inputs, "attention_mask", None) is not None: | |
| kwargs["attention_mask"] = inputs.attention_mask.to(self.device) | |
| frontend = self.wav2vec_model.extract_projected_frontend(**kwargs) | |
| feat = self.wav2vec_model.encode_from_projected_frontend( | |
| frontend | |
| ).last_hidden_state.detach().float().cpu()[0].contiguous() | |
| return feat | |
| def dump_last_session(self, *, source: str = "") -> Optional[Path]: | |
| if not self.dump_motion or not self._session_motion_parts: | |
| return None | |
| self.dump_dir.mkdir(parents=True, exist_ok=True) | |
| session_dir = self.dump_dir / "last_session" | |
| session_dir.mkdir(parents=True, exist_ok=True) | |
| motion = torch.cat(self._session_motion_parts, dim=0).contiguous() | |
| audio = np.concatenate(self._session_audio_parts, axis=0) if self._session_audio_parts else np.empty(0, dtype=np.float32) | |
| motion_path = session_dir / "full_motion.pt" | |
| helium_path = session_dir / "full_helium_raw.pt" | |
| adapter_50_path = session_dir / "full_adapter_w2v_50hz.pt" | |
| adapter_25_path = session_dir / "full_adapter_w2v_25fps.pt" | |
| wav2vec_50_path = session_dir / "full_wav2vec_50hz.pt" | |
| projected_audio_path = session_dir / "full_projected_audio_32.pt" | |
| audio_path = session_dir / "full_moshi_reply_24k.wav" | |
| live_tokens_path = session_dir / "live_mimi_tokens.pt" | |
| reply_events_path = session_dir / "reply_events.jsonl" | |
| reply_text_path = session_dir / "reply_text.txt" | |
| meta_path = session_dir / "meta.json" | |
| helium = None | |
| adapter_50 = None | |
| adapter_25 = None | |
| wav2vec_50 = None | |
| projected_audio = None | |
| live_tokens = None | |
| if self._session_helium_parts: | |
| helium = torch.cat(self._session_helium_parts, dim=0).contiguous() | |
| if self._session_adapter_50_parts: | |
| adapter_50 = torch.cat(self._session_adapter_50_parts, dim=0).contiguous() | |
| if self._session_adapter_25_parts: | |
| adapter_25 = torch.cat(self._session_adapter_25_parts, dim=0).contiguous() | |
| if self._session_projected_audio_parts: | |
| projected_audio = torch.cat(self._session_projected_audio_parts, dim=0).contiguous() | |
| if self._session_live_token_parts: | |
| live_tokens = torch.cat(self._session_live_token_parts, dim=2).contiguous() | |
| if audio.size > 0: | |
| wav2vec_50 = self._extract_wav2vec_raw_50hz(audio) | |
| torch.save({ | |
| "motion": motion, | |
| "chunks": self._session_chunk_rows, | |
| "fps": float(self.fps), | |
| "audio_chunk_sec": float(self.audio_chunk_sec), | |
| "fm_chunk_frames": int(self.fm_chunk_frames), | |
| "audio_feat_dim": int(getattr(self.args, "audio_feat_dim", 768)), | |
| "audio_adapter_dim": int(getattr(self.args, "audio_adapter_dim", 512)), | |
| "wav2vec_sec": float(self.args.wav2vec_sec), | |
| "ref_path": str(self.args.ref_path), | |
| "generator_path": str(self.args.generator_path), | |
| "renderer_path": str(self.args.renderer_path), | |
| "source": source, | |
| }, motion_path) | |
| if helium is not None: | |
| torch.save({ | |
| "helium": helium, | |
| "chunks": self._session_chunk_rows, | |
| "fps": float(self.fps), | |
| "audio_chunk_sec": float(self.audio_chunk_sec), | |
| "fm_chunk_frames": int(self.fm_chunk_frames), | |
| "audio_feat_dim": int(getattr(self.args, "audio_feat_dim", 4096)), | |
| "source": source, | |
| }, helium_path) | |
| if adapter_50 is not None: | |
| torch.save({ | |
| "adapter_feat_50": adapter_50, | |
| "chunks": self._session_chunk_rows, | |
| "source": source, | |
| }, adapter_50_path) | |
| if adapter_25 is not None: | |
| torch.save({ | |
| "adapter_feat_25": adapter_25, | |
| "chunks": self._session_chunk_rows, | |
| "fps": float(self.fps), | |
| "source": source, | |
| }, adapter_25_path) | |
| if wav2vec_50 is not None: | |
| torch.save({ | |
| "wav2vec_50hz": wav2vec_50, | |
| "chunks": self._session_chunk_rows, | |
| "sample_rate": int(WAV2VEC_SR), | |
| "source": source, | |
| }, wav2vec_50_path) | |
| if projected_audio is not None: | |
| torch.save({ | |
| "projected_audio": projected_audio, | |
| "chunks": self._session_chunk_rows, | |
| "fps": float(self.fps), | |
| "source": source, | |
| }, projected_audio_path) | |
| if live_tokens is not None: | |
| torch.save({ | |
| "live_mimi_tokens": live_tokens, | |
| "chunks": self._session_chunk_rows, | |
| "source": source, | |
| }, live_tokens_path) | |
| if audio.size > 0: | |
| torchaudio.save(str(audio_path), torch.from_numpy(audio).view(1, -1), TARGET_SR) | |
| if self._session_reply_events: | |
| with reply_events_path.open("w", encoding="utf-8") as f: | |
| for row in self._session_reply_events: | |
| f.write(json.dumps(row, ensure_ascii=False) + "\n") | |
| reply_text = "".join(str(row.get("piece", "")) for row in self._session_reply_events) | |
| reply_text_path.write_text(reply_text, encoding="utf-8") | |
| meta_path.write_text(json.dumps({ | |
| "source": source, | |
| "session_started_wall": float(self._session_started_wall), | |
| "fps": float(self.fps), | |
| "audio_chunk_sec": float(self.audio_chunk_sec), | |
| "fm_chunk_frames": int(self.fm_chunk_frames), | |
| "motion_frames": int(motion.shape[0]), | |
| "helium_frames": int(helium.shape[0]) if helium is not None else 0, | |
| "adapter_50_frames": int(adapter_50.shape[0]) if adapter_50 is not None else 0, | |
| "adapter_25_frames": int(adapter_25.shape[0]) if adapter_25 is not None else 0, | |
| "wav2vec_50_frames": int(wav2vec_50.shape[0]) if wav2vec_50 is not None else 0, | |
| "projected_audio_frames": int(projected_audio.shape[0]) if projected_audio is not None else 0, | |
| "audio_samples": int(audio.shape[0]), | |
| "audio_seconds": float(audio.shape[0] / TARGET_SR) if audio.size > 0 else 0.0, | |
| "reply_text_chars": int(sum(len(str(row.get("piece", ""))) for row in self._session_reply_events)), | |
| "reply_events": int(len(self._session_reply_events)), | |
| "chunks": self._session_chunk_rows, | |
| "ref_path": str(self.args.ref_path), | |
| "generator_path": str(self.args.generator_path), | |
| "renderer_path": str(self.args.renderer_path), | |
| }, indent=2), encoding="utf-8") | |
| print(f"[liveTryHeliumFM] dumped last session -> {session_dir}", flush=True) | |
| return session_dir | |
| def _extract_motion_tensor_from_payload(self, payload, path: str) -> torch.Tensor: | |
| if isinstance(payload, torch.Tensor): | |
| motion = payload | |
| elif isinstance(payload, dict): | |
| candidates = ["motion", "motion_latents", "latents", "full_motion", "pred_motion", "x"] | |
| motion = None | |
| for key in candidates: | |
| value = payload.get(key) | |
| if isinstance(value, torch.Tensor): | |
| motion = value | |
| break | |
| if motion is None: | |
| tensor_items = [ | |
| value | |
| for value in payload.values() | |
| if isinstance(value, torch.Tensor) and value.ndim >= 2 and value.shape[-1] == int(self.args.dim_w) | |
| ] | |
| if tensor_items: | |
| motion = tensor_items[0] | |
| if motion is None: | |
| raise ValueError( | |
| f"No motion tensor found in blink_motion_path={path}. " | |
| f"Available keys={list(payload.keys())[:20]}" | |
| ) | |
| else: | |
| raise TypeError(f"Unsupported blink motion payload type {type(payload)} from {path}") | |
| motion = motion.detach().float().cpu() | |
| if motion.ndim == 3 and motion.shape[0] == 1: | |
| motion = motion[0] | |
| if motion.ndim != 2 or motion.shape[-1] != int(self.args.dim_w): | |
| raise ValueError( | |
| f"Blink motion must have shape (T,{int(self.args.dim_w)}) or (1,T,{int(self.args.dim_w)}); " | |
| f"got {tuple(motion.shape)} from {path}" | |
| ) | |
| return motion.contiguous() | |
| def _make_eye_mask(self, height: int, width: int) -> torch.Tensor: | |
| yy = torch.linspace(0.0, 1.0, int(height), device=self.device, dtype=self.dtype).view(1, 1, height, 1) | |
| xx = torch.linspace(0.0, 1.0, int(width), device=self.device, dtype=self.dtype).view(1, 1, 1, width) | |
| center_y = float(getattr(self.args, "eye_center_y", 0.405)) | |
| radius_x = max(float(getattr(self.args, "eye_radius_x", 0.145)), 1e-6) | |
| radius_y = max(float(getattr(self.args, "eye_radius_y", 0.070)), 1e-6) | |
| feather = max(float(getattr(self.args, "eye_feather", 0.10)), 1e-6) | |
| mask = torch.zeros(1, 1, height, width, device=self.device, dtype=self.dtype) | |
| for center_x in ( | |
| float(getattr(self.args, "eye_left_x", 0.36)), | |
| float(getattr(self.args, "eye_right_x", 0.64)), | |
| ): | |
| dist = ((xx - center_x) / radius_x).square() + ((yy - center_y) / radius_y).square() | |
| ellipse = ((1.0 + feather) - dist).clamp(0.0, feather) / feather | |
| mask = torch.maximum(mask, ellipse) | |
| return mask.clamp(0.0, 1.0).contiguous() | |
| def _init_eye_blink_composite(self) -> None: | |
| blink_path = str(getattr(self.args, "blink_motion_path", "") or "") | |
| if not blink_path: | |
| raise ValueError("--enable_eye_blink_composite requires --blink_motion_path") | |
| if not Path(blink_path).is_file(): | |
| raise FileNotFoundError(f"blink_motion_path does not exist: {blink_path}") | |
| payload = torch.load(blink_path, map_location="cpu") | |
| blink_motion = self._extract_motion_tensor_from_payload(payload, blink_path) | |
| blink_maps_parts: list[list[torch.Tensor]] = [] | |
| chunk = max(1, int(getattr(self.args, "render_sub_batch", 8))) | |
| for start in range(0, int(blink_motion.shape[0]), chunk): | |
| sub = blink_motion[start:start + chunk].to(self.device, dtype=self.dtype) | |
| g_sub = self.g_r.expand(int(sub.shape[0]), -1) | |
| ta_b = self.renderer.adapt(sub, g_sub) | |
| maps_b = self.renderer.latent_token_decoder(ta_b) | |
| if not blink_maps_parts: | |
| blink_maps_parts = [[] for _ in range(len(maps_b))] | |
| for idx, map_b in enumerate(maps_b): | |
| blink_maps_parts[idx].append(map_b.detach()) | |
| self._blink_maps = tuple(torch.cat(parts, dim=0).contiguous() for parts in blink_maps_parts) | |
| self._eye_masks = tuple(self._make_eye_mask(m.shape[-2], m.shape[-1]) for m in self._blink_maps) | |
| _sync_cuda() | |
| shapes = [tuple(m.shape) for m in self._blink_maps] | |
| print( | |
| f"[liveTryHeliumFM][blink] cached blink maps from {blink_path}: " | |
| f"frames={int(blink_motion.shape[0])} shapes={shapes}", | |
| flush=True, | |
| ) | |
| def _composite_eye_blink_maps( | |
| self, | |
| current_maps: tuple[torch.Tensor, ...] | list[torch.Tensor], | |
| start_frame: int, | |
| num_frames: int, | |
| ) -> tuple[torch.Tensor, ...]: | |
| if self._blink_maps is None or self._eye_masks is None: | |
| return tuple(current_maps) | |
| blink_len = int(self._blink_maps[0].shape[0]) | |
| if blink_len <= 0: | |
| return tuple(current_maps) | |
| indices = (torch.arange(int(num_frames), device=self.device) + int(start_frame)) % blink_len | |
| composited: list[torch.Tensor] = [] | |
| for cur, blink_all, mask in zip(current_maps, self._blink_maps, self._eye_masks): | |
| blink = blink_all.index_select(0, indices).to(device=cur.device, dtype=cur.dtype) | |
| mask = mask.to(device=cur.device, dtype=cur.dtype) | |
| composited.append(blink * mask + cur * (1.0 - mask)) | |
| return tuple(composited) | |
| def _render_motion(self, motion: torch.Tensor) -> tuple[np.ndarray, dict]: | |
| timings: dict = {} | |
| t_total = time.perf_counter() | |
| motion = motion.to(self.device, dtype=self.dtype) | |
| n = int(motion.shape[0]) | |
| g_r_sub = self.g_r.expand(n, -1) | |
| m_r_sub = tuple(m.expand(n, -1, -1, -1) for m in self.m_r) | |
| f_r_sub = [f.expand(n, -1, -1, -1) for f in self.f_r] | |
| render_start = self._render_frame_cursor | |
| fused = getattr(self.renderer, '_fused_render', None) | |
| if self.eye_blink_enabled: | |
| fused = None | |
| if fused is not None: | |
| frames = fused(motion, g_r_sub, m_r_sub, f_r_sub) | |
| else: | |
| ta_c = self.renderer.adapt(motion, g_r_sub) | |
| m_c = self.renderer.latent_token_decoder(ta_c) | |
| if self.eye_blink_enabled: | |
| m_c = self._composite_eye_blink_maps(m_c, render_start, n) | |
| frames = self.renderer.decode(m_c, m_r_sub, f_r_sub) | |
| self._render_frame_cursor += n | |
| frames_np = frames.detach().float().clamp(0, 1).mul(255).to(torch.uint8) | |
| frames_np = frames_np.permute(0, 2, 3, 1).contiguous().cpu().numpy() | |
| timings["total_ms"] = _ms(t_total) | |
| return frames_np, timings | |
| def render_and_encode_subbatch( | |
| self, | |
| motion_sub: torch.Tensor, | |
| audio_slices: list[np.ndarray], | |
| abs_start: int, | |
| text_payload: str, | |
| avatar_chunk_id: int, | |
| total_gen_ms: float, | |
| ) -> list[dict]: | |
| """Render a sub-batch of frames, JPEG-encode in parallel, return packet dicts.""" | |
| frames_np, _render_info = self._render_motion(motion_sub) | |
| jpeg_futures = [] | |
| for frame_rgb in frames_np: | |
| jpeg_futures.append( | |
| self._jpeg_pool.submit(encode_jpeg_bytes, frame_rgb, self.jpeg_quality) | |
| ) | |
| packets = [] | |
| gen_ms_i = int(round(float(total_gen_ms))) | |
| sr_i = int(round(float(TARGET_SR))) | |
| for j, fut in enumerate(jpeg_futures): | |
| idx = abs_start + j | |
| audio_slice = audio_slices[j] if j < len(audio_slices) else np.zeros( | |
| int(round(TARGET_SR / self.fps)), dtype=np.float32 | |
| ) | |
| jpeg_bytes = fut.result() | |
| output_audio_codec = str( | |
| getattr(self.args, "output_audio_codec", "pcm") | |
| ).lower() | |
| pcm_b = ( | |
| b"" | |
| if output_audio_codec == "opus" | |
| else _pcm_f32_to_i16_bytes(audio_slice) | |
| ) | |
| if os.environ.get("SESSION_DUMP") == "1" and idx < 150: | |
| try: | |
| _dir = "/workspace/session_frames" | |
| os.makedirs(_dir, exist_ok=True) | |
| with open(f"{_dir}/f{idx:04d}.jpg", "wb") as _f: | |
| _f.write(jpeg_bytes) | |
| except Exception: | |
| pass | |
| blob = _wsbin.pack_av_frame( | |
| idx, | |
| idx + 1, | |
| gen_ms_i, | |
| sr_i, | |
| jpeg_bytes, | |
| pcm_b, | |
| text_payload, | |
| int(avatar_chunk_id), | |
| ) | |
| packets.append( | |
| { | |
| "frame_number": idx, | |
| "ws_kind": "bytes", | |
| "data": blob, | |
| "audio_pcm": np.asarray(audio_slice, dtype=np.float32).copy(), | |
| "t_ready": time.perf_counter(), | |
| } | |
| ) | |
| return packets | |
| def audio_slice(self, frame_idx: int) -> np.ndarray: | |
| frame_samples = int(round(TARGET_SR / self.fps)) | |
| src = self.audio_pcm | |
| if src is None and self._session_audio_parts: | |
| # live reply mode: the audio is recorded per chunk instead | |
| src = np.concatenate(self._session_audio_parts, axis=0) | |
| if src is None: | |
| return np.zeros(frame_samples, dtype=np.float32) | |
| start = frame_idx * frame_samples | |
| chunk = src[start:start + frame_samples] | |
| if chunk.shape[0] < frame_samples: | |
| chunk = np.pad(chunk, (0, frame_samples - chunk.shape[0])) | |
| return chunk | |
| # --------------------------------------------------------------------------- | |
| # WebSocket streaming coroutine (file-driven mode, unchanged) | |
| # --------------------------------------------------------------------------- | |
| async def stream_from_file(ws: WebSocket, engine: LiveHeliumFMEngine) -> None: | |
| """Simulate live streaming using --audio_path, sending frames back over WS.""" | |
| if engine.audio_pcm is None: | |
| await ws.send_json({"type": "error", "msg": "No --audio_path given for file-streaming mode"}) | |
| return | |
| audio = engine.audio_pcm | |
| lookahead_chunks = max(0, int(getattr(engine.args, "file_chunk_lookahead", 0))) | |
| total_chunks = int(np.ceil(len(audio) / engine.audio_chunk_samples)) | |
| start_wall = time.perf_counter() | |
| emitted = 0 | |
| print( | |
| f"[liveTryHeliumFM] stream_from_file: {total_chunks} chunks " | |
| f"lookahead={lookahead_chunks}", | |
| flush=True, | |
| ) | |
| async def _emit_motion_chunk( | |
| motion: torch.Tensor, | |
| fm_info: dict, | |
| chunk_label: int, | |
| emitted_so_far: int, | |
| ) -> int: | |
| helium_ms = float(fm_info["helium_ms"]) | |
| fm_ms = float(fm_info["fm_ms"]) | |
| n_frames = int(motion.shape[0]) | |
| all_frames_np: list[np.ndarray] = [] | |
| render_ms = 0.0 | |
| for sb_start in range(0, n_frames, engine.render_sub_batch): | |
| sub = motion[sb_start:sb_start + engine.render_sub_batch].to( | |
| engine.device, dtype=engine.dtype | |
| ) | |
| frames_np, render_info = engine._render_motion(sub) | |
| render_ms += float(render_info["total_ms"]) | |
| all_frames_np.extend(frames_np) | |
| print( | |
| f"[liveTryHeliumFM][chunk#{chunk_label}] " | |
| f"helium={helium_ms:.0f}ms fm={fm_ms:.0f}ms " | |
| f"render={render_ms:.0f}ms frames={n_frames} " | |
| f"emo={fm_info.get('emo_cls', '-')}/{fm_info.get('emo_inten', '-')} " | |
| f"abs_start={fm_info['abs_start']}", | |
| flush=True, | |
| ) | |
| for j, frame_rgb in enumerate(all_frames_np): | |
| idx = emitted_so_far + j | |
| chunk_id = idx + 1 | |
| audio_b64 = _pcm_f32_to_i16_b64(engine.audio_slice(idx)) | |
| jpeg_b64 = encode_jpeg_b64(frame_rgb, engine.jpeg_quality) | |
| if str(getattr(self.args, "output_audio_codec", "pcm")).lower() != "pcm": | |
| # with pcm the frames carry their own audio -> sending it again | |
| # as JSON made the client schedule both, inserting silence into | |
| # the playback cursor and pushing the audio ahead of the mouth | |
| await ws.send_json({ | |
| "type": "chunk_audio", | |
| "chunk_id": chunk_id, | |
| "sample_rate": TARGET_SR, | |
| "pcm_s16le_b64": audio_b64, | |
| }) | |
| if os.environ.get("SESSION_DUMP") == "1" and chunk_id <= 120: | |
| try: | |
| import base64 as _b64 | |
| _dir = "/workspace/session_frames" | |
| os.makedirs(_dir, exist_ok=True) | |
| with open(f"{_dir}/f{chunk_id:04d}.jpg", "wb") as _f: | |
| _f.write(_b64.b64decode(jpeg_b64)) | |
| except Exception: | |
| pass | |
| await ws.send_json({ | |
| "type": "chunk_frame", | |
| "chunk_id": chunk_id, | |
| "frame_idx": 0, | |
| "jpeg_b64": jpeg_b64, | |
| "moshi_text": ( | |
| f"Helium+FM | chunk#{chunk_label} " | |
| f"helium={helium_ms:.0f}ms fm={fm_ms:.0f}ms " | |
| f"render={render_ms:.0f}ms" | |
| ), | |
| "server_fps": round(float(engine.fps), 1), | |
| "chunks_done": chunk_label, | |
| }) | |
| target_t = start_wall + (idx + 1) / engine.fps | |
| await asyncio.sleep(max(0.0, target_t - time.perf_counter())) | |
| return emitted_so_far + len(all_frames_np) | |
| if lookahead_chunks <= 0: | |
| for chunk_idx in range(total_chunks): | |
| pcm_chunk = audio[ | |
| chunk_idx * engine.audio_chunk_samples:(chunk_idx + 1) * engine.audio_chunk_samples | |
| ] | |
| pcm_real = pcm_chunk.copy() | |
| target_frames = int(round(pcm_chunk.shape[0] * engine.fps / TARGET_SR)) | |
| if pcm_chunk.shape[0] < engine.audio_chunk_samples: | |
| pcm_chunk = np.pad(pcm_chunk, (0, engine.audio_chunk_samples - pcm_chunk.shape[0])) | |
| motion, fm_info = engine._process_pcm_chunk(pcm_chunk, target_frames) | |
| engine._record_session_chunk(pcm_real, motion, fm_info) | |
| emitted = await _emit_motion_chunk(motion, fm_info, chunk_idx + 1, emitted) | |
| else: | |
| pending_real: list[np.ndarray] = [] | |
| prefix_audio = np.empty(0, dtype=np.float32) | |
| chunk_counter = 0 | |
| def _process_exact_pending(pcm_real: np.ndarray) -> tuple[torch.Tensor, dict]: | |
| nonlocal prefix_audio | |
| target_frames = int(round(pcm_real.shape[0] * engine.fps / TARGET_SR)) | |
| t0 = time.perf_counter() | |
| helium = engine.extractor.extract_exact_chunk_from_prefix( | |
| prefix_audio, | |
| engine.abs_frame, | |
| target_frames, | |
| ) | |
| _sync_cuda() | |
| fm_info: dict = {"helium_ms": _ms(t0)} | |
| motion, sample_info = engine._sample_motion_from_helium(helium, target_frames) | |
| fm_info.update(sample_info) | |
| return motion, fm_info | |
| for chunk_idx in range(total_chunks): | |
| pcm_real = audio[ | |
| chunk_idx * engine.audio_chunk_samples:(chunk_idx + 1) * engine.audio_chunk_samples | |
| ].copy() | |
| prefix_audio = np.concatenate([prefix_audio, pcm_real], axis=0) | |
| pending_real.append(pcm_real) | |
| while len(pending_real) > lookahead_chunks: | |
| chunk_counter += 1 | |
| oldest = pending_real.pop(0) | |
| motion, fm_info = _process_exact_pending(oldest) | |
| engine._record_session_chunk(oldest, motion, fm_info) | |
| emitted = await _emit_motion_chunk(motion, fm_info, chunk_counter, emitted) | |
| while pending_real: | |
| chunk_counter += 1 | |
| oldest = pending_real.pop(0) | |
| motion, fm_info = _process_exact_pending(oldest) | |
| engine._record_session_chunk(oldest, motion, fm_info) | |
| emitted = await _emit_motion_chunk(motion, fm_info, chunk_counter, emitted) | |
| await ws.send_json({"type": "stream_end", "total_frames": emitted}) | |
| engine.dump_last_session(source=str(engine.args.audio_path)) | |
| print(f"[liveTryHeliumFM] stream done: {emitted} frames", flush=True) | |
| # --------------------------------------------------------------------------- | |
| # Options | |
| # --------------------------------------------------------------------------- | |
| _EMO_RULES = [ | |
| (0, ["unacceptable", "ridiculous", "furious", "angry", "never", "stop it"]), | |
| (1, ["disgusting", "gross", "awful", "revolting", "sickening"]), | |
| (2, ["afraid", "scared", "worried", "danger", "fear", "terrified"]), | |
| (3, ["great", "wonderful", "glad", "happy", "love", "fantastic", "excellent", "yay", "!" ]), | |
| (5, ["sorry", "sad", "unfortunate", "loss", "miss", "bad news", "heartbreaking"]), | |
| (6, ["wow", "amazing", "surprising", "really?", "unbelievable", "oh my"]), | |
| (7, ["whatever", "sure you did", "as if", "right", "clearly"]), | |
| ] | |
| def _text_emotion(text: str) -> tuple[int, float]: | |
| """Cheap demo-grade semantic emotion: keyword match -> (class, weight).""" | |
| t = (text or "").lower() | |
| best, score = 4, 0 | |
| for cls, words in _EMO_RULES: | |
| hits = sum(1 for w in words if w in t) | |
| if hits > score: | |
| best, score = cls, hits | |
| return best, min(1.0, 0.45 + 0.2 * score) | |
| _EMO_NAMES = { | |
| "anger": 0, "angry": 0, "disgust": 1, "fear": 2, "afraid": 2, | |
| "happy": 3, "joy": 3, "neutral": 4, "sad": 5, "sadness": 5, | |
| "surprise": 6, "surprised": 6, "contempt": 7, "shy": 7, | |
| } | |
| def _smoothstep(x: float) -> float: | |
| return x * x * (3.0 - 2.0 * x) | |
| class LiveEmotionState: | |
| """Emotion conditioning for the live IMTalker generator. | |
| Mirrors the offline conversation demos that worked (render_conversation.py): | |
| * one-hot emotion per frame, | |
| * smoothstep blend across a switch over `blend` frames (15 = 0.6 s), | |
| * intensity eased in/out over `ramp` frames (12 = 0.48 s), | |
| * intensities around 0.3-0.5 (1.0 saturates), | |
| * emo_scale 1.0, e_cfg ~2.5. | |
| The schedule is keyed by absolute reply-audio time, so a chunk gets the | |
| exact per-frame slice it would have had in the single continuous render, | |
| instead of a chunk-constant class that pops at the boundary. | |
| """ | |
| def __init__(self, args) -> None: | |
| self._args = args | |
| self.neutral_as_happy = int(getattr(args, "neutral_as_happy", 1)) | |
| self.enable = bool(int(getattr(args, "emo_enable", 1))) | |
| self.mode = str(getattr(args, "emo_mode", "auto") or "auto") | |
| self.manual_cls = int(getattr(args, "emo_class", -1)) | |
| self.manual_inten = float(getattr(args, "emo_inten", 0.5)) | |
| self.gain = float(getattr(args, "emo_gain", 1.0)) | |
| # 0.8+ is caricature; conversations look best around 0.3-0.55 | |
| self.inten_max = float(getattr(args, "emo_inten_max", 0.55)) | |
| self.ema = float(getattr(args, "emo_ema", 0.35)) | |
| self.fps = float(getattr(args, "fps", 25.0)) | |
| self.blend = max(0, int(getattr(args, "emo_blend_frames", 15))) | |
| self.ramp = max(0, int(getattr(args, "emo_ramp_frames", 12))) | |
| self.emo_scale = float(getattr(args, "emo_scale", 1.0)) | |
| self.t = 0.0 | |
| # autotext: emotion turns learned from the reply TEXT stream | |
| self.turns: list = [] # (start_sec, cls, inten) | |
| self.pending_text = "" | |
| self._last_piece_t = None | |
| self._last_piece_wall = None | |
| self.lookahead_ms = float(getattr(args, "emo_lookahead_ms", 400.0)) | |
| self.active = True | |
| self.cls = 4 | |
| self.inten = 0.0 | |
| self.prob = np.zeros(8, np.float32) | |
| self.prob[4] = 1.0 | |
| self._prev_vec = np.zeros(8, np.float32) | |
| self._prev_vec[4] = 1.0 | |
| self._prev_inten = 0.0 | |
| # script entries: (start_sec, class, intensity) | |
| self.script: list[tuple[float, int, float]] = [] | |
| raw = str(getattr(args, "emo_script", "") or "") | |
| for part in raw.split(","): | |
| part = part.strip() | |
| if not part or "@" not in part: | |
| continue | |
| name, rest = part.split("@", 1) | |
| name = name.strip().lower() | |
| it = None | |
| if ":" in rest: | |
| rest, it_s = rest.split(":", 1) | |
| try: | |
| it = float(it_s) | |
| except ValueError: | |
| it = None | |
| try: | |
| t_s = float(rest) | |
| except ValueError: | |
| continue | |
| cls = int(name) if name.lstrip("-").isdigit() else _EMO_NAMES.get(name, 4) | |
| if not (0 <= cls <= 7): | |
| cls = 4 | |
| self.script.append( | |
| (t_s, cls, float(self.manual_inten if it is None else it)) | |
| ) | |
| self.script.sort(key=lambda x: x[0]) | |
| def set_manual(self, mode: str = "manual", cls: int = -1, inten: float = 0.5): | |
| self.mode = str(mode) | |
| self.manual_cls = int(cls) | |
| self.manual_inten = float(inten) | |
| def reset(self) -> None: | |
| self.t = 0.0 | |
| self.active = True | |
| self.cls = 4 | |
| self.inten = 0.0 | |
| self.prob = np.zeros(8, np.float32) | |
| self.prob[4] = 1.0 | |
| self._prev_vec = np.zeros(8, np.float32) | |
| self._prev_vec[4] = 1.0 | |
| self._prev_inten = 0.0 | |
| def update(self, used_audio, used_steps, assistant_active: bool) -> None: | |
| """Chunk-level target for auto mode (script mode ignores this).""" | |
| self.active = bool(assistant_active) | |
| if self.mode != "auto" or not self.enable: | |
| return | |
| if not self.active: | |
| tgt_cls, tgt_inten = 4, 0.0 | |
| else: | |
| text = " ".join( | |
| str(s.get("audio_text") or s.get("sampled_text") or s.get("piece") or "") | |
| for s in (used_steps or []) | |
| ) | |
| cls, w = _text_emotion(text) | |
| rms = ( | |
| float(np.mean([float(s.get("reply_rms", 0.0)) for s in used_steps])) | |
| if used_steps else 0.0 | |
| ) | |
| tgt_cls = cls | |
| tgt_inten = min(1.0, max(0.0, (0.25 + 2.5 * rms) * w)) * self.gain | |
| onehot = np.zeros(8, np.float32) | |
| onehot[tgt_cls] = 1.0 | |
| self.prob = (1.0 - self.ema) * self.prob + self.ema * onehot | |
| self.inten = (1.0 - self.ema) * self.inten + self.ema * tgt_inten | |
| self.cls = int(self.prob.argmax()) | |
| def _schedule_window(self, t_start: float, n_frames: int): | |
| """Per-frame (one-hot, intensity) over [t_start, t_start + n/fps). | |
| One clean crossfade per boundary: the first `blend` frames of a segment | |
| move from the previous emotion to the new one, then hold; intensity | |
| eases from the previous segment's value over `ramp` frames. (The offline | |
| helper blends forward into already-blended frames, which re-triggers on | |
| short segments and washes the emotion out.) | |
| """ | |
| if not self.script: | |
| return None | |
| fps = self.fps | |
| n = int(n_frames) | |
| g0 = int(round(t_start * fps)) | |
| frames = g0 + np.arange(n) | |
| sch = np.zeros((n, 8), np.float32) | |
| inten = np.zeros(n, np.float32) | |
| starts = [int(round(s[0] * fps)) for s in self.script] | |
| idx = np.searchsorted(starts, frames, side="right") - 1 | |
| idx = np.clip(idx, 0, len(self.script) - 1) | |
| for i in range(len(self.script)): | |
| sel = np.where(idx == i)[0] | |
| if sel.size == 0: | |
| continue | |
| off = frames[sel] - starts[i] | |
| p = np.clip((off + 1) / (self.blend + 1), 0.0, 1.0) | |
| a_b = p * p * (3.0 - 2.0 * p) | |
| q = np.clip((off + 1) / (self.ramp + 1), 0.0, 1.0) | |
| a_r = q * q * (3.0 - 2.0 * q) | |
| prev_cls = self.script[i - 1][1] if i > 0 else 4 | |
| prev_it = self.script[i - 1][2] if i > 0 else 0.0 | |
| cls, it = self.script[i][1], self.script[i][2] | |
| prev_oh = np.zeros(8, np.float32) | |
| prev_oh[prev_cls] = 1.0 | |
| cur_oh = np.zeros(8, np.float32) | |
| cur_oh[cls] = 1.0 | |
| sch[sel] = ( | |
| (1.0 - a_b)[:, None] * prev_oh[None, :] | |
| + a_b[:, None] * cur_oh[None, :] | |
| ) | |
| inten[sel] = (1.0 - a_r) * prev_it + a_r * it | |
| return sch, inten | |
| # ---------------- autotext: emotion from the reply text stream ---------------- | |
| _LEX = { | |
| 3: ("great", "good to see", "love", "awesome", "congrats", "glad", "excited", | |
| "perfect", "spirit", "good luck", "enjoy", "nice", "happy for"), | |
| 6: ("really?", "wow", "what happened", "unbelievable", "seriously", "no way", | |
| "won't believe", "guess what"), | |
| 0: ("worse", "damn", "annoying", "frustrat", "angry", "ridiculous", | |
| "moved the whole", "screwed", "fed up"), | |
| 1: ("gross", "awful", "terrible", "disgust", "yuck", "nasty", | |
| "can't stand", "boring"), | |
| 5: ("sorry", "rough", "tough", "unfortunately", "unfair", "poor you", | |
| "okay with that", "hang in"), | |
| } | |
| _LEX_ORDER = (6, 1, 0, 5, 3) | |
| # HF label -> our class index (0 Angry .. 7 Contempt) | |
| _HF_MAP = { | |
| "anger": 0, "disgust": 1, "fear": 2, "joy": 3, "neutral": 4, | |
| "sadness": 5, "surprise": 6, "surprised": 6, "happiness": 3, | |
| "sad": 5, "happy": 3, "angry": 0, "contempt": 7, | |
| } | |
| _CLF = None | |
| _CLF_TRIED = False | |
| _CLF_BUSY = False | |
| def _load_clf(self): | |
| """Load the text-emotion classifier on CPU (GPU is busy rendering).""" | |
| if self._CLF is not None or type(self)._CLF_TRIED: | |
| return | |
| type(self)._CLF_TRIED = True | |
| try: | |
| from transformers import pipeline | |
| import torch as _torch | |
| _torch.set_num_threads(2) | |
| type(self)._CLF = pipeline( | |
| "text-classification", | |
| model="j-hartmann/emotion-english-distilroberta-base", | |
| top_k=None, device=-1, # CPU: never contend with the renderer | |
| ) | |
| # warm-up (first inference is much slower than the rest) | |
| for _ in range(2): | |
| type(self)._CLF("warm up sentence for the emotion classifier") | |
| print("[emo-autotext] CPU emotion classifier loaded + warmed", flush=True) | |
| except Exception as e: | |
| print(f"[emo-autotext] classifier load failed ({e}); using lexicon", flush=True) | |
| type(self)._CLF = None | |
| def _ensure_clf_worker(self): | |
| if getattr(self, "_clf_worker", None) is not None: | |
| return | |
| import threading | |
| import queue as _queue | |
| self._clf_worker_q = _queue.Queue() | |
| self._clf_lock = threading.Lock() | |
| def _worker(): | |
| self._load_clf() | |
| while True: | |
| item = self._clf_worker_q.get() | |
| if item is None: | |
| break | |
| text, t_now = item | |
| try: | |
| cls, inten = self._classify_text(text) | |
| if int(getattr(self._args, "neutral_as_happy", 1)) and cls == 4: | |
| cls = 3 | |
| inten = min(max(inten, 0.22), 0.30) | |
| except Exception as _e: | |
| print(f"[emo-autotext] classify error: {_e}", flush=True) | |
| cls, inten = 4, 0.25 | |
| dur = min(6.0, max(1.0, len(text) * 0.055)) | |
| start = t_now - dur | |
| with self._clf_lock: | |
| if self.turns: | |
| start = max(start, self.turns[-1][0] + 1.0) | |
| if self.turns and self.turns[-1][1] == cls: | |
| self.turns[-1] = (min(self.turns[-1][0], start), cls, | |
| max(self.turns[-1][2], inten)) | |
| else: | |
| self.turns.append((start, cls, inten)) | |
| print(f"[emo-autotext] turn@{start:.1f}s cls={cls} " | |
| f"inten={inten:.2f} | {text[:48]!r}", flush=True) | |
| self._clf_worker = threading.Thread(target=_worker, daemon=True) | |
| self._clf_worker.start() | |
| def on_reply_piece(self, piece: str, t_reply: float = 0.0) -> None: | |
| """Feed the streaming reply text (called once per text token).""" | |
| if self.mode != "autotext" or not self.enable or not piece: | |
| return | |
| import time as _time | |
| now = _time.perf_counter() | |
| # PersonaPlex text often has NO punctuation, so a pause is the only | |
| # sentence boundary. Use WALL-CLOCK time: self.t only advances once per | |
| # 2 s chunk (after this check), so comparing against it never fires. | |
| if (self.pending_text.strip() and self._last_piece_wall is not None | |
| and (now - self._last_piece_wall) > 0.9): | |
| self._finalize_sentence() | |
| self.pending_text += piece | |
| self._last_piece_t = self.t | |
| self._last_piece_wall = now | |
| s = self.pending_text.strip() | |
| if s and s[-1] in ".!?…": | |
| self._finalize_sentence() | |
| elif len(self.pending_text) > 120: | |
| self._finalize_sentence() | |
| def _finalize_sentence(self) -> None: | |
| text = self.pending_text.strip() | |
| self.pending_text = "" | |
| if len(text) < 3: | |
| return | |
| # classify in the background so the render thread never blocks | |
| self._ensure_clf_worker() | |
| self._clf_worker_q.put((text, float(self.t))) | |
| def _classify_text(self, text: str): | |
| # 1) trained classifier (CPU, background thread) | |
| self._load_clf() | |
| if type(self)._CLF is not None: | |
| try: | |
| res = type(self)._CLF(text[:480])[0] # list of {label, score} | |
| best = max(res, key=lambda r: float(r.get("score", 0.0))) | |
| lab = str(best.get("label", "")).lower() | |
| p = float(best.get("score", 0.0)) | |
| cls = self._HF_MAP.get(lab) | |
| if cls is not None: | |
| # guard against noisy short-sentence predictions: if the | |
| # winner is weak or neutral is close, stay Neutral | |
| neutral_p = 0.0 | |
| for r in res: | |
| if str(r.get("label", "")).lower() == "neutral": | |
| neutral_p = float(r.get("score", 0.0)) | |
| if p < 0.55 or neutral_p > (p - 0.12): | |
| cls = 4 | |
| inten = max(0.22, min(0.75, 0.22 + 0.55 * max(p, neutral_p if cls == 4 else p))) | |
| if "!" in text: | |
| inten = min(0.85, inten + 0.08) | |
| return cls, min(inten, self.inten_max) | |
| except Exception as e: | |
| print(f"[emo-autotext] classifier error: {e}", flush=True) | |
| # 2) lexicon fallback | |
| low = " " + text.lower() + " " | |
| for cls in self._LEX_ORDER: | |
| for kw in self._LEX[cls]: | |
| if kw in low: | |
| inten = 0.45 | |
| if "!" in text: | |
| inten += 0.1 | |
| letters = [ch for ch in text if ch.isalpha()] | |
| if letters and text.upper() == text: | |
| inten += 0.1 | |
| return cls, min(inten, self.inten_max) | |
| return 4, 0.25 | |
| def _autotext_window(self, t0: float, n: int): | |
| """Per-frame (one-hot, intensity) for [t0, t0+n/fps). | |
| A turn is a *state* that stays active until the next turn. Turns that | |
| started before this window must still colour the whole window; the | |
| previous implementation used negative slice indices for those and | |
| silently fell back to Neutral@0 (v35: lips fully closed). | |
| """ | |
| if not self.turns: | |
| return None | |
| lk = self.lookahead_ms / 1000.0 | |
| fps = self.fps | |
| if self.neutral_as_happy and self.active: | |
| cur_c, cur_i = 3, 0.25 | |
| else: | |
| cur_c, cur_i = 4, 0.0 | |
| emo_f = np.zeros((n, 8), np.float32) | |
| emo_f[:, cur_c] = 1.0 | |
| inten_f = np.full(n, cur_i, np.float32) | |
| for (s0, c, it) in self.turns: | |
| st = max(0.0, s0 - lk) # pre-ramp before the turn | |
| f0 = int(np.floor((st - t0) * fps)) | |
| if f0 >= n: | |
| break | |
| f1 = f0 + self.blend | |
| vec = np.zeros(8, np.float32) | |
| vec[c] = 1.0 | |
| prev = np.zeros(8, np.float32) | |
| prev[cur_c] = 1.0 | |
| lo, hi = max(0, f0), min(n, f1) | |
| if hi > lo: | |
| a = np.array( | |
| [_smoothstep((j - f0 + 1) / (self.blend + 1)) for j in range(lo, hi)], | |
| np.float32, | |
| ) | |
| emo_f[lo:hi] = (1 - a)[:, None] * prev[None, :] + a[:, None] * vec[None, :] | |
| inten_f[lo:hi] = (1 - a) * cur_i + a * it | |
| if hi < n: | |
| # blend finished (or the turn fully predates this window) | |
| emo_f[hi:] = vec[None, :] | |
| inten_f[hi:] = it | |
| cur_c, cur_i = c, it | |
| return emo_f, inten_f | |
| def conditioning(self, n_frames: int) -> dict: | |
| """Build the per-frame conditioning for this chunk and advance time.""" | |
| n = max(1, int(n_frames)) | |
| if not self.enable: | |
| self.t += n / self.fps | |
| return {} | |
| if self.mode == "autotext": | |
| # finalize a pending sentence when the text stream pauses (>1.2 s): | |
| # replies often end without punctuation ("I'm doing good") | |
| import time as _time | |
| if (self.pending_text.strip() and self._last_piece_wall is not None | |
| and (_time.perf_counter() - self._last_piece_wall) > 0.9): | |
| self._finalize_sentence() | |
| win = self._autotext_window(self.t, n) if self.turns else None | |
| if win is None: | |
| # No emotion turn yet (classifier warming up / first sentence | |
| # not finalised). v35's Neutral at intensity 0 CLOSES the lips, | |
| # so a speaking avatar would look mute. Start from the | |
| # "speaking neutral" (mild Happy) used by the offline renders. | |
| if self.neutral_as_happy and self.active: | |
| _cls0, _it0 = 3, 0.25 | |
| else: | |
| _cls0, _it0 = 4, 0.0 | |
| emo_f = np.zeros((n, 8), np.float32) | |
| emo_f[:, _cls0] = 1.0 | |
| inten_f = np.full(n, _it0, np.float32) | |
| else: | |
| emo_f, inten_f = win | |
| mid = max(0, n // 2) | |
| self.cls = int(emo_f[mid].argmax()) | |
| self.inten = float(inten_f[mid]) | |
| self._prev_vec = emo_f[-1].copy() | |
| self._prev_inten = float(inten_f[-1]) | |
| elif self.mode == "script" and self.script: | |
| if self.active: | |
| win = self._schedule_window(self.t, n) | |
| else: | |
| win = None | |
| if win is None: | |
| emo_f = np.zeros((n, 8), np.float32) | |
| emo_f[:, 4] = 1.0 | |
| inten_f = np.zeros(n, np.float32) | |
| else: | |
| emo_f, inten_f = win | |
| mid = n // 2 | |
| self.cls = int(emo_f[mid].argmax()) | |
| self.inten = float(inten_f[mid]) | |
| elif self.mode == "manual" and self.manual_cls >= 0: | |
| emo_f = np.zeros((n, 8), np.float32) | |
| emo_f[:, self.manual_cls] = 1.0 | |
| inten_f = np.full(n, self.manual_inten, np.float32) | |
| self.cls, self.inten = self.manual_cls, self.manual_inten | |
| else: | |
| neutral = self.mode == "neutral" or not self.active | |
| cls = 4 if neutral else int(self.cls) | |
| inten = 0.0 if neutral else float(self.inten) | |
| onehot = np.zeros(8, np.float32) | |
| onehot[cls] = 1.0 | |
| emo_f = np.repeat(onehot[None, :], n, axis=0) | |
| inten_f = np.full(n, inten, np.float32) | |
| k = min(self.ramp, n) | |
| if k > 0: | |
| a = np.array( | |
| [_smoothstep((i + 1) / (k + 1)) for i in range(k)], np.float32 | |
| ) | |
| emo_f[:k] = ( | |
| (1.0 - a)[:, None] * self._prev_vec[None, :] | |
| + a[:, None] * onehot[None, :] | |
| ) | |
| inten_f[:k] = (1.0 - a) * self._prev_inten + a * inten | |
| self.cls, self.inten = int(cls), float(inten) | |
| self._prev_vec, self._prev_inten = onehot.copy(), inten | |
| self.t += n / self.fps | |
| return { | |
| "emo": emo_f, | |
| "inten": inten_f, | |
| "emo_scale": self.emo_scale, | |
| "static": np.array([1.0], np.float32), | |
| "static_scale": 1.0, | |
| "_cls": int(self.cls), | |
| "_inten": float(self.inten), | |
| } | |
| class LiveHeliumFMOptions(BaseOptions): | |
| def initialize(self, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: | |
| parser = super().initialize(parser) | |
| parser.set_defaults(wav2vec_sec=0.96) | |
| parser.add_argument("--host", default="0.0.0.0") | |
| parser.add_argument("--port", type=int, default=8998) | |
| parser.add_argument("--html_path", default=str(ROOT / "static" / "index_v3_binary_fullscreen.html")) | |
| parser.add_argument("--generator_path", required=True) | |
| parser.add_argument("--lora_generator_path", default="", help="Optional LoRA generator checkpoint to apply on top of --generator_path") | |
| parser.add_argument("--lora_rank", type=int, default=64) | |
| parser.add_argument("--lora_alpha", type=float, default=128.0) | |
| parser.add_argument("--lora_dropout", type=float, default=0.05) | |
| parser.add_argument("--no_lora_pose_projection", action="store_true") | |
| parser.add_argument("--no_lora_audio_projection", action="store_true") | |
| parser.add_argument("--only_lora_pose_projection", action="store_true") | |
| parser.add_argument("--renderer_path", required=True) | |
| parser.add_argument("--adapter_path", default="", help="Frontend fp32 Helium->Wav2Vec2 projected-frontend adapter checkpoint (not needed with --audio_direct 1)") | |
| parser.add_argument( | |
| "--adapter_type", | |
| choices=("frontend", "unitalk_last_layer"), | |
| default="frontend", | |
| help="Adapter architecture. UniTalk passes only layer 12 to IMTalker.", | |
| ) | |
| parser.add_argument( | |
| "--adapter_window_mode", | |
| choices=("tail", "lookahead"), | |
| default="tail", | |
| ) | |
| parser.add_argument( | |
| "--adapter_future_steps", | |
| type=int, | |
| default=6, | |
| help="12.5 Hz future-context steps retained in lookahead mode.", | |
| ) | |
| parser.add_argument("--adapter_num_layers", type=int, default=6, help="Transformer layers in the frontend adapter checkpoint") | |
| parser.add_argument("--adapter_dropout", type=float, default=0.1, help="Dropout value used when constructing the frontend adapter") | |
| parser.add_argument("--stats_path", default="", help="Unused for frontend adapter mode; accepted for compatibility") | |
| parser.add_argument( | |
| "--silence_helium_path", | |
| default="", | |
| help="Optional real-silence Helium mean used to initialize the deque.", | |
| ) | |
| parser.add_argument("--ref_path", required=True) | |
| parser.add_argument("--audio_path", default="", help="WAV to stream in fixed chunks (simulate-live mode)") | |
| # Moshi | |
| parser.add_argument("--moshi_root", default="/workspace/moshi") | |
| parser.add_argument("--mimi_hf_repo", default="kyutai/moshiko-pytorch-bf16") | |
| parser.add_argument("--moshi_weight", default="", help="Optional local PersonaPlex/Moshi LM checkpoint") | |
| parser.add_argument("--mimi_weight", default="", help="Optional local Mimi checkpoint") | |
| parser.add_argument("--tokenizer", default="", help="Optional local sentencepiece tokenizer") | |
| parser.add_argument("--quantize_4bit", action="store_true", help="Load PersonaPlex/Moshi LM with bnb 4-bit quantization") | |
| parser.add_argument("--num_codebooks", type=int, default=8, help="PersonaPlex/Moshi audio codebooks") | |
| parser.add_argument("--moshi_context", type=int, default=0, help="Optional PersonaPlex/Moshi KV context length") | |
| parser.add_argument("--voice_prompt", default="", help="PersonaPlex voice prompt filename, e.g. NATM0.pt") | |
| parser.add_argument("--voice_prompt_dir", default="", help="Optional PersonaPlex voice prompt directory") | |
| parser.add_argument("--text_prompt", default="", help="Optional PersonaPlex system text prompt") | |
| parser.add_argument("--moshi_reply_device", default=None, help="Optional separate CUDA device for Moshi reply generation") | |
| parser.add_argument("--enable_moshi_reply", action="store_true", help="Mic -> Moshi reply audio -> Helium/FM avatar") | |
| parser.add_argument("--moshi_cfg_coef", type=float, default=1.0) | |
| parser.add_argument("--direct_reply_hidden", action="store_true", default=True, help="Use Moshi generation hidden directly instead of re-encoding reply audio") | |
| parser.add_argument("--no_direct_reply_hidden", dest="direct_reply_hidden", action="store_false") | |
| parser.add_argument( | |
| "--disable_assistant_output_gate", | |
| action="store_true", | |
| help="Disable assistant-output RMS gating and always drive IMTalker from live PersonaPlex hidden states", | |
| ) | |
| parser.add_argument( | |
| "--assistant_speech_rms_threshold", | |
| type=float, | |
| default=0.006, | |
| help="RMS threshold on PersonaPlex assistant reply audio before hidden states are allowed to drive avatar motion", | |
| ) | |
| parser.add_argument( | |
| "--assistant_speech_hold_chunks", | |
| type=int, | |
| default=1, | |
| help="Keep assistant gate open for this many avatar chunks after reply audio drops below threshold", | |
| ) | |
| # FM | |
| parser.add_argument("--audio_chunk_sec", type=float, default=0.96) | |
| parser.add_argument("--fm_chunk_frames", type=int, default=24, help="Must match wav2vec_sec×fps") | |
| parser.add_argument( | |
| "--enable_live_sliding_window", | |
| action="store_true", | |
| help="Run IMTalker/FM on a past/current/future feature window and emit only the current slice.", | |
| ) | |
| parser.add_argument( | |
| "--slide_past_frames", | |
| type=int, | |
| default=10, | |
| help="Past 25Hz frames used by --enable_live_sliding_window.", | |
| ) | |
| parser.add_argument( | |
| "--slide_future_frames", | |
| type=int, | |
| default=3, | |
| help="Future 25Hz frames used by --enable_live_sliding_window; 3 frames = 120ms.", | |
| ) | |
| parser.add_argument("--skip_fm_audio_encoder", action="store_true", help="Skip FMGenerator's raw-audio Wav2Vec encoder; live PersonaPlex passes precomputed adapter features") | |
| # emotion conditioning (audio + semantic) for the live IMTalker generator | |
| parser.add_argument("--emo_enable", type=int, default=1, help="1 = emotion conditioning on") | |
| parser.add_argument("--emo_mode", default="auto", choices=["auto", "manual", "neutral", "script", "autotext"]) | |
| parser.add_argument("--emo_class", type=int, default=-1, help="manual emotion class 0..7 (-1 = off)") | |
| parser.add_argument("--emo_inten", type=float, default=0.5, help="manual/script emotion intensity 0..1") | |
| parser.add_argument("--emo_inten_max", type=float, default=0.55, | |
| help="Upper bound for autotext emotion intensity (live)") | |
| parser.add_argument("--emo_gain", type=float, default=1.0, help="auto-mode intensity multiplier") | |
| parser.add_argument("--emo_ema", type=float, default=0.35, help="per-chunk emotion EMA (0..1)") | |
| parser.add_argument("--emo_script", default="", | |
| help="scheduled emotions in reply-audio seconds, e.g. " | |
| "'happy@0:0.35,neutral@5:0,sad@6:0.45'. Optional " | |
| "':intensity' overrides --emo_inten.") | |
| parser.add_argument("--neutral_as_happy", type=int, default=1, | |
| help="1 = render Neutral as a mild Happy (0.22-0.30): v35's Neutral " | |
| "conditioning closes the lips completely") | |
| parser.add_argument("--emo_lookahead_ms", type=float, default=400.0, | |
| help="autotext: pre-ramp each detected turn this much earlier " | |
| "(text tags are known at sentence end)") | |
| parser.add_argument("--head_pin", type=float, default=0.6, | |
| help="remove the slow latent component (head drift) in FM.sample; " | |
| "0 disables. Helps between emotion changes and during silence") | |
| parser.add_argument("--emo_scale", type=float, default=1.0, | |
| help="multiplier on the emotion embedding (1.0 = training scale)") | |
| parser.add_argument("--emo_blend_frames", type=int, default=15, | |
| help="smoothstep one-hot blend across an emotion switch " | |
| "(15 = 0.6s at 25fps, same as the offline renders)") | |
| parser.add_argument("--emo_ramp_frames", type=int, default=12, | |
| help="frames over which intensity eases in/out " | |
| "(12 = 0.48s at 25fps, same as the offline renders)") | |
| parser.add_argument("--e_cfg_scale", type=float, default=3.0, help="emotion classifier-free guidance scale") | |
| parser.add_argument("--audio_direct", type=int, default=0, | |
| help="1 = feed the reply PCM straight into the IMTalker wav2vec encoder " | |
| "(no Helium, no Helium->wav2vec adapter); 0 = legacy Helium path") | |
| parser.add_argument("--static_pose_cam", action="store_true", default=True, help="pin pose/cam to the measured static values") | |
| parser.add_argument("--no_static_pose_cam", dest="static_pose_cam", action="store_false") | |
| parser.add_argument("--reply_hidden_steps_per_chunk", type=int, default=0, help="Raw Moshi 12.5Hz hidden steps per avatar chunk; 0 derives from fm_chunk_frames/fps") | |
| parser.add_argument("--drain_chunks", type=int, default=1, | |
| help="silence chunks emitted at each reply start (video pipeline warm-up; " | |
| "the reply audio is buffered, not dropped)") | |
| parser.add_argument("--prebuffer_chunks", type=int, default=3, help="Avatar chunks queued before sender starts pacing") | |
| parser.add_argument("--frame_q_backpressure", type=int, default=160) | |
| parser.add_argument( | |
| "--file_chunk_lookahead", | |
| type=int, | |
| default=0, | |
| help="For --audio_path mode, wait this many future chunks before emitting the oldest chunk", | |
| ) | |
| parser.add_argument("--render_sub_batch", type=int, default=8) | |
| parser.add_argument("--idle_still", type=int, default=1, | |
| help="Render a still, closed-mouth reference pose for silent chunks") | |
| parser.add_argument("--idle_rms_threshold", type=float, default=0.004, | |
| help="Chunk RMS below this is treated as silence") | |
| parser.add_argument("--idle_crossfade_frames", type=int, default=10) | |
| parser.add_argument("--motion_crossfade_frames", type=int, default=5, | |
| help="frames blended at each chunk boundary to hide the " | |
| "cold-regeneration seam (0 disables)") | |
| parser.add_argument("--jpeg_quality", type=int, default=86) | |
| parser.add_argument("--reply_audio_gain", type=float, default=1.0, help="Accepted for launch-script compatibility") | |
| parser.add_argument( | |
| "--motion_ref_blend", | |
| type=float, | |
| default=0.0, | |
| help="Blend generated motion latent toward the reference image latent to reduce head tilt.", | |
| ) | |
| parser.add_argument("--device", default="cuda") | |
| parser.add_argument("--buffer_ms", type=int, default=80) | |
| parser.add_argument( | |
| "--renderer_precision", | |
| choices=("fp32", "fp16", "bf16"), | |
| default="fp32", | |
| ) | |
| parser.add_argument( | |
| "--output_audio_codec", | |
| choices=("pcm", "opus"), | |
| default="pcm", | |
| help="Assistant audio transport; Opus is one persistent stream per session.", | |
| ) | |
| parser.add_argument("--dump_motion", action="store_true", help="Dump last session motion/audio to disk") | |
| parser.add_argument("--dump_dir", default=str(ROOT / "live_try_dumps")) | |
| # Shared noise | |
| parser.add_argument("--shared_noise", action="store_true") | |
| parser.add_argument("--noise_seed", type=int, default=1234) | |
| parser.add_argument("--noise_max_frames", type=int, default=5000) | |
| parser.add_argument( | |
| "--noise_temporal_corr", | |
| type=float, | |
| default=0.0, | |
| help="AR(1)-style temporal correlation applied to FM initial noise.", | |
| ) | |
| parser.add_argument( | |
| "--motion_prior_noise_blend", | |
| type=float, | |
| default=0.0, | |
| help="Small blend from previous/generated motion into FM initial noise.", | |
| ) | |
| # Precision | |
| parser.add_argument("--fp32", action="store_true") | |
| parser.add_argument("--tf32", action="store_true") | |
| parser.add_argument("--compile_renderer", action="store_true") | |
| # Eye blink motion-map compositing | |
| parser.add_argument("--enable_eye_blink_composite", action="store_true") | |
| parser.add_argument("--blink_motion_path", default="", help="Cached blink motion latent .pt file") | |
| parser.add_argument("--eye_left_x", type=float, default=0.36) | |
| parser.add_argument("--eye_right_x", type=float, default=0.64) | |
| parser.add_argument("--eye_center_y", type=float, default=0.405) | |
| parser.add_argument("--eye_radius_x", type=float, default=0.145) | |
| parser.add_argument("--eye_radius_y", type=float, default=0.070) | |
| parser.add_argument("--eye_feather", type=float, default=0.10) | |
| return parser | |
| # --------------------------------------------------------------------------- | |
| # FastAPI app | |
| # --------------------------------------------------------------------------- | |
| def build_app(args: argparse.Namespace) -> FastAPI: | |
| app = FastAPI(title="IMTalker Helium MotionField Deque FM liveTry") | |
| assets_dir = ROOT / "static" / "assets" | |
| if assets_dir.is_dir(): | |
| app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets") | |
| started_at = time.perf_counter() | |
| html_path = Path(args.html_path) | |
| engine: LiveHeliumFMEngine | None = LiveHeliumFMEngine(args) | |
| moshi_engine: MoshiOnlyEngine | None = None | |
| def get_engine() -> LiveHeliumFMEngine: | |
| nonlocal engine | |
| if engine is None: | |
| engine = LiveHeliumFMEngine(args) | |
| return engine | |
| def get_moshi_engine() -> MoshiOnlyEngine: | |
| nonlocal moshi_engine | |
| if moshi_engine is None: | |
| moshi_engine = MoshiOnlyEngineWithHidden( | |
| moshi_root=args.moshi_root, | |
| mimi_hf_repo=args.mimi_hf_repo, | |
| device=getattr(args, "moshi_reply_device", None) or args.device, | |
| cfg_coef=float(args.moshi_cfg_coef), | |
| placeholder_jpeg_b64="", | |
| moshi_weight=getattr(args, "moshi_weight", ""), | |
| mimi_weight=getattr(args, "mimi_weight", ""), | |
| tokenizer=getattr(args, "tokenizer", ""), | |
| quantize_4bit=bool(getattr(args, "quantize_4bit", False)), | |
| num_codebooks=int(getattr(args, "num_codebooks", 8)), | |
| context=(int(args.moshi_context) if int(getattr(args, "moshi_context", 0)) > 0 else None), | |
| voice_prompt=getattr(args, "voice_prompt", ""), | |
| voice_prompt_dir=getattr(args, "voice_prompt_dir", ""), | |
| text_prompt=getattr(args, "text_prompt", ""), | |
| ) | |
| return moshi_engine | |
| split_sessions: dict[str, dict] = {} | |
| async def _get_split_media_epoch(session: dict) -> float: | |
| while not session["prebuffer_ready"].is_set() and not session.get("closed", False): | |
| await asyncio.sleep(0.01) | |
| async with session["media_epoch_lock"]: | |
| if session.get("media_epoch") is None: | |
| session["media_epoch"] = time.perf_counter() + 0.08 | |
| return float(session["media_epoch"]) | |
| if bool(getattr(args, "enable_moshi_reply", False)): | |
| print("[liveTryHeliumFM_ws_binary] eager-loading Moshi/PersonaPlex reply engine", flush=True) | |
| get_moshi_engine() | |
| async def index(): | |
| if html_path.is_file(): | |
| return FileResponse( | |
| html_path, | |
| headers={ | |
| "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", | |
| "Pragma": "no-cache", | |
| "Expires": "0", | |
| }, | |
| ) | |
| return HTMLResponse(f"<h1>Missing HTML</h1><p>Expected: {html_path}</p>", status_code=500) | |
| async def health(): | |
| return JSONResponse({ | |
| "ok": True, | |
| "stage": "live_moshi_reply_helium_fm" if args.enable_moshi_reply else "live_helium_fm", | |
| "uptime_sec": round(time.perf_counter() - started_at, 3), | |
| "loaded": engine is not None, | |
| }) | |
| async def video_stream(ws: WebSocket): | |
| await ws.accept() | |
| session_id = str(ws.query_params.get("session_id", "")) | |
| session = split_sessions.get(session_id) | |
| if session is None: | |
| await ws.send_json({ | |
| "type": "error", | |
| "message": "unknown or expired video session", | |
| }) | |
| await ws.close() | |
| return | |
| frame_q: asyncio.Queue = session["frame_q"] | |
| send_start_wall = await _get_split_media_epoch(session) | |
| frames_sent = 0 | |
| starvation_events = 0 | |
| starve_start: float | None = None | |
| print( | |
| f"[AJ][VIDEO] connected session={session_id[:8]} " | |
| f"queued={frame_q.qsize()}", | |
| flush=True, | |
| ) | |
| try: | |
| while True: | |
| try: | |
| packet = frame_q.get_nowait() | |
| except asyncio.QueueEmpty: | |
| if starve_start is None: | |
| starve_start = time.perf_counter() | |
| starvation_events += 1 | |
| await asyncio.sleep(0.004) | |
| if session.get("closed", False) and frame_q.empty(): | |
| break | |
| continue | |
| if packet is None: | |
| break | |
| if starve_start is not None: | |
| gap_ms = 1000.0 * (time.perf_counter() - starve_start) | |
| if gap_ms > 100: | |
| print( | |
| f"[AJ][VIDEO] STARVED {gap_ms:.0f}ms " | |
| f"(event #{starvation_events}) " | |
| f"frame_q={frame_q.qsize()} sent={frames_sent}", | |
| flush=True, | |
| ) | |
| starve_start = None | |
| idx = int(packet["frame_number"]) | |
| target_t = send_start_wall + idx / float(args.fps) | |
| sleep_s = target_t - time.perf_counter() | |
| if sleep_s > 0: | |
| await asyncio.sleep(sleep_s) | |
| if packet.get("ws_kind") == "bytes": | |
| await ws.send_bytes(packet["data"]) | |
| else: | |
| await ws.send_json(packet["msg"]) | |
| frames_sent += 1 | |
| late_s = time.perf_counter() - target_t | |
| if late_s > 0.5: | |
| print( | |
| f"[AJ][VIDEO] frame {idx} is {late_s*1000:.0f}ms late", | |
| flush=True, | |
| ) | |
| if frames_sent % 50 == 0: | |
| elapsed = time.perf_counter() - send_start_wall | |
| print( | |
| f"[AJ][VIDEO] sent={frames_sent} frame={idx} " | |
| f"frame_q={frame_q.qsize()} elapsed={elapsed:.1f}s " | |
| f"starve_events={starvation_events}", | |
| flush=True, | |
| ) | |
| except (WebSocketDisconnect, RuntimeError, Exception) as exc: | |
| print(f"[AJ][VIDEO] closed session={session_id[:8]} {exc!r}", flush=True) | |
| finally: | |
| print(f"[AJ][VIDEO] done session={session_id[:8]} sent={frames_sent}", flush=True) | |
| async def conversation(ws: WebSocket): | |
| await ws.accept() | |
| fm_engine = get_engine() | |
| fm_engine.reset_session() | |
| reply_engine = get_moshi_engine() if args.enable_moshi_reply else None | |
| if reply_engine is not None: | |
| reply_engine.reset_session() | |
| browser_input_sr = 48000 | |
| opus_reader = sphn.OpusStreamReader(TARGET_SR) | |
| audio_packets_seen = 0 | |
| # -- Queues for the reply pipeline -- | |
| # mic_q: (raw_bytes, input_sr) or None to stop | |
| # frame_q: per-frame packet dicts or None to stop | |
| mic_q: queue.Queue[tuple[bytes, int] | None] | None = None | |
| frame_q: asyncio.Queue[dict | None] | None = None | |
| audio_q: asyncio.Queue[dict | None] | None = None | |
| gpu_thread: threading.Thread | None = None | |
| sender_task: asyncio.Task | None = None | |
| audio_sender_task: asyncio.Task | None = None | |
| event_loop: asyncio.AbstractEventLoop | None = None | |
| ws_send_lock = asyncio.Lock() | |
| media_epoch_lock = asyncio.Lock() | |
| media_epoch: float | None = None | |
| session_id = uuid.uuid4().hex | |
| prebuffer_ready = threading.Event() | |
| session_started = threading.Event() | |
| last_mic_level_log_wall = 0.0 | |
| stream_task: asyncio.Task | None = None | |
| if reply_engine is not None: | |
| mic_q = queue.Queue(maxsize=512) | |
| frame_q = asyncio.Queue(maxsize=512) | |
| audio_q = asyncio.Queue(maxsize=512) | |
| event_loop = asyncio.get_running_loop() | |
| split_sessions[session_id] = { | |
| "frame_q": frame_q, | |
| "prebuffer_ready": prebuffer_ready, | |
| "media_epoch_lock": media_epoch_lock, | |
| "media_epoch": media_epoch, | |
| "closed": False, | |
| "created_at": time.perf_counter(), | |
| } | |
| await ws.send_json({ | |
| "type": "server_ready", | |
| "variant": ( | |
| "AJ-NETWORK-ISO-CACHED-FP32" | |
| if os.environ.get("IMTALKER_CACHED_ENGINE", "0").strip().lower() | |
| in {"1", "true", "yes", "on"} | |
| else "AJ-NETWORK-ISO-FP32" | |
| ), | |
| "session_id": session_id, | |
| "video_ws_path": f"/ws/video?session_id={session_id}", | |
| "sample_rate": TARGET_SR, | |
| "output_audio_codec": str(args.output_audio_codec), | |
| "model_type": "moshi_reply+helium_fm+renderer" if args.enable_moshi_reply else "helium_fm+renderer", | |
| "tokens_per_chunk": int(args.fm_chunk_frames), | |
| "has_audio_file": fm_engine.audio_pcm is not None, | |
| "buffer_ms": int(args.buffer_ms), | |
| "av_transport": "binary", | |
| "target_fps": round(float(args.fps), 2), | |
| }) | |
| print("[liveTryHeliumFM] websocket connected; sent server_ready", flush=True) | |
| if reply_engine is not None: | |
| def _gpu_producer_thread() -> None: | |
| """GPU thread: Moshi -> Helium -> FM -> render -> JPEG -> frame_q. | |
| Runs Moshi at maximum GPU speed. When real mic audio is not | |
| yet available, pads Moshi input with silence so it can keep | |
| generating reply audio without waiting for real-time mic | |
| arrival. This dramatically cuts first-reply latency. | |
| """ | |
| assert ( | |
| mic_q is not None | |
| and frame_q is not None | |
| and audio_q is not None | |
| and event_loop is not None | |
| ) | |
| pending_reply_steps: list[dict] = [] | |
| pending_reply_hidden: list[torch.Tensor] = [] | |
| pending_reply_audio: list[np.ndarray] = [] | |
| reply_step_history: list[dict] = [] | |
| reply_audio_history: list[np.ndarray] = [] | |
| reply_avatar_chunk_idx = 0 | |
| chunk_produce_count = 0 | |
| stopped = False | |
| # Pause producer when queue is this deep (qsize() is heuristic across threads). | |
| FRAME_Q_BACKPRESS = max(1, int(getattr(args, "frame_q_backpressure", 96))) | |
| FRAME_Q_PUT_TIMEOUT_S = 120.0 | |
| prebuffer_chunks = max(0, int(getattr(args, "prebuffer_chunks", PREBUFFER_CHUNKS))) | |
| hidden_steps_per_chunk = int(getattr(args, "reply_hidden_steps_per_chunk", 0)) | |
| if hidden_steps_per_chunk <= 0: | |
| hidden_steps_per_chunk = int(round(float(args.fm_chunk_frames) * 12.5 / float(args.fps))) | |
| hidden_steps_per_chunk = max(1, hidden_steps_per_chunk) | |
| max_moshi_steps_per_loop = 1 | |
| was_silent = True | |
| assistant_gate_hold = 0 | |
| last_motion_frame: torch.Tensor | None = None | |
| def _enqueue_frame(pkt: dict) -> None: | |
| """Block until frame_q accepts pkt (real backpressure). Must run from GPU thread.""" | |
| fut = asyncio.run_coroutine_threadsafe(frame_q.put(pkt), event_loop) | |
| try: | |
| fut.result(timeout=FRAME_Q_PUT_TIMEOUT_S) | |
| except TimeoutError: | |
| print( | |
| f"[GPU] WARNING frame_q.put timeout frame={pkt.get('frame_number')}", | |
| flush=True, | |
| ) | |
| except Exception as e: | |
| print(f"[GPU] WARNING frame_q.put failed: {e!r}", flush=True) | |
| def _enqueue_audio(pkt: dict) -> None: | |
| # NEVER block the GPU thread on the legacy audio queue: the | |
| # binary AV frames already carry the audio, and the aj_nodrop | |
| # client never opens a separate audio websocket. Without a | |
| # consumer this queue used to fill at 512 packets and stall | |
| # production for seconds -> the avatar froze after ~20 s. | |
| def _put(): | |
| try: | |
| audio_q.put_nowait(pkt) | |
| except asyncio.QueueFull: | |
| try: | |
| audio_q.get_nowait() # drop the oldest | |
| except asyncio.QueueEmpty: | |
| pass | |
| try: | |
| audio_q.put_nowait(pkt) | |
| except asyncio.QueueFull: | |
| pass | |
| try: | |
| event_loop.call_soon_threadsafe(_put) | |
| except Exception as e: | |
| print(f"[GPU] WARNING audio_q.put failed: {e!r}", flush=True) | |
| except Exception as e: | |
| print(f"[GPU] WARNING audio_q.put failed: {e!r}", flush=True) | |
| if prebuffer_chunks <= 0 and not prebuffer_ready.is_set(): | |
| prebuffer_ready.set() | |
| print("[GPU] prebuffer=0, sender starts immediately", flush=True) | |
| while not stopped: | |
| # --- Phase 1: drain all available mic audio (non-blocking) --- | |
| while True: | |
| try: | |
| item = mic_q.get_nowait() | |
| except queue.Empty: | |
| break | |
| if item is None: | |
| stopped = True | |
| break | |
| raw_bytes, input_sr = item | |
| if raw_bytes: | |
| reply_engine.append_browser_pcm( | |
| np.frombuffer(raw_bytes, dtype=np.int16), input_sr | |
| ) | |
| if stopped: | |
| break | |
| # --- Phase 2: yield if outbound queue is deep (sender is the bottleneck) --- | |
| while frame_q.qsize() >= FRAME_Q_BACKPRESS: | |
| time.sleep(0.004) | |
| if not session_started.is_set(): | |
| time.sleep(0.003) | |
| continue | |
| if reply_engine.input_buffer.shape[0] < MIMI_FRAME_SIZE: | |
| time.sleep(0.003) | |
| continue | |
| # --- Phase 3: run all Moshi steps and feed FM --- | |
| t_recv = time.perf_counter() | |
| for ev in reply_engine.process_ready_steps_limited(max_moshi_steps_per_loop): | |
| pending_reply_steps.append(ev) | |
| fm_engine._session_reply_events.append({ | |
| "step": int(ev.get("step", -1)), | |
| "token": int(ev.get("token", -1)), | |
| "piece": str(ev.get("piece", "")), | |
| "audio_text": str(ev.get("audio_text", "")), | |
| "reply_rms": float(ev.get("reply_rms", 0.0)), | |
| "reply_peak": float(ev.get("reply_peak", 0.0)), | |
| "input_rms": float(ev.get("input_rms", 0.0)), | |
| "hidden": bool(isinstance(ev.get("helium_hidden"), torch.Tensor)), | |
| "total_ms": float(ev.get("total_ms", 0.0)), | |
| }) | |
| if str(ev.get("piece", "")): | |
| try: | |
| fm_engine.emo.on_reply_piece(str(ev.get("piece", ""))) | |
| except Exception as _e: | |
| print(f"[emo-autotext] ingest error {_e}", flush=True) | |
| reply_pcm = ( | |
| np.frombuffer(base64.b64decode(ev["reply_i16_b64"]), dtype=np.int16) | |
| .astype(np.float32) / 32768.0 | |
| ) | |
| if bool(getattr(args, "direct_reply_hidden", False)): | |
| hidden = ev.get("helium_hidden") | |
| if not isinstance(hidden, torch.Tensor): | |
| continue | |
| pending_reply_hidden.append(hidden.squeeze(0).contiguous()) | |
| pending_reply_audio.append(reply_pcm) | |
| if len(pending_reply_hidden) < hidden_steps_per_chunk: | |
| continue | |
| used_hidden = pending_reply_hidden[:hidden_steps_per_chunk] | |
| used_audio = pending_reply_audio[:hidden_steps_per_chunk] | |
| used_steps = pending_reply_steps[:hidden_steps_per_chunk] | |
| pending_reply_hidden = pending_reply_hidden[hidden_steps_per_chunk:] | |
| pending_reply_audio = pending_reply_audio[hidden_steps_per_chunk:] | |
| pending_reply_steps = pending_reply_steps[hidden_steps_per_chunk:] | |
| if str(getattr(args, "adapter_window_mode", "tail")) == "lookahead": | |
| history_size = int(fm_engine.helium_deque_size) | |
| future_steps = int( | |
| getattr(args, "adapter_future_steps", 6) | |
| ) | |
| reply_audio_history.extend(used_audio) | |
| reply_step_history.extend(used_steps) | |
| reply_audio_history = reply_audio_history[-history_size:] | |
| reply_step_history = reply_step_history[-history_size:] | |
| zero_audio = np.zeros_like(used_audio[0]) | |
| audio_window = ( | |
| [zero_audio] | |
| * (history_size - len(reply_audio_history)) | |
| + reply_audio_history | |
| ) | |
| silent_step = { | |
| "token": 0, | |
| "reply_rms": 0.0, | |
| "total_ms": 0.0, | |
| "audio_text": "", | |
| } | |
| step_window = ( | |
| [silent_step] | |
| * (history_size - len(reply_step_history)) | |
| + reply_step_history | |
| ) | |
| output_end = history_size - future_steps | |
| output_start = ( | |
| output_end - hidden_steps_per_chunk | |
| ) | |
| used_audio = audio_window[output_start:output_end] | |
| used_steps = step_window[output_start:output_end] | |
| pcm_chunk = np.concatenate(used_audio, axis=0).astype(np.float32, copy=False) | |
| reply_rms = float(np.sqrt(np.mean(np.square(pcm_chunk)))) if pcm_chunk.size else 0.0 | |
| step_rms = max( | |
| ( | |
| float(s.get("reply_rms", 0.0) or 0.0) | |
| for s in used_steps | |
| ), | |
| default=0.0, | |
| ) | |
| speech_threshold = float( | |
| getattr(args, "assistant_speech_rms_threshold", 0.006) | |
| ) | |
| assistant_active_now = max(reply_rms, step_rms) > speech_threshold | |
| if assistant_active_now: | |
| assistant_gate_hold = max( | |
| 0, | |
| int(getattr(args, "assistant_speech_hold_chunks", 1)), | |
| ) | |
| elif assistant_gate_hold > 0: | |
| assistant_gate_hold -= 1 | |
| assistant_active = assistant_active_now or assistant_gate_hold > 0 | |
| if bool(getattr(args, "disable_assistant_output_gate", False)): | |
| assistant_active = True | |
| previous_active = not was_silent | |
| transition = assistant_active != previous_active | |
| if transition: | |
| print( | |
| f"[GPU] Avatar gate transition " | |
| f"{'speech' if assistant_active else 'idle'} " | |
| f"reply_rms={reply_rms:.5f} step_rms={step_rms:.5f}", | |
| flush=True, | |
| ) | |
| was_silent = not assistant_active | |
| if assistant_active: | |
| helium_chunk = torch.cat(used_hidden, dim=0) | |
| else: | |
| if fm_engine.silence_helium_seed is not None: | |
| helium_chunk = fm_engine.silence_helium_seed.expand( | |
| hidden_steps_per_chunk, -1 | |
| ).contiguous() | |
| else: | |
| hidden_dim = int(used_hidden[0].shape[-1]) | |
| helium_chunk = torch.zeros( | |
| hidden_steps_per_chunk, | |
| hidden_dim, | |
| device=fm_engine.device, | |
| dtype=torch.float32, | |
| ) | |
| # ---- DRAIN: give the video pipeline a head start ---- | |
| # While the first `drain_chunks` of a reply are emitted | |
| # as silence, the A/V pipeline warms up; the reply audio | |
| # is buffered and played afterwards, so the mouth is | |
| # already synced when speech starts (fixes the perceived | |
| # "avatar lags behind the audio" at reply start). | |
| _drain_n = int(getattr(args, "drain_chunks", 1)) | |
| _prev_active = bool(getattr(fm_engine, "_was_active", False)) | |
| if assistant_active and not _prev_active: | |
| fm_engine._drain_left = _drain_n | |
| fm_engine._drain_buf = [] | |
| if _drain_n > 0: | |
| print(f"[drain] reply start: {_drain_n} silence chunk(s) " | |
| f"({_drain_n * args.audio_chunk_sec:.1f}s lead-in)", | |
| flush=True) | |
| fm_engine._was_active = assistant_active | |
| if not hasattr(fm_engine, "_drain_buf") or fm_engine._drain_buf is None: | |
| fm_engine._drain_buf = [] | |
| if not hasattr(fm_engine, "_drain_left"): | |
| fm_engine._drain_left = 0 | |
| if used_audio: | |
| fm_engine._drain_buf.append( | |
| np.concatenate(used_audio).astype(np.float32)) | |
| if int(fm_engine._drain_left) > 0 and fm_engine._drain_buf: | |
| fm_engine._drain_left = int(fm_engine._drain_left) - 1 | |
| _emit_audio = np.zeros( | |
| int(round(args.audio_chunk_sec * TARGET_SR)), np.float32) | |
| elif fm_engine._drain_buf: | |
| _emit_audio = fm_engine._drain_buf.pop(0) | |
| else: | |
| _emit_audio = (np.concatenate(used_audio).astype(np.float32) | |
| if used_audio else np.zeros(0, np.float32)) | |
| if _emit_audio.shape[0] == 0: | |
| _emit_audio = np.zeros( | |
| int(round(args.audio_chunk_sec * TARGET_SR)), np.float32) | |
| target_frames = max(1, int(round(len(_emit_audio) * float(args.fps) / TARGET_SR))) | |
| # CRITICAL: the frames (and the recorded session audio) must | |
| # carry exactly the audio the motion was conditioned on. | |
| # The drain delays the reply by `drain_chunks`; using the | |
| # undelayed `pcm_chunk` here put the audio ~2 s AHEAD of | |
| # the lip motion -> the avatar "lagged behind the audio". | |
| pcm_chunk = _emit_audio | |
| _emit_rms = (float(np.sqrt(np.mean(np.square(_emit_audio)))) | |
| if _emit_audio.size else 0.0) | |
| _emo_active = bool( | |
| assistant_active | |
| and _emit_rms > float(getattr(args, "assistant_speech_rms_threshold", 0.006)) | |
| ) | |
| fm_engine.emo.update(used_audio, used_steps, _emo_active) | |
| fm_engine.pending_audio = ( | |
| _emit_audio if _emit_audio.shape[0] else None | |
| ) | |
| motion, fm_info = fm_engine._sample_motion_from_helium(helium_chunk, target_frames) | |
| # Every chunk is generated independently (cold), so always | |
| # blend the first frames onto the previous chunk's last frame. | |
| # Without this the boundary shows a visible head/jaw jump. | |
| if last_motion_frame is not None: | |
| blend_frames = min( | |
| int(getattr(args, "motion_crossfade_frames", 4)), | |
| int(motion.shape[0]), | |
| ) | |
| if blend_frames > 0: | |
| alpha = torch.linspace( | |
| 0.0, | |
| 1.0, | |
| blend_frames + 2, | |
| device=motion.device, | |
| dtype=motion.dtype, | |
| )[1:-1].unsqueeze(-1) | |
| previous = last_motion_frame.to( | |
| device=motion.device, | |
| dtype=motion.dtype, | |
| ).unsqueeze(0) | |
| motion[:blend_frames] = ( | |
| previous * (1.0 - alpha) | |
| + motion[:blend_frames] * alpha | |
| ) | |
| last_motion_frame = motion[-1].detach().clone() | |
| fm_info["assistant_active"] = bool(assistant_active) | |
| fm_info["assistant_reply_rms"] = float(reply_rms) | |
| fm_info["assistant_step_rms"] = float(step_rms) | |
| used_codes = [ | |
| s["reply_codes"].to(dtype=torch.int16).contiguous() | |
| for s in used_steps | |
| if isinstance(s.get("reply_codes"), torch.Tensor) | |
| ] | |
| if used_codes: | |
| fm_engine._session_live_token_parts.extend(used_codes) | |
| fm_engine._record_session_chunk(pcm_chunk, motion, fm_info) | |
| else: | |
| result = fm_engine.feed_pcm_f32(reply_pcm) | |
| if result is None: | |
| continue | |
| motion, fm_info, pcm_chunk = result | |
| steps_per_avatar_chunk = max(1, int(round(len(pcm_chunk) / MIMI_FRAME_SIZE))) | |
| used_steps = pending_reply_steps[:steps_per_avatar_chunk] | |
| pending_reply_steps = pending_reply_steps[steps_per_avatar_chunk:] | |
| used_codes = [ | |
| s["reply_codes"].to(dtype=torch.int16).contiguous() | |
| for s in used_steps | |
| if isinstance(s.get("reply_codes"), torch.Tensor) | |
| ] | |
| if used_codes: | |
| fm_engine._session_live_token_parts.extend(used_codes) | |
| reply_avatar_chunk_idx += 1 | |
| chunk_produce_count += 1 | |
| moshi_total_ms = sum(float(s.get("total_ms", 0.0)) for s in used_steps) | |
| avatar_chunk_id = len(fm_engine._session_chunk_rows) | |
| try: | |
| if os.environ.get("DUMP_CHUNKS") == "1": | |
| os.makedirs("/workspace/dbg_chunks", exist_ok=True) | |
| np.save(f"/workspace/dbg_chunks/ck{reply_avatar_chunk_idx-1:02d}.npy", | |
| np.asarray(pcm_chunk, dtype=np.float32)) | |
| except Exception as _e: | |
| print(f"[DUMP] err {_e}", flush=True) | |
| if os.environ.get("DUMP_CHUNKS") == "1": | |
| try: | |
| _fr = float(np.sqrt(np.mean(np.square(pcm_chunk)))) if pcm_chunk.size else 0.0 | |
| _pa = getattr(fm_engine, "pending_audio", None) | |
| _pr = float(np.sqrt(np.mean(np.square(_pa)))) if _pa is not None and _pa.size else -1.0 | |
| print(f"[FRAME-AUDIO] chunk#{reply_avatar_chunk_idx-1} frame_rms={_fr:.4f} " | |
| f"model_rms={_pr:.4f} n={pcm_chunk.size}", flush=True) | |
| except Exception as _e: | |
| print(f"[FRAME-AUDIO] err {_e}", flush=True) | |
| frame_audio = split_audio_into_frame_slices(pcm_chunk, args.fps) | |
| n_frames = int(motion.shape[0]) | |
| emitted = int(fm_info["abs_start"]) | |
| output_step = used_steps[-1] if used_steps else ev | |
| text_payload = ( | |
| output_step.get("audio_text") | |
| or output_step.get("sampled_text") | |
| or "" | |
| ) | |
| total_gen_ms = ( | |
| moshi_total_ms + float(fm_info["helium_ms"]) + float(fm_info["fm_ms"]) | |
| ) | |
| for frame_offset, audio_slice in enumerate(frame_audio[:n_frames]): | |
| _enqueue_audio({ | |
| "frame_number": emitted + frame_offset, | |
| "audio_pcm": np.asarray( | |
| audio_slice, | |
| dtype=np.float32, | |
| ).copy(), | |
| }) | |
| t_chunk_start = time.perf_counter() | |
| for sb_start in range(0, n_frames, fm_engine.render_sub_batch): | |
| sb_end = min(sb_start + fm_engine.render_sub_batch, n_frames) | |
| sub_motion = motion[sb_start:sb_end] | |
| sub_audio = frame_audio[sb_start:sb_end] | |
| packets = fm_engine.render_and_encode_subbatch( | |
| sub_motion, | |
| sub_audio, | |
| abs_start=emitted + sb_start, | |
| text_payload=text_payload, | |
| avatar_chunk_id=avatar_chunk_id, | |
| total_gen_ms=total_gen_ms, | |
| ) | |
| for pkt in packets: | |
| _enqueue_frame(pkt) | |
| chunk_wall_ms = _ms(t_chunk_start) | |
| produce_latency_ms = _ms(t_recv) | |
| q_depth = frame_q.qsize() if frame_q is not None else -1 | |
| print( | |
| f"[GPU][chunk#{reply_avatar_chunk_idx}] " | |
| f"moshi={moshi_total_ms:.0f}ms " | |
| f"helium={float(fm_info['helium_ms']):.0f}ms " | |
| f"fm={float(fm_info['fm_ms']):.0f}ms " | |
| f"render+jpeg={chunk_wall_ms:.0f}ms " | |
| f"frames={n_frames} " | |
| f"emo={fm_info.get('emo_cls', '-')}/{fm_info.get('emo_inten', '-')} " | |
| f"produce_latency={produce_latency_ms:.0f}ms " | |
| f"frame_q={q_depth} " | |
| f"abs={emitted}", | |
| flush=True, | |
| ) | |
| if ( | |
| chunk_produce_count == prebuffer_chunks | |
| and not prebuffer_ready.is_set() | |
| ): | |
| prebuffer_ready.set() | |
| print( | |
| f"[GPU] prebuffer ready: {q_depth} frames queued " | |
| f"after {chunk_produce_count} chunks", | |
| flush=True, | |
| ) | |
| fut_done = asyncio.run_coroutine_threadsafe(frame_q.put(None), event_loop) | |
| try: | |
| fut_done.result(timeout=30.0) | |
| except Exception as e: | |
| print(f"[GPU] WARNING sentinel put: {e!r}", flush=True) | |
| fut_audio_done = asyncio.run_coroutine_threadsafe( | |
| audio_q.put(None), | |
| event_loop, | |
| ) | |
| try: | |
| fut_audio_done.result(timeout=30.0) | |
| except Exception as e: | |
| print(f"[GPU] WARNING audio sentinel put: {e!r}", flush=True) | |
| async def _get_media_epoch() -> float: | |
| nonlocal media_epoch | |
| while not prebuffer_ready.is_set(): | |
| await asyncio.sleep(0.01) | |
| async with media_epoch_lock: | |
| session = split_sessions.get(session_id) | |
| if media_epoch is None and session is not None and session.get("media_epoch") is not None: | |
| media_epoch = float(session["media_epoch"]) | |
| if media_epoch is None: | |
| media_epoch = time.perf_counter() + 0.08 | |
| if session is not None: | |
| session["media_epoch"] = media_epoch | |
| return media_epoch | |
| async def _reply_sender() -> None: | |
| """Wait for prebuffer, then drain frame_q at 25fps.""" | |
| assert frame_q is not None | |
| send_start_wall = await _get_media_epoch() | |
| frames_sent = 0 | |
| starvation_events = 0 | |
| starve_start: float | None = None | |
| ws_closed = False | |
| q_depth_at_start = frame_q.qsize() | |
| print( | |
| f"[SENDER] prebuffer filled, starting pacing with " | |
| f"{q_depth_at_start} frames queued", | |
| flush=True, | |
| ) | |
| while True: | |
| if ws_closed: | |
| break | |
| try: | |
| packet = frame_q.get_nowait() | |
| except asyncio.QueueEmpty: | |
| if send_start_wall is not None and starve_start is None: | |
| starve_start = time.perf_counter() | |
| starvation_events += 1 | |
| await asyncio.sleep(0.004) | |
| continue | |
| if packet is None: | |
| break | |
| if starve_start is not None: | |
| gap_ms = 1000.0 * (time.perf_counter() - starve_start) | |
| if gap_ms > 100: | |
| print( | |
| f"[SENDER] STARVED {gap_ms:.0f}ms " | |
| f"(event #{starvation_events}) " | |
| f"frame_q={frame_q.qsize()} sent={frames_sent}", | |
| flush=True, | |
| ) | |
| starve_start = None | |
| idx = int(packet["frame_number"]) | |
| target_t = send_start_wall + idx / float(args.fps) | |
| sleep_s = target_t - time.perf_counter() | |
| if sleep_s > 0: | |
| await asyncio.sleep(sleep_s) | |
| try: | |
| async with ws_send_lock: | |
| if packet.get("ws_kind") == "bytes": | |
| await ws.send_bytes(packet["data"]) | |
| else: | |
| await ws.send_json(packet["msg"]) | |
| except (WebSocketDisconnect, RuntimeError, Exception): | |
| ws_closed = True | |
| break | |
| frames_sent += 1 | |
| late_s = time.perf_counter() - target_t | |
| if late_s > 0.5: | |
| print( | |
| f"[SENDER] video frame {idx} is {late_s*1000:.0f}ms late", | |
| flush=True, | |
| ) | |
| if frames_sent % 50 == 0: | |
| q_depth = frame_q.qsize() | |
| elapsed = time.perf_counter() - send_start_wall | |
| print( | |
| f"[SENDER] sent={frames_sent} frame={idx} " | |
| f"frame_q={q_depth} " | |
| f"elapsed={elapsed:.1f}s " | |
| f"starve_events={starvation_events}", | |
| flush=True, | |
| ) | |
| async def _audio_sender() -> None: | |
| assert audio_q is not None | |
| output_audio_codec = str(args.output_audio_codec).lower() | |
| if output_audio_codec != "opus": | |
| raise RuntimeError("AJ requires --output_audio_codec opus") | |
| opus_writer = sphn.OpusStreamWriter(TARGET_SR) | |
| send_start_wall = await _get_media_epoch() | |
| packets_sent = 0 | |
| bytes_sent = 0 | |
| # AG: prevent catch-up bursts after GPU/render stalls. | |
| # Even if target_t is already late, keep websocket audio writes | |
| # spaced near the media frame cadence instead of flushing a | |
| # backlog back-to-back into the browser decoder/worklet. | |
| min_send_interval_s = max(0.001, 0.92 / float(args.fps)) | |
| next_send_wall = send_start_wall | |
| while True: | |
| packet = await audio_q.get() | |
| if packet is None: | |
| break | |
| idx = int(packet["frame_number"]) | |
| target_t = send_start_wall + idx / float(args.fps) | |
| target_t = max(target_t, next_send_wall) | |
| sleep_s = target_t - time.perf_counter() | |
| if sleep_s > 0: | |
| await asyncio.sleep(sleep_s) | |
| next_send_wall = max(target_t, time.perf_counter()) + min_send_interval_s | |
| audio_pcm = np.asarray( | |
| packet["audio_pcm"], | |
| dtype=np.float32, | |
| ).reshape(-1) | |
| opus_payload = opus_writer.append_pcm(audio_pcm) | |
| if opus_payload is None and hasattr(opus_writer, "read_bytes"): | |
| opus_payload = opus_writer.read_bytes() | |
| if opus_payload: | |
| try: | |
| async with ws_send_lock: | |
| await ws.send_bytes(b"\x01" + opus_payload) | |
| except (WebSocketDisconnect, RuntimeError, Exception): | |
| break | |
| packets_sent += 1 | |
| bytes_sent += len(opus_payload) | |
| if packets_sent and packets_sent % 50 == 0: | |
| print( | |
| f"[AUDIO] sent={packets_sent} frame={idx} " | |
| f"audio_q={audio_q.qsize()} bytes={bytes_sent}", | |
| flush=True, | |
| ) | |
| gpu_thread = threading.Thread(target=_gpu_producer_thread, daemon=True, name="gpu-producer") | |
| gpu_thread.start() | |
| print( | |
| f"[AJ][AUDIO] session={session_id[:8]} " | |
| f"video_path=/ws/video?session_id={session_id}", | |
| flush=True, | |
| ) | |
| audio_sender_task = asyncio.create_task(_audio_sender()) | |
| try: | |
| while True: | |
| msg = await ws.receive() | |
| if msg["type"] == "websocket.disconnect": | |
| break | |
| data = msg.get("bytes") | |
| if data is not None: | |
| if reply_engine is None: | |
| continue | |
| assert mic_q is not None | |
| packet_sr = int(browser_input_sr) | |
| raw = bytes(data) | |
| is_ogg = raw[:4] == b"OggS" or raw[1:5] == b"OggS" | |
| if is_ogg: | |
| payload = raw[1:] if raw[1:5] == b"OggS" else raw | |
| try: | |
| decoded_pcm = opus_reader.append_bytes(payload) | |
| except Exception as _e: | |
| print(f"[MIC] opus decode error (skipped): {_e}", flush=True) | |
| continue | |
| if decoded_pcm.shape[-1] == 0: | |
| continue | |
| decoded_pcm = np.asarray(decoded_pcm, dtype=np.float32).reshape(-1) | |
| decoded_i16 = ( | |
| np.clip(decoded_pcm, -1.0, 1.0) * 32767.0 | |
| ).astype(np.int16) | |
| data = decoded_i16.tobytes() | |
| packet_sr = TARGET_SR | |
| audio_packets_seen += 1 | |
| pcm_i16 = np.frombuffer(data, dtype=np.int16) | |
| mic_rms = float(np.sqrt(np.mean((pcm_i16.astype(np.float32) / 32768.0) ** 2))) if pcm_i16.size else 0.0 | |
| mic_peak = float(np.max(np.abs(pcm_i16.astype(np.float32) / 32768.0))) if pcm_i16.size else 0.0 | |
| now_wall = time.perf_counter() | |
| if audio_packets_seen <= 3 or now_wall - last_mic_level_log_wall >= 1.0: | |
| voice = "VOICE" if mic_rms >= 0.02 else "quiet" | |
| print( | |
| f"[MIC] packet={audio_packets_seen} rms={mic_rms:.5f} " | |
| f"peak={mic_peak:.3f} sr={packet_sr} {voice}", | |
| flush=True, | |
| ) | |
| last_mic_level_log_wall = now_wall | |
| if audio_packets_seen <= 3: | |
| print( | |
| f"[liveTryHeliumFM] rx binary mic packet#{audio_packets_seen} " | |
| f"bytes={len(data)} sr={packet_sr}", | |
| flush=True, | |
| ) | |
| if not session_started.is_set(): | |
| session_started.set() | |
| print( | |
| "[liveTryHeliumFM] auto-started session from first binary mic packet", | |
| flush=True, | |
| ) | |
| try: | |
| mic_q.put_nowait((bytes(data), int(packet_sr))) | |
| except queue.Full: | |
| pass | |
| continue | |
| text = msg.get("text") | |
| if text is None: | |
| continue | |
| try: | |
| payload = json.loads(text) | |
| except json.JSONDecodeError: | |
| continue | |
| msg_type = str(payload.get("type", "")).lower() | |
| print(f"[liveTryHeliumFM] rx text type={msg_type or '<empty>'}", flush=True) | |
| if msg_type == "start": | |
| browser_input_sr = int(payload.get("sample_rate", payload.get("sampleRate", browser_input_sr))) | |
| if fm_engine.audio_pcm is not None and (stream_task is None or stream_task.done()): | |
| fm_engine.reset_session() | |
| stream_task = asyncio.create_task(stream_from_file(ws, fm_engine)) | |
| def _on_stream_done(t, _ws=ws): | |
| if t.cancelled(): | |
| print("[stream_from_file] cancelled", flush=True) | |
| return | |
| exc = t.exception() | |
| if exc is not None: | |
| import traceback as _tb | |
| print("[stream_from_file] CRASHED:", repr(exc), flush=True) | |
| _tb.print_exception(type(exc), exc, exc.__traceback__) | |
| stream_task.add_done_callback(_on_stream_done) | |
| if reply_engine is not None: | |
| reply_engine.reset_session() | |
| session_started.set() | |
| # tell the UI the session is armed: the try21 client only | |
| # enables its microphone on this message (without it the | |
| # user is never heard and no frames are ever produced) | |
| try: | |
| await ws.send_json({ | |
| "type": "session_started", | |
| "reset_ms": 0.0, | |
| }) | |
| print("[liveTryHeliumFM] sent session_started to client", flush=True) | |
| except Exception as _e: | |
| print(f"[liveTryHeliumFM] session_started send failed: {_e}", flush=True) | |
| print( | |
| "[liveTryHeliumFM] start → " | |
| + ("streaming from file" if fm_engine.audio_pcm is not None else "live Moshi reply mode"), | |
| flush=True, | |
| ) | |
| elif msg_type == "chunk_audio": | |
| pcm_b64 = payload.get("pcm_s16le_b64", "") | |
| if not pcm_b64: | |
| continue | |
| pcm_bytes = base64.b64decode(pcm_b64) | |
| result = fm_engine.feed_pcm(pcm_bytes) | |
| if result is not None: | |
| motion, fm_info, _pcm_chunk = result | |
| avatar_chunk_id = len(fm_engine._session_chunk_rows) | |
| n_frames = int(motion.shape[0]) | |
| emitted = fm_info["abs_start"] | |
| for sb_start in range(0, n_frames, fm_engine.render_sub_batch): | |
| sub = motion[sb_start:sb_start + fm_engine.render_sub_batch].to( | |
| fm_engine.device, dtype=fm_engine.dtype | |
| ) | |
| frames_np, _ = fm_engine._render_motion(sub) | |
| for j, frame_rgb in enumerate(frames_np): | |
| idx = emitted + sb_start + j | |
| await ws.send_json({ | |
| "type": "chunk_frame", | |
| "chunk_id": idx + 1, | |
| "frame_idx": 0, | |
| "jpeg_b64": encode_jpeg_b64(frame_rgb, fm_engine.jpeg_quality), | |
| "moshi_text": ( | |
| f"live Helium+FM " | |
| f"helium={fm_info['helium_ms']:.0f}ms " | |
| f"fm={fm_info['fm_ms']:.0f}ms" | |
| ), | |
| "server_fps": round(float(args.fps), 1), | |
| "chunks_done": avatar_chunk_id, | |
| }) | |
| elif msg_type == "stop": | |
| print("[liveTryHeliumFM] stop requested", flush=True) | |
| break | |
| except (WebSocketDisconnect, RuntimeError): | |
| pass | |
| finally: | |
| session = split_sessions.get(session_id) | |
| if session is not None: | |
| session["closed"] = True | |
| if stream_task is not None: | |
| stream_task.cancel() | |
| with contextlib.suppress(asyncio.CancelledError): | |
| await stream_task | |
| if mic_q is not None: | |
| with contextlib.suppress(queue.Full): | |
| mic_q.put_nowait(None) | |
| if gpu_thread is not None: | |
| await asyncio.to_thread(gpu_thread.join, 30.0) | |
| if sender_task is not None: | |
| sender_task.cancel() | |
| with contextlib.suppress(asyncio.CancelledError, WebSocketDisconnect, RuntimeError): | |
| await sender_task | |
| if audio_sender_task is not None: | |
| audio_sender_task.cancel() | |
| with contextlib.suppress(asyncio.CancelledError, WebSocketDisconnect, RuntimeError): | |
| await audio_sender_task | |
| fm_engine.dump_last_session(source="websocket_live") | |
| split_sessions.pop(session_id, None) | |
| print("[liveTryHeliumFM] websocket closed", flush=True) | |
| return app | |
| # --------------------------------------------------------------------------- | |
| # Entry point | |
| # --------------------------------------------------------------------------- | |
| def main() -> None: | |
| parser = LiveHeliumFMOptions() | |
| args = parser.parse() | |
| args.rank = args.device | |
| parser.print_options() | |
| app = build_app(args) | |
| import uvicorn | |
| print(f"[liveTryHeliumFM_ws_binary] serving {args.html_path} (binary av_transport)") | |
| print(f"[liveTryHeliumFM] open http://{args.host}:{args.port}/") | |
| uvicorn.run(app, host=args.host, port=args.port, log_level="info") | |
| if __name__ == "__main__": | |
| main() | |