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
718 lines
30 KiB
Python
718 lines
30 KiB
Python
# Copyright (c) 2026, 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.
|
|
|
|
"""Parallel Expert Speech Encoder.
|
|
|
|
Runs a Sortformer speaker-diarization expert and an ASR Conformer encoder on the
|
|
same mel input, then fuses their outputs (LayerNorm + sinusoidal speaker-kernel +
|
|
ADD). Expects un-normalised mels; the ASR branch re-applies ``normalize_batch``
|
|
internally. I/O matches :class:`ConformerEncoder` (drop-in). Only self-contained PE
|
|
bundles (inline ``asr_encoder_cfg`` + ``diarization_model_cfg`` in
|
|
``model_config.yaml``) are supported.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import math
|
|
import os
|
|
import tarfile
|
|
from typing import List, Optional, Union
|
|
|
|
import torch
|
|
import torch.distributed as dist
|
|
from lightning.pytorch import Trainer
|
|
from omegaconf import DictConfig, OmegaConf
|
|
from torch import nn
|
|
from tqdm import tqdm
|
|
|
|
from nemo.collections.asr.modules.conformer_encoder import ConformerEncoder
|
|
from nemo.collections.asr.parts.preprocessing.features import normalize_batch
|
|
from nemo.core.classes import ModelPT
|
|
from nemo.core.classes.common import PretrainedModelInfo
|
|
from nemo.core.classes.module import freeze, unfreeze
|
|
from nemo.utils import logging
|
|
from nemo.utils.decorators import experimental
|
|
|
|
__all__ = [
|
|
'ParallelExpertEncoder',
|
|
'ParallelExpertEncoderPT',
|
|
]
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _default_dtype(dtype: torch.dtype):
|
|
"""Temporarily set the global default float dtype.
|
|
|
|
Makes ``SortformerModules.init_streaming_state`` allocate its dtype-less
|
|
speaker-cache / FIFO buffers in the diarizer's dtype, avoiding fp32/bf16 mismatch.
|
|
"""
|
|
prev = torch.get_default_dtype()
|
|
if dtype == prev or not dtype.is_floating_point:
|
|
yield
|
|
return
|
|
torch.set_default_dtype(dtype)
|
|
try:
|
|
yield
|
|
finally:
|
|
torch.set_default_dtype(prev)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _disable_dist_feature_sync():
|
|
"""Temporarily make ``torch.distributed`` look uninitialized.
|
|
|
|
Skips the cross-rank ``all_reduce`` in ``SortformerEncLabelModel.forward_streaming``,
|
|
which is unnecessary and unsafe for single-recording inference (e.g. a vLLM worker).
|
|
The original ``dist.is_initialized`` is always restored.
|
|
"""
|
|
if not (hasattr(dist, "is_initialized") and dist.is_initialized()):
|
|
yield
|
|
return
|
|
orig_is_initialized = dist.is_initialized
|
|
dist.is_initialized = lambda: False
|
|
try:
|
|
yield
|
|
finally:
|
|
dist.is_initialized = orig_is_initialized
|
|
|
|
|
|
def _clone_config(config: Optional[DictConfig]) -> Optional[DictConfig]:
|
|
"""Deep-copy a ``DictConfig`` without resolving interpolations.
|
|
|
|
``from_config_dict`` mutates its input in place, so sub-target builders get a copy.
|
|
"""
|
|
if config is None:
|
|
return None
|
|
return OmegaConf.create(OmegaConf.to_container(config, resolve=False))
|
|
|
|
|
|
@experimental
|
|
class ParallelExpertEncoderPT(ModelPT):
|
|
"""ModelPT shell so a :class:`ParallelExpertEncoder` can be saved/restored as a
|
|
``.nemo`` archive (inline ``asr_encoder_cfg`` + ``diarization_model_cfg``).
|
|
"""
|
|
|
|
def __init__(self, cfg: DictConfig, trainer: Optional[Trainer] = None):
|
|
super().__init__(cfg=cfg, trainer=trainer)
|
|
self.encoder = ParallelExpertEncoder(
|
|
asr_encoder_cfg=self._cfg.get('asr_encoder_cfg', None),
|
|
diarization_model_cfg=self._cfg.get('diarization_model_cfg', None),
|
|
asr_normalize_type=self._cfg.get('asr_normalize_type', None),
|
|
freeze_diar=self._cfg.get('freeze_diar', True),
|
|
freeze_asr=self._cfg.get('freeze_asr', False),
|
|
online_inference_length=self._cfg.get('online_inference_length', 500),
|
|
chunk_left_context=self._cfg.get('chunk_left_context', 50),
|
|
chunk_right_context=self._cfg.get('chunk_right_context', 50),
|
|
diar_fifo_len=self._cfg.get('diar_fifo_len', 40),
|
|
diar_spkcache_update_period=self._cfg.get('diar_spkcache_update_period', 300),
|
|
diar_spkcache_len=self._cfg.get('diar_spkcache_len', 188),
|
|
)
|
|
|
|
@classmethod
|
|
def list_available_models(cls) -> List[PretrainedModelInfo]:
|
|
return []
|
|
|
|
def setup_training_data(self, train_data_config: Union[DictConfig, dict]):
|
|
pass
|
|
|
|
def setup_validation_data(self, val_data_config: Union[DictConfig, dict]):
|
|
pass
|
|
|
|
@staticmethod
|
|
def is_pe_nemo(nemo_path: str) -> bool:
|
|
"""Detect whether a ``.nemo`` archive is a :class:`ParallelExpertEncoderPT` bundle.
|
|
|
|
Reads only ``model_config.yaml`` and checks its ``target:``.
|
|
|
|
Args:
|
|
nemo_path (str): Path to a ``.nemo`` archive.
|
|
|
|
Returns:
|
|
``True`` if ``target`` ends with ``ParallelExpertEncoderPT``, else ``False``.
|
|
"""
|
|
if not (isinstance(nemo_path, str) and nemo_path.endswith('.nemo') and os.path.isfile(nemo_path)):
|
|
return False
|
|
try:
|
|
with tarfile.open(nemo_path, mode='r') as tf:
|
|
for member in tf.getmembers():
|
|
if os.path.basename(member.name) == 'model_config.yaml':
|
|
fobj = tf.extractfile(member)
|
|
if fobj is None:
|
|
return False
|
|
cfg = OmegaConf.create(fobj.read().decode('utf-8'))
|
|
return str(cfg.get('target', '')).endswith('ParallelExpertEncoderPT')
|
|
except (tarfile.TarError, OSError) as exc:
|
|
logging.warning("[ParallelExpertEncoder] Could not inspect %s: %s", nemo_path, exc)
|
|
return False
|
|
return False
|
|
|
|
@classmethod
|
|
def load_from_nemo(
|
|
cls,
|
|
model_path_or_name: str,
|
|
*,
|
|
map_location: Union[str, torch.device] = 'cpu',
|
|
strict: bool = True,
|
|
) -> ParallelExpertEncoder:
|
|
"""Load a self-contained PE bundle and return its inner encoder.
|
|
|
|
Follows the standard NeMo :class:`~nemo.core.classes.common.Model`
|
|
convention for resolving a checkpoint reference:
|
|
|
|
* a local ``.nemo`` file is restored with :meth:`ModelPT.restore_from`;
|
|
* otherwise ``model_path_or_name`` is treated as a pretrained model
|
|
identifier -- a HuggingFace Hub repo id (``{repo}/{name}``) or an NGC
|
|
alias -- and resolved with :meth:`Model.from_pretrained`, which
|
|
downloads/caches the ``.nemo`` (honouring the HuggingFace cache and
|
|
``HF_HUB_OFFLINE``, so a prefetched cache works on offline nodes).
|
|
|
|
This mirrors ``speechlm2.parts.pretrained.load_pretrained_nemo`` so PE
|
|
bundles load uniformly from local files or model cards.
|
|
|
|
Args:
|
|
model_path_or_name (str): Local ``.nemo`` path or pretrained model id.
|
|
map_location (str | torch.device): Device to map weights onto.
|
|
strict (bool): Enforce exact state-dict match.
|
|
|
|
Returns:
|
|
The restored :class:`ParallelExpertEncoder`.
|
|
"""
|
|
if (
|
|
isinstance(model_path_or_name, str)
|
|
and model_path_or_name.endswith('.nemo')
|
|
and os.path.isfile(model_path_or_name)
|
|
):
|
|
bundle = cls.restore_from(
|
|
restore_path=model_path_or_name,
|
|
map_location=map_location,
|
|
strict=strict,
|
|
)
|
|
else:
|
|
bundle = cls.from_pretrained(
|
|
model_name=model_path_or_name,
|
|
map_location=map_location,
|
|
strict=strict,
|
|
)
|
|
return bundle.encoder
|
|
|
|
@classmethod
|
|
def save_to_nemo(
|
|
cls,
|
|
encoder: ParallelExpertEncoder,
|
|
output_nemo_path: str,
|
|
*,
|
|
template_bundle_path: str,
|
|
) -> None:
|
|
"""Save ``encoder`` as a self-contained PE ``.nemo``, reusing ``model_config.yaml``
|
|
from ``template_bundle_path``.
|
|
|
|
The template must describe the same architecture (``d_model``, ``n_spk``);
|
|
mismatches raise :class:`ValueError` fail-fast.
|
|
|
|
Args:
|
|
encoder (ParallelExpertEncoder): The encoder whose weights are persisted.
|
|
output_nemo_path (str): Destination ``.nemo`` path.
|
|
template_bundle_path (str): Existing PE ``.nemo`` whose ``model_config.yaml`` is reused.
|
|
"""
|
|
if not isinstance(encoder, ParallelExpertEncoder):
|
|
raise TypeError(f"save_to_nemo expects a ParallelExpertEncoder, " f"got {type(encoder).__name__}")
|
|
if not os.path.isfile(template_bundle_path):
|
|
raise FileNotFoundError(f"template_bundle_path does not exist: {template_bundle_path}")
|
|
|
|
template_cfg: Optional[DictConfig] = None
|
|
with tarfile.open(template_bundle_path, mode='r') as tf:
|
|
for member in tf.getmembers():
|
|
if os.path.basename(member.name) == 'model_config.yaml':
|
|
fobj = tf.extractfile(member)
|
|
if fobj is not None:
|
|
template_cfg = OmegaConf.create(fobj.read().decode('utf-8'))
|
|
break
|
|
if template_cfg is None:
|
|
raise RuntimeError(f"Could not read 'model_config.yaml' from template bundle: {template_bundle_path}")
|
|
|
|
tmpl_asr = template_cfg.get('asr_encoder_cfg', None)
|
|
tmpl_diar = template_cfg.get('diarization_model_cfg', None)
|
|
if tmpl_asr in (None, {}, '') or tmpl_diar in (None, {}, ''):
|
|
raise ValueError(
|
|
f"Template bundle {template_bundle_path} is not self-contained "
|
|
"(asr_encoder_cfg / diarization_model_cfg missing); it cannot be "
|
|
"used as a save template."
|
|
)
|
|
|
|
tmpl_d_model = int(tmpl_asr.get('d_model', -1))
|
|
tmpl_n_spk = int(tmpl_diar.get('sortformer_modules', {}).get('num_spks', -1))
|
|
enc_d_model = int(encoder.d_model)
|
|
enc_n_spk = int(encoder.n_spk)
|
|
if tmpl_d_model != enc_d_model:
|
|
raise ValueError(
|
|
f"Template asr_encoder_cfg.d_model={tmpl_d_model} does not match "
|
|
f"encoder.d_model={enc_d_model}; the saved bundle would fail "
|
|
"strict reload."
|
|
)
|
|
if tmpl_n_spk != enc_n_spk:
|
|
raise ValueError(
|
|
f"Template diarization_model_cfg.sortformer_modules.num_spks="
|
|
f"{tmpl_n_spk} does not match encoder.n_spk={enc_n_spk}; the "
|
|
"saved bundle would fail strict reload."
|
|
)
|
|
|
|
# Fresh PT shell from the template cfg to reuse NeMo's save_to; swap in encoder.
|
|
shell = cls(cfg=template_cfg, trainer=None)
|
|
shell.encoder = encoder
|
|
# Pin `_cfg` to the verbatim template so save_to round-trips it exactly.
|
|
shell._cfg = template_cfg
|
|
|
|
shell.save_to(output_nemo_path)
|
|
logging.info(
|
|
"[ParallelExpertEncoder] Saved PE bundle to %s using template config from %s",
|
|
output_nemo_path,
|
|
template_bundle_path,
|
|
)
|
|
|
|
|
|
@experimental
|
|
class ParallelExpertEncoder(nn.Module):
|
|
"""Sortformer-diarizer + ASR Conformer encoder; I/O identical to :class:`ConformerEncoder`.
|
|
|
|
Reconstructed from inline configs in the PE bundle's ``model_config.yaml``.
|
|
|
|
Args:
|
|
asr_encoder_cfg (DictConfig): Inline config for the ASR-side :class:`ConformerEncoder`.
|
|
diarization_model_cfg (DictConfig): Inline config for the :class:`SortformerEncLabelModel`.
|
|
asr_normalize_type (str, optional): Normalization replayed on the ASR branch. Defaults to ``per_feature``.
|
|
freeze_diar (bool): Freeze the Sortformer parameters. Defaults to ``True``.
|
|
freeze_asr (bool): Freeze the wrapped ASR ConformerEncoder. Defaults to ``False``.
|
|
online_inference_length (int): Online-inference window in encoder output frames
|
|
(default ``500`` ~= 40s); ``<= 0`` disables it.
|
|
chunk_left_context (int): Left context (output frames) per online window, shared by
|
|
both branches. Default ``50``.
|
|
chunk_right_context (int): Right context (output frames) per online window, shared by
|
|
both branches. Default ``50``.
|
|
diar_fifo_len (int): Sortformer streaming ``fifo_len``. Default ``40``.
|
|
diar_spkcache_update_period (int): Sortformer streaming ``spkcache_update_period``. Default ``300``.
|
|
diar_spkcache_len (int): Sortformer streaming ``spkcache_len``. Default ``188``.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
asr_encoder_cfg: DictConfig,
|
|
diarization_model_cfg: DictConfig,
|
|
asr_normalize_type: Optional[str] = None,
|
|
freeze_diar: bool = True,
|
|
freeze_asr: bool = False,
|
|
online_inference_length: int = 500,
|
|
chunk_left_context: int = 50,
|
|
chunk_right_context: int = 50,
|
|
diar_fifo_len: int = 40,
|
|
diar_spkcache_update_period: int = 300,
|
|
diar_spkcache_len: int = 188,
|
|
):
|
|
super().__init__()
|
|
|
|
# Lazy import: SortformerEncLabelModel imports from asr.modules (circular).
|
|
from nemo.collections.asr.models.sortformer_diar_models import SortformerEncLabelModel
|
|
|
|
if asr_encoder_cfg is None or diarization_model_cfg is None:
|
|
raise ValueError(
|
|
"ParallelExpertEncoder requires both `asr_encoder_cfg` and "
|
|
"`diarization_model_cfg`; self-contained PE bundles supply "
|
|
"these inline in their model_config.yaml."
|
|
)
|
|
|
|
self.asr_encoder = ConformerEncoder.from_config_dict(_clone_config(asr_encoder_cfg))
|
|
if not isinstance(self.asr_encoder, ConformerEncoder):
|
|
raise TypeError(
|
|
f"Expected `asr_encoder_cfg._target_` to instantiate a "
|
|
f"ConformerEncoder, got {type(self.asr_encoder).__name__} instead."
|
|
)
|
|
self.asr_normalize_type = asr_normalize_type or 'per_feature'
|
|
self._feat_in = self.asr_encoder._feat_in
|
|
|
|
self.diarization_model = SortformerEncLabelModel.from_config_dict(_clone_config(diarization_model_cfg))
|
|
|
|
self.freeze_diar = freeze_diar
|
|
self.freeze_asr = freeze_asr
|
|
|
|
# Long-form / online inference configuration.
|
|
self.online_inference_length = int(online_inference_length)
|
|
# Overlap-and-trim context (output frames) shared by both branches.
|
|
self.chunk_left_context = max(0, int(chunk_left_context))
|
|
self.chunk_right_context = max(0, int(chunk_right_context))
|
|
# Online-inference window + context in input mel frames (constant per session).
|
|
self.chunk_feat_len = self.online_inference_length * self.asr_encoder.subsampling_factor
|
|
self.left_ctx_feat_len = self.chunk_left_context * self.asr_encoder.subsampling_factor
|
|
self.right_ctx_feat_len = self.chunk_right_context * self.asr_encoder.subsampling_factor
|
|
self.diar_fifo_len = int(diar_fifo_len)
|
|
self.diar_spkcache_update_period = int(diar_spkcache_update_period)
|
|
self.diar_spkcache_len = int(diar_spkcache_len)
|
|
|
|
self.n_spk = int(self.diarization_model.sortformer_modules.n_spk)
|
|
self.asr_d_model = self.asr_encoder.d_model
|
|
|
|
self.asr_norm = nn.LayerNorm(self.asr_d_model)
|
|
self.diar_norm = nn.LayerNorm(self.n_spk)
|
|
self.register_buffer(
|
|
"diar_kernel",
|
|
self._build_sinusoid_position_encoding(self.n_spk, self.asr_d_model),
|
|
persistent=False,
|
|
)
|
|
|
|
if self.freeze_diar:
|
|
self.diarization_model.eval()
|
|
for p in self.diarization_model.parameters():
|
|
p.requires_grad = False
|
|
if self.freeze_asr:
|
|
self.asr_encoder.eval()
|
|
for p in self.asr_encoder.parameters():
|
|
p.requires_grad = False
|
|
|
|
def train(self, mode: bool = True) -> "ParallelExpertEncoder":
|
|
"""Set training mode, but keep frozen sub-branches in eval.
|
|
|
|
The parent ``model.train()`` recurses into every sub-module, which would re-enable
|
|
dropout / BatchNorm stat updates in a frozen branch. This re-asserts ``eval()`` on
|
|
the frozen Sortformer (and ASR encoder) so their outputs stay deterministic.
|
|
|
|
Args:
|
|
mode (bool): Whether to set training mode (``True``) or eval mode (``False``).
|
|
|
|
Returns:
|
|
ParallelExpertEncoder: ``self``, matching ``nn.Module.train``d.
|
|
"""
|
|
super().train(mode)
|
|
if self.freeze_diar:
|
|
self.diarization_model.eval()
|
|
if self.freeze_asr:
|
|
self.asr_encoder.eval()
|
|
return self
|
|
|
|
# ConformerEncoder-compatible properties (drop-in for SALM perception).
|
|
@property
|
|
def d_model(self) -> int:
|
|
return self.asr_d_model
|
|
|
|
@property
|
|
def subsampling_factor(self) -> int:
|
|
return self.asr_encoder.subsampling_factor
|
|
|
|
@property
|
|
def pre_encode(self):
|
|
return self.asr_encoder.pre_encode
|
|
|
|
# freeze/unfreeze parity (plain nn.Module re-exposing the standalone helpers).
|
|
def freeze(self) -> None:
|
|
freeze(self)
|
|
|
|
def unfreeze(self, partial: bool = False) -> None:
|
|
unfreeze(self, partial=partial)
|
|
|
|
# Fusion helpers
|
|
@staticmethod
|
|
def _build_sinusoid_position_encoding(max_position: int, embedding_dim: int) -> torch.Tensor:
|
|
"""Mirror of ``MSEncDecMultiTaskModel.get_sinusoid_position_encoding``."""
|
|
position = torch.arange(max_position, dtype=torch.float32).unsqueeze(1)
|
|
div_term = torch.exp(
|
|
torch.arange(0, embedding_dim, 2, dtype=torch.float32) * -(math.log(10000.0) / embedding_dim)
|
|
)
|
|
pe = torch.zeros(max_position, embedding_dim, dtype=torch.float32)
|
|
pe[:, 0::2] = torch.sin(position * div_term)
|
|
pe[:, 1::2] = torch.cos(position * div_term)
|
|
return pe
|
|
|
|
@staticmethod
|
|
def _align_diar_frames(spk_targets: torch.Tensor, target_len: int) -> torch.Tensor:
|
|
"""Pad-by-repeat or truncate ``spk_targets`` to ``target_len`` along time."""
|
|
cur_len = spk_targets.shape[1]
|
|
if cur_len < target_len:
|
|
last = spk_targets[:, -1:, :]
|
|
spk_targets = torch.cat([spk_targets, last.repeat(1, target_len - cur_len, 1)], dim=1)
|
|
elif cur_len > target_len:
|
|
spk_targets = spk_targets[:, :target_len, :]
|
|
return spk_targets
|
|
|
|
@staticmethod
|
|
def _match_module_io(tensor: torch.Tensor, module: nn.Module) -> torch.Tensor:
|
|
"""Cast ``tensor`` to ``module``'s parameter device & dtype (mels arrive fp32, experts run bf16).
|
|
|
|
Args:
|
|
tensor (Tensor): Input to align (e.g. mel features).
|
|
module (nn.Module): Module whose first parameter sets the target device/dtype.
|
|
|
|
Returns:
|
|
``tensor`` moved to the module's device/dtype, or unchanged if it has no parameters.
|
|
"""
|
|
param = next(module.parameters(), None)
|
|
if param is None:
|
|
return tensor
|
|
return tensor.to(device=param.device, dtype=param.dtype)
|
|
|
|
def _fuse_diar_and_asr(self, asr_encoded: torch.Tensor, spk_targets: torch.Tensor) -> torch.Tensor:
|
|
"""Fuse ASR states with speaker-activity preds (LayerNorm + sinusoidal kernel + ADD).
|
|
|
|
Args:
|
|
asr_encoded (Tensor): ASR encoder output. Shape ``(B, D, T_asr)``.
|
|
spk_targets (Tensor): Speaker-activity predictions. Shape ``(B, T_diar, n_spk)``.
|
|
|
|
Returns:
|
|
Fused encoder output. Shape ``(B, D, T_asr)``.
|
|
"""
|
|
asr_enc_states = asr_encoded.transpose(1, 2) # (B, T, D)
|
|
spk_targets = self._align_diar_frames(spk_targets, asr_enc_states.shape[1]).to(asr_enc_states.dtype)
|
|
|
|
asr_enc_states = self.asr_norm(asr_enc_states)
|
|
spk_targets = self.diar_norm(spk_targets)
|
|
speaker_infusion = torch.matmul(spk_targets, self.diar_kernel.to(spk_targets.dtype))
|
|
fused = speaker_infusion + asr_enc_states
|
|
|
|
return fused.transpose(1, 2) # (B, D, T)
|
|
|
|
# Forward — identical signature to ConformerEncoder.forward
|
|
def forward(
|
|
self,
|
|
audio_signal,
|
|
length,
|
|
spk_targets=None,
|
|
):
|
|
"""Encode ``audio_signal``, optionally fusing diarization.
|
|
|
|
Dispatches to :meth:`_forward` (offline) or :meth:`_forward_online` (long-form,
|
|
inference-only, when the input exceeds one window).
|
|
|
|
Args:
|
|
audio_signal (Tensor): Un-normalised mel features. Shape ``(B, feat_in, n_frames)``.
|
|
length (Tensor): Per-sample feature lengths. Shape ``(B,)``.
|
|
spk_targets (Tensor, optional): ``(B, T, n_spk)`` speaker-activity override (RTTM/oracle);
|
|
when ``None`` the wrapped Sortformer is run.
|
|
|
|
Returns:
|
|
Tuple ``(outputs, encoded_lengths)`` with ``outputs`` of shape ``(B, D, T_asr)``.
|
|
"""
|
|
if spk_targets is not None:
|
|
use_online = False
|
|
elif self.online_inference_length > 0 and not self.training:
|
|
# Even if spk_targets is None, use offline if audio is short enough
|
|
use_online = audio_signal.shape[-1] > self.chunk_feat_len
|
|
else:
|
|
use_online = False
|
|
|
|
if use_online:
|
|
return self._forward_online(audio_signal=audio_signal, length=length, spk_targets=spk_targets)
|
|
|
|
return self._forward(
|
|
audio_signal=audio_signal,
|
|
length=length,
|
|
spk_targets=spk_targets,
|
|
)
|
|
|
|
def _forward(
|
|
self,
|
|
audio_signal,
|
|
length,
|
|
spk_targets=None,
|
|
):
|
|
"""Offline (non-chunked) forward pass. See :meth:`forward` for argument semantics."""
|
|
if spk_targets is None:
|
|
# Cast fp32 mels to the diarizer's device/dtype before its conv subsampling.
|
|
diar_signal = self._match_module_io(audio_signal, self.diarization_model)
|
|
diar_length = length.to(device=diar_signal.device)
|
|
with torch.set_grad_enabled(not self.freeze_diar):
|
|
emb_seq, emb_seq_length = self.diarization_model.frontend_encoder(
|
|
processed_signal=diar_signal,
|
|
processed_signal_length=diar_length,
|
|
bypass_pre_encode=False,
|
|
)
|
|
spk_targets = self.diarization_model.forward_infer(
|
|
emb_seq=emb_seq,
|
|
emb_seq_length=emb_seq_length,
|
|
)
|
|
|
|
if self.asr_normalize_type:
|
|
asr_audio_signal, _, _ = normalize_batch(
|
|
audio_signal,
|
|
length,
|
|
normalize_type=self.asr_normalize_type,
|
|
)
|
|
else:
|
|
asr_audio_signal = audio_signal
|
|
# Cast fp32 mels to the ASR encoder's device/dtype before its conv subsampling.
|
|
asr_audio_signal = self._match_module_io(asr_audio_signal, self.asr_encoder)
|
|
asr_length = length.to(device=asr_audio_signal.device)
|
|
|
|
with torch.set_grad_enabled(not self.freeze_asr):
|
|
asr_encoded, asr_encoded_len = self.asr_encoder(
|
|
audio_signal=asr_audio_signal,
|
|
length=asr_length,
|
|
)
|
|
|
|
if spk_targets is not None:
|
|
outputs = self._fuse_diar_and_asr(asr_encoded, spk_targets)
|
|
else:
|
|
outputs = asr_encoded
|
|
|
|
return outputs, asr_encoded_len
|
|
|
|
def _forward_online(self, audio_signal, length, spk_targets=None):
|
|
"""Long-form online inference: a lock-step loop over fixed windows.
|
|
|
|
Walks the recording in non-overlapping windows of ``online_inference_length``
|
|
output frames. Both experts run on the same context-extended slice
|
|
``[stt - left : end + right]`` (differing only in normalization): the ASR
|
|
encoder uses overlap-and-trim, while the streaming Sortformer carries its
|
|
speaker-cache / FIFO state across windows and trims context internally.
|
|
Per-window diar outputs are aligned to the ASR frame count, then both buffers
|
|
are concatenated and fused once.
|
|
|
|
Args:
|
|
audio_signal (Tensor): Un-normalised mel features. Shape ``(B, feat_in, n_frames)``.
|
|
length (Tensor): Per-sample feature lengths. Shape ``(B,)``.
|
|
spk_targets (Tensor, optional): ``(B, T, n_spk)`` override; when given, only ASR is chunked.
|
|
|
|
Returns:
|
|
Tuple ``(outputs, encoded_lengths)`` with ``outputs`` of shape ``(B, D, T_asr)``.
|
|
"""
|
|
total_feat_len = min(audio_signal.shape[-1], int(length.max().item()))
|
|
num_chunks = max(1, math.ceil(total_feat_len / self.chunk_feat_len))
|
|
|
|
# Normalise the whole utterance once (not per chunk) to match offline stats.
|
|
if self.asr_normalize_type:
|
|
asr_audio_signal, _, _ = normalize_batch(
|
|
audio_signal,
|
|
length,
|
|
normalize_type=self.asr_normalize_type,
|
|
)
|
|
else:
|
|
asr_audio_signal = audio_signal
|
|
|
|
# Match the ASR encoder's device/dtype (mels arrive fp32, encoder runs bf16).
|
|
asr_audio_signal = self._match_module_io(asr_audio_signal, self.asr_encoder)
|
|
length = length.to(device=asr_audio_signal.device)
|
|
|
|
run_streaming_diar = spk_targets is None
|
|
if run_streaming_diar:
|
|
streaming_state, stream_dtype, diar_audio_signal, diar_length = self._init_streaming_diar(
|
|
audio_signal,
|
|
length,
|
|
batch_size=audio_signal.shape[0],
|
|
)
|
|
n_spk = self.diarization_model.sortformer_modules.n_spk
|
|
total_preds = torch.zeros(
|
|
(diar_audio_signal.shape[0], 0, n_spk),
|
|
device=diar_audio_signal.device,
|
|
dtype=stream_dtype,
|
|
)
|
|
|
|
asr_chunks: List[torch.Tensor] = []
|
|
diar_chunks: List[torch.Tensor] = []
|
|
asr_encoded_len = torch.zeros_like(length)
|
|
|
|
for chunk_idx in tqdm(
|
|
range(num_chunks),
|
|
total=num_chunks,
|
|
desc="PEE online inference",
|
|
disable=getattr(self, '_suppress_online_pbar', False),
|
|
):
|
|
stt = chunk_idx * self.chunk_feat_len
|
|
end = min(stt + self.chunk_feat_len, total_feat_len)
|
|
|
|
# Shared context-extended window (input mel frames) for both branches.
|
|
enc_stt = max(stt - self.left_ctx_feat_len, 0)
|
|
enc_end = min(end + self.right_ctx_feat_len, total_feat_len)
|
|
left_offset = stt - enc_stt
|
|
right_offset = enc_end - end
|
|
|
|
asr_chunk = asr_audio_signal[:, :, enc_stt:enc_end]
|
|
chunk_length = (length - enc_stt).clamp(min=0, max=enc_end - enc_stt)
|
|
with torch.set_grad_enabled(not self.freeze_asr):
|
|
enc_ctx, _ = self.asr_encoder(audio_signal=asr_chunk, length=chunk_length)
|
|
# Trim context off in output-frame space using rounded cumulative positions.
|
|
left_drop = left_offset // self.subsampling_factor
|
|
core_len = round(end / self.subsampling_factor) - round(stt / self.subsampling_factor)
|
|
core_len = max(0, min(core_len, enc_ctx.shape[-1] - left_drop))
|
|
enc_chunk = enc_ctx[:, :, left_drop : left_drop + core_len]
|
|
asr_chunks.append(enc_chunk)
|
|
asr_encoded_len += core_len
|
|
align_target = enc_chunk.shape[-1]
|
|
|
|
# Diar branch: stream the same window; Sortformer trims context internally.
|
|
if run_streaming_diar:
|
|
prev_len = total_preds.shape[1]
|
|
diar_chunk = diar_audio_signal[:, :, enc_stt:enc_end].transpose(1, 2) # (B, t, feat_in)
|
|
diar_chunk_length = (diar_length - enc_stt).clamp(min=0, max=enc_end - enc_stt)
|
|
with (
|
|
torch.set_grad_enabled(not self.freeze_diar),
|
|
_disable_dist_feature_sync(),
|
|
_default_dtype(stream_dtype),
|
|
):
|
|
streaming_state, total_preds = self.diarization_model.forward_streaming_step(
|
|
processed_signal=diar_chunk,
|
|
processed_signal_length=diar_chunk_length,
|
|
streaming_state=streaming_state,
|
|
total_preds=total_preds,
|
|
left_offset=left_offset,
|
|
right_offset=right_offset,
|
|
)
|
|
diar_raw = total_preds[:, prev_len:]
|
|
# Newly emitted frames, aligned to the ASR chunk (frame-parallel).
|
|
new_preds = self._align_diar_frames(diar_raw, align_target)
|
|
diar_chunks.append(new_preds)
|
|
|
|
asr_encoded = torch.cat(asr_chunks, dim=2) # (B, D, T_asr)
|
|
if run_streaming_diar:
|
|
spk_targets = torch.cat(diar_chunks, dim=1) # (B, T_asr, n_spk)
|
|
|
|
if spk_targets is not None:
|
|
outputs = self._fuse_diar_and_asr(asr_encoded, spk_targets)
|
|
else:
|
|
outputs = asr_encoded
|
|
|
|
return outputs, asr_encoded_len
|
|
|
|
def _init_streaming_diar(self, audio_signal: torch.Tensor, length: torch.Tensor, batch_size: int):
|
|
"""Configure the wrapped Sortformer for streaming and build its initial state.
|
|
|
|
Args:
|
|
audio_signal (Tensor): Input mel features. Shape ``(B, feat_in, n_frames)``.
|
|
length (Tensor): Per-sample feature lengths. Shape ``(B,)``.
|
|
batch_size (int): Batch size for the streaming state.
|
|
|
|
Returns:
|
|
``(streaming_state, stream_dtype, diar_audio_signal, diar_length)`` cast onto
|
|
the diarizer's device & dtype.
|
|
"""
|
|
sm = self.diarization_model.sortformer_modules
|
|
sm.chunk_len = self.online_inference_length
|
|
sm.fifo_len = self.diar_fifo_len
|
|
sm.spkcache_update_period = self.diar_spkcache_update_period
|
|
sm.spkcache_len = self.diar_spkcache_len
|
|
sm._check_streaming_parameters()
|
|
|
|
diar_param = next(self.diarization_model.parameters(), None)
|
|
if diar_param is not None:
|
|
self.diarization_model.to(diar_param.device)
|
|
diar_device, stream_dtype = diar_param.device, diar_param.dtype
|
|
else:
|
|
diar_device, stream_dtype = audio_signal.device, torch.get_default_dtype()
|
|
|
|
diar_audio_signal = audio_signal.to(device=diar_device, dtype=stream_dtype)
|
|
diar_length = length.to(device=diar_device)
|
|
|
|
with _disable_dist_feature_sync(), _default_dtype(stream_dtype):
|
|
streaming_state = sm.init_streaming_state(
|
|
batch_size=batch_size,
|
|
async_streaming=self.diarization_model.async_streaming,
|
|
device=diar_device,
|
|
)
|
|
return streaming_state, stream_dtype, diar_audio_signal, diar_length
|