Files
wehub-resource-sync ba4be087d5
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
CICD NeMo / cicd-test-container-build (push) Has been cancelled
CICD NeMo / cicd-import-tests (push) Has been cancelled
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Has been cancelled
CICD NeMo / cicd-main-unit-tests (push) Has been cancelled
CICD NeMo / cicd-main-speech (push) Has been cancelled
CICD NeMo / Nemo_CICD_Test (push) Has been cancelled
CICD NeMo / Coverage (e2e) (push) Has been cancelled
CICD NeMo / Coverage (unit-test) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CICD NeMo / cicd-wait-in-queue (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:58 +08:00

387 lines
16 KiB
Python

# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import soundfile as sf
import torch
import torch.distributed as dist
from transformers import DynamicCache
from nemo.collections.audio.parts.utils.transforms import resample
from nemo.collections.speechlm2.parts.text_utils import tokens_to_str
from nemo.utils import logging
class DuplexSTTStreamingInference:
"""Streaming inference engine for DuplexSTT model."""
def __init__(self, model):
"""Initialize the streaming inference engine with a reference to the parent model.
Args:
model: The DuplexSTTModel instance that owns this inference engine.
"""
self.model = model
def _init_inference(
self,
input_signal: torch.Tensor,
input_signal_lens: torch.Tensor,
input_pad_len: int,
prompt_tokens: torch.Tensor,
prompt_token_lens: torch.Tensor,
):
"""Initialize inference resources and prepare inputs."""
if self.model.cfg.get("custom_sample_inference", None):
device = input_signal.device
audio, sr = sf.read(self.model.cfg.custom_sample_inference)
# sf.read returns (samples,) for mono or (samples, channels) for stereo
# Convert to (channels, samples) format
if audio.ndim == 1:
audio = audio[None, :] # Add channel dimension for mono
else:
audio = audio.T # Transpose to (channels, samples) for stereo
input_signal = torch.from_numpy(audio).float().to(device)[:1, :]
input_signal = resample(input_signal, sr, self.model.source_sample_rate)
input_signal_lens = torch.tensor([input_signal.size(-1)]).to(device)
if input_pad_len > 0:
input_signal = torch.nn.functional.pad(input_signal, (0, input_pad_len), mode='constant', value=0)
input_signal_lens = input_signal_lens + input_pad_len
source_encoded, lengths, _ = self.model.perception(
input_signal=input_signal, input_signal_length=input_signal_lens, return_encoder_emb=True
)
B, T_local, H = source_encoded.shape
if prompt_tokens is not None and prompt_token_lens is not None:
prompt_embedded = self.model.embed_tokens(prompt_tokens)
B_prompt, max_prompt_len, H_prompt = prompt_embedded.shape
assert B == B_prompt, f"Batch size mismatch: source={B}, prompt={B_prompt}"
assert H == H_prompt, f"Hidden size mismatch: source={H}, prompt={H_prompt}"
new_source_encoded = torch.zeros(
B, max_prompt_len + T_local, H, dtype=source_encoded.dtype, device=source_encoded.device
)
for i, prompt_len in enumerate(prompt_token_lens):
prompt_len = prompt_len.item()
if prompt_len > 0:
new_source_encoded[i, :prompt_len, :] = prompt_embedded[i, :prompt_len, :]
src_len = lengths[i].item()
new_source_encoded[i, prompt_len : prompt_len + src_len, :] = source_encoded[i, :src_len, :]
lengths[i] = prompt_len + src_len
source_encoded = new_source_encoded
T_local = source_encoded.shape[1]
B, T_local, H = source_encoded.shape
if self.model._use_fsdp:
T_tensor = torch.tensor([T_local], device=source_encoded.device)
dist.all_reduce(T_tensor, op=dist.ReduceOp.MAX)
T = int(T_tensor.item())
if T > T_local:
last_frame_source = source_encoded[:, T_local - 1 : T_local, :]
pad_source = last_frame_source.repeat(1, T - T_local, 1)
source_encoded = torch.cat([source_encoded, pad_source], dim=1)
else:
T = T_local
input_embeds = source_encoded.clone()
input_embeds *= self.model.cfg.get("duplex_user_channel_weight", 1.0)
use_cache = True
if 'Nemotron' in self.model.cfg.pretrained_llm:
cache = None
use_cache = False
logging.info("Using no-cache mode for Nemotron (full history each step)")
else:
cache = DynamicCache()
use_cache = True
gen_text = torch.empty(B, T, device=self.model.device, dtype=torch.long)
if self.model.predict_user_text:
gen_asr = torch.empty(B, T, device=self.model.device, dtype=torch.long)
else:
gen_asr = None
if prompt_tokens is not None and prompt_token_lens is not None:
for i, prompt_len in enumerate(prompt_token_lens):
prompt_len = prompt_len.item()
if prompt_len > 0:
gen_text[i, :prompt_len] = self.model.text_pad_id
if self.model.predict_user_text:
gen_asr[i, :prompt_len] = self.model.text_pad_id
input_embeds[:, 0] += self.model._get_bos_embedding() * self.model.cfg.get("duplex_text_channel_weight", 1.0)
if self.model.predict_user_text:
input_embeds[:, 0] += self.model._get_asr_bos_embedding() * self.model.cfg.get(
"duplex_asr_text_weight", 1.0
)
start_gen_pos = 0
if prompt_token_lens is not None:
max_prompt_len = prompt_token_lens.max().item()
start_gen_pos = max_prompt_len
is_prompt_position_mask = torch.zeros(B, T, dtype=torch.bool, device=self.model.device)
if prompt_token_lens is not None:
for i, prompt_len in enumerate(prompt_token_lens):
prompt_len_val = prompt_len.item()
if prompt_len_val > 0:
is_prompt_position_mask[i, :prompt_len_val] = True
return {
"input_signal": input_signal,
"input_signal_lens": input_signal_lens,
"lengths": lengths,
"B": B,
"T": T,
"T_local": T_local,
"input_embeds": input_embeds,
"cache": cache,
"use_cache": use_cache,
"gen_text": gen_text,
"gen_asr": gen_asr,
"start_gen_pos": start_gen_pos,
"is_prompt_position_mask": is_prompt_position_mask,
}
def _step_zero(self, inference_state):
"""Perform inference for the first step (position 0)."""
ans = self.model(
inference_state["input_embeds"][:, :1],
cache=inference_state["cache"],
)
if inference_state["start_gen_pos"] == 0:
inference_state["gen_text"][:, 0] = ans["text_logits"][:, -1].argmax(dim=-1)
if self.model.predict_user_text:
inference_state["gen_asr"][:, 0] = ans["asr_logits"][:, -1].argmax(dim=-1)
return ans, inference_state
def _maybe_apply_forced_turn_taking(self, t, inference_state, is_prompt_position):
"""Apply forced turn-taking rules based on ASR channel tokens."""
if not self.model.cfg.get("force_turn_taking", False):
return
threshold = self.model.cfg.get("force_turn_taking_threshold", 40)
pad_window_steps = self.model.cfg.get("force_turn_taking_pad_window", 25)
# Backward compatibility: support old checkpoints trained with '^' and '$'
# For old models, set model.user_bos_token="^" and model.user_eos_token="$" in config
user_bos_token = self.model.cfg.get("user_bos_token", None)
user_eos_token = self.model.cfg.get("user_eos_token", None)
if user_bos_token is not None:
legacy_user_bos_id = self.model.tokenizer.text_to_ids(user_bos_token)[0]
else:
legacy_user_bos_id = None
if user_eos_token is not None:
legacy_user_eos_id = self.model.tokenizer.text_to_ids(user_eos_token)[0]
else:
legacy_user_eos_id = None
for batch_idx in range(inference_state["B"]):
if is_prompt_position[batch_idx]:
continue
lookback_start = max(0, t - threshold)
agent_text_window = inference_state["gen_text"][batch_idx, lookback_start:t]
current_asr_token = inference_state["gen_asr"][batch_idx, t]
# ASR EOS or ~1 sec of pad tokens → insert agent BOS if not present in window
# Skip if we don't have enough tokens at the beginning
if t < pad_window_steps:
continue
pad_lookback_start = t - pad_window_steps
asr_recent_tokens = inference_state["gen_asr"][batch_idx, pad_lookback_start:t]
has_pad_window = (
(asr_recent_tokens == self.model.text_pad_id).all() if len(asr_recent_tokens) > 0 else False
)
# Require that the pad window starts after a non-pad token
if has_pad_window and pad_lookback_start > 0:
token_before_window = inference_state["gen_asr"][batch_idx, pad_lookback_start - 1]
has_pad_window = token_before_window != self.model.text_pad_id
elif has_pad_window and pad_lookback_start == 0:
# If the pad window starts at position 0, it doesn't meet the requirement
has_pad_window = False
# Check for user EOS: either tokenizer.eos (new) or legacy user_eos (old models)
is_user_eos = current_asr_token == self.model.tokenizer.eos
if legacy_user_eos_id is not None:
is_user_eos = is_user_eos or (current_asr_token == legacy_user_eos_id)
# Check for user BOS: either text_bos_id (new) or legacy user_bos (old models)
is_user_bos = current_asr_token == self.model.text_bos_id
if legacy_user_bos_id is not None:
is_user_bos = is_user_bos or (current_asr_token == legacy_user_bos_id)
if is_user_eos or has_pad_window:
# User has finished talking or remains silent for a while
if not (agent_text_window == self.model.text_bos_id).any():
inference_state["gen_text"][batch_idx, t] = self.model.text_bos_id
elif is_user_bos:
# User has started talking but agent has not stopped yet
if not (agent_text_window == self.model.text_eos_id).any():
inference_state["gen_text"][batch_idx, t] = self.model.text_eos_id
def _step_inference(self, t, inference_state, ans):
"""Perform inference for one step t in the autoregressive loop."""
last_emb = self.model.embed_tokens(inference_state["gen_text"][:, t - 1]) * self.model.cfg.get(
"duplex_text_channel_weight", 1.0
)
if self.model.predict_user_text:
last_asr_emb = self.model.embed_asr_tokens(inference_state["gen_asr"][:, t - 1]) * self.model.cfg.get(
"duplex_asr_text_weight", 1.0
)
last_emb += last_asr_emb
inference_state["input_embeds"][:, t] += last_emb
is_prompt_position = inference_state["is_prompt_position_mask"][:, t]
if inference_state["use_cache"]:
ans = self.model(
inference_state["input_embeds"][:, t : t + 1],
cache=ans["cache"],
)
if not is_prompt_position.all():
generated_tokens = ans["text_logits"][:, -1].argmax(dim=-1)
inference_state["gen_text"][:, t] = torch.where(
is_prompt_position, inference_state["gen_text"][:, t], generated_tokens
)
else:
ans = self.model(
inference_state["input_embeds"][:, : t + 1],
cache=None,
)
if not is_prompt_position.all():
generated_tokens = ans["text_logits"][:, -1].argmax(dim=-1)
inference_state["gen_text"][:, t] = torch.where(
is_prompt_position, inference_state["gen_text"][:, t], generated_tokens
)
if self.model.predict_user_text:
if not is_prompt_position.all():
generated_asr = ans["asr_logits"][:, -1].argmax(dim=-1)
inference_state["gen_asr"][:, t] = torch.where(
is_prompt_position, inference_state["gen_asr"][:, t], generated_asr
)
self._maybe_apply_forced_turn_taking(t, inference_state, is_prompt_position)
return ans
def _post_inference(self, inference_state, prompt_token_lens):
"""Post-process inference results and prepare output."""
gen_text = inference_state["gen_text"]
gen_asr = inference_state["gen_asr"]
lengths = inference_state["lengths"]
T_local = inference_state["T_local"]
T = inference_state["T"]
B = inference_state["B"]
if self.model._use_fsdp and T > T_local:
gen_text = gen_text[:, :T_local]
if self.model.predict_user_text:
gen_asr = gen_asr[:, :T_local]
if self.model.predict_user_text:
gen_text_src = gen_asr
src_text_cleaned = [
self.model.tokenizer.ids_to_text(gen_text_src[b]) for b in range(gen_text_src.shape[0])
]
else:
gen_text_src = None
src_text_cleaned = None
if prompt_token_lens is not None:
max_prompt_len = prompt_token_lens.max().item()
if max_prompt_len > 0:
current_T = gen_text.shape[1]
gen_text_trimmed = torch.zeros(
B, current_T - max_prompt_len, device=self.model.device, dtype=torch.long
)
gen_asr_trimmed = None
if self.model.predict_user_text:
gen_asr_trimmed = torch.zeros(
B, current_T - max_prompt_len, device=self.model.device, dtype=torch.long
)
lengths_trimmed = lengths.clone()
for i, prompt_len in enumerate(prompt_token_lens):
prompt_len_val = prompt_len.item()
actual_len = lengths[i].item() - prompt_len_val
if actual_len > 0:
gen_text_trimmed[i, :actual_len] = gen_text[i, prompt_len_val : prompt_len_val + actual_len]
if self.model.predict_user_text:
gen_asr_trimmed[i, :actual_len] = gen_asr[i, prompt_len_val : prompt_len_val + actual_len]
lengths_trimmed[i] = actual_len
gen_text = gen_text_trimmed
if self.model.predict_user_text:
gen_asr = gen_asr_trimmed
gen_text_src = gen_asr
lengths = lengths_trimmed
ans = {
"text": tokens_to_str(
gen_text,
lengths,
tokenizer=self.model.tokenizer,
pad_id=self.model.text_pad_id,
eval_text_turn_taking=self.model.cfg.get("eval_text_turn_taking", True),
),
"src_text": src_text_cleaned,
"tokens_text_src": gen_text_src,
"tokens_text": gen_text,
"tokens_len": lengths,
"source_audio": inference_state["input_signal"],
"source_audio_len": inference_state["input_signal_lens"],
}
return ans
@torch.no_grad()
def offline_inference(
self,
input_signal: torch.Tensor,
input_signal_lens: torch.Tensor,
input_pad_len: int = 0,
prompt_tokens: torch.Tensor = None,
prompt_token_lens: torch.Tensor = None,
) -> dict[str, torch.Tensor]:
"""
Autoregressive prediction (text only).
"""
inference_state = self._init_inference(
input_signal, input_signal_lens, input_pad_len, prompt_tokens, prompt_token_lens
)
ans, inference_state = self._step_zero(inference_state)
for t in range(1, inference_state["T"]):
ans = self._step_inference(t, inference_state, ans)
return self._post_inference(inference_state, prompt_token_lens)