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
1003 lines
39 KiB
Python
1003 lines
39 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 random
|
|
import re
|
|
from copy import deepcopy
|
|
|
|
import torch
|
|
import torch.nn.functional as F
|
|
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.collections.speechlm2.parts.precision import fp32_precision
|
|
from nemo.collections.tts.parts.utils.helpers import get_mask_from_lengths
|
|
from nemo.utils import logging
|
|
|
|
|
|
class DuplexEARTTSDataset(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"].
|
|
|
|
p_drop_description (float, optional):
|
|
Probability of dropping text descriptions. Default: `0.0`.
|
|
|
|
add_text_bos_and_eos_in_each_turn (bool, optional):
|
|
If True, each conversational turn from any speaker is explicitly delimited
|
|
with BOS and EOS tokens in the text stream.
|
|
Default: `True`.
|
|
|
|
add_audio_prompt (bool, optional):
|
|
If True, an optional audio/speaker prompt is appended after the description.
|
|
Default: `True`.
|
|
|
|
audio_prompt_duration (float, optional):
|
|
Duration (in seconds) of the audio prompt appended when
|
|
`add_audio_prompt=True`. Default: `3.0`.
|
|
|
|
num_delay_speech_tokens (int, optional):
|
|
Number of PAD tokens to insert before speech tokens to artificially
|
|
delay the start of speech. Default: `0`.
|
|
|
|
Returns:
|
|
A dictionary with the following keys:
|
|
- sample_id: List of sample IDs for each cut in the batch [B]
|
|
|
|
- non_prompt_mask: Bool tensor [B, T] marking positions that are not part of the prompt
|
|
- prompt_lens: Tensor of description + audio prompt lengths [B]
|
|
|
|
- aligned_attention_mask: Bool tensor [B, T] used by alignment-aware transformer models
|
|
- aligned_position_ids: Tensor of position indices aligned to audio frames [B, T]
|
|
|
|
- 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_text_tokens: Tensor of frame-aligned input text tokens [B, T],
|
|
including BOS/EOS/PAD when enabled
|
|
- target_token_lens: Tensor of target token sequence lengths [B]
|
|
|
|
- source_tokens: Tensor of frame-aligned source text tokens [B, T],
|
|
including BOS/EOS/PAD
|
|
- source_token_lens: Tensor of source token sequence lengths [B]
|
|
|
|
- target_texts: List of full target texts joined from output_roles supervisions [B]
|
|
|
|
- audio_prompt: Tensor of optional speaker reference waveform samples [B, T]
|
|
- audio_prompt_lens: Tensor of speaker reference audio lengths [B]
|
|
|
|
- task: List indicating the task to use for each cut (default "s2s_duplex") [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,
|
|
frame_length: Seconds,
|
|
source_sample_rate: int,
|
|
target_sample_rate: int,
|
|
input_roles: list[str] = None,
|
|
output_roles: list[str] = None,
|
|
p_drop_description: float = 0.0,
|
|
add_text_bos_and_eos_in_each_turn: bool = True,
|
|
add_audio_prompt: bool = True,
|
|
audio_prompt_duration: float = 3.0,
|
|
num_delay_speech_tokens: int = 0,
|
|
add_system_prompt: bool = False,
|
|
ignore_data_system_prompt: bool = True,
|
|
):
|
|
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"]))
|
|
self.p_drop_description = p_drop_description
|
|
self.add_text_bos_and_eos_in_each_turn = add_text_bos_and_eos_in_each_turn
|
|
self.add_audio_prompt = add_audio_prompt
|
|
self.audio_prompt_duration = audio_prompt_duration
|
|
self.num_delay_speech_tokens = num_delay_speech_tokens
|
|
self.add_system_prompt = add_system_prompt
|
|
self.ignore_data_system_prompt = ignore_data_system_prompt
|
|
|
|
# compute source and target samples_per_frame
|
|
self.source_samples_per_frame = int(self.source_sample_rate * self.frame_length)
|
|
self.target_samples_per_frame = int(self.target_sample_rate * self.frame_length)
|
|
|
|
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)
|
|
# ensures fp32 audio load to avoid issues of duration mistakes on fp16 training
|
|
with fp32_precision():
|
|
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_text_tokens, target_token_lens = collate_token_channel(
|
|
cuts,
|
|
self.tokenizer,
|
|
self.frame_length,
|
|
roles=self.output_roles,
|
|
add_text_bos_and_eos_in_each_turn=self.add_text_bos_and_eos_in_each_turn,
|
|
)
|
|
source_tokens, source_token_lens = collate_token_channel(
|
|
cuts,
|
|
self.tokenizer,
|
|
self.frame_length,
|
|
roles=self.input_roles,
|
|
add_text_bos_and_eos_in_each_turn=self.add_text_bos_and_eos_in_each_turn,
|
|
)
|
|
with fp32_precision():
|
|
audio_prompt, audio_prompt_lens = get_audio_prompt(
|
|
cuts, self.target_sample_rate, roles=self.output_roles, recording_field="target_audio"
|
|
)
|
|
|
|
# add speech channel delay if needed
|
|
if self.num_delay_speech_tokens:
|
|
source_audio, source_audio_lens, target_audio, target_audio_lens = add_speech_delay(
|
|
source_audio,
|
|
source_audio_lens,
|
|
target_audio,
|
|
target_audio_lens,
|
|
self.num_delay_speech_tokens,
|
|
self.target_samples_per_frame,
|
|
self.source_samples_per_frame,
|
|
)
|
|
|
|
if self.add_system_prompt:
|
|
with fp32_precision():
|
|
system_prompts, system_prompts_lens, system_prompts_raw = collate_system_prompt(
|
|
cuts, self.tokenizer, ignore_data_system_prompt=self.ignore_data_system_prompt
|
|
)
|
|
else:
|
|
system_prompts = None
|
|
system_prompts_lens = None
|
|
system_prompts_raw = None
|
|
|
|
# get dataset name/type
|
|
dataset_type = [getattr(c, "type", "") for c in cuts]
|
|
|
|
# add audio prompt if needed
|
|
(
|
|
target_text_tokens,
|
|
target_token_lens,
|
|
source_tokens,
|
|
source_token_lens,
|
|
source_audio,
|
|
source_audio_lens,
|
|
target_audio,
|
|
target_audio_lens,
|
|
prompt_lens,
|
|
) = self.maybe_add_audio_prompt(
|
|
target_text_tokens,
|
|
target_token_lens,
|
|
source_tokens,
|
|
source_token_lens,
|
|
target_audio,
|
|
target_audio_lens,
|
|
source_audio,
|
|
source_audio_lens,
|
|
audio_prompt,
|
|
audio_prompt_lens,
|
|
system_prompts,
|
|
system_prompts_lens,
|
|
)
|
|
|
|
# create non_prompt_mask that should mask desc plus audio prompt if used
|
|
non_prompt_mask = get_mask_from_lengths(target_token_lens)
|
|
for i, frame in enumerate(prompt_lens):
|
|
non_prompt_mask[i, : frame - 1] = 0.0
|
|
|
|
max_len = max(target_token_lens)
|
|
|
|
# Segment IDs per sequence (padded)
|
|
aligned_segment_ids = torch.stack(
|
|
[
|
|
torch.nn.functional.pad(torch.full((seq_len,), i), (0, max_len - seq_len), value=-1) # -1 for padding
|
|
for i, seq_len in enumerate(target_token_lens)
|
|
],
|
|
dim=0,
|
|
) # [B, max_len]
|
|
|
|
# Attention mask: same-segment & causal
|
|
aligned_attention_mask = (
|
|
aligned_segment_ids.unsqueeze(-2) == aligned_segment_ids.unsqueeze(-1)
|
|
) & ( # [B, max_len, max_len]
|
|
torch.arange(max_len).unsqueeze(0).unsqueeze(1) <= torch.arange(max_len).unsqueeze(0).unsqueeze(-1)
|
|
) # causal tril
|
|
|
|
aligned_attention_mask = aligned_attention_mask.unsqueeze(1) # [B, 1, max_len, max_len]
|
|
|
|
# create position IDs from the aligned length
|
|
aligned_position_ids = torch.stack(
|
|
[
|
|
torch.nn.functional.pad(
|
|
torch.arange(seq_len), (0, max(target_token_lens) - seq_len), value=0
|
|
) # value=0 is safe for padding
|
|
for seq_len in target_token_lens
|
|
],
|
|
dim=0,
|
|
)
|
|
|
|
return {
|
|
"sample_id": [str(cut.id) for cut in cuts],
|
|
"non_prompt_mask": non_prompt_mask.bool(),
|
|
"prompt_lens": prompt_lens,
|
|
"aligned_attention_mask": aligned_attention_mask.bool(),
|
|
"aligned_position_ids": aligned_position_ids,
|
|
"source_audio": source_audio,
|
|
"source_audio_lens": source_audio_lens,
|
|
"target_audio": target_audio,
|
|
"target_audio_lens": target_audio_lens,
|
|
"target_text_tokens": target_text_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
|
|
],
|
|
"audio_prompt": audio_prompt,
|
|
"audio_prompt_lens": audio_prompt_lens,
|
|
"system_prompts_raw": system_prompts_raw,
|
|
"dataset_type": dataset_type,
|
|
"task": [getattr(cut, "task", "s2s_duplex") for cut in cuts],
|
|
}
|
|
|
|
def maybe_add_audio_prompt(
|
|
self,
|
|
target_text_tokens: torch.Tensor,
|
|
target_token_lens: torch.Tensor,
|
|
source_tokens: torch.Tensor,
|
|
source_token_lens: torch.Tensor,
|
|
target_audio: torch.Tensor,
|
|
target_audio_lens: torch.Tensor,
|
|
source_audio: torch.Tensor,
|
|
source_audio_lens: torch.Tensor,
|
|
audio_prompt: torch.Tensor,
|
|
audio_prompt_lens: torch.Tensor,
|
|
system_prompts: torch.Tensor = None,
|
|
system_prompts_lens: torch.Tensor = None,
|
|
):
|
|
"""
|
|
Prepend an audio-based speaker prompt and aligned text tokens to the Duplex S2S inputs.
|
|
|
|
This method optionally injects a speaker-reference audio prompt at the beginning of each
|
|
sample in the batch. The prompt is inserted in the target-audio channel and aligned text
|
|
padding is inserted into the text-token streams (input text tokens and source tokens).
|
|
|
|
Args:
|
|
target_text_tokens (torch.Tensor):
|
|
Tensor of input text tokens with shape [B, T_text].
|
|
dtype: torch.long.
|
|
|
|
target_token_lens (torch.Tensor):
|
|
Lengths of target_text_tokens per batch element (before padding). shape [B].
|
|
|
|
source_tokens (torch.Tensor):
|
|
Source-side text tokens, shape [B, T_src_text], dtype torch.long.
|
|
|
|
source_token_lens (torch.Tensor):
|
|
Source text token lengths per batch element, shape [B].
|
|
|
|
target_audio (torch.Tensor):
|
|
Target-side audio waveforms, shape [B, T_audio], dtype torch.float32/float16.
|
|
|
|
target_audio_lens (torch.Tensor):
|
|
Target audio lengths per batch element, shape [B].
|
|
|
|
source_audio (torch.Tensor):
|
|
Source-side audio waveforms, shape [B, T_audio], dtype torch.float32/float16.
|
|
|
|
source_audio_lens (torch.Tensor):
|
|
Source audio lengths per batch element, shape [B].
|
|
|
|
audio_prompt (torch.Tensor):
|
|
Audio prompt waveforms to sample from, shape [B, T_prompt_audio].
|
|
|
|
audio_prompt_lens (torch.Tensor):
|
|
Valid lengths for audio_prompt per batch element.
|
|
|
|
Returns:
|
|
Tuple containing:
|
|
target_text_tokens (torch.Tensor):
|
|
Updated text tokens with prepended prompt-aligned tokens. Shape [B, T'].
|
|
|
|
target_token_lens (torch.Tensor):
|
|
Updated token lengths per batch element.
|
|
|
|
source_tokens (torch.Tensor):
|
|
Updated source text tokens with prompt padding included. Shape [B, T'].
|
|
|
|
source_token_lens (torch.Tensor):
|
|
Updated source token lengths per batch element.
|
|
|
|
source_audio (torch.Tensor):
|
|
Updated source audio with silence padding. Shape [B, T_audio'].
|
|
|
|
source_audio_lens (torch.Tensor):
|
|
Updated source audio lengths.
|
|
|
|
target_audio (torch.Tensor):
|
|
Updated target audio with prompt audio and silence padding. Shape [B, T_audio'].
|
|
|
|
target_audio_lens (torch.Tensor):
|
|
Updated target audio lengths.
|
|
|
|
prompt_lens (list[int]):
|
|
Length (in text-token units) of the prompt region per batch item.
|
|
"""
|
|
|
|
text_pad_id = get_pad_id(self.tokenizer)
|
|
|
|
target_text_tokens_ = []
|
|
source_tokens_ = []
|
|
source_audio_ = []
|
|
target_audio_ = []
|
|
prompt_lens = []
|
|
for i in range(target_text_tokens.size(0)):
|
|
if system_prompts is not None:
|
|
text_prompt = system_prompts[i][: system_prompts_lens[i]]
|
|
else:
|
|
text_prompt = torch.tensor(
|
|
[self.tokenizer.eos],
|
|
dtype=torch.long,
|
|
device=target_text_tokens.device,
|
|
)
|
|
|
|
if self.add_audio_prompt:
|
|
# Compute audio prompt duration in samples (rounded to frame boundaries)
|
|
prompt_audio_size = int(
|
|
((self.audio_prompt_duration * self.target_sample_rate) // self.target_samples_per_frame)
|
|
* self.target_samples_per_frame
|
|
)
|
|
|
|
prompt_audio = sample_audio_segments_repeat(
|
|
audio_prompt, audio_prompt_lens, prompt_audio_size, sample=True
|
|
)
|
|
|
|
# add silence at the end of the prompt
|
|
prompt_audio[:, -int(self.target_samples_per_frame * 2) :] = 0
|
|
|
|
# Number of text tokens to insert to align with prompt_audio frames
|
|
prompt_audio_text_pad_size = prompt_audio_size // self.target_samples_per_frame
|
|
|
|
prompt_audio_text_pad = (
|
|
torch.ones(
|
|
prompt_audio_text_pad_size,
|
|
device=target_text_tokens.device,
|
|
dtype=target_text_tokens.dtype,
|
|
)
|
|
* text_pad_id
|
|
)
|
|
prompt_audio_text_pad[-1] = self.tokenizer.eos
|
|
|
|
new_target_text_tokens = torch.cat(
|
|
[
|
|
text_prompt.to(target_text_tokens.dtype),
|
|
prompt_audio_text_pad,
|
|
target_text_tokens[i],
|
|
]
|
|
)
|
|
target_text_tokens_.append(new_target_text_tokens)
|
|
target_token_lens[i] += len(text_prompt) + prompt_audio_text_pad_size
|
|
|
|
new_source_tokens = torch.cat([text_prompt, prompt_audio_text_pad, source_tokens[i]])
|
|
source_tokens_.append(new_source_tokens)
|
|
source_token_lens[i] += len(text_prompt) + prompt_audio_text_pad_size
|
|
|
|
# Silence in source audio during prompt processing
|
|
pad_size_src = (len(text_prompt) * self.source_samples_per_frame) + prompt_audio.size(1)
|
|
pad_audio_src = torch.zeros(
|
|
pad_size_src,
|
|
device=source_audio.device,
|
|
dtype=source_audio.dtype,
|
|
)
|
|
source_audio_.append(torch.cat([pad_audio_src, source_audio[i]]))
|
|
source_audio_lens[i] += pad_size_src
|
|
|
|
# Insert prompt audio in the target channel
|
|
pad_size_tgt = len(text_prompt) * self.target_samples_per_frame
|
|
pad_audio_tgt = torch.zeros(
|
|
pad_size_tgt,
|
|
device=target_audio.device,
|
|
dtype=target_audio.dtype,
|
|
)
|
|
target_audio_.append(torch.cat([pad_audio_tgt, prompt_audio[i], target_audio[i]]))
|
|
target_audio_lens[i] += pad_size_tgt + prompt_audio.size(1)
|
|
|
|
prompt_lens.append(len(text_prompt) + prompt_audio_text_pad_size - 1)
|
|
|
|
else:
|
|
# Add only a single text-frame (EOS) as prompt
|
|
target_text_tokens_.append(torch.cat([text_prompt, target_text_tokens[i]]))
|
|
target_token_lens[i] += len(text_prompt)
|
|
|
|
source_tokens_.append(torch.cat([text_prompt, source_tokens[i]]))
|
|
source_token_lens[i] += len(text_prompt)
|
|
|
|
pad_size_src = len(text_prompt) * self.source_samples_per_frame
|
|
pad_audio_src = torch.zeros(
|
|
pad_size_src,
|
|
device=source_audio.device,
|
|
dtype=source_audio.dtype,
|
|
)
|
|
source_audio_.append(torch.cat([pad_audio_src, source_audio[i]]))
|
|
source_audio_lens[i] += pad_size_src
|
|
|
|
pad_size_tgt = len(text_prompt) * self.target_samples_per_frame
|
|
pad_audio_tgt = torch.zeros(
|
|
pad_size_tgt,
|
|
device=target_audio.device,
|
|
dtype=target_audio.dtype,
|
|
)
|
|
target_audio_.append(torch.cat([pad_audio_tgt, target_audio[i]]))
|
|
target_audio_lens[i] += pad_size_tgt
|
|
|
|
prompt_lens.append(len(text_prompt))
|
|
|
|
target_text_tokens = collate_vectors(target_text_tokens_, padding_value=text_pad_id)
|
|
source_tokens = collate_vectors(source_tokens_, padding_value=text_pad_id)
|
|
source_audio = collate_vectors(source_audio_, padding_value=0)
|
|
target_audio = collate_vectors(target_audio_, padding_value=0)
|
|
|
|
return (
|
|
target_text_tokens,
|
|
target_token_lens,
|
|
source_tokens,
|
|
source_token_lens,
|
|
source_audio,
|
|
source_audio_lens,
|
|
target_audio,
|
|
target_audio_lens,
|
|
prompt_lens,
|
|
)
|
|
|
|
|
|
def add_speech_delay(
|
|
source_audio: torch.Tensor,
|
|
source_audio_lens: torch.Tensor,
|
|
target_audio: torch.Tensor,
|
|
target_audio_lens: torch.Tensor,
|
|
num_delay_speech_tokens: int,
|
|
target_samples_per_frame: int,
|
|
source_samples_per_frame: int,
|
|
):
|
|
"""
|
|
Apply a speech delay by padding audio waveforms based on the number of delay speech tokens.
|
|
|
|
Behavior:
|
|
- Target audio is *left padded* to force the model to predict initial silence.
|
|
- Source audio is *right padded* to maintain size consistency for attention alignment.
|
|
|
|
Args:
|
|
source_audio (FloatTensor [B, T_src]):
|
|
Source/input audio waveforms.
|
|
|
|
source_audio_lens (LongTensor [B]):
|
|
Lengths of source audio in samples.
|
|
|
|
target_audio (FloatTensor [B, T_tgt]):
|
|
Target/output audio waveforms.
|
|
|
|
target_audio_lens (LongTensor [B]):
|
|
Lengths of target audio in samples.
|
|
|
|
num_delay_speech_tokens (int):
|
|
Number of delay tokens inserted on the text side.
|
|
|
|
target_samples_per_frame (int):
|
|
Number of audio samples per frame for target audio.
|
|
|
|
source_samples_per_frame (int):
|
|
Number of audio samples per frame for source audio.
|
|
|
|
Returns:
|
|
Tuple containing:
|
|
- source_audio (FloatTensor [B, T_src + pad])
|
|
- source_audio_lens (LongTensor [B])
|
|
- target_audio (FloatTensor [B, T_tgt + pad])
|
|
- target_audio_lens (LongTensor [B])
|
|
"""
|
|
# Compute target-side left padding for the delay
|
|
extra_target_samples = int(num_delay_speech_tokens * target_samples_per_frame)
|
|
|
|
# Left-pad target audio: forces model to generate silence initially
|
|
target_audio = F.pad(target_audio, (extra_target_samples, 0))
|
|
target_audio_lens = target_audio_lens + extra_target_samples
|
|
|
|
# Compute source-side right padding to maintain alignment
|
|
extra_source_samples = int(num_delay_speech_tokens * source_samples_per_frame)
|
|
|
|
# Right-pad source audio: avoids mismatch when aligning source/target frames
|
|
source_audio = F.pad(source_audio, (0, extra_source_samples))
|
|
source_audio_lens = source_audio_lens + extra_source_samples
|
|
|
|
return source_audio, source_audio_lens, target_audio, target_audio_lens
|
|
|
|
|
|
def collate_system_prompt(
|
|
cuts: CutSet,
|
|
tokenizer: TokenizerSpec,
|
|
ignore_data_system_prompt: bool = False,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
"""
|
|
Collate system prompts from cuts.
|
|
System prompts should be stored in cut.custom['system_prompt'].
|
|
"""
|
|
pad_id = get_pad_id(tokenizer)
|
|
tokens = []
|
|
system_prompts_raw = []
|
|
for c in cuts:
|
|
# Check if system prompt exists in custom field
|
|
if c.custom and c.custom.get("system_prompt", None) and not ignore_data_system_prompt:
|
|
prompt_text = c.custom["system_prompt"]
|
|
tokens.append(
|
|
torch.as_tensor(
|
|
[tokenizer.bos] + tokenizer.text_to_ids(prompt_text) + [tokenizer.eos], dtype=torch.long
|
|
)
|
|
)
|
|
system_prompts_raw.append(prompt_text)
|
|
else:
|
|
# use dataset type name (defined on the config)
|
|
if getattr(c, "type", None):
|
|
prompt_text = c.type
|
|
tokens.append(
|
|
torch.as_tensor(
|
|
[tokenizer.bos] + tokenizer.text_to_ids(prompt_text) + [tokenizer.eos], dtype=torch.long
|
|
)
|
|
)
|
|
system_prompts_raw.append(prompt_text)
|
|
else:
|
|
logging.warning(
|
|
"No system prompt or dataset type defined on the config! Using a eos token as system prompt!"
|
|
)
|
|
# No system prompt for this cut, add just a eos that indicates that the prompt is finished
|
|
tokens.append(torch.as_tensor([tokenizer.eos], dtype=torch.long))
|
|
system_prompts_raw.append("")
|
|
|
|
token_lens = torch.tensor([len(tt) for tt in tokens])
|
|
tokens = collate_vectors(tokens, padding_value=pad_id)
|
|
return tokens, token_lens, system_prompts_raw
|
|
|
|
|
|
def get_audio_prompt(
|
|
cuts: CutSet,
|
|
target_sample_rate: int,
|
|
roles: set[str],
|
|
recording_field: str = "target_audio",
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
"""
|
|
Retrieve an audio prompt for speaker conditioning.
|
|
|
|
This function returns:
|
|
- context audio if available (per-cut),
|
|
- otherwise a random turn belonging to the desired speaker roles.
|
|
|
|
Behavior:
|
|
1. If `cut.context_audio` exists, use it as the reference.
|
|
2. Otherwise, select a random audio turn from the same target-role speakers.
|
|
|
|
Args:
|
|
cuts (CutSet):
|
|
Batch of cuts from which to extract reference audio.
|
|
|
|
target_sample_rate (int):
|
|
Sample rate to which reference audio is resampled.
|
|
|
|
roles (set[str]):
|
|
Set of speaker roles to sample from when selecting random turns.
|
|
|
|
recording_field (str, optional):
|
|
Name of the audio field in the cut ("recording", "target_audio", etc.).
|
|
Used when sampling random reference turns.
|
|
|
|
Returns:
|
|
Tuple containing:
|
|
- audio_prompt (FloatTensor [B, T]):
|
|
Padded batch of reference waveforms.
|
|
- audio_prompt_lens (LongTensor [B]):
|
|
Lengths of each reference waveform before padding.
|
|
"""
|
|
# use provided context audio directly
|
|
if hasattr(cuts[0], "context_audio"):
|
|
audio_prompt = []
|
|
audio_prompt_lens = []
|
|
|
|
for cut in cuts:
|
|
ref_audio = cut.context_audio.resample(target_sample_rate).load_audio()
|
|
ref_audio = torch.tensor(ref_audio).float() # shape: [1, T]
|
|
ref_audio_len = ref_audio.shape[1]
|
|
|
|
audio_prompt.append(ref_audio.squeeze(0)) # [T]
|
|
audio_prompt_lens.append(ref_audio_len)
|
|
|
|
audio_prompt = collate_vectors(audio_prompt, padding_value=0).float()
|
|
audio_prompt_lens = torch.tensor(audio_prompt_lens).long()
|
|
|
|
else:
|
|
# Sanitize cuts to remove out-of-bounds supervisions
|
|
# this prevents crashes when sampling from truncated audio.
|
|
cuts = sanitize_cuts(cuts)
|
|
# sample a reference turn from the target-role speakers
|
|
audio_prompt, audio_prompt_lens = collate_random_turn_audio(
|
|
cuts.resample(target_sample_rate, recording_field=recording_field),
|
|
roles=roles,
|
|
recording_field=recording_field,
|
|
)
|
|
|
|
return audio_prompt, audio_prompt_lens
|
|
|
|
|
|
def sanitize_cuts(cuts: CutSet) -> CutSet:
|
|
"""
|
|
Adjusts supervisions to fit within the cut's truncated duration.
|
|
|
|
- If a supervision extends beyond the cut end, it is TRUNCATED (using deepcopy).
|
|
- If a supervision starts after the cut end, it is DROPPED.
|
|
|
|
Args:
|
|
cuts (CutSet): The batch of cuts to sanitize.
|
|
|
|
Returns:
|
|
CutSet: A new CutSet with valid, potentially truncated supervisions.
|
|
"""
|
|
sanitized_list = []
|
|
|
|
for cut in cuts:
|
|
valid_supervisions = []
|
|
for sup in cut.supervisions:
|
|
# Case 1: Supervision starts after the audio ends -> Drop
|
|
if sup.start >= cut.duration:
|
|
continue
|
|
|
|
# Case 2: Supervision starts inside but ends after the audio -> Truncate
|
|
if sup.end > cut.duration:
|
|
# Calculate the remaining duration available in the audio
|
|
new_duration = cut.duration - sup.start
|
|
|
|
if new_duration <= 0:
|
|
continue
|
|
|
|
# Create a deepcopy and update duration
|
|
new_sup = deepcopy(sup)
|
|
new_sup.duration = new_duration
|
|
valid_supervisions.append(new_sup)
|
|
|
|
# Case 3: Supervision is fully inside -> Keep
|
|
else:
|
|
valid_supervisions.append(sup)
|
|
|
|
# Update the cut with the cleaned list
|
|
cut.supervisions = valid_supervisions
|
|
sanitized_list.append(cut)
|
|
|
|
return cuts.from_cuts(sanitized_list)
|
|
|
|
|
|
def collate_random_turn_audio(
|
|
cuts: CutSet,
|
|
roles: set[str],
|
|
recording_field: str = "target_audio",
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
"""
|
|
Sample and collate reference audio from random speaker turns.
|
|
|
|
For each cut in the batch, this function:
|
|
- selects a random supervision belonging to one of the specified roles,
|
|
- extracts the corresponding audio segment,
|
|
- collates all segments into a padded batch.
|
|
|
|
Args:
|
|
cuts (CutSet):
|
|
Batch of cuts to sample from.
|
|
|
|
roles (set[str]):
|
|
Set of speaker roles to consider when selecting random turns.
|
|
|
|
recording_field (str, optional):
|
|
Name of the audio field to load from the cut
|
|
(e.g., "recording", "target_audio").
|
|
|
|
Returns:
|
|
Tuple containing:
|
|
- audio (FloatTensor [B, T]):
|
|
Padded batch of sampled reference waveforms.
|
|
- audio_lens (LongTensor [B]):
|
|
Lengths of each waveform before padding.
|
|
"""
|
|
selected_turn_audios = []
|
|
selected_turn_audios_lens = []
|
|
for cut in cuts:
|
|
# Filter supervisions matching roles
|
|
matching_supervisions = [s for s in cut.supervisions if s.speaker in roles]
|
|
# if there is no target speaker sample, prompt with a silence audio
|
|
if len(matching_supervisions) == 0:
|
|
# Create 5 seconds of silence
|
|
target_duration = 5.0
|
|
num_samples = int(target_duration * cut.sampling_rate)
|
|
|
|
# Create a zero tensor of shape [T] (assuming mono audio)
|
|
silence_tensor = torch.zeros(num_samples, dtype=torch.float32)
|
|
selected_turn_audios.append(silence_tensor)
|
|
selected_turn_audios_lens.append(num_samples)
|
|
logging.warning(
|
|
"There is no target speaker supervision available on this sample! Using a silence audio as audio prompt!"
|
|
)
|
|
else:
|
|
# Randomly select one supervision
|
|
selected_supervision = random.choice(matching_supervisions)
|
|
|
|
# Truncate audio according to supervision
|
|
truncated_audio = cut.truncate(
|
|
offset=max(0, selected_supervision.start), duration=selected_supervision.duration
|
|
).load_custom(recording_field)
|
|
|
|
selected_turn_audios.append(truncated_audio.squeeze(0))
|
|
selected_turn_audios_lens.append(truncated_audio.shape[-1])
|
|
|
|
return collate_vectors(selected_turn_audios, padding_value=0), torch.tensor(selected_turn_audios_lens)
|
|
|
|
|
|
def collate_token_channel(
|
|
cuts: CutSet,
|
|
tokenizer: TokenizerSpec,
|
|
frame_length: Seconds,
|
|
roles: set[str],
|
|
add_text_bos_and_eos_in_each_turn: bool = True,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
"""
|
|
Build and collate token channels aligned to the audio frame grid.
|
|
|
|
This function converts text supervisions into frame-level token
|
|
representations for each cut, pads them to a uniform length, and
|
|
returns both the padded tokens and their true lengths.
|
|
|
|
Args:
|
|
cuts (CutSet):
|
|
Batch of cuts from which to extract token channels.
|
|
|
|
tokenizer (TokenizerSpec):
|
|
Tokenizer used to convert text into token IDs.
|
|
|
|
frame_length (Seconds):
|
|
Duration of a single audio frame, used to align text tokens
|
|
to audio frames.
|
|
|
|
roles (set[str]):
|
|
Speaker roles whose text will be included in the token channel.
|
|
|
|
add_text_bos_and_eos_in_each_turn (bool, optional):
|
|
Whether to insert BOS at the beginning and EOS at the end of
|
|
each speaking turn.
|
|
|
|
Returns:
|
|
Tuple containing:
|
|
- tokens (LongTensor [B, T]):
|
|
Padded batch of frame-aligned token sequences.
|
|
- token_lens (LongTensor [B]):
|
|
Length of each sequence before padding.
|
|
"""
|
|
pad_id = get_pad_id(tokenizer)
|
|
tokens = [
|
|
build_token_channel(
|
|
c,
|
|
tokenizer=tokenizer,
|
|
frame_length=frame_length,
|
|
roles=roles,
|
|
pad_id=pad_id,
|
|
add_text_bos_and_eos_in_each_turn=add_text_bos_and_eos_in_each_turn,
|
|
)
|
|
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,
|
|
add_text_bos_and_eos_in_each_turn: bool = True,
|
|
) -> torch.Tensor:
|
|
"""
|
|
Build a frame-aligned token sequence for a single cut.
|
|
|
|
This function maps speaking turns into a token channel aligned to the
|
|
audio frame grid. Tokens are inserted at frame positions corresponding
|
|
to supervision start times, with optional BOS and EOS insertion.
|
|
|
|
Any region not covered by text is filled with `pad_id`.
|
|
|
|
Args:
|
|
cut (Cut):
|
|
Input cut containing audio and supervisions.
|
|
|
|
tokenizer (TokenizerSpec):
|
|
Tokenizer used to encode text into tokens.
|
|
|
|
frame_length (Seconds):
|
|
Duration of one frame, used to align text to the audio grid.
|
|
|
|
roles (set[str]):
|
|
Speaker roles whose text should be included.
|
|
|
|
pad_id (int, optional):
|
|
Token ID used for padding empty frames.
|
|
|
|
add_text_bos_and_eos_in_each_turn (bool, optional):
|
|
Whether to insert BOS before and EOS after each supervision.
|
|
|
|
Returns:
|
|
tokens (LongTensor [T]):
|
|
Frame-aligned token sequence for the cut.
|
|
"""
|
|
|
|
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 = supervision.text
|
|
if add_text_bos_and_eos_in_each_turn:
|
|
text_ids = torch.as_tensor([tokenizer.bos] + tokenizer.text_to_ids(text))
|
|
else:
|
|
text_ids = torch.as_tensor(tokenizer.text_to_ids(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.
|
|
if add_text_bos_and_eos_in_each_turn:
|
|
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
|
|
|
|
|
|
def sample_audio_segments_repeat(
|
|
prompt_audio: torch.Tensor,
|
|
prompt_audio_lens: torch.Tensor,
|
|
n_sample: int,
|
|
sample: bool = True,
|
|
) -> torch.Tensor:
|
|
"""
|
|
Extract audio segments of length n_sample.
|
|
If sample=True: randomly sample segments (repeating if shorter).
|
|
If sample=False: always take from the beginning (repeating if shorter).
|
|
|
|
Args:
|
|
prompt_audio: Tensor [B, T]
|
|
prompt_audio_lens: Tensor [B] with valid lengths
|
|
n_sample: int, target length per segment
|
|
sample: bool, whether to randomly sample (True) or take first seconds (False)
|
|
|
|
Returns:
|
|
Tensor [B, n_sample]
|
|
"""
|
|
B, T = prompt_audio.shape
|
|
device = prompt_audio.device
|
|
out = torch.zeros(B, n_sample, device=device, dtype=prompt_audio.dtype)
|
|
|
|
for b in range(B):
|
|
length = min(prompt_audio_lens[b].item(), T)
|
|
|
|
# Case: empty audio
|
|
if length <= 0:
|
|
continue
|
|
|
|
if length >= n_sample:
|
|
if sample:
|
|
# Random start (safe bounds)
|
|
max_start = max(1, length - n_sample + 1)
|
|
start = torch.randint(0, max_start, (1,), device=device).item()
|
|
else:
|
|
# Deterministic: take from start
|
|
start = 0
|
|
out[b] = prompt_audio[b, start : start + n_sample]
|
|
|
|
else:
|
|
# Audio shorter than target → repeat
|
|
start = 0
|
|
segment = prompt_audio[b, start:length]
|
|
|
|
repeat_times = (n_sample + (length - start) - 1) // (length - start)
|
|
repeated = segment.repeat(repeat_times)[:n_sample]
|
|
out[b] = repeated
|
|
|
|
return out
|