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
205 lines
9.1 KiB
Python
205 lines
9.1 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 re
|
|
|
|
import torch
|
|
import torch.utils.data
|
|
from lhotse import CutSet, Seconds, compute_num_frames
|
|
from lhotse.cut import Cut
|
|
from lhotse.dataset.collation import collate_audio, collate_vectors
|
|
from lhotse.utils import ifnone
|
|
|
|
from nemo.collections.common.tokenizers import TokenizerSpec
|
|
from nemo.collections.speechlm2.data.utils import get_pad_id
|
|
from nemo.utils import logging
|
|
|
|
|
|
class DuplexS2SDataset(torch.utils.data.Dataset):
|
|
"""
|
|
A dataset for duplex speech-to-speech models that handles bidirectional conversations.
|
|
|
|
This dataset processes Lhotse CutSet objects containing recordings with supervision segments
|
|
from different speakers (roles). It creates aligned representations of audio and text for
|
|
both source (input) and target (output) channels, preserving temporal alignment between
|
|
audio frames and text tokens.
|
|
|
|
Args:
|
|
tokenizer (TokenizerSpec):
|
|
Tokenizer for converting text to token IDs and vice versa. Must support BOS and EOS tokens.
|
|
It's expected to support PAD token as well, otherwise we will use 0 as the pad token
|
|
and emit a warning.
|
|
|
|
frame_length (Seconds):
|
|
Duration of a single frame in seconds. Used to calculate frame positions for token alignment.
|
|
|
|
source_sample_rate (int):
|
|
Sample rate for source audio (e.g., 16000 Hz).
|
|
|
|
target_sample_rate (int):
|
|
Sample rate for target audio (e.g., 22050 Hz).
|
|
|
|
input_roles (list[str], optional):
|
|
List of speaker roles (cut.supervisions[:].speaker) to consider as inputs. Defaults to ["user"].
|
|
|
|
output_roles (list[str], optional):
|
|
List of speaker roles (cut.supervisions[:].speaker) to consider as outputs. Defaults to ["agent"].
|
|
|
|
Returns:
|
|
A dictionary with the following keys:
|
|
- source_audio: Tensor of source waveform samples [B, T]
|
|
- source_audio_lens: Tensor of source audio lengths [B]
|
|
- target_audio: Tensor of target waveform samples [B, T]
|
|
- target_audio_lens: Tensor of target audio lengths [B]
|
|
- target_tokens: Tensor of target text tokens [B, T], with special tokens (BOS/EOS/PAD)
|
|
at positions aligned with audio frames
|
|
- target_token_lens: Tensor of target token sequence lengths [B]
|
|
- source_tokens: Tensor of source text tokens [B, T], with special tokens (BOS/EOS/PAD)
|
|
at positions aligned with audio frames
|
|
- source_token_lens: Tensor of source token sequence lengths [B]
|
|
- target_texts: List of full target texts joined from output_roles supervisions [B]
|
|
|
|
Notes:
|
|
- The dataset ensures frame-level alignment between audio and text by inserting tokens at
|
|
specific frame positions based on the timing of supervision segments.
|
|
- PAD tokens (typically 0) are used to fill gaps where there's no text.
|
|
- BOS tokens mark the beginning of each speech segment.
|
|
- EOS tokens mark the end of each speech segment.
|
|
- Text tokens from each speaker are placed at frame positions corresponding to their
|
|
timestamp in the original recording, preserving the temporal relationship.
|
|
This is a segment-level alignment only, not word-level alignment.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
tokenizer: TokenizerSpec,
|
|
frame_length: Seconds,
|
|
source_sample_rate: int,
|
|
target_sample_rate: int,
|
|
input_roles: list[str] = None,
|
|
output_roles: list[str] = None,
|
|
):
|
|
self.tokenizer = tokenizer
|
|
self.frame_length = frame_length
|
|
self.source_sample_rate = source_sample_rate
|
|
self.target_sample_rate = target_sample_rate
|
|
self.input_roles = set(ifnone(input_roles, ["user"]))
|
|
self.output_roles = set(ifnone(output_roles, ["agent"]))
|
|
|
|
assert tokenizer.bos is not None, "BOS support in the tokenizer is required for S2S models."
|
|
assert tokenizer.eos is not None, "EOS support in the tokenizer is required for S2S models."
|
|
|
|
def __getitem__(self, cuts: CutSet) -> dict:
|
|
cuts = cuts.transform_text(_strip_timestamps)
|
|
source_audio, source_audio_lens = collate_audio(cuts.resample(self.source_sample_rate))
|
|
target_audio, target_audio_lens = collate_audio(
|
|
cuts.resample(self.target_sample_rate, recording_field="target_audio"), recording_field="target_audio"
|
|
)
|
|
target_tokens, target_token_lens = collate_token_channel(
|
|
cuts, self.tokenizer, self.frame_length, roles=self.output_roles
|
|
)
|
|
source_tokens, source_token_lens = collate_token_channel(
|
|
cuts, self.tokenizer, self.frame_length, roles=self.input_roles
|
|
)
|
|
return {
|
|
"source_audio": source_audio,
|
|
"source_audio_lens": source_audio_lens,
|
|
"target_audio": target_audio,
|
|
"target_audio_lens": target_audio_lens,
|
|
"target_tokens": target_tokens,
|
|
"target_token_lens": target_token_lens,
|
|
"source_tokens": source_tokens,
|
|
"source_token_lens": source_token_lens,
|
|
"target_texts": [
|
|
" ".join(s.text for s in cut.supervisions if s.speaker in self.output_roles) for cut in cuts
|
|
],
|
|
}
|
|
|
|
|
|
def collate_token_channel(
|
|
cuts: CutSet,
|
|
tokenizer: TokenizerSpec,
|
|
frame_length: Seconds,
|
|
roles: set[str],
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
pad_id = get_pad_id(tokenizer)
|
|
tokens = [
|
|
build_token_channel(c, tokenizer=tokenizer, frame_length=frame_length, roles=roles, pad_id=pad_id)
|
|
for c in cuts
|
|
]
|
|
token_lens = torch.tensor([len(tt) for tt in tokens])
|
|
tokens = collate_vectors(tokens, padding_value=pad_id)
|
|
return tokens, token_lens
|
|
|
|
|
|
def build_token_channel(
|
|
cut: Cut,
|
|
tokenizer: TokenizerSpec,
|
|
frame_length: Seconds,
|
|
roles: set[str],
|
|
pad_id: int = -1,
|
|
) -> torch.Tensor:
|
|
diagnostic = f"Extra info: {cut.id=}"
|
|
if getattr(cut, "shard_origin", None) is not None:
|
|
diagnostic = f"{diagnostic} {cut.shard_origin=}"
|
|
|
|
total = compute_num_frames(cut.duration, frame_length, cut.sampling_rate)
|
|
tokens = torch.ones(total, dtype=torch.long) * pad_id
|
|
for supervision in cut.supervisions:
|
|
if supervision.speaker in roles:
|
|
text_ids = torch.as_tensor([tokenizer.bos] + tokenizer.text_to_ids(supervision.text))
|
|
|
|
# Determine the frame offset for the start of the supervision to insert the text tokens.
|
|
pos = compute_num_frames(supervision.start, frame_length, cut.sampling_rate)
|
|
if pos > len(tokens):
|
|
logging.warning(
|
|
f"Ill-constructed example: the beginning offset of a supervision {pos} is larger than the example's length {len(tokens)}. {diagnostic}"
|
|
)
|
|
continue
|
|
|
|
# Determine the frame offset for the last non-EOS text token to form a valid range for insertion;
|
|
# Note that EOS will be placed possibly much later, at the frame that coincides with end of speech,
|
|
# rather than end of text. The gap between last non-EOS token and EOS token will be filled with `pad_id`.
|
|
endpos = pos + len(text_ids)
|
|
if endpos > len(tokens):
|
|
trunc_len = len(tokens) - pos
|
|
logging.warning(
|
|
f"Truncating training example's text_ids of length {len(text_ids)} by {trunc_len} because {endpos=} > {len(tokens)=}. {diagnostic}"
|
|
)
|
|
text_ids = text_ids[:trunc_len]
|
|
try:
|
|
tokens[pos:endpos] = text_ids
|
|
except Exception as e:
|
|
raise RuntimeError(f"{tokens.shape=} {pos=} {endpos=} {text_ids.shape=} {diagnostic}") from e
|
|
|
|
# Insert EOS at the end of the supervision segment.
|
|
eospos = compute_num_frames(supervision.end, frame_length, cut.sampling_rate)
|
|
if eospos < len(tokens): # skip otherwise - unfinished turn
|
|
tokens[eospos] = tokenizer.eos
|
|
|
|
return tokens
|
|
|
|
|
|
def _strip_timestamps(
|
|
text: str, _TIMESTAMP_PATTERN=re.compile(r"<\|\d+\|>"), _SPACE_PATTERN=re.compile(r"\s+")
|
|
) -> str:
|
|
"""
|
|
Strips timestamp tokens from text, e.g. turns:
|
|
'<|0|> Hey <|3|> <|3|> how <|5|> <|7|> are <|8|> <|8|> <|10|> you? <|12|>'
|
|
into:
|
|
'Hey how are you?'
|
|
"""
|
|
# Regexp pattern args are cached compiled patterns (micro-optimization).
|
|
text = _TIMESTAMP_PATTERN.sub("", text) # strip timestamp tokens if present
|
|
return _SPACE_PATTERN.sub(" ", text).strip() # strip multi-whitespaces
|