chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
NeMo (**Ne**ural **Mo**dules) is a toolkit for creating AI applications built around **neural modules**, conceptual blocks of neural networks that take *typed* inputs and produce *typed* outputs.
|
||||
|
||||
## **collections/**
|
||||
* **ASR** - Collection of modules and models for building speech recognition networks.
|
||||
* **TTS** - Collection of modules and models for building speech synthesis networks.
|
||||
* **Audio** - Collection of modules and models for building audio processing networks.
|
||||
* **SpeechLM2** - Collection of modules and models for building multimodal LLM.
|
||||
|
||||
## **core/**
|
||||
Provides fundamental APIs and utilities for NeMo modules, including:
|
||||
- **Classes** - Base classes for datasets, models, and losses.
|
||||
- **Config** - Configuration management utilities.
|
||||
- **Neural Types** - Typed inputs/outputs for module interaction.
|
||||
- **Optim** - Optimizers and learning rate schedulers.
|
||||
|
||||
## **lightning/**
|
||||
Integration with PyTorch Lightning for training and distributed execution:
|
||||
- **Strategies & Plugins** - Custom Lightning strategies.
|
||||
- **Fabric** - Lightweight wrapper for model training.
|
||||
- **Checkpointing & Logging** - Utilities for managing model states.
|
||||
|
||||
## **utils/**
|
||||
General utilities for debugging, distributed training, logging, and model management:
|
||||
- **callbacks/** - Hooks for training processes.
|
||||
- **loggers/** - Logging utilities for different backends.
|
||||
- **debugging & profiling** - Performance monitoring tools.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Copyright (c) 2020, 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.
|
||||
|
||||
|
||||
from nemo.package_info import (
|
||||
__contact_emails__,
|
||||
__contact_names__,
|
||||
__description__,
|
||||
__download_url__,
|
||||
__homepage__,
|
||||
__keywords__,
|
||||
__license__,
|
||||
__package_name__,
|
||||
__repository_url__,
|
||||
__shortversion__,
|
||||
__version__,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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.
|
||||
|
||||
try:
|
||||
import pipecat
|
||||
except ImportError:
|
||||
raise ImportError("pipecat is not installed. Please install it with `pip install pipecat-ai`.")
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,26 @@
|
||||
# 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.
|
||||
|
||||
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
from pipecat.frames.frames import DataFrame
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiarResultFrame(DataFrame):
|
||||
"""Diarization frame."""
|
||||
|
||||
diar_result: np.ndarray | int
|
||||
stream_id: str = "default"
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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.
|
||||
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.frames.frames import Frame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, TTSTextFrame
|
||||
from pipecat.observers.base_observer import FramePushed
|
||||
from pipecat.processors.frameworks.rtvi import (
|
||||
RTVIBotLLMStartedMessage,
|
||||
RTVIBotLLMStoppedMessage,
|
||||
RTVIBotTranscriptionMessage,
|
||||
RTVIBotTTSTextMessage,
|
||||
)
|
||||
from pipecat.processors.frameworks.rtvi import RTVIObserver as _RTVIObserver
|
||||
from pipecat.processors.frameworks.rtvi import RTVIProcessor, RTVITextMessageData
|
||||
from pipecat.transports.base_output import BaseOutputTransport
|
||||
|
||||
|
||||
class RTVIObserver(_RTVIObserver):
|
||||
"""
|
||||
An observer that processes RTVI frames and pushes them to the transport.
|
||||
"""
|
||||
|
||||
def __init__(self, rtvi: RTVIProcessor, *args, **kwargs):
|
||||
super().__init__(rtvi, *args, **kwargs)
|
||||
|
||||
async def on_push_frame(self, data: FramePushed):
|
||||
"""Process a frame being pushed through the pipeline.
|
||||
|
||||
Args:
|
||||
data: Frame push event data containing source, frame, direction, and timestamp.
|
||||
"""
|
||||
src = data.source
|
||||
frame: Frame = data.frame
|
||||
|
||||
if frame.id in self._frames_seen:
|
||||
return
|
||||
|
||||
if not self._params.bot_llm_enabled:
|
||||
if isinstance(frame, LLMFullResponseStartFrame):
|
||||
await self.send_rtvi_message(RTVIBotLLMStartedMessage())
|
||||
self._frames_seen.add(frame.id)
|
||||
elif isinstance(frame, LLMFullResponseEndFrame):
|
||||
await self.send_rtvi_message(RTVIBotLLMStoppedMessage())
|
||||
self._frames_seen.add(frame.id)
|
||||
elif isinstance(frame, TTSTextFrame) and isinstance(src, BaseOutputTransport):
|
||||
message = RTVIBotTTSTextMessage(data=RTVITextMessageData(text=frame.text))
|
||||
await self.send_rtvi_message(message)
|
||||
await self._push_bot_transcription(frame.text)
|
||||
self._frames_seen.add(frame.id)
|
||||
else:
|
||||
await super().on_push_frame(data)
|
||||
else:
|
||||
await super().on_push_frame(data)
|
||||
|
||||
async def _push_bot_transcription(self, text: str):
|
||||
"""Push accumulated bot transcription as a message."""
|
||||
if len(text.strip()) > 0:
|
||||
message = RTVIBotTranscriptionMessage(data=RTVITextMessageData(text=text))
|
||||
logger.debug(f"Pushing bot transcription: `{text}`")
|
||||
await self.send_rtvi_message(message)
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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.
|
||||
|
||||
from .diar import NemoDiarService
|
||||
from .llm import HuggingFaceLLMService
|
||||
from .stt import NemoSTTService
|
||||
from .tts import NeMoFastPitchHiFiGANTTSService
|
||||
from .turn_taking import NeMoTurnTakingService
|
||||
@@ -0,0 +1,844 @@
|
||||
# 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 json
|
||||
import threading
|
||||
import wave
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
from pipecat.frames.frames import TranscriptionFrame
|
||||
from pipecat.observers.base_observer import BaseObserver, FramePushed
|
||||
|
||||
|
||||
class AudioLogger:
|
||||
"""
|
||||
Utility class for logging audio data and transcriptions during voice agent interactions.
|
||||
|
||||
This logger saves:
|
||||
- Audio files in WAV format
|
||||
- Transcriptions with metadata in JSON format
|
||||
- Session information and metadata
|
||||
|
||||
File structure:
|
||||
log_dir/
|
||||
├── session_YYYYMMDD_HHMMSS/
|
||||
│ ├── user/
|
||||
│ │ ├── 00001_HHMMSS.wav
|
||||
│ │ ├── 00001_HHMMSS.json
|
||||
│ │ ├── 00002_HHMMSS.wav
|
||||
│ │ └── 00002_HHMMSS.json
|
||||
│ ├── agent/
|
||||
│ │ ├── 00001_HHMMSS.wav
|
||||
│ │ ├── 00001_HHMMSS.json
|
||||
│ └── session_metadata.json
|
||||
|
||||
Args:
|
||||
log_dir: Base directory for storing logs (default: "./audio_logs")
|
||||
session_id: Optional custom session ID. If None, auto-generated from timestamp
|
||||
enabled: Whether logging is enabled (default: True)
|
||||
|
||||
# 12/19/2025 Note: Stereo conversation recording is implemented,
|
||||
# but -0.8 seconds offset needs to be applied to make the session sound synced.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
log_dir: Union[str, Path] = "./audio_logs",
|
||||
session_id: Optional[str] = None,
|
||||
enabled: bool = True,
|
||||
user_audio_sample_rate: int = 16000,
|
||||
pre_roll_time_sec: float = 0.8,
|
||||
round_precision: int = 2,
|
||||
):
|
||||
self.enabled = enabled
|
||||
if not self.enabled:
|
||||
logger.info("[AudioLogger] AudioLogger is disabled")
|
||||
return
|
||||
|
||||
self.log_dir = Path(log_dir)
|
||||
|
||||
# Generate session ID if not provided
|
||||
self.session_start_time = datetime.now()
|
||||
if session_id is None:
|
||||
session_id = f"session_{self.session_start_time.strftime('%Y%m%d_%H%M%S')}"
|
||||
self.first_audio_timestamp = None
|
||||
self.session_id = session_id
|
||||
self.session_dir = self.log_dir / session_id
|
||||
|
||||
# Create directories
|
||||
self.user_dir = self.session_dir / "user"
|
||||
self.agent_dir = self.session_dir / "agent"
|
||||
|
||||
self.user_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Counters for file naming (thread-safe)
|
||||
self._user_counter = 0
|
||||
self._agent_counter = 0
|
||||
self._turn_index = 0 # Turn index for conversation turns
|
||||
self._current_speaker = None # Track current speaker for turn transitions
|
||||
self._agent_turn_start_time = None # Captured when BotStartedSpeakingFrame is received
|
||||
self._lock = threading.Lock()
|
||||
self.staged_metadata = None
|
||||
self._staged_audio_data = None
|
||||
self._pre_roll_time_sec = pre_roll_time_sec
|
||||
self._round_precision = round_precision
|
||||
|
||||
self.turn_audio_buffer = []
|
||||
self.continuous_user_audio_buffer = []
|
||||
self.turn_transcription_buffer = []
|
||||
|
||||
# Stereo conversation recording (left=agent, right=user)
|
||||
self._stereo_conversation_filename = "conversation_stereo.wav"
|
||||
self._stereo_conversation_file = self.session_dir / self._stereo_conversation_filename
|
||||
self._stereo_sample_rate = user_audio_sample_rate # Use user audio sample rate (downsample agent audio)
|
||||
self._stereo_audio_buffer_left: list = [] # Agent audio (left channel)
|
||||
self._stereo_audio_buffer_right: list = [] # User audio (right channel)
|
||||
|
||||
# Session metadata
|
||||
# agent_entries is a list of lists: each sublist contains segments for one turn
|
||||
# e.g., [[seg1, seg2, seg3], [seg4, seg5], ...] where each [] is a turn
|
||||
self.session_metadata = {
|
||||
"session_id": session_id,
|
||||
"start_time": self.session_start_time.isoformat(),
|
||||
"user_entries": [],
|
||||
"agent_entries": [], # List of turns, each turn is a list of segments
|
||||
}
|
||||
|
||||
logger.info(f"[AudioLogger] AudioLogger initialized: {self.session_dir}")
|
||||
|
||||
def append_continuous_user_audio(self, audio_data: bytes):
|
||||
"""
|
||||
Append audio data to the continuous user audio buffer for stereo conversation.
|
||||
|
||||
This method should be called for EVERY audio frame received from the user,
|
||||
regardless of VAD state, to record the complete conversation audio.
|
||||
|
||||
Args:
|
||||
audio_data: Raw audio data as bytes
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
self.continuous_user_audio_buffer.append(audio_data)
|
||||
|
||||
def _resample_audio(
|
||||
self,
|
||||
audio_data: Union[bytes, np.ndarray],
|
||||
orig_sr: int,
|
||||
target_sr: int,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Resample audio data to a target sample rate using librosa.
|
||||
|
||||
Args:
|
||||
audio_data: Audio data as bytes (int16) or numpy array
|
||||
orig_sr: Original sample rate
|
||||
target_sr: Target sample rate
|
||||
|
||||
Returns:
|
||||
Resampled audio as numpy array (float32)
|
||||
"""
|
||||
# Convert bytes to numpy array if needed
|
||||
if isinstance(audio_data, bytes):
|
||||
audio_array = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
elif audio_data.dtype == np.int16:
|
||||
audio_array = audio_data.astype(np.float32) / 32768.0
|
||||
else:
|
||||
audio_array = audio_data.astype(np.float32)
|
||||
|
||||
# Resample if needed
|
||||
if orig_sr != target_sr:
|
||||
audio_array = librosa.resample(audio_array, orig_sr=orig_sr, target_sr=target_sr)
|
||||
|
||||
return audio_array
|
||||
|
||||
def _append_to_stereo_conversation(
|
||||
self,
|
||||
audio_data: Union[bytes, np.ndarray],
|
||||
channel: str,
|
||||
start_time: float,
|
||||
sample_rate: int,
|
||||
):
|
||||
"""
|
||||
Append audio to the stereo conversation buffer at the correct time position.
|
||||
|
||||
Args:
|
||||
audio_data: Audio data as bytes or numpy array
|
||||
channel: "left" for agent, "right" for user
|
||||
start_time: Start time in seconds from session start
|
||||
sample_rate: Sample rate of the input audio
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
# Resample to stereo sample rate if needed
|
||||
audio_float = self._resample_audio(audio_data, sample_rate, self._stereo_sample_rate)
|
||||
|
||||
# Calculate the sample position for this audio
|
||||
start_sample = int(start_time * self._stereo_sample_rate)
|
||||
|
||||
# Get the appropriate buffer
|
||||
if channel == "left":
|
||||
buffer = self._stereo_audio_buffer_left
|
||||
else:
|
||||
buffer = self._stereo_audio_buffer_right
|
||||
|
||||
# Extend buffer with zeros if needed to reach start position
|
||||
current_length = len(buffer)
|
||||
if start_sample > current_length:
|
||||
buffer.extend([0.0] * (start_sample - current_length))
|
||||
|
||||
# Append or overwrite audio samples
|
||||
for i, sample in enumerate(audio_float):
|
||||
pos = start_sample + i
|
||||
if pos < len(buffer):
|
||||
# Mix with existing audio (in case of overlap)
|
||||
buffer[pos] = np.clip(buffer[pos] + sample, -1.0, 1.0)
|
||||
else:
|
||||
buffer.append(sample)
|
||||
|
||||
logger.debug(
|
||||
f"[AudioLogger] Appended {len(audio_float)} samples to {channel} channel "
|
||||
f"at position {start_sample} (buffer now {len(buffer)} samples)"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[AudioLogger] Error appending to stereo conversation: {e}")
|
||||
|
||||
def save_stereo_conversation(self):
|
||||
"""
|
||||
Save the stereo conversation buffer to a WAV file.
|
||||
Left channel = Agent, Right channel = User.
|
||||
|
||||
User audio comes from continuous_user_audio_buffer (not affected by VAD).
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
if not self._stereo_audio_buffer_left and not self.continuous_user_audio_buffer:
|
||||
logger.warning("[AudioLogger] No stereo conversation audio to save")
|
||||
return
|
||||
|
||||
try:
|
||||
# Build right channel (user) from continuous buffer
|
||||
# This is raw bytes at user sample rate, no resampling needed since stereo uses user sample rate
|
||||
if self.continuous_user_audio_buffer:
|
||||
continuous_audio_bytes = b"".join(self.continuous_user_audio_buffer)
|
||||
right_array = np.frombuffer(continuous_audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
else:
|
||||
right_array = np.array([], dtype=np.float32)
|
||||
|
||||
left_array = np.array(self._stereo_audio_buffer_left, dtype=np.float32)
|
||||
|
||||
# Pad the shorter buffer with zeros
|
||||
max_length = max(len(left_array), len(right_array))
|
||||
|
||||
# Pad to same length
|
||||
if len(left_array) < max_length:
|
||||
left_array = np.pad(left_array, (0, max_length - len(left_array)))
|
||||
if len(right_array) < max_length:
|
||||
right_array = np.pad(right_array, (0, max_length - len(right_array)))
|
||||
|
||||
# Create stereo array (interleaved: L, R, L, R, ...)
|
||||
stereo_array = np.column_stack((left_array, right_array))
|
||||
|
||||
# Convert to int16
|
||||
stereo_int16 = (stereo_array * 32767).astype(np.int16)
|
||||
|
||||
# Save as WAV
|
||||
with wave.open(str(self._stereo_conversation_file), 'wb') as wav_file: # type: ignore[union-attr]
|
||||
wav_file.setnchannels(2) # Stereo
|
||||
wav_file.setsampwidth(2) # 16-bit
|
||||
wav_file.setframerate(self._stereo_sample_rate)
|
||||
wav_file.writeframes(stereo_int16.tobytes())
|
||||
|
||||
duration_sec = max_length / self._stereo_sample_rate
|
||||
logger.info(
|
||||
f"[AudioLogger] Saved stereo conversation: {self._stereo_conversation_file} "
|
||||
f"({duration_sec:.2f} seconds, {max_length} samples)"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[AudioLogger] Error saving stereo conversation: {e}")
|
||||
|
||||
def get_time_from_start_of_session(self, timestamp: datetime = None) -> float:
|
||||
"""Get the time from the start of the session to the given datetime string."""
|
||||
# get the time difference in seconds.
|
||||
if self.first_audio_timestamp is None:
|
||||
raise ValueError("First audio timestamp is not set. Aborting time calculation.")
|
||||
time_diff = (timestamp if timestamp else datetime.now()) - self.first_audio_timestamp
|
||||
return time_diff.total_seconds()
|
||||
|
||||
def _get_next_counter(self, speaker: str) -> int:
|
||||
"""Get the next counter value for a speaker in a thread-safe manner."""
|
||||
with self._lock:
|
||||
if speaker == "user":
|
||||
self._user_counter += 1
|
||||
return self._user_counter
|
||||
else:
|
||||
self._agent_counter += 1
|
||||
return self._agent_counter
|
||||
|
||||
def increment_turn_index(self, speaker: str = None) -> int:
|
||||
"""
|
||||
Increment the turn index if the speaker has changed.
|
||||
|
||||
Args:
|
||||
speaker: "user" or "agent". If provided, only increments
|
||||
if this is different from the current speaker.
|
||||
If None, always increments.
|
||||
|
||||
Returns:
|
||||
The current turn index after any increment.
|
||||
"""
|
||||
with self._lock:
|
||||
if speaker is None:
|
||||
# Always increment if no speaker specified
|
||||
self._turn_index += 1
|
||||
logger.debug(f"[AudioLogger] Turn index incremented to {self._turn_index}")
|
||||
elif speaker != self._current_speaker:
|
||||
# Only increment if speaker changed
|
||||
self._current_speaker = speaker
|
||||
self._turn_index += 1
|
||||
# Reset agent turn start time when speaker changes
|
||||
if speaker == "agent":
|
||||
self._agent_turn_start_time = None
|
||||
logger.debug(
|
||||
f"[AudioLogger] Speaker changed to {speaker}, turn index incremented to {self._turn_index}"
|
||||
)
|
||||
# else: same speaker, no increment
|
||||
return self._turn_index
|
||||
|
||||
def set_agent_turn_start_time(self):
|
||||
"""
|
||||
Set the start time for the current agent turn.
|
||||
|
||||
This should be called when BotStartedSpeakingFrame is received,
|
||||
which indicates the audio is actually starting to play (not just generated).
|
||||
This provides more accurate timing than capturing time during TTS generation.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
# Only set if not already set for this turn
|
||||
if self._agent_turn_start_time is None:
|
||||
self._agent_turn_start_time = self.get_time_from_start_of_session()
|
||||
logger.debug(f"[AudioLogger] Agent turn start time set to {self._agent_turn_start_time:.3f}s")
|
||||
|
||||
def _save_audio_wav(
|
||||
self,
|
||||
audio_data: Union[bytes, np.ndarray],
|
||||
file_path: Path,
|
||||
sample_rate: int,
|
||||
num_channels: int = 1,
|
||||
):
|
||||
"""
|
||||
Save audio data to a WAV file.
|
||||
|
||||
Args:
|
||||
audio_data: Audio data as bytes or numpy array
|
||||
file_path: Path to save the WAV file
|
||||
sample_rate: Audio sample rate in Hz
|
||||
num_channels: Number of audio channels (default: 1)
|
||||
"""
|
||||
try:
|
||||
# Convert audio data to bytes if it's a numpy array
|
||||
if isinstance(audio_data, np.ndarray):
|
||||
if audio_data.dtype in [np.float32, np.float64]:
|
||||
# Convert float [-1, 1] to int16 [-32768, 32767]
|
||||
audio_data = np.clip(audio_data, -1.0, 1.0)
|
||||
audio_data = (audio_data * 32767).astype(np.int16)
|
||||
elif audio_data.dtype != np.int16:
|
||||
audio_data = audio_data.astype(np.int16)
|
||||
audio_bytes = audio_data.tobytes()
|
||||
else:
|
||||
audio_bytes = audio_data
|
||||
|
||||
# Write WAV file
|
||||
with wave.open(str(file_path), 'wb') as wav_file: # type: ignore[union-attr]
|
||||
wav_file.setnchannels(num_channels)
|
||||
wav_file.setsampwidth(2) # 16-bit audio
|
||||
wav_file.setframerate(sample_rate)
|
||||
wav_file.writeframes(audio_bytes)
|
||||
|
||||
logger.debug(f"[AudioLogger] Saved audio to {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"[AudioLogger] Error saving audio to {file_path}: {e}")
|
||||
raise
|
||||
|
||||
def _save_metadata_json(self, metadata: dict, file_path: Path):
|
||||
"""Save metadata to a JSON file."""
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
||||
logger.debug(f"[AudioLogger] Saved metadata to {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"[AudioLogger] Error saving metadata to {file_path}: {e}")
|
||||
raise
|
||||
|
||||
def clear_user_audio_buffer(self):
|
||||
"""
|
||||
Clear the user audio buffer if the user stopped speaking detected by VAD.
|
||||
"""
|
||||
# Clear turn buffers if logging wasn't completed (e.g., no final transcription)
|
||||
if len(self.turn_audio_buffer) > 0 or len(self.turn_transcription_buffer) > 0:
|
||||
logger.debug(
|
||||
"[AudioLogger] Clearing turn audio and transcription buffers due to VAD user stopped speaking"
|
||||
)
|
||||
self.turn_audio_buffer = []
|
||||
self.turn_transcription_buffer = []
|
||||
|
||||
def stage_user_audio(
|
||||
self,
|
||||
timestamp_now: datetime,
|
||||
transcription: str,
|
||||
sample_rate: int = 16000,
|
||||
num_channels: int = 1,
|
||||
is_first_frame: bool = False,
|
||||
is_backchannel: bool = False,
|
||||
additional_metadata: Optional[dict] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Stage user audio metadata and transcription (from STT).
|
||||
This data will be saved when the turn is complete by `save_user_audio` method.
|
||||
Audio data is retrieved from continuous_user_audio_buffer based on timestamps.
|
||||
|
||||
Args:
|
||||
timestamp_now: Timestamp when the audio was received
|
||||
transcription: Transcribed text
|
||||
sample_rate: Audio sample rate in Hz (default: 16000)
|
||||
num_channels: Number of audio channels (default: 1)
|
||||
is_first_frame: Whether this is the first frame of a turn (default: False)
|
||||
is_backchannel: Whether this is a backchannel utterance (default: False)
|
||||
additional_metadata: Additional metadata to include
|
||||
|
||||
Returns:
|
||||
Dictionary with logged file paths, or None if logging is disabled
|
||||
"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get counter and generate filenames
|
||||
counter = self._get_next_counter("user")
|
||||
# timestamp_now = datetime.now()
|
||||
base_name = f"{counter:05d}_{timestamp_now.strftime('%H%M%S')}"
|
||||
|
||||
audio_file = self.user_dir / f"{base_name}.wav"
|
||||
metadata_file = self.user_dir / f"{base_name}.json"
|
||||
|
||||
if is_first_frame or self.staged_metadata is None or "start_time" not in self.staged_metadata:
|
||||
raw_start_time = self.get_time_from_start_of_session(timestamp=timestamp_now)
|
||||
# Apply pre-roll: go back pre_roll_time_sec, but don't go before the last entry's end time
|
||||
pre_roll_start = raw_start_time - self._pre_roll_time_sec
|
||||
if self.session_metadata["user_entries"]:
|
||||
last_entry_end_time = self.session_metadata["user_entries"][-1]["end_time"]
|
||||
_start_time = max(pre_roll_start, last_entry_end_time)
|
||||
else:
|
||||
# No previous entries, just ensure we don't go negative
|
||||
_start_time = max(pre_roll_start, 0.0)
|
||||
else:
|
||||
# start_time is stored as float (seconds from session start), not ISO string
|
||||
_start_time = self.staged_metadata["start_time"]
|
||||
|
||||
# Make end time into float (seconds from session start)
|
||||
_end_time = self.get_time_from_start_of_session(timestamp=datetime.now())
|
||||
audio_duration_sec = round(_end_time - _start_time, self._round_precision)
|
||||
|
||||
# Prepare metadata (initialize if None to allow update)
|
||||
if self.staged_metadata is None:
|
||||
self.staged_metadata = {}
|
||||
self.staged_metadata.update(
|
||||
{
|
||||
"base_name": base_name,
|
||||
"counter": counter,
|
||||
"turn_index": self._turn_index,
|
||||
"speaker": "user",
|
||||
"timestamp": timestamp_now.isoformat(),
|
||||
"start_time": _start_time,
|
||||
"end_time": _end_time,
|
||||
"transcription": transcription,
|
||||
"audio_file": audio_file.name,
|
||||
"sample_rate": sample_rate,
|
||||
"num_channels": num_channels,
|
||||
"audio_duration_sec": audio_duration_sec,
|
||||
"is_backchannel": is_backchannel,
|
||||
}
|
||||
)
|
||||
|
||||
if additional_metadata:
|
||||
self.staged_metadata.update(additional_metadata)
|
||||
|
||||
return {
|
||||
"audio_file": str(audio_file),
|
||||
"metadata_file": str(metadata_file),
|
||||
"counter": counter,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error logging user audio: {e}")
|
||||
return None
|
||||
|
||||
def stage_turn_audio_and_transcription(
|
||||
self,
|
||||
timestamp_now: datetime,
|
||||
is_first_frame: bool = False,
|
||||
additional_metadata: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
Stage the complete turn audio and accumulated transcriptions.
|
||||
|
||||
This method is called when a final transcription is received.
|
||||
It joins all accumulated audio and transcription chunks and stages them together.
|
||||
|
||||
Args:
|
||||
timestamp_now: Timestamp when the audio was received
|
||||
is_first_frame: Whether this is the first frame of a turn (default: False)
|
||||
additional_metadata: Additional metadata to include (e.g., model, backend info)
|
||||
"""
|
||||
if not self.turn_audio_buffer or not self.turn_transcription_buffer:
|
||||
logger.debug("[AudioLogger] No audio or transcription to stage")
|
||||
return
|
||||
|
||||
try:
|
||||
complete_transcription = "".join(self.turn_transcription_buffer)
|
||||
|
||||
logger.debug(
|
||||
f"[AudioLogger] Staging a turn with: {len(self.turn_audio_buffer)} audio chunks, "
|
||||
f"{len(self.turn_transcription_buffer)} transcription chunks"
|
||||
)
|
||||
|
||||
metadata = {
|
||||
"num_transcription_chunks": len(self.turn_transcription_buffer),
|
||||
"num_audio_chunks": len(self.turn_audio_buffer),
|
||||
}
|
||||
if additional_metadata:
|
||||
metadata.update(additional_metadata)
|
||||
|
||||
self.stage_user_audio(
|
||||
timestamp_now=timestamp_now,
|
||||
transcription=complete_transcription,
|
||||
sample_rate=self._stereo_sample_rate,
|
||||
num_channels=1,
|
||||
is_first_frame=is_first_frame,
|
||||
additional_metadata=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[AudioLogger] Staged the audio and transcription for turn: '{complete_transcription[:50]}...'"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[AudioLogger] Failed to stage user audio: {e}")
|
||||
|
||||
def save_user_audio(self, is_backchannel: bool = False, float_divisor: float = 32768.0):
|
||||
"""Save the user audio to the disk.
|
||||
|
||||
Args:
|
||||
is_backchannel: Whether this audio is a backchannel utterance (default: False)
|
||||
"""
|
||||
# Safety check: ensure staged metadata exists and has required fields
|
||||
if self.staged_metadata is None or "base_name" not in self.staged_metadata:
|
||||
# This is expected - multiple TranscriptionFrames may be pushed but only one has audio staged
|
||||
logger.debug("[AudioLogger] No staged metadata to save (this is normal for multiple frame pushes)")
|
||||
return
|
||||
|
||||
try:
|
||||
# Add backchannel metadata (only set if not already True to preserve turn-taking detection)
|
||||
if is_backchannel or not self.staged_metadata.get("is_backchannel", False):
|
||||
self.staged_metadata["is_backchannel"] = is_backchannel
|
||||
|
||||
audio_file = self.user_dir / f"{self.staged_metadata['base_name']}.wav"
|
||||
metadata_file = self.user_dir / f"{self.staged_metadata['base_name']}.json"
|
||||
|
||||
# Get the audio data from continuous user audio buffer
|
||||
stt, end = self.staged_metadata["start_time"], self.staged_metadata["end_time"]
|
||||
continuous_audio_bytes = b"".join(self.continuous_user_audio_buffer)
|
||||
full_audio_array = np.frombuffer(continuous_audio_bytes, dtype=np.int16).astype(np.float32) / float_divisor
|
||||
start_idx = int(stt * self._stereo_sample_rate)
|
||||
end_idx = int(end * self._stereo_sample_rate)
|
||||
staged_audio_data = full_audio_array[start_idx:end_idx]
|
||||
|
||||
self._save_audio_wav(
|
||||
audio_data=staged_audio_data,
|
||||
file_path=audio_file,
|
||||
sample_rate=self.staged_metadata["sample_rate"],
|
||||
)
|
||||
|
||||
self._save_metadata_json(metadata=self.staged_metadata, file_path=metadata_file)
|
||||
backchannel_label = " [BACKCHANNEL]" if is_backchannel else ""
|
||||
transcription_preview = self.staged_metadata['transcription'][:50]
|
||||
ellipsis = '...' if len(self.staged_metadata['transcription']) > 50 else ''
|
||||
logger.info(
|
||||
f"[AudioLogger] Saved user audio #{self.staged_metadata['counter']}"
|
||||
f"{backchannel_label}: '{transcription_preview}{ellipsis}'"
|
||||
)
|
||||
|
||||
# Note: User audio for stereo conversation is handled via continuous_user_audio_buffer
|
||||
# which is populated in append_continuous_user_audio() (not affected by VAD)
|
||||
|
||||
# Update session metadata
|
||||
with self._lock:
|
||||
self.session_metadata["user_entries"].append(self.staged_metadata)
|
||||
self._save_session_metadata()
|
||||
|
||||
self.clear_user_audio_buffer()
|
||||
|
||||
# Clear staged data after successful save
|
||||
self.staged_metadata = None
|
||||
self._staged_audio_data = None
|
||||
except Exception as e:
|
||||
logger.error(f"[AudioLogger] Error saving user audio: {e}")
|
||||
raise
|
||||
|
||||
def log_agent_audio(
|
||||
self,
|
||||
audio_data: Union[bytes, np.ndarray],
|
||||
text: str,
|
||||
sample_rate: int = 22050,
|
||||
num_channels: int = 1,
|
||||
additional_metadata: Optional[dict] = None,
|
||||
tts_generation_time: Optional[float] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Log agent audio and text (from TTS).
|
||||
|
||||
Args:
|
||||
audio_data: Generated audio data as bytes or numpy array
|
||||
text: Input text that was synthesized
|
||||
sample_rate: Audio sample rate in Hz (default: 22050)
|
||||
num_channels: Number of audio channels (default: 1)
|
||||
additional_metadata: Additional metadata to include
|
||||
tts_generation_time: Time when TTS generation started (seconds from session start).
|
||||
Used to calculate actual start_time for first segment of a turn.
|
||||
|
||||
Returns:
|
||||
Dictionary with logged file paths, or None if logging is disabled
|
||||
"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get counter and generate filenames
|
||||
counter = self._get_next_counter("agent")
|
||||
timestamp_now = datetime.now()
|
||||
base_name = f"{counter:05d}_{timestamp_now.strftime('%H%M%S')}"
|
||||
|
||||
audio_file = self.agent_dir / f"{base_name}.wav"
|
||||
metadata_file = self.agent_dir / f"{base_name}.json"
|
||||
|
||||
# Save audio
|
||||
self._save_audio_wav(audio_data, audio_file, sample_rate, num_channels)
|
||||
|
||||
# Calculate audio duration
|
||||
audio_duration_sec = (
|
||||
len(audio_data) / (sample_rate * num_channels * 2)
|
||||
if isinstance(audio_data, bytes)
|
||||
else len(audio_data) / sample_rate
|
||||
)
|
||||
|
||||
# Determine start_time based on previous segment in the same turn
|
||||
# If this is the first segment of the turn, use tts_generation_time
|
||||
# Otherwise, use the previous segment's end_time for sequential playback
|
||||
start_time = None
|
||||
with self._lock:
|
||||
agent_entries = self.session_metadata["agent_entries"]
|
||||
# agent_entries is a list of turns, each turn is a list of segments
|
||||
if agent_entries and agent_entries[-1]: # If there's a current turn with segments
|
||||
last_segment = agent_entries[-1][-1] # Last segment of last turn
|
||||
if last_segment["turn_index"] == self._turn_index:
|
||||
# Same turn - start after previous segment ends
|
||||
start_time = last_segment["end_time"]
|
||||
|
||||
if start_time is None:
|
||||
# First segment of the turn - use agent_turn_start_time (from BotStartedSpeakingFrame)
|
||||
# This is more accurate than tts_generation_time as it reflects actual playback start
|
||||
if self._agent_turn_start_time is not None:
|
||||
start_time = self._agent_turn_start_time
|
||||
elif tts_generation_time is not None:
|
||||
# Fallback to tts_generation_time if agent_turn_start_time not set
|
||||
start_time = tts_generation_time
|
||||
else:
|
||||
start_time = self.get_time_from_start_of_session(timestamp=timestamp_now)
|
||||
|
||||
end_time = start_time + audio_duration_sec
|
||||
|
||||
# Prepare metadata
|
||||
# cutoff_time is None by default (no interruption)
|
||||
# It will be set by set_agent_cutoff_time() if TTS is interrupted
|
||||
metadata = {
|
||||
"base_name": base_name,
|
||||
"counter": counter,
|
||||
"turn_index": self._turn_index,
|
||||
"speaker": "agent",
|
||||
"timestamp": timestamp_now.isoformat(),
|
||||
"start_time": round(start_time, self._round_precision),
|
||||
"end_time": round(end_time, self._round_precision),
|
||||
"cutoff_time": None, # None means not interrupted; float if interrupted
|
||||
"text": text,
|
||||
"audio_file": audio_file.name,
|
||||
"sample_rate": sample_rate,
|
||||
"num_channels": num_channels,
|
||||
"audio_duration_sec": round(audio_duration_sec, self._round_precision),
|
||||
}
|
||||
|
||||
if additional_metadata:
|
||||
metadata.update(additional_metadata)
|
||||
|
||||
# Save metadata
|
||||
self._save_metadata_json(metadata, metadata_file)
|
||||
|
||||
# Append to stereo conversation (left channel = agent)
|
||||
self._append_to_stereo_conversation(
|
||||
audio_data=audio_data,
|
||||
channel="left",
|
||||
start_time=start_time,
|
||||
sample_rate=sample_rate,
|
||||
)
|
||||
|
||||
# Update session metadata
|
||||
# agent_entries is a list of turns, each turn is a list of segments
|
||||
with self._lock:
|
||||
agent_entries = self.session_metadata["agent_entries"]
|
||||
# Check if we need to start a new turn or append to existing turn
|
||||
if not agent_entries or agent_entries[-1][-1]["turn_index"] != self._turn_index:
|
||||
# Start a new turn (new sublist)
|
||||
agent_entries.append([metadata])
|
||||
else:
|
||||
# Append to current turn
|
||||
agent_entries[-1].append(metadata)
|
||||
self._save_session_metadata()
|
||||
|
||||
logger.info(f"[AudioLogger] Logged agent audio #{counter}: '{text[:50]}{'...' if len(text) > 50 else ''}'")
|
||||
|
||||
return {
|
||||
"audio_file": str(audio_file),
|
||||
"metadata_file": str(metadata_file),
|
||||
"counter": counter,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[AudioLogger] Error logging agent audio: {e}")
|
||||
return None
|
||||
|
||||
def set_agent_cutoff_time(self, cutoff_time: Optional[float] = None):
|
||||
"""
|
||||
Set the cutoff time for the most recent agent audio entry.
|
||||
|
||||
This method should be called when TTS is interrupted by user speech.
|
||||
The cutoff_time represents when the agent audio was actually cut off,
|
||||
which may be earlier than the natural end_time.
|
||||
|
||||
Args:
|
||||
cutoff_time: The cutoff time in seconds from session start.
|
||||
If None, uses current time from session start.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
if cutoff_time is None:
|
||||
cutoff_time = self.get_time_from_start_of_session()
|
||||
|
||||
with self._lock:
|
||||
agent_entries = self.session_metadata["agent_entries"]
|
||||
if not agent_entries or not agent_entries[-1]:
|
||||
logger.warning("[AudioLogger] No agent entries to set cutoff time")
|
||||
return
|
||||
|
||||
# Get the current turn (last sublist) and update ALL segments in it
|
||||
current_turn = agent_entries[-1]
|
||||
turn_index = current_turn[0]["turn_index"]
|
||||
|
||||
# Update cutoff_time for ALL segments in the current turn
|
||||
for segment in current_turn:
|
||||
segment["cutoff_time"] = cutoff_time
|
||||
# Also update individual JSON files
|
||||
try:
|
||||
metadata_file = self.agent_dir / f"{segment['base_name']}.json"
|
||||
self._save_metadata_json(segment, metadata_file)
|
||||
except Exception as e:
|
||||
logger.error(f"[AudioLogger] Error updating agent cutoff time for segment: {e}")
|
||||
|
||||
# Truncate the stereo buffer (left channel = agent) at the cutoff point
|
||||
cutoff_sample = int(cutoff_time * self._stereo_sample_rate)
|
||||
if cutoff_sample < len(self._stereo_audio_buffer_left):
|
||||
# Zero out agent audio after cutoff point
|
||||
for i in range(cutoff_sample, len(self._stereo_audio_buffer_left)):
|
||||
self._stereo_audio_buffer_left[i] = 0.0
|
||||
logger.debug(
|
||||
f"[AudioLogger] Truncated agent stereo buffer at sample {cutoff_sample} "
|
||||
f"(cutoff_time={cutoff_time:.3f}s)"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[AudioLogger] Set cutoff_time={cutoff_time:.3f}s for turn {turn_index} "
|
||||
f"({len(current_turn)} segments)"
|
||||
)
|
||||
|
||||
# Save updated session metadata
|
||||
self._save_session_metadata()
|
||||
|
||||
def _save_session_metadata(self):
|
||||
"""Save the session metadata to disk."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
metadata_file = self.session_dir / "session_metadata.json"
|
||||
self.session_metadata["last_updated"] = datetime.now().isoformat()
|
||||
self._save_metadata_json(self.session_metadata, metadata_file)
|
||||
except Exception as e:
|
||||
logger.error(f"[AudioLogger] Error saving session metadata: {e}")
|
||||
|
||||
def finalize_session(self):
|
||||
"""Finalize the session and save final metadata."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
# Save stereo conversation before finalizing
|
||||
self.save_stereo_conversation()
|
||||
|
||||
self.session_metadata["end_time"] = datetime.now().isoformat()
|
||||
self.session_metadata["total_user_entries"] = self._user_counter
|
||||
self.session_metadata["total_agent_segments"] = self._agent_counter
|
||||
self.session_metadata["total_agent_turns"] = len(self.session_metadata["agent_entries"])
|
||||
self._save_session_metadata()
|
||||
logger.info(
|
||||
f"[AudioLogger] Session finalized: {self.session_id} "
|
||||
f"(User: {self._user_counter}, Agent: {self._agent_counter} segments in "
|
||||
f"{len(self.session_metadata['agent_entries'])} turns)"
|
||||
)
|
||||
|
||||
|
||||
class RTVIAudioLoggerObserver(BaseObserver):
|
||||
"""Observer that triggers audio logging when TranscriptionFrame is pushed."""
|
||||
|
||||
def __init__(self, audio_logger: AudioLogger):
|
||||
super().__init__()
|
||||
self._audio_logger = audio_logger
|
||||
|
||||
async def on_push_frame(self, data: FramePushed):
|
||||
"""Handle frame push events and save user audio on TranscriptionFrame."""
|
||||
frame = data.frame
|
||||
if isinstance(frame, TranscriptionFrame) and self._audio_logger:
|
||||
self._audio_logger.save_user_audio()
|
||||
# Call parent class's on_push_frame method
|
||||
await super().on_push_frame(data)
|
||||
@@ -0,0 +1,360 @@
|
||||
# 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 asyncio
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
from pipecat.frames.frames import (
|
||||
CancelFrame,
|
||||
EndFrame,
|
||||
ErrorFrame,
|
||||
Frame,
|
||||
StartFrame,
|
||||
VADUserStartedSpeakingFrame,
|
||||
VADUserStoppedSpeakingFrame,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.services.stt_service import STTService
|
||||
from pipecat.transcriptions.language import Language
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
from pipecat.utils.tracing.service_decorators import traced_stt
|
||||
from pydantic import BaseModel
|
||||
|
||||
from nemo.agents.voice_agent.pipecat.frames.frames import DiarResultFrame
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.streaming_diar import DiarizationConfig, NeMoStreamingDiarService
|
||||
|
||||
|
||||
class NeMoDiarInputParams(BaseModel):
|
||||
threshold: Optional[float] = (
|
||||
0.4 # threshold value used to determine if a speaker exists or not, setting it to a lower value will increase the sensitivity of the diarization model
|
||||
)
|
||||
language: Optional[Language] = Language.EN_US
|
||||
frame_len_in_secs: Optional[float] = 0.08 # 80ms for FastConformer model
|
||||
config_path: Optional[str] = None # path to the Niva ASR config file
|
||||
raw_audio_frame_len_in_secs: Optional[float] = 0.016 # 16ms for websocket transport
|
||||
buffer_size: Optional[int] = (
|
||||
30 # number of audio frames to buffer, 1 frame is 16ms, streaming Sortformer was trained with 6*0.08=0.48s chunks
|
||||
)
|
||||
|
||||
|
||||
class NemoDiarService(STTService):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: Optional[str] = "",
|
||||
device: Optional[str] = "cuda:0",
|
||||
sample_rate: Optional[int] = 16000,
|
||||
params: Optional[NeMoDiarInputParams] = None,
|
||||
use_vad: bool = True,
|
||||
audio_passthrough: bool = True,
|
||||
backend: Optional[str] = "legacy",
|
||||
enabled: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(audio_passthrough=audio_passthrough, **kwargs)
|
||||
|
||||
self._enabled = enabled
|
||||
self._queue = asyncio.Queue()
|
||||
self._response_queue = asyncio.Queue() # Add response queue
|
||||
self._processing_task = None # Add processing task
|
||||
self._response_task = None # Add response task
|
||||
self._device = device
|
||||
self._sample_rate = sample_rate
|
||||
self._audio_passthrough = audio_passthrough
|
||||
params.buffer_size = params.frame_len_in_secs // params.raw_audio_frame_len_in_secs
|
||||
self._params = params
|
||||
self._model_name = model
|
||||
self._use_vad = use_vad
|
||||
self._backend = backend
|
||||
if not params:
|
||||
raise ValueError("params is required")
|
||||
|
||||
self._load_model()
|
||||
|
||||
self._vad_user_speaking = False
|
||||
self._audio_buffer = []
|
||||
self._current_speaker_id = None
|
||||
self._processing_running = False
|
||||
|
||||
if not self._use_vad:
|
||||
self._vad_user_speaking = True
|
||||
|
||||
def _load_model(self):
|
||||
if not self._enabled or not self._model_name:
|
||||
self._model = None
|
||||
self._enabled = False
|
||||
return
|
||||
|
||||
if self._backend == "legacy":
|
||||
cfg = DiarizationConfig()
|
||||
cfg.device = self._device
|
||||
self._model = NeMoStreamingDiarService(
|
||||
cfg, self._model_name, frame_len_in_secs=self._params.frame_len_in_secs, sample_rate=self.sample_rate
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid backend: {self._backend}")
|
||||
logger.info(f"Diarization service initialized on device: {self._device}")
|
||||
|
||||
def can_generate_metrics(self) -> bool:
|
||||
"""Indicates whether this service can generate metrics.
|
||||
|
||||
Returns:
|
||||
bool: True, as this service supports metric generation.
|
||||
"""
|
||||
return True
|
||||
|
||||
async def start(self, frame: StartFrame):
|
||||
"""Handle service start."""
|
||||
await super().start(frame)
|
||||
|
||||
# Initialize the model if not already done
|
||||
if not hasattr(self, "_model"):
|
||||
self._load_model()
|
||||
|
||||
# Start background processing task
|
||||
if not self._processing_task:
|
||||
self._processing_task = self.create_task(self._processing_task_handler())
|
||||
|
||||
# Start response handling task
|
||||
if not self._response_task:
|
||||
self._response_task = self.create_task(self._response_task_handler())
|
||||
|
||||
async def stop(self, frame: EndFrame):
|
||||
"""Handle service stop."""
|
||||
await super().stop(frame)
|
||||
await self._stop_tasks()
|
||||
|
||||
async def cancel(self, frame: CancelFrame):
|
||||
"""Handle service cancellation."""
|
||||
await super().cancel(frame)
|
||||
await self._stop_tasks()
|
||||
|
||||
async def _stop_tasks(self):
|
||||
"""Stop background processing tasks."""
|
||||
await self._queue.put(None) # Signal to stop processing
|
||||
if self._processing_task:
|
||||
await self.cancel_task(self._processing_task)
|
||||
self._processing_task = None
|
||||
|
||||
if self._response_task:
|
||||
await self.cancel_task(self._response_task)
|
||||
self._response_task = None
|
||||
|
||||
def _diarization_processor(self):
|
||||
"""Background processor that handles diarization calls."""
|
||||
try:
|
||||
while self._processing_running:
|
||||
try:
|
||||
# Get audio from queue - blocking call that will be interrupted by cancellation
|
||||
future = asyncio.run_coroutine_threadsafe(self._queue.get(), self.get_event_loop())
|
||||
audio = future.result()
|
||||
|
||||
if audio is None: # Stop signal
|
||||
logger.debug("Received stop signal in background processor")
|
||||
break
|
||||
|
||||
# Process diarization
|
||||
diar_result = self._model.diarize(audio)
|
||||
|
||||
# Send result back to async loop
|
||||
asyncio.run_coroutine_threadsafe(self._response_queue.put(diar_result), self.get_event_loop())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in background diarization processor: {e}")
|
||||
# Send error back to async loop
|
||||
asyncio.run_coroutine_threadsafe(self._response_queue.put(('error', e)), self.get_event_loop())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Background diarization processor fatal error: {e}")
|
||||
finally:
|
||||
logger.debug("Background diarization processor stopped")
|
||||
|
||||
async def _processing_task_handler(self):
|
||||
"""Handler for background processing task."""
|
||||
try:
|
||||
self._processing_running = True
|
||||
logger.debug("Starting background processing task")
|
||||
await asyncio.to_thread(self._diarization_processor)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Background processing task cancelled")
|
||||
self._processing_running = False
|
||||
raise
|
||||
finally:
|
||||
self._processing_running = False
|
||||
|
||||
async def _handle_diarization_result(self, diar_result):
|
||||
"""Handle diarization result from background processing."""
|
||||
try:
|
||||
if diar_result is None:
|
||||
return
|
||||
dominant_speaker_id = self._get_dominant_speaker_id(diar_result)
|
||||
# logger.debug(f"Dominant speaker ID: {dominant_speaker_id}")
|
||||
if dominant_speaker_id is not None and dominant_speaker_id != self._current_speaker_id:
|
||||
self._current_speaker_id = dominant_speaker_id
|
||||
logger.debug(f"Pushing DiarResultFrame with speaker {dominant_speaker_id}")
|
||||
await self.push_frame(DiarResultFrame(dominant_speaker_id, stream_id="default"))
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling diarization result: {e}")
|
||||
await self.push_frame(
|
||||
ErrorFrame(
|
||||
str(e),
|
||||
time_now_iso8601(),
|
||||
)
|
||||
)
|
||||
|
||||
async def _response_task_handler(self):
|
||||
"""Handler for processing diarization results."""
|
||||
logger.debug("Response task handler started")
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
result = await self._response_queue.get()
|
||||
|
||||
if isinstance(result, tuple) and result[0] == 'error':
|
||||
# Handle error from background processing
|
||||
error = result[1]
|
||||
logger.error(f"Error in NeMo Diarization processing: {error}")
|
||||
await self.push_frame(
|
||||
ErrorFrame(
|
||||
str(error),
|
||||
time_now_iso8601(),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Handle successful diarization result
|
||||
await self._handle_diarization_result(result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in response task handler: {e}")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Response task handler cancelled")
|
||||
raise
|
||||
|
||||
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
|
||||
"""Process audio data and generate transcription frames.
|
||||
|
||||
Args:
|
||||
audio: Raw audio bytes to transcribe
|
||||
|
||||
Yields:
|
||||
Frame: Transcription frames containing the results
|
||||
"""
|
||||
if self._vad_user_speaking and self._enabled:
|
||||
self._audio_buffer.append(audio)
|
||||
if len(self._audio_buffer) >= self._params.buffer_size:
|
||||
await self.start_ttfb_metrics()
|
||||
await self.start_processing_metrics()
|
||||
audio = b"".join(self._audio_buffer)
|
||||
self._audio_buffer = []
|
||||
# Queue audio for background processing
|
||||
await self._queue.put(audio)
|
||||
yield None
|
||||
|
||||
@traced_stt
|
||||
async def _handle_transcription(self, transcript: str, is_final: bool, language: Optional[str] = None):
|
||||
"""Handle a transcription result.
|
||||
|
||||
Args:
|
||||
transcript: The transcribed text
|
||||
is_final: Whether this is a final transcription
|
||||
language: The language of the transcription
|
||||
"""
|
||||
pass # Base implementation - can be extended for specific handling needs
|
||||
|
||||
async def set_language(self, language: Language):
|
||||
"""Update the service's recognition language.
|
||||
|
||||
Args:
|
||||
language: New language for recognition
|
||||
"""
|
||||
if self._params:
|
||||
self._params.language = language
|
||||
else:
|
||||
self._params = NeMoDiarInputParams(language=language)
|
||||
|
||||
logger.info(f"Switching STT language to: {language}")
|
||||
|
||||
async def set_model(self, model: str):
|
||||
"""Update the service's model.
|
||||
|
||||
Args:
|
||||
model: New model name/path to use
|
||||
"""
|
||||
await super().set_model(model)
|
||||
self._model_name = model
|
||||
self._load_model()
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
"""Process audio data and generate transcription frames.
|
||||
|
||||
Args:
|
||||
audio: Raw audio bytes to transcribe
|
||||
|
||||
Yields:
|
||||
Frame: Transcription frames containing the results
|
||||
"""
|
||||
if not self._enabled:
|
||||
# if diarization is disabled, just pass the frame through
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
|
||||
await super().process_frame(frame, direction)
|
||||
if isinstance(frame, VADUserStartedSpeakingFrame):
|
||||
self._vad_user_speaking = True
|
||||
self._audio_buffer = []
|
||||
logger.debug("VADUserStartedSpeakingFrame received")
|
||||
elif isinstance(frame, VADUserStoppedSpeakingFrame):
|
||||
self._vad_user_speaking = False
|
||||
logger.debug("VADUserStoppedSpeakingFrame received")
|
||||
self._current_speaker_id = None
|
||||
self._audio_buffer = []
|
||||
|
||||
def reset(self):
|
||||
"""Reset the diarization service."""
|
||||
self._current_speaker_id = None
|
||||
self._audio_buffer = []
|
||||
self._vad_user_speaking = False
|
||||
self._model.reset_state()
|
||||
|
||||
def _get_dominant_speaker_id(self, spk_pred: np.ndarray):
|
||||
spk_pred = (spk_pred > self._params.threshold).astype(int)
|
||||
dominant_speaker_id = None
|
||||
if spk_pred.sum() > 0:
|
||||
# get the dominant speaker id
|
||||
# Filter to only keep frames that have any speaker probability > 0.0
|
||||
valid_frame_mask = spk_pred.sum(axis=1) > 0
|
||||
|
||||
# Filter diar_result to only keep valid frames
|
||||
filtered_diar_result = spk_pred[valid_frame_mask] # ndarray of shape [num_valid_frames, num_speakers]
|
||||
|
||||
# Get the primary speaker for each valid frame
|
||||
primary_spk = np.argmax(filtered_diar_result, axis=1) # ndarray of shape [num_valid_frames]
|
||||
# logger.debug(f"Primary speaker for valid frames: {primary_spk}")
|
||||
|
||||
# count the number of different speakers in the primary speaker sequence
|
||||
num_speakers = len(np.unique(primary_spk))
|
||||
# logger.debug(f"Number of different speakers: {num_speakers}")
|
||||
|
||||
# If there are multiple speakers, get the dominant one
|
||||
if num_speakers > 1:
|
||||
# Count occurrences of each speaker
|
||||
speaker_counts = np.bincount(primary_spk)
|
||||
dominant_speaker_id = np.argmax(speaker_counts)
|
||||
else:
|
||||
# Only one speaker, return that speaker ID
|
||||
dominant_speaker_id = primary_spk[0]
|
||||
return dominant_speaker_id
|
||||
@@ -0,0 +1,760 @@
|
||||
# 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 asyncio
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from threading import Thread
|
||||
from typing import AsyncGenerator, List, Mapping, Optional
|
||||
|
||||
import psutil
|
||||
import requests
|
||||
from jinja2.exceptions import TemplateError
|
||||
from loguru import logger
|
||||
from omegaconf import DictConfig, OmegaConf
|
||||
from openai import APITimeoutError, AsyncStream, BadRequestError
|
||||
from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
|
||||
from pipecat.adapters.services.open_ai_adapter import OpenAILLMInvocationParams
|
||||
from pipecat.frames.frames import (
|
||||
CancelFrame,
|
||||
EndFrame,
|
||||
LLMFullResponseEndFrame,
|
||||
LLMFullResponseStartFrame,
|
||||
LLMTextFrame,
|
||||
)
|
||||
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from transformers import AsyncTextIteratorStreamer, AutoModelForCausalLM, AutoTokenizer
|
||||
from vllm.config import ModelConfig as vllmModelConfig
|
||||
|
||||
DEFAULT_GENERATION_KWARGS = {
|
||||
"max_new_tokens": 256,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"do_sample": True,
|
||||
}
|
||||
|
||||
|
||||
class LLMUtilsMixin:
|
||||
"""Utils for local LLM services."""
|
||||
|
||||
def _maybe_add_user_message(self, messages: List[ChatCompletionMessageParam]) -> List[ChatCompletionMessageParam]:
|
||||
"""
|
||||
Some LLMs like "nvidia/Llama-3.1-Nemotron-Nano-8B-v1" requires a user turn after the system prompt,
|
||||
this function is used to add a dummy user turn if the system prompt is followed by an assistant turn.
|
||||
"""
|
||||
if len(messages) > 1 and messages[0]["role"] == "system" and messages[1]["role"] == "assistant":
|
||||
message = {"role": "user", "content": "Hi"}
|
||||
messages.insert(1, message)
|
||||
elif len(messages) == 1 and messages[0]["role"] == "system":
|
||||
messages.append({"role": "user", "content": "Hi"})
|
||||
return messages
|
||||
|
||||
def _maybe_merge_consecutive_user_turns(
|
||||
self, messages: List[ChatCompletionMessageParam]
|
||||
) -> List[ChatCompletionMessageParam]:
|
||||
"""
|
||||
Merge consecutive user turns into a single turn,
|
||||
since some LLMs like "nvidia/Llama-3.1-Nemotron-Nano-8B-v1" do not support consecutive user turns.
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
merged_messages = []
|
||||
|
||||
user_content = ""
|
||||
for message in messages:
|
||||
role = message["role"]
|
||||
if role != "user":
|
||||
# check if there's any preceeding user content, add them first
|
||||
if user_content:
|
||||
merged_messages.append({"role": "user", "content": user_content})
|
||||
user_content = ""
|
||||
merged_messages.append(message)
|
||||
else:
|
||||
if user_content:
|
||||
user_content += "; " + message["content"]
|
||||
else:
|
||||
user_content = message["content"]
|
||||
|
||||
# add the last user content
|
||||
if user_content:
|
||||
merged_messages.append({"role": "user", "content": user_content})
|
||||
|
||||
return merged_messages
|
||||
|
||||
|
||||
class HuggingFaceLLMLocalService(LLMUtilsMixin):
|
||||
"""
|
||||
HuggingFace LLM local service.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str = "meta-llama/Meta-Llama-3-8B-Instruct",
|
||||
device: str = "cuda:0",
|
||||
dtype: str = "bfloat16",
|
||||
thinking_budget: int = 0,
|
||||
generation_kwargs: dict = None,
|
||||
apply_chat_template_kwargs: dict = None,
|
||||
):
|
||||
self.device = device
|
||||
self.dtype = dtype
|
||||
self.thinking_budget = thinking_budget
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(model)
|
||||
self.model = AutoModelForCausalLM.from_pretrained(
|
||||
model, device_map=device, dtype=dtype, trust_remote_code=True
|
||||
) # type: AutoModelForCausalLM
|
||||
|
||||
self.generation_kwargs = generation_kwargs if generation_kwargs else DEFAULT_GENERATION_KWARGS
|
||||
logger.debug(f"LLM generation kwargs: {self.generation_kwargs}")
|
||||
|
||||
self.apply_chat_template_kwargs = apply_chat_template_kwargs if apply_chat_template_kwargs else {}
|
||||
if "tokenize" in self.apply_chat_template_kwargs:
|
||||
if self.apply_chat_template_kwargs["tokenize"] is not False:
|
||||
logger.warning(
|
||||
f"Found `tokenize=True` in apply_chat_template_kwargs={self.apply_chat_template_kwargs},"
|
||||
"it will be ignored and forced to `False`"
|
||||
)
|
||||
self.apply_chat_template_kwargs.pop("tokenize")
|
||||
|
||||
logger.debug(f"LLM apply_chat_template kwargs: {self.apply_chat_template_kwargs}")
|
||||
|
||||
def _apply_chat_template(self, messages: List[ChatCompletionMessageParam]) -> str:
|
||||
"""
|
||||
Apply the chat template to the messages.
|
||||
"""
|
||||
return self.tokenizer.apply_chat_template(messages, tokenize=False, **self.apply_chat_template_kwargs)
|
||||
|
||||
def _get_prompt_from_messages(self, messages: List[ChatCompletionMessageParam]) -> str:
|
||||
"""
|
||||
Get the formatted prompt from the conversation history messages.
|
||||
This function also tries to fix the messages if the LLM cannot handle consecutive turns of the same role,
|
||||
or requires a user turn after the system prompt.
|
||||
"""
|
||||
try:
|
||||
prompt = self._apply_chat_template(messages)
|
||||
return prompt
|
||||
except TemplateError as e:
|
||||
logger.warning(f"Got TemplateError: {e}.")
|
||||
|
||||
logger.debug(f"Input LLM messages: {messages}")
|
||||
if len(messages) > 1 and messages[0]["role"] == "system" and messages[1]["role"] == "assistant":
|
||||
logger.warning("Trying to fix by adding dummy user message after system prompt...")
|
||||
try:
|
||||
messages = self._maybe_add_user_message(messages)
|
||||
logger.debug(f"LLM messages after adding dummy user message: {messages}")
|
||||
prompt = self._apply_chat_template(messages)
|
||||
return prompt
|
||||
except TemplateError as e:
|
||||
logger.warning(f"Got TemplateError: {e}. Trying to fix by merging consecutive turns if possible.")
|
||||
|
||||
try:
|
||||
new_messages = self._maybe_merge_consecutive_user_turns(messages)
|
||||
logger.debug(f"LLM messages after merging consecutive user turns: {new_messages}")
|
||||
prompt = self._apply_chat_template(new_messages)
|
||||
# Update the messages in place if successful
|
||||
messages.clear()
|
||||
messages.extend(new_messages)
|
||||
return prompt
|
||||
except Exception as e:
|
||||
logger.warning(f"Got Exception: {e}, messages: {messages}")
|
||||
raise e
|
||||
|
||||
async def generate_stream(
|
||||
self, messages: List[ChatCompletionMessageParam], **kwargs
|
||||
) -> AsyncGenerator[ChatCompletionChunk, None]:
|
||||
"""
|
||||
Generate a stream of chat completion chunks from the messages.
|
||||
"""
|
||||
# Convert messages to prompt format
|
||||
prompt = self._get_prompt_from_messages(messages)
|
||||
|
||||
logger.debug(f"LLM prompt: {prompt}")
|
||||
|
||||
inputs = self.tokenizer(prompt, add_special_tokens=False, return_tensors="pt").to(self.device)
|
||||
|
||||
# Generate with streaming
|
||||
streamer = AsyncTextIteratorStreamer(self.tokenizer, skip_prompt=True, skip_special_tokens=True)
|
||||
generation_kwargs = {
|
||||
**inputs,
|
||||
"streamer": streamer,
|
||||
**self.generation_kwargs,
|
||||
}
|
||||
|
||||
# Start generation in background
|
||||
thread = Thread(
|
||||
target=self.model.generate,
|
||||
kwargs=generation_kwargs,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
# Stream the output
|
||||
async for text in streamer:
|
||||
# logger.debug(f"Streamer yielded text: {text}")
|
||||
chunk = ChatCompletionChunk(
|
||||
id="hf-" + str(uuid.uuid4()),
|
||||
choices=[{"delta": {"content": text}, "finish_reason": None, "index": 0}],
|
||||
created=int(time.time()),
|
||||
model=self.model.config._name_or_path,
|
||||
object="chat.completion.chunk",
|
||||
)
|
||||
yield chunk
|
||||
|
||||
|
||||
class HuggingFaceLLMService(OpenAILLMService):
|
||||
"""
|
||||
LLM service that hosts a HuggingFace model.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: str = "google/gemma-7b-it",
|
||||
device: str = "cuda",
|
||||
dtype: str = "bfloat16",
|
||||
thinking_budget: int = 0,
|
||||
generation_kwargs: dict = None,
|
||||
apply_chat_template_kwargs: dict = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._model_name = model
|
||||
self._device = device
|
||||
self._dtype = dtype
|
||||
self._thinking_budget = thinking_budget
|
||||
self._generation_kwargs = generation_kwargs if generation_kwargs is not None else DEFAULT_GENERATION_KWARGS
|
||||
self._apply_chat_template_kwargs = apply_chat_template_kwargs if apply_chat_template_kwargs is not None else {}
|
||||
super().__init__(model=model, **kwargs)
|
||||
|
||||
def create_client(self, api_key=None, base_url=None, **kwargs):
|
||||
"""
|
||||
Create a HuggingFaceLLMLocalService client.
|
||||
"""
|
||||
return HuggingFaceLLMLocalService(
|
||||
model=self._model_name,
|
||||
device=self._device,
|
||||
dtype=self._dtype,
|
||||
thinking_budget=self._thinking_budget,
|
||||
generation_kwargs=self._generation_kwargs,
|
||||
apply_chat_template_kwargs=self._apply_chat_template_kwargs,
|
||||
)
|
||||
|
||||
async def _process_context(self, context: OpenAILLMContext):
|
||||
"""Process a context through the LLM and push text frames.
|
||||
|
||||
Args:
|
||||
context (OpenAILLMContext): The context to process, containing messages
|
||||
and other information needed for the LLM interaction.
|
||||
"""
|
||||
await self.push_frame(LLMFullResponseStartFrame())
|
||||
cumulative_text = ""
|
||||
try:
|
||||
await self.start_ttfb_metrics()
|
||||
messages = context.get_messages()
|
||||
async for chunk in self._client.generate_stream(messages):
|
||||
if chunk.choices[0].delta.content:
|
||||
await self.stop_ttfb_metrics()
|
||||
text = chunk.choices[0].delta.content
|
||||
cumulative_text += text
|
||||
frame = LLMTextFrame(text)
|
||||
await self.push_frame(frame)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in _process_context: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
cumulative_text = " ".join(cumulative_text.split()).strip()
|
||||
if not cumulative_text:
|
||||
logger.warning(f"LLM response is empty for context: {context}")
|
||||
await self.push_frame(LLMFullResponseEndFrame())
|
||||
|
||||
async def get_chat_completions(
|
||||
self, params_from_context: OpenAILLMInvocationParams
|
||||
) -> AsyncGenerator[ChatCompletionChunk, None]:
|
||||
"""Create a streaming chat completion using HuggingFace model.
|
||||
|
||||
Args:
|
||||
context (OpenAILLMContext): The context object containing tools configuration
|
||||
and other settings for the chat completion.
|
||||
messages (List[ChatCompletionMessageParam]): The list of messages comprising
|
||||
the conversation history and current request.
|
||||
|
||||
Returns:
|
||||
AsyncGenerator[ChatCompletionChunk]: A streaming response of chat completion
|
||||
chunks that can be processed asynchronously.
|
||||
"""
|
||||
messages = params_from_context["messages"]
|
||||
|
||||
return self._client.generate_stream(messages)
|
||||
|
||||
|
||||
class VLLMService(OpenAILLMService, LLMUtilsMixin):
|
||||
"""
|
||||
LLM service that hosts a vLLM server.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
device: str = "cuda",
|
||||
api_key="None",
|
||||
base_url="http://localhost:8000/v1",
|
||||
organization="None",
|
||||
project="None",
|
||||
default_headers: Optional[Mapping[str, str]] = None,
|
||||
params: Optional[OpenAILLMService.InputParams] = None,
|
||||
thinking_budget: int = 0,
|
||||
start_vllm_on_init: bool = False,
|
||||
vllm_server_params: Optional[str] = None,
|
||||
vllm_server_max_wait_time: int = 3600, # 1 hour max wait time
|
||||
vllm_server_check_interval: int = 5, # check server every 5 seconds
|
||||
**kwargs,
|
||||
):
|
||||
self._device = device
|
||||
self._vllm_server_max_wait_time = vllm_server_max_wait_time
|
||||
self._vllm_server_check_interval = vllm_server_check_interval
|
||||
if start_vllm_on_init:
|
||||
base_url = self._start_vllm_server(model, vllm_server_params, base_url)
|
||||
|
||||
super().__init__(
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
organization=organization,
|
||||
project=project,
|
||||
default_headers=default_headers,
|
||||
params=params,
|
||||
**kwargs,
|
||||
)
|
||||
self._thinking_budget = thinking_budget
|
||||
self._vllm_server_params = vllm_server_params
|
||||
self._start_vllm_on_init = start_vllm_on_init
|
||||
|
||||
# TODO: handle thinking budget
|
||||
logger.info(
|
||||
f"VLLMService initialized with model: {model}, api_key: {api_key}, base_url: {base_url},"
|
||||
f"params: {params}, thinking_budget: {thinking_budget}"
|
||||
)
|
||||
|
||||
def _start_vllm_server(
|
||||
self, model: str, vllm_server_params: Optional[str] = None, base_url: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Start a vllm server and return the base url.
|
||||
"""
|
||||
|
||||
requested_port = None
|
||||
# If base_url is provided, extract port from it
|
||||
if base_url:
|
||||
try:
|
||||
# Extract port from base_url like "http://localhost:8003/v1"
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed_url = urlparse(base_url)
|
||||
if parsed_url.port:
|
||||
requested_port = parsed_url.port
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not parse port from base_url {base_url}: {e}, using port from vllm_server_params"
|
||||
)
|
||||
|
||||
# Parse port from vllm_server_params, default to 8000
|
||||
if vllm_server_params:
|
||||
params_list = vllm_server_params.split()
|
||||
for i, param in enumerate(params_list):
|
||||
if param == "--port" and i + 1 < len(params_list):
|
||||
try:
|
||||
param_port = int(params_list[i + 1])
|
||||
if requested_port is None:
|
||||
requested_port = param_port
|
||||
else:
|
||||
if param_port != requested_port:
|
||||
logger.warning(
|
||||
f"Port {param_port} from vllm_server_params is different from base_url port"
|
||||
f"{requested_port}, using new port {param_port}"
|
||||
)
|
||||
requested_port = param_port
|
||||
break
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid port number: {params_list[i + 1]}, using default 8000")
|
||||
|
||||
if requested_port is None:
|
||||
# try to use default port
|
||||
requested_port = 8000
|
||||
|
||||
def find_available_port(start_port: int) -> int:
|
||||
"""Find an available port starting from start_port"""
|
||||
for port in range(start_port, start_port + 100): # Try up to 100 ports
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('localhost', port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError(f"Could not find an available port starting from {start_port}")
|
||||
|
||||
def get_pid_on_port(port: int) -> Optional[int]:
|
||||
for conn in psutil.net_connections(kind="inet"):
|
||||
if conn.laddr.port == port and conn.status == psutil.CONN_LISTEN:
|
||||
return conn.pid
|
||||
return None
|
||||
|
||||
def check_server_model(port: int, verbose: bool = False) -> tuple[bool, str]:
|
||||
"""Check if server is running on port and return (is_running, model_name)"""
|
||||
try:
|
||||
response = requests.get(f"http://localhost:{port}/v1/models", timeout=5)
|
||||
if response.status_code == 200:
|
||||
# get the PID for the server process
|
||||
pid = get_pid_on_port(port)
|
||||
if pid is not None and verbose:
|
||||
logger.warning(
|
||||
f"Found vLLM server process (PID: {pid}) on port {port}, you can use `lsof -i :{port}`"
|
||||
"to find the process and kill it if you want to start a new server."
|
||||
)
|
||||
models_data = response.json()
|
||||
if "data" in models_data and models_data["data"]:
|
||||
served_model = models_data["data"][0].get("id", "")
|
||||
return True, served_model
|
||||
return True, ""
|
||||
return False, ""
|
||||
except (requests.exceptions.RequestException, requests.exceptions.Timeout):
|
||||
return False, ""
|
||||
|
||||
# First, check if vLLM server is already running on the requested port
|
||||
is_running, served_model = check_server_model(requested_port, verbose=True)
|
||||
if is_running:
|
||||
if served_model == model:
|
||||
final_base_url = f"http://localhost:{requested_port}/v1"
|
||||
logger.info(f"vLLM server is already running at {final_base_url} with the correct model: {model}")
|
||||
return final_base_url
|
||||
else:
|
||||
logger.warning(
|
||||
f"vLLM server on port {requested_port} is serving model '{served_model}' but we need '{model}'."
|
||||
"Finding new port..."
|
||||
)
|
||||
|
||||
# Find an available port for our model
|
||||
port = find_available_port(requested_port)
|
||||
if port != requested_port:
|
||||
logger.info(f"Using port {port} instead of requested port {requested_port}")
|
||||
|
||||
final_base_url = f"http://localhost:{port}/v1"
|
||||
|
||||
# Check if there's already a vLLM process running on the same port and model
|
||||
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
|
||||
try:
|
||||
if proc.info['cmdline'] and any('vllm' in arg and 'serve' in arg for arg in proc.info['cmdline']):
|
||||
# Check if this process is using the same port and model
|
||||
cmdline_str = ' '.join(proc.info['cmdline'])
|
||||
if f"--port {port}" in cmdline_str:
|
||||
# Extract the model from the command line
|
||||
cmdline_parts = proc.info['cmdline']
|
||||
model_index = -1
|
||||
for i, arg in enumerate(cmdline_parts):
|
||||
if arg == "serve" and i + 1 < len(cmdline_parts):
|
||||
model_index = i + 1
|
||||
break
|
||||
|
||||
if model_index != -1 and model_index < len(cmdline_parts):
|
||||
running_model = cmdline_parts[model_index]
|
||||
if running_model == model:
|
||||
logger.info(
|
||||
f"Found existing vLLM server process (PID: {proc.info['pid']}) on port {port}"
|
||||
f"serving model {model}"
|
||||
)
|
||||
# Wait a bit and check if it's responding
|
||||
time.sleep(2)
|
||||
is_running, served_model = check_server_model(port)
|
||||
if is_running and served_model == model:
|
||||
logger.info(
|
||||
f"Existing vLLM server is responding at {final_base_url} with correct model"
|
||||
)
|
||||
return final_base_url
|
||||
else:
|
||||
logger.warning(
|
||||
f"Existing vLLM process found on port {port} but not responding correctly,"
|
||||
"will start new server"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Found vLLM process on port {port} but serving different model '{running_model}'"
|
||||
f"(need '{model}'). Will start new server."
|
||||
)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
# Build the command with the determined port
|
||||
cmd_parts = ["vllm", "serve", model]
|
||||
|
||||
# Parse and modify vllm_server_params to use the correct port
|
||||
if vllm_server_params:
|
||||
# parse the vllm_server_params and add the port to the command
|
||||
params_list = vllm_server_params.split()
|
||||
modified_params = []
|
||||
i = 0
|
||||
while i < len(params_list):
|
||||
if params_list[i] == "--port" and i + 1 < len(params_list):
|
||||
# Replace the port with our determined port
|
||||
modified_params.extend(["--port", str(port)])
|
||||
i += 2 # Skip the original port value
|
||||
else:
|
||||
modified_params.append(params_list[i])
|
||||
i += 1
|
||||
cmd_parts.extend(modified_params)
|
||||
else:
|
||||
# Add port if vllm_server_params is not provided
|
||||
cmd_parts.extend(["--port", str(port)])
|
||||
|
||||
logger.info(f"Starting vLLM server with command: {' '.join(cmd_parts)}")
|
||||
logger.warning("It will take a while to download the model if it's not already downloaded.")
|
||||
# Set up environment variables for device configuration
|
||||
env = os.environ.copy()
|
||||
if self._device and self._device != "cpu":
|
||||
# Extract CUDA device number if it's in format "cuda:0", "cuda:1", etc.
|
||||
if self._device.startswith("cuda:"):
|
||||
device_id = self._device.split(":")[1]
|
||||
env["CUDA_VISIBLE_DEVICES"] = device_id
|
||||
logger.info(f"Setting CUDA_VISIBLE_DEVICES={device_id}")
|
||||
elif self._device == "cuda":
|
||||
# Use default CUDA device (don't set CUDA_VISIBLE_DEVICES)
|
||||
logger.info("Using default CUDA device")
|
||||
else:
|
||||
# For other device strings, try to extract device number
|
||||
logger.warning(f"Unknown device format: {self._device}, using as-is")
|
||||
env["CUDA_VISIBLE_DEVICES"] = self._device
|
||||
elif self._device == "cpu":
|
||||
env["CUDA_VISIBLE_DEVICES"] = ""
|
||||
logger.info("Setting CUDA_VISIBLE_DEVICES='' to use CPU")
|
||||
|
||||
try:
|
||||
# Start the vLLM server process with environment variables
|
||||
process = subprocess.Popen(
|
||||
cmd_parts,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env=env,
|
||||
preexec_fn=os.setsid if os.name != 'nt' else None, # Create new process group
|
||||
)
|
||||
|
||||
# Store the process for potential cleanup later
|
||||
self._vllm_process = process
|
||||
|
||||
# Wait for server to start up
|
||||
max_wait_time = self._vllm_server_max_wait_time
|
||||
check_interval = self._vllm_server_check_interval
|
||||
waited_time = 0
|
||||
|
||||
logger.info(f"Waiting for vLLM server to start on port {port}...")
|
||||
while waited_time < max_wait_time:
|
||||
is_running, served_model = check_server_model(port)
|
||||
if is_running and served_model == model:
|
||||
logger.info(f"vLLM server started successfully at {final_base_url} serving model: {model}")
|
||||
return final_base_url
|
||||
elif is_running and served_model != model:
|
||||
logger.warning(
|
||||
f"vLLM server started but serving wrong model '{served_model}' instead of '{model}'."
|
||||
"Continuing to wait..."
|
||||
)
|
||||
|
||||
# Check if process is still running
|
||||
if process.poll() is not None:
|
||||
# Process has terminated
|
||||
stdout, stderr = process.communicate()
|
||||
logger.error(f"vLLM server process terminated unexpectedly. stdout: {stdout}, stderr: {stderr}")
|
||||
raise RuntimeError(f"Failed to start vLLM server: {stderr}")
|
||||
|
||||
time.sleep(check_interval)
|
||||
waited_time += check_interval
|
||||
logger.debug(f"Still waiting for vLLM server on port {port}... ({waited_time}s)")
|
||||
|
||||
# If we get here, server didn't start in time
|
||||
logger.error(f"vLLM server failed to start within {max_wait_time} seconds on port {port}")
|
||||
process.terminate()
|
||||
raise RuntimeError(f"vLLM server failed to start within {max_wait_time} seconds on port {port}")
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error("vLLM not found. Please install vLLM: pip install vllm")
|
||||
raise RuntimeError("vLLM not found. Please install vLLM: pip install vllm")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start vLLM server: {e}")
|
||||
self._stop_vllm_server()
|
||||
raise e
|
||||
|
||||
def _stop_vllm_server(self):
|
||||
"""Stop the vLLM server process if it's running."""
|
||||
if hasattr(self, '_vllm_process') and self._vllm_process:
|
||||
logger.info(f"Stopping vLLM server process {self._vllm_process.pid}")
|
||||
self._vllm_process.terminate()
|
||||
|
||||
async def stop(self, frame: EndFrame):
|
||||
"""Stop the LLM service.
|
||||
|
||||
Args:
|
||||
frame: The end frame.
|
||||
"""
|
||||
await super().stop(frame)
|
||||
self._stop_vllm_server()
|
||||
|
||||
async def cancel(self, frame: CancelFrame):
|
||||
"""Cancel the LLM service.
|
||||
|
||||
Args:
|
||||
frame: The cancel frame.
|
||||
"""
|
||||
await super().cancel(frame)
|
||||
self._stop_vllm_server()
|
||||
|
||||
async def get_chat_completions(
|
||||
self, params_from_context: OpenAILLMInvocationParams
|
||||
) -> AsyncStream[ChatCompletionChunk]:
|
||||
"""Get streaming chat completions from OpenAI API.
|
||||
|
||||
Args:
|
||||
context: The LLM context containing tools and configuration.
|
||||
messages: List of chat completion messages to send.
|
||||
|
||||
Returns:
|
||||
Async stream of chat completion chunks.
|
||||
"""
|
||||
|
||||
params = self.build_chat_completion_params(params_from_context)
|
||||
messages = params_from_context["messages"]
|
||||
if self._retry_on_timeout:
|
||||
try:
|
||||
chunks = await asyncio.wait_for(
|
||||
self._get_response_from_client(messages, params), timeout=self._retry_timeout_secs
|
||||
)
|
||||
return chunks
|
||||
except (APITimeoutError, asyncio.TimeoutError):
|
||||
# Retry, this time without a timeout so we get a response
|
||||
logger.debug(f"{self}: Retrying chat completion due to timeout")
|
||||
chunks = await self._get_response_from_client(messages, params)
|
||||
return chunks
|
||||
else:
|
||||
chunks = await self._get_response_from_client(messages, params)
|
||||
return chunks
|
||||
|
||||
async def _get_response_from_client(
|
||||
self, messages: List[ChatCompletionMessageParam], params: dict
|
||||
) -> AsyncStream[ChatCompletionChunk]:
|
||||
"""Get a response from the client."""
|
||||
|
||||
try:
|
||||
chunks = await self._client.chat.completions.create(**params)
|
||||
except BadRequestError as e:
|
||||
logger.error(f"Error in _get_response_from_client: {e}, trying to fix...")
|
||||
logger.debug(f"LLM messages before fixing: {messages}")
|
||||
messages = self._maybe_add_user_message(messages)
|
||||
messages = self._maybe_merge_consecutive_user_turns(messages)
|
||||
logger.debug(f"LLM messages after fixing: {messages}")
|
||||
params["messages"] = messages
|
||||
chunks = await self._client.chat.completions.create(**params)
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def get_llm_service_from_config(config: DictConfig) -> OpenAILLMService:
|
||||
"""Get an LLM service from the configuration."""
|
||||
backend = config.type
|
||||
|
||||
logger.info(f"Initializing LLM service from config: {config}")
|
||||
|
||||
# If backend is "auto", try to detect the best backend
|
||||
if backend == "auto":
|
||||
model_name = config.get("model")
|
||||
if not model_name:
|
||||
raise ValueError("Model name is required for LLM")
|
||||
|
||||
try:
|
||||
_ = vllmModelConfig(model_name, trust_remote_code=True)
|
||||
backend = "vllm"
|
||||
logger.info(f"Auto-detected vLLM as the best backend for model {model_name}")
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
f"The LLM doesn't seem to be supported by vLLM yet (error: {e}), using HuggingFace as the backend"
|
||||
f"for model: {model_name}. If you are sure that the LLM is supported by vLLM, you can set `type: vllm`"
|
||||
"in the config file to force using vLLM."
|
||||
)
|
||||
backend = "hf"
|
||||
|
||||
assert backend in [
|
||||
"hf",
|
||||
"vllm",
|
||||
"auto",
|
||||
], f"Invalid backend: {backend}, only `hf`, `vllm`, and `auto` are supported."
|
||||
|
||||
if backend == "hf":
|
||||
llm_model = config.model
|
||||
llm_device = config.device
|
||||
llm_dtype = config.dtype
|
||||
llm_generation_kwargs = config.get("generation_kwargs", {})
|
||||
if llm_generation_kwargs is not None:
|
||||
llm_generation_kwargs = OmegaConf.to_container(llm_generation_kwargs, resolve=True)
|
||||
llm_apply_chat_template_kwargs = config.get("apply_chat_template_kwargs", None)
|
||||
if llm_apply_chat_template_kwargs is not None:
|
||||
llm_apply_chat_template_kwargs = OmegaConf.to_container(llm_apply_chat_template_kwargs, resolve=True)
|
||||
llm_thinking_budget = config.get("thinking_budget", 0)
|
||||
return HuggingFaceLLMService(
|
||||
model=llm_model,
|
||||
device=llm_device,
|
||||
dtype=llm_dtype,
|
||||
generation_kwargs=llm_generation_kwargs,
|
||||
apply_chat_template_kwargs=llm_apply_chat_template_kwargs,
|
||||
thinking_budget=llm_thinking_budget,
|
||||
)
|
||||
elif backend == "vllm":
|
||||
llm_model = config.get("model", "vllm_server")
|
||||
llm_api_key = config.get("api_key", "None")
|
||||
llm_base_url = config.get("base_url", "http://localhost:8000/v1")
|
||||
llm_organization = config.get("organization", "None")
|
||||
llm_project = config.get("project", "None")
|
||||
llm_default_headers = config.get("default_headers", None)
|
||||
llm_params = config.get("vllm_generation_params", None)
|
||||
llm_dtype = config.dtype
|
||||
vllm_server_params = config.get("vllm_server_params", None)
|
||||
if vllm_server_params is not None:
|
||||
if "dtype" not in vllm_server_params:
|
||||
vllm_server_params = f"--dtype {llm_dtype} {vllm_server_params}"
|
||||
logger.info(f"Adding dtype {llm_dtype} to vllm_server_params: {vllm_server_params}")
|
||||
if llm_params is not None:
|
||||
# cast into OpenAILLMService.InputParams object
|
||||
llm_params = OmegaConf.to_container(llm_params, resolve=True)
|
||||
extra = llm_params.get("extra", None)
|
||||
# ensure extra is a dictionary
|
||||
if extra is None:
|
||||
llm_params["extra"] = {}
|
||||
elif not isinstance(extra, dict):
|
||||
raise ValueError(f"extra must be a dictionary, got {type(extra)}")
|
||||
llm_params = OpenAILLMService.InputParams(**llm_params)
|
||||
else:
|
||||
llm_params = OpenAILLMService.InputParams()
|
||||
llm_thinking_budget = config.get("thinking_budget", 0)
|
||||
return VLLMService(
|
||||
model=llm_model,
|
||||
api_key=llm_api_key,
|
||||
base_url=llm_base_url,
|
||||
organization=llm_organization,
|
||||
project=llm_project,
|
||||
default_headers=llm_default_headers,
|
||||
params=llm_params,
|
||||
thinking_budget=llm_thinking_budget,
|
||||
start_vllm_on_init=config.get("start_vllm_on_init", False),
|
||||
vllm_server_params=vllm_server_params,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid LLM backend: {backend}")
|
||||
@@ -0,0 +1,319 @@
|
||||
# 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.
|
||||
# NOTE: This file will be deprecated in the future, as the new inference pipeline will replace it.
|
||||
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from omegaconf import open_dict
|
||||
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.utils import CacheFeatureBufferer
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
|
||||
from nemo.collections.common.tokenizers.sentencepiece_tokenizer import SentencePieceTokenizer
|
||||
|
||||
|
||||
@dataclass
|
||||
class ASRResult:
|
||||
text: str
|
||||
is_final: bool
|
||||
eou_prob: Optional[float] = None
|
||||
eob_prob: Optional[float] = None
|
||||
eou_latency: Optional[float] = None
|
||||
eob_latency: Optional[float] = None
|
||||
processing_time: Optional[float] = None
|
||||
|
||||
|
||||
class NemoStreamingASRService:
|
||||
def __init__(
|
||||
self,
|
||||
model: str = "nvidia/parakeet_realtime_eou_120m-v1",
|
||||
att_context_size: List[int] = [70, 1],
|
||||
device: str = "cuda",
|
||||
eou_string: str = "<EOU>",
|
||||
eob_string: str = "<EOB>",
|
||||
decoder_type: str = None,
|
||||
chunk_size: int = -1,
|
||||
shift_size: int = -1,
|
||||
left_chunks: int = 2,
|
||||
sample_rate: int = 16000,
|
||||
frame_len_in_secs: float = 0.08,
|
||||
use_amp: bool = False,
|
||||
chunk_size_in_secs: float = 0.08,
|
||||
):
|
||||
self.model = model
|
||||
self.eou_string = eou_string
|
||||
self.eob_string = eob_string
|
||||
self.device = device
|
||||
self.att_context_size = att_context_size
|
||||
self.decoder_type = decoder_type
|
||||
self.chunk_size = chunk_size
|
||||
self.shift_size = shift_size
|
||||
self.left_chunks = left_chunks
|
||||
self.asr_model = self._load_model(model)
|
||||
self.tokenizer: SentencePieceTokenizer = self.asr_model.tokenizer
|
||||
self.use_amp = use_amp
|
||||
self.pad_and_drop_preencoded = False
|
||||
self.blank_id = self.get_blank_id()
|
||||
self.chunk_size_in_secs = chunk_size_in_secs
|
||||
|
||||
assert len(self.att_context_size) == 2, "Att context size must be a list of two integers"
|
||||
assert (
|
||||
self.att_context_size[0] >= 0
|
||||
), f"Left att context size must be greater than 0: {self.att_context_size[0]}"
|
||||
assert (
|
||||
self.att_context_size[1] >= 0
|
||||
), f"Right att context size must be greater than 0: {self.att_context_size[1]}"
|
||||
|
||||
window_stride_in_secs = self.asr_model.cfg.preprocessor.window_stride
|
||||
model_stride = self.asr_model.cfg.encoder.subsampling_factor
|
||||
self.model_chunk_size = self.asr_model.encoder.streaming_cfg.chunk_size
|
||||
if isinstance(self.model_chunk_size, list):
|
||||
self.model_chunk_size = self.model_chunk_size[1]
|
||||
self.pre_encode_cache_size = self.asr_model.encoder.streaming_cfg.pre_encode_cache_size
|
||||
if isinstance(self.pre_encode_cache_size, list):
|
||||
self.pre_encode_cache_size = self.pre_encode_cache_size[1]
|
||||
self.pre_encode_cache_size_in_secs = self.pre_encode_cache_size * window_stride_in_secs
|
||||
|
||||
self.tokens_per_frame = math.ceil(np.trunc(self.chunk_size_in_secs / window_stride_in_secs) / model_stride)
|
||||
# overwrite the encoder streaming params with proper shift size for cache aware streaming
|
||||
self.asr_model.encoder.setup_streaming_params(
|
||||
chunk_size=self.model_chunk_size // model_stride, shift_size=self.tokens_per_frame
|
||||
)
|
||||
|
||||
model_chunk_size_in_secs = self.model_chunk_size * window_stride_in_secs
|
||||
|
||||
self.buffer_size_in_secs = self.pre_encode_cache_size_in_secs + model_chunk_size_in_secs
|
||||
|
||||
self._audio_buffer = CacheFeatureBufferer(
|
||||
sample_rate=sample_rate,
|
||||
buffer_size_in_secs=self.buffer_size_in_secs,
|
||||
chunk_size_in_secs=self.chunk_size_in_secs,
|
||||
preprocessor_cfg=self.asr_model.cfg.preprocessor,
|
||||
device=self.device,
|
||||
)
|
||||
self._reset_cache()
|
||||
self._previous_hypotheses = self._get_blank_hypothesis()
|
||||
self._last_transcript_timestamp = time.time()
|
||||
print(f"NemoStreamingASRService initialized with model `{model}` on device `{self.device}`")
|
||||
|
||||
def _reset_cache(self):
|
||||
(
|
||||
self._cache_last_channel, # [17, B, 70, 512]
|
||||
self._cache_last_time, # [17, B, 512, 8]
|
||||
self._cache_last_channel_len, # B
|
||||
) = self.asr_model.encoder.get_initial_cache_state(
|
||||
1
|
||||
) # batch size is 1
|
||||
|
||||
def _get_blank_hypothesis(self) -> List[Hypothesis]:
|
||||
blank_hypothesis = Hypothesis(score=0.0, y_sequence=[], dec_state=None, timestamp=[], last_token=None)
|
||||
return [blank_hypothesis]
|
||||
|
||||
@property
|
||||
def drop_extra_pre_encoded(self):
|
||||
return self.asr_model.encoder.streaming_cfg.drop_extra_pre_encoded
|
||||
|
||||
def get_blank_id(self):
|
||||
return len(self.tokenizer.vocab)
|
||||
|
||||
def get_text_from_tokens(self, tokens: List[int]) -> str:
|
||||
sep = "\u2581" # '▁'
|
||||
tokens = [int(t) for t in tokens if t != self.blank_id]
|
||||
if tokens:
|
||||
pieces = self.tokenizer.ids_to_tokens(tokens)
|
||||
text = "".join([p.replace(sep, ' ') if p.startswith(sep) else p for p in pieces])
|
||||
else:
|
||||
text = ""
|
||||
return text
|
||||
|
||||
def _load_model(self, model: str):
|
||||
if model.endswith(".nemo"):
|
||||
asr_model = nemo_asr.models.ASRModel.restore_from(model, map_location=torch.device(self.device))
|
||||
else:
|
||||
asr_model = nemo_asr.models.ASRModel.from_pretrained(model, map_location=torch.device(self.device))
|
||||
|
||||
if self.decoder_type is not None and hasattr(asr_model, "cur_decoder"):
|
||||
asr_model.change_decoding_strategy(decoder_type=self.decoder_type)
|
||||
elif isinstance(asr_model, nemo_asr.models.EncDecCTCModel):
|
||||
self.decoder_type = "ctc"
|
||||
elif isinstance(asr_model, nemo_asr.models.EncDecRNNTModel):
|
||||
self.decoder_type = "rnnt"
|
||||
else:
|
||||
raise ValueError("Decoder type not supported for this model.")
|
||||
|
||||
if self.att_context_size is not None:
|
||||
if hasattr(asr_model.encoder, "set_default_att_context_size"):
|
||||
asr_model.encoder.set_default_att_context_size(att_context_size=self.att_context_size)
|
||||
else:
|
||||
raise ValueError("Model does not support multiple lookaheads.")
|
||||
else:
|
||||
self.att_context_size = asr_model.cfg.encoder.att_context_size
|
||||
|
||||
decoding_cfg = asr_model.cfg.decoding
|
||||
with open_dict(decoding_cfg):
|
||||
decoding_cfg.strategy = "greedy"
|
||||
decoding_cfg.compute_timestamps = False
|
||||
decoding_cfg.preserve_alignments = True
|
||||
if hasattr(asr_model, 'joint'): # if an RNNT model
|
||||
decoding_cfg.greedy.max_symbols = 10
|
||||
decoding_cfg.fused_batch_size = -1
|
||||
asr_model.change_decoding_strategy(decoding_cfg)
|
||||
|
||||
if hasattr(asr_model.encoder, "set_default_att_context_size"):
|
||||
asr_model.encoder.set_default_att_context_size(att_context_size=self.att_context_size)
|
||||
|
||||
# chunk_size is set automatically for models trained for streaming.
|
||||
# For models trained for offline mode with full context, we need to pass the chunk_size explicitly.
|
||||
if self.chunk_size > 0:
|
||||
if self.shift_size < 0:
|
||||
shift_size = self.chunk_size
|
||||
else:
|
||||
shift_size = self.shift_size
|
||||
asr_model.encoder.setup_streaming_params(
|
||||
chunk_size=self.chunk_size, left_chunks=self.left_chunks, shift_size=shift_size
|
||||
)
|
||||
|
||||
asr_model.eval()
|
||||
return asr_model
|
||||
|
||||
def _get_best_hypothesis(self, encoded, encoded_len, partial_hypotheses=None):
|
||||
if self.decoder_type == "ctc":
|
||||
best_hyp = self.asr_model.decoding.ctc_decoder_predictions_tensor(
|
||||
encoded,
|
||||
encoded_len,
|
||||
return_hypotheses=True,
|
||||
)
|
||||
elif self.decoder_type == "rnnt":
|
||||
best_hyp = self.asr_model.decoding.rnnt_decoder_predictions_tensor(
|
||||
encoded, encoded_len, return_hypotheses=True, partial_hypotheses=partial_hypotheses
|
||||
)
|
||||
else:
|
||||
raise ValueError("Decoder type not supported for this model.")
|
||||
return best_hyp
|
||||
|
||||
def _get_tokens_and_probs_from_alignments(self, alignments):
|
||||
tokens = []
|
||||
probs = []
|
||||
if self.decoder_type == "ctc":
|
||||
all_logits = alignments[0]
|
||||
all_tokens = alignments[1]
|
||||
for i in range(len(all_tokens)):
|
||||
token_id = int(all_tokens[i])
|
||||
if token_id != self.blank_id:
|
||||
tokens.append(token_id)
|
||||
logits = all_logits[i] # shape (vocab_size,)
|
||||
probs_i = torch.softmax(logits, dim=-1)[token_id].item()
|
||||
probs.append(probs_i)
|
||||
elif self.decoder_type == "rnnt":
|
||||
for t in range(len(alignments)):
|
||||
for u in range(len(alignments[t])):
|
||||
logits, token_id = alignments[t][u] # (logits, token_id)
|
||||
token_id = int(token_id)
|
||||
if token_id != self.blank_id:
|
||||
tokens.append(token_id)
|
||||
probs_i = torch.softmax(logits, dim=-1)[token_id].item()
|
||||
probs.append(probs_i)
|
||||
else:
|
||||
raise ValueError("Decoder type not supported for this model.")
|
||||
|
||||
return tokens, probs
|
||||
|
||||
def transcribe(self, audio: bytes, stream_id: str = "default") -> ASRResult:
|
||||
start_time = time.time()
|
||||
|
||||
# Convert bytes to numpy array
|
||||
audio_array = np.frombuffer(audio, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
self._audio_buffer.update(audio_array)
|
||||
|
||||
features = self._audio_buffer.get_feature_buffer()
|
||||
feature_lengths = torch.tensor([features.shape[1]], device=self.device)
|
||||
features = features.unsqueeze(0) # Add batch dimension
|
||||
|
||||
with torch.no_grad():
|
||||
(
|
||||
encoded,
|
||||
encoded_len,
|
||||
cache_last_channel,
|
||||
cache_last_time,
|
||||
cache_last_channel_len,
|
||||
) = self.asr_model.encoder.cache_aware_stream_step(
|
||||
processed_signal=features,
|
||||
processed_signal_length=feature_lengths,
|
||||
cache_last_channel=self._cache_last_channel,
|
||||
cache_last_time=self._cache_last_time,
|
||||
cache_last_channel_len=self._cache_last_channel_len,
|
||||
keep_all_outputs=False,
|
||||
drop_extra_pre_encoded=self.drop_extra_pre_encoded,
|
||||
)
|
||||
|
||||
best_hyp = self._get_best_hypothesis(encoded, encoded_len, partial_hypotheses=self._previous_hypotheses)
|
||||
|
||||
self._previous_hypotheses = best_hyp
|
||||
self._cache_last_channel = cache_last_channel
|
||||
self._cache_last_time = cache_last_time
|
||||
self._cache_last_channel_len = cache_last_channel_len
|
||||
|
||||
tokens, probs = self._get_tokens_and_probs_from_alignments(best_hyp[0].alignments)
|
||||
|
||||
text = self.get_text_from_tokens(tokens)
|
||||
|
||||
is_final = False
|
||||
eou_latency = None
|
||||
eob_latency = None
|
||||
eou_prob = None
|
||||
eob_prob = None
|
||||
current_timestamp = time.time()
|
||||
if self.eou_string in text or self.eob_string in text:
|
||||
is_final = True
|
||||
if self.eou_string in text:
|
||||
eou_latency = (
|
||||
current_timestamp - self._last_transcript_timestamp if text.strip() == self.eou_string else 0.0
|
||||
)
|
||||
eou_prob = self.get_eou_probability(tokens, probs, self.eou_string)
|
||||
if self.eob_string in text:
|
||||
eob_latency = (
|
||||
current_timestamp - self._last_transcript_timestamp if text.strip() == self.eob_string else 0.0
|
||||
)
|
||||
eob_prob = self.get_eou_probability(tokens, probs, self.eob_string)
|
||||
self.reset_state(stream_id=stream_id)
|
||||
if text.strip():
|
||||
self._last_transcript_timestamp = current_timestamp
|
||||
|
||||
processing_time = time.time() - start_time
|
||||
return ASRResult(
|
||||
text=text,
|
||||
is_final=is_final,
|
||||
eou_latency=eou_latency,
|
||||
eob_latency=eob_latency,
|
||||
eou_prob=eou_prob,
|
||||
eob_prob=eob_prob,
|
||||
processing_time=processing_time,
|
||||
)
|
||||
|
||||
def reset_state(self, stream_id: str = "default"):
|
||||
self._audio_buffer.reset()
|
||||
self._reset_cache()
|
||||
self._previous_hypotheses = self._get_blank_hypothesis()
|
||||
self._last_transcript_timestamp = time.time()
|
||||
|
||||
def get_eou_probability(self, tokens: List[int], probs: List[float], eou_string: str = "<EOU>") -> float:
|
||||
text_tokens = self.tokenizer.ids_to_tokens(tokens)
|
||||
eou_index = text_tokens.index(eou_string)
|
||||
return probs[eou_index]
|
||||
@@ -0,0 +1,212 @@
|
||||
# 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.
|
||||
# NOTE: This file will be deprecated in the future, as the new inference pipeline will replace it.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Tuple
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch import Tensor
|
||||
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.utils import CacheFeatureBufferer
|
||||
from nemo.collections.asr.models import SortformerEncLabelModel
|
||||
|
||||
from nemo.collections.asr.modules.sortformer_modules import StreamingSortformerState
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiarizationConfig:
|
||||
"""Diarization configuration parameters for inference."""
|
||||
|
||||
model_path: str = "nvidia/diar_streaming_sortformer_4spk-v2"
|
||||
device: str = "cuda"
|
||||
|
||||
log: bool = False # If True, log will be printed
|
||||
max_num_speakers: int = 4
|
||||
spkcache_len: int = 188
|
||||
spkcache_refresh_rate: int = 144
|
||||
fifo_len: int = 188
|
||||
chunk_len: int = 6
|
||||
chunk_left_context: int = 1
|
||||
chunk_right_context: int = 7
|
||||
|
||||
|
||||
class NeMoStreamingDiarService:
|
||||
def __init__(
|
||||
self,
|
||||
cfg: DiarizationConfig,
|
||||
model: str,
|
||||
frame_len_in_secs: float = 0.08,
|
||||
sample_rate: int = 16000,
|
||||
left_offset: int = 8,
|
||||
right_offset: int = 8,
|
||||
use_amp: bool = False,
|
||||
compute_dtype: torch.dtype = torch.float32,
|
||||
):
|
||||
self.model = model
|
||||
self.cfg = cfg
|
||||
self.cfg.model_path = model
|
||||
self.diarizer = self.build_diarizer()
|
||||
self.device = cfg.device
|
||||
self.use_amp = use_amp
|
||||
self.compute_dtype = compute_dtype
|
||||
self.frame_len_in_secs = frame_len_in_secs
|
||||
self.left_offset = left_offset
|
||||
self.right_offset = right_offset
|
||||
self.chunk_size = self.cfg.chunk_len
|
||||
self.buffer_size_in_secs = (
|
||||
self.cfg.chunk_len * self.frame_len_in_secs + (self.left_offset + self.right_offset) * 0.01
|
||||
)
|
||||
self.max_num_speakers = self.cfg.max_num_speakers
|
||||
|
||||
self.feature_bufferer = CacheFeatureBufferer(
|
||||
sample_rate=sample_rate,
|
||||
buffer_size_in_secs=self.buffer_size_in_secs,
|
||||
chunk_size_in_secs=self.cfg.chunk_len * self.frame_len_in_secs,
|
||||
preprocessor_cfg=self.diarizer.cfg.preprocessor,
|
||||
device=self.device,
|
||||
)
|
||||
self.streaming_state = self.init_streaming_state(batch_size=1)
|
||||
self.total_preds = torch.zeros((1, 0, self.max_num_speakers), device=self.diarizer.device)
|
||||
|
||||
print(f"NeMoStreamingDiarService initialized with model `{model}` on device `{self.device}`")
|
||||
|
||||
def build_diarizer(self):
|
||||
if self.cfg.model_path.endswith(".nemo"):
|
||||
diar_model = SortformerEncLabelModel.restore_from(self.cfg.model_path, map_location=self.cfg.device)
|
||||
else:
|
||||
diar_model = SortformerEncLabelModel.from_pretrained(self.cfg.model_path, map_location=self.cfg.device)
|
||||
|
||||
# Steaming mode setup
|
||||
diar_model.sortformer_modules.chunk_len = self.cfg.chunk_len
|
||||
diar_model.sortformer_modules.spkcache_len = self.cfg.spkcache_len
|
||||
diar_model.sortformer_modules.chunk_left_context = self.cfg.chunk_left_context
|
||||
diar_model.sortformer_modules.chunk_right_context = self.cfg.chunk_right_context
|
||||
diar_model.sortformer_modules.fifo_len = self.cfg.fifo_len
|
||||
diar_model.sortformer_modules.log = self.cfg.log
|
||||
diar_model.sortformer_modules.spkcache_refresh_rate = self.cfg.spkcache_refresh_rate
|
||||
diar_model.eval()
|
||||
|
||||
return diar_model
|
||||
|
||||
def print_diar_result(self, diar_result: np.ndarray):
|
||||
for t in range(diar_result.shape[0]):
|
||||
spk_probs = ""
|
||||
for s in range(diar_result.shape[1]):
|
||||
spk_probs += f"{diar_result[t, s]:.2f} "
|
||||
print(f"Time {t}: {spk_probs}")
|
||||
|
||||
def diarize(self, audio: bytes, stream_id: str = "default") -> str:
|
||||
|
||||
audio_array = np.frombuffer(audio, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
self.feature_bufferer.update(audio_array)
|
||||
|
||||
features = self.feature_bufferer.get_feature_buffer()
|
||||
feature_buffers = features.unsqueeze(0) # add batch dimension
|
||||
feature_buffers = feature_buffers.transpose(1, 2) # [batch, feature, time] -> [batch, time, feature]
|
||||
feature_buffer_lens = torch.tensor([feature_buffers.shape[1]], device=self.device)
|
||||
self.streaming_state, chunk_preds = self.stream_step(
|
||||
processed_signal=feature_buffers,
|
||||
processed_signal_length=feature_buffer_lens,
|
||||
streaming_state=self.streaming_state,
|
||||
total_preds=self.total_preds,
|
||||
left_offset=self.left_offset,
|
||||
right_offset=self.right_offset,
|
||||
)
|
||||
self.total_preds = chunk_preds
|
||||
diar_result = chunk_preds[:, -self.chunk_size :, :].clone().cpu().numpy()
|
||||
return diar_result[0] # tensor of shape [6, 4]
|
||||
|
||||
def reset_state(self, stream_id: str = "default"):
|
||||
self.feature_bufferer.reset()
|
||||
self.streaming_state = self.init_streaming_state(batch_size=1)
|
||||
self.total_preds = torch.zeros((1, 0, self.max_num_speakers), device=self.diarizer.device)
|
||||
|
||||
def init_streaming_state(self, batch_size: int = 1) -> StreamingSortformerState:
|
||||
"""
|
||||
Initialize the streaming state for the diarization model.
|
||||
|
||||
Args:
|
||||
batch_size: The batch size to use.
|
||||
|
||||
Returns:
|
||||
SortformerStreamingState: The initialized streaming state.
|
||||
"""
|
||||
# Use the model's init_streaming_state method but convert to SortformerStreamingState format
|
||||
nemo_state = self.diarizer.sortformer_modules.init_streaming_state(
|
||||
batch_size=batch_size, async_streaming=self.diarizer.async_streaming, device=self.device
|
||||
)
|
||||
|
||||
return nemo_state
|
||||
|
||||
def stream_step(
|
||||
self,
|
||||
processed_signal: Tensor,
|
||||
processed_signal_length: Tensor,
|
||||
streaming_state: StreamingSortformerState,
|
||||
total_preds: Tensor,
|
||||
left_offset: int = 0,
|
||||
right_offset: int = 0,
|
||||
) -> Tuple[StreamingSortformerState, Tensor]:
|
||||
"""
|
||||
Execute a single streaming step for diarization.
|
||||
|
||||
Args:
|
||||
processed_signal: The processed audio signal.
|
||||
processed_signal_length: The length of the processed signal.
|
||||
streaming_state: The current streaming state.
|
||||
total_preds: The total predictions so far.
|
||||
left_offset: The left offset for the current chunk.
|
||||
right_offset: The right offset for the current chunk.
|
||||
|
||||
Returns:
|
||||
Tuple[SortformerStreamingState, Tensor]: The updated streaming state and predictions.
|
||||
"""
|
||||
# Move tensors to correct device
|
||||
if processed_signal.device != self.device:
|
||||
processed_signal = processed_signal.to(self.device)
|
||||
|
||||
if processed_signal_length.device != self.device:
|
||||
processed_signal_length = processed_signal_length.to(self.device)
|
||||
|
||||
if total_preds is not None and total_preds.device != self.device:
|
||||
total_preds = total_preds.to(self.device)
|
||||
|
||||
with (
|
||||
torch.amp.autocast(device_type=self.device, dtype=self.compute_dtype, enabled=self.use_amp),
|
||||
torch.inference_mode(),
|
||||
torch.no_grad(),
|
||||
):
|
||||
try:
|
||||
# Call the model's forward_streaming_step method
|
||||
streaming_state, diar_pred_out_stream = self.diarizer.forward_streaming_step(
|
||||
processed_signal=processed_signal,
|
||||
processed_signal_length=processed_signal_length,
|
||||
streaming_state=streaming_state,
|
||||
total_preds=total_preds,
|
||||
left_offset=left_offset,
|
||||
right_offset=right_offset,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error in diarizer streaming step: {e}")
|
||||
# print the stack trace
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
# Return the existing state and preds if there's an error
|
||||
return streaming_state, total_preds
|
||||
|
||||
return streaming_state, diar_pred_out_stream
|
||||
@@ -0,0 +1,316 @@
|
||||
# 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 asyncio
|
||||
from datetime import datetime
|
||||
from typing import AsyncGenerator, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.frames.frames import (
|
||||
CancelFrame,
|
||||
EndFrame,
|
||||
ErrorFrame,
|
||||
Frame,
|
||||
InterimTranscriptionFrame,
|
||||
StartFrame,
|
||||
TranscriptionFrame,
|
||||
VADUserStartedSpeakingFrame,
|
||||
VADUserStoppedSpeakingFrame,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.services.stt_service import STTService
|
||||
from pipecat.transcriptions.language import Language
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
from pipecat.utils.tracing.service_decorators import traced_stt
|
||||
from pydantic import BaseModel
|
||||
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.audio_logger import AudioLogger
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.streaming_asr import NemoStreamingASRService
|
||||
|
||||
ASR_EOU_MODELS = ["nvidia/parakeet_realtime_eou_120m-v1"]
|
||||
|
||||
try:
|
||||
# disable nemo logging
|
||||
from nemo.utils import logging
|
||||
|
||||
level = logging.getEffectiveLevel()
|
||||
logging.setLevel(logging.CRITICAL)
|
||||
|
||||
|
||||
except ModuleNotFoundError as e:
|
||||
logger.error(f"Exception: {e}")
|
||||
logger.error('In order to use NVIDIA NeMo STT, you need to `pip install "nemo-toolkit[all]"`.')
|
||||
raise Exception(f"Missing module: {e}")
|
||||
|
||||
|
||||
class NeMoSTTInputParams(BaseModel):
|
||||
"""Input parameters for NeMo STT service."""
|
||||
|
||||
language: Optional[Language] = Language.EN_US
|
||||
att_context_size: Optional[List] = [70, 1]
|
||||
frame_len_in_secs: Optional[float] = 0.08 # 80ms for FastConformer model
|
||||
config_path: Optional[str] = None # path to the Niva ASR config file
|
||||
raw_audio_frame_len_in_secs: Optional[float] = 0.016 # 16ms for websocket transport
|
||||
buffer_size: Optional[int] = 5 # number of audio frames to buffer, 1 frame is 16ms
|
||||
|
||||
|
||||
class NemoSTTService(STTService):
|
||||
"""NeMo Speech-to-Text service for Pipecat integration."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: Optional[str] = "nnvidia/parakeet_realtime_eou_120m-v1",
|
||||
device: Optional[str] = "cuda:0",
|
||||
sample_rate: Optional[int] = 16000,
|
||||
params: Optional[NeMoSTTInputParams] = None,
|
||||
has_turn_taking: Optional[bool] = None, # if None, it will be set by the model name
|
||||
backend: Optional[str] = "legacy",
|
||||
decoder_type: Optional[str] = "rnnt",
|
||||
audio_logger: Optional[AudioLogger] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self._queue = asyncio.Queue()
|
||||
self._sample_rate = sample_rate
|
||||
self._params = params or NeMoSTTInputParams()
|
||||
self._model_name = model
|
||||
if has_turn_taking is None:
|
||||
has_turn_taking = True if model in ASR_EOU_MODELS else False
|
||||
logger.info(f"Setting has_turn_taking to `{has_turn_taking}` based on model name: `{model}`")
|
||||
self._has_turn_taking = has_turn_taking
|
||||
self._backend = backend
|
||||
self._decoder_type = decoder_type
|
||||
self._audio_logger = audio_logger
|
||||
self._is_vad_active = False
|
||||
logger.info(f"NeMoSTTInputParams: {self._params}")
|
||||
|
||||
self._device = device
|
||||
|
||||
self._load_model()
|
||||
|
||||
self.audio_buffer = []
|
||||
self.user_is_speaking = False
|
||||
|
||||
def _load_model(self):
|
||||
if self._backend == "legacy":
|
||||
self._model = NemoStreamingASRService(
|
||||
self._model_name,
|
||||
self._params.att_context_size,
|
||||
device=self._device,
|
||||
decoder_type=self._decoder_type,
|
||||
frame_len_in_secs=self._params.frame_len_in_secs,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid ASR backend: {self._backend}")
|
||||
|
||||
def can_generate_metrics(self) -> bool:
|
||||
"""Indicates whether this service can generate metrics.
|
||||
|
||||
Returns:
|
||||
bool: True, as this service supports metric generation.
|
||||
"""
|
||||
return True
|
||||
|
||||
async def start(self, frame: StartFrame):
|
||||
"""Handle service start.
|
||||
|
||||
Args:
|
||||
frame: StartFrame containing initial configuration
|
||||
"""
|
||||
await super().start(frame)
|
||||
|
||||
# Initialize the model if not already done
|
||||
if not hasattr(self, "_model"):
|
||||
self._load_model()
|
||||
|
||||
async def stop(self, frame: EndFrame):
|
||||
"""Handle service stop.
|
||||
|
||||
Args:
|
||||
frame: EndFrame that triggered this method
|
||||
"""
|
||||
await super().stop(frame)
|
||||
# Clear any internal state if needed
|
||||
await self._queue.put(None) # Signal to stop processing
|
||||
|
||||
async def cancel(self, frame: CancelFrame):
|
||||
"""Handle service cancellation.
|
||||
|
||||
Args:
|
||||
frame: CancelFrame that triggered this method
|
||||
"""
|
||||
await super().cancel(frame)
|
||||
# Clear any internal state
|
||||
await self._queue.put(None) # Signal to stop processing
|
||||
self._queue = asyncio.Queue() # Reset the queue
|
||||
|
||||
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
|
||||
"""Process audio data and generate transcription frames.
|
||||
|
||||
Args:
|
||||
audio: Raw audio bytes to transcribe
|
||||
|
||||
Yields:
|
||||
Frame: Transcription frames containing the results
|
||||
"""
|
||||
timestamp_now = datetime.now()
|
||||
await self.start_ttfb_metrics()
|
||||
await self.start_processing_metrics()
|
||||
if self._audio_logger is not None and self._audio_logger.first_audio_timestamp is None:
|
||||
self._audio_logger.first_audio_timestamp = timestamp_now
|
||||
|
||||
try:
|
||||
is_final = False
|
||||
user_has_finished = False
|
||||
transcription = None
|
||||
self.audio_buffer.append(audio)
|
||||
if len(self.audio_buffer) >= self._params.buffer_size:
|
||||
audio = b"".join(self.audio_buffer)
|
||||
self.audio_buffer = []
|
||||
|
||||
# Append to continuous user audio buffer for stereo conversation recording
|
||||
if self._audio_logger is not None:
|
||||
self._audio_logger.append_continuous_user_audio(audio)
|
||||
|
||||
asr_result = self._model.transcribe(audio)
|
||||
transcription = asr_result.text
|
||||
is_final = asr_result.is_final
|
||||
if self._audio_logger is not None:
|
||||
if self._is_vad_active:
|
||||
is_first_frame = False
|
||||
self._audio_logger.turn_audio_buffer.append(audio)
|
||||
# Accumulate transcriptions for turn-based logging
|
||||
if transcription:
|
||||
self._audio_logger.turn_transcription_buffer.append(transcription)
|
||||
self._audio_logger.stage_turn_audio_and_transcription(
|
||||
timestamp_now=timestamp_now,
|
||||
is_first_frame=is_first_frame,
|
||||
additional_metadata={
|
||||
"model": self._model_name,
|
||||
"backend": self._backend,
|
||||
},
|
||||
)
|
||||
eou_latency = asr_result.eou_latency
|
||||
eob_latency = asr_result.eob_latency
|
||||
eou_prob = asr_result.eou_prob
|
||||
eob_prob = asr_result.eob_prob
|
||||
if eou_latency is not None:
|
||||
logger.debug(
|
||||
f"EOU latency: {eou_latency: .4f} seconds. EOU probability: {eou_prob: .2f}."
|
||||
f"Processing time: {asr_result.processing_time: .4f} seconds."
|
||||
)
|
||||
user_has_finished = True
|
||||
if eob_latency is not None:
|
||||
logger.debug(
|
||||
f"EOB latency: {eob_latency: .4f} seconds. EOB probability: {eob_prob: .2f}."
|
||||
f"Processing time: {asr_result.processing_time: .4f} seconds."
|
||||
)
|
||||
user_has_finished = True
|
||||
await self.stop_ttfb_metrics()
|
||||
await self.stop_processing_metrics()
|
||||
|
||||
if transcription:
|
||||
logger.debug(f"Transcription (is_final={is_final}): `{transcription}`")
|
||||
self.user_is_speaking = True if not user_has_finished else False
|
||||
|
||||
# Get the language from params or default to EN_US
|
||||
language = self._params.language if self._params else Language.EN_US
|
||||
|
||||
# Create and push the transcription frame
|
||||
if self._has_turn_taking:
|
||||
# if turn taking is enabled, we push interim transcription frames
|
||||
# and let the turn taking service handle the final transcription
|
||||
frame_type = InterimTranscriptionFrame
|
||||
else:
|
||||
# otherwise, we use the is_final flag to determine the frame type
|
||||
frame_type = TranscriptionFrame if is_final else InterimTranscriptionFrame
|
||||
await self.push_frame(
|
||||
frame_type(
|
||||
transcription,
|
||||
"", # No speaker ID in this implementation
|
||||
time_now_iso8601(),
|
||||
language,
|
||||
result={"text": transcription},
|
||||
)
|
||||
)
|
||||
|
||||
# Handle the transcription
|
||||
await self._handle_transcription(
|
||||
transcript=transcription,
|
||||
is_final=is_final,
|
||||
language=language,
|
||||
)
|
||||
|
||||
yield None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in NeMo STT processing: {e}")
|
||||
await self.push_frame(
|
||||
ErrorFrame(
|
||||
str(e),
|
||||
time_now_iso8601(),
|
||||
)
|
||||
)
|
||||
yield None
|
||||
|
||||
@traced_stt
|
||||
async def _handle_transcription(self, transcript: str, is_final: bool, language: Optional[str] = None):
|
||||
"""Handle a transcription result.
|
||||
|
||||
Args:
|
||||
transcript: The transcribed text
|
||||
is_final: Whether this is a final transcription
|
||||
language: The language of the transcription
|
||||
"""
|
||||
pass # Base implementation - can be extended for specific handling needs
|
||||
|
||||
async def set_language(self, language: Language):
|
||||
"""Update the service's recognition language.
|
||||
|
||||
Args:
|
||||
language: New language for recognition
|
||||
"""
|
||||
if self._params:
|
||||
self._params.language = language
|
||||
else:
|
||||
self._params = NeMoSTTInputParams(language=language)
|
||||
|
||||
logger.info(f"Switching STT language to: {language}")
|
||||
|
||||
async def set_model(self, model: str):
|
||||
"""Update the service's model.
|
||||
|
||||
Args:
|
||||
model: New model name/path to use
|
||||
"""
|
||||
await super().set_model(model)
|
||||
self._model_name = model
|
||||
self._load_model()
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
"""Process incoming frames and handle VAD events."""
|
||||
if isinstance(frame, VADUserStoppedSpeakingFrame) and isinstance(self._model, NemoStreamingASRService):
|
||||
# manualy reset the state of the model when end of utterance is detected by VAD
|
||||
logger.debug("Resetting state of the model due to VADUserStoppedSpeakingFrame")
|
||||
if self.user_is_speaking:
|
||||
logger.debug(
|
||||
"[EOU missing] STT failed to detect end of utterance before VAD detected user stopped speaking"
|
||||
)
|
||||
self._model.reset_state()
|
||||
self._is_vad_active = False
|
||||
elif isinstance(frame, VADUserStartedSpeakingFrame):
|
||||
self._is_vad_active = True
|
||||
|
||||
await super().process_frame(frame, direction)
|
||||
@@ -0,0 +1,892 @@
|
||||
# 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 asyncio
|
||||
import inspect
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime
|
||||
from typing import Iterator, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from loguru import logger
|
||||
from omegaconf import DictConfig, OmegaConf
|
||||
from pipecat.frames.frames import (
|
||||
CancelFrame,
|
||||
EndFrame,
|
||||
ErrorFrame,
|
||||
Frame,
|
||||
LLMTextFrame,
|
||||
StartFrame,
|
||||
TTSAudioRawFrame,
|
||||
TTSStartedFrame,
|
||||
TTSStoppedFrame,
|
||||
)
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.services.tts_service import TTSService
|
||||
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.audio_logger import AudioLogger
|
||||
from nemo.agents.voice_agent.pipecat.utils.text.simple_text_aggregator import SimpleSegmentedTextAggregator
|
||||
from nemo.agents.voice_agent.utils.tool_calling.mixins import ToolCallingMixin
|
||||
from nemo.collections.tts.models import FastPitchModel, HifiGanModel
|
||||
|
||||
|
||||
class BaseNemoTTSService(TTSService, ToolCallingMixin):
|
||||
"""Text-to-Speech service using Nemo TTS models.
|
||||
|
||||
This service works with any TTS model that exposes a generate(text) method
|
||||
that returns audio data. The TTS generation runs in a dedicated background thread to
|
||||
avoid blocking the main asyncio event loop, following the same pattern as NemoDiarService.
|
||||
|
||||
Args:
|
||||
model: TTS model instance with a generate(text) method
|
||||
sample_rate: Audio sample rate in Hz (defaults to 22050)
|
||||
**kwargs: Additional arguments passed to TTSService
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model,
|
||||
device: str = "cuda",
|
||||
sample_rate: int = 22050,
|
||||
think_tokens: Optional[List[str]] = None,
|
||||
audio_logger: Optional[AudioLogger] = None,
|
||||
ignore_strings: Optional[List[str]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
||||
logger.info(f"Initializing TTS service with model: {model} and device: {device}")
|
||||
self._model_name = model
|
||||
self._device = device
|
||||
self._model = self._setup_model()
|
||||
self._think_tokens = think_tokens
|
||||
self._audio_logger = audio_logger
|
||||
if think_tokens is not None:
|
||||
assert (
|
||||
isinstance(think_tokens, list) and len(think_tokens) == 2
|
||||
), f"think_tokens must be a list of two strings, but got type {type(think_tokens)}: {think_tokens}"
|
||||
self._ignore_strings = set(ignore_strings) if ignore_strings is not None else None
|
||||
# Background processing infrastructure - no response handler needed
|
||||
self._tts_queue = asyncio.Queue()
|
||||
self._processing_task = None
|
||||
self._processing_running = False
|
||||
|
||||
# Track pending requests with their response queues
|
||||
self._pending_requests = {}
|
||||
self._have_seen_think_tokens = False
|
||||
|
||||
def reset(self):
|
||||
"""Reset the TTS service."""
|
||||
self._text_aggregator.reset()
|
||||
|
||||
def setup_tool_calling(self):
|
||||
"""
|
||||
Setup the tool calling mixin by registering all available tools.
|
||||
"""
|
||||
pass # No tools by default
|
||||
|
||||
def _setup_model(self):
|
||||
raise NotImplementedError("Subclass must implement _setup_model")
|
||||
|
||||
def _generate_audio(self, text: str) -> Iterator[np.ndarray]:
|
||||
raise NotImplementedError("Subclass must implement _generate_audio")
|
||||
|
||||
def can_generate_metrics(self) -> bool:
|
||||
"""If the TTS service can generate metrics."""
|
||||
return True
|
||||
|
||||
async def start(self, frame: StartFrame):
|
||||
"""Handle service start."""
|
||||
await super().start(frame)
|
||||
|
||||
# Initialize the model if not already done
|
||||
if not hasattr(self, "_model") or self._model is None:
|
||||
self._model = self._setup_model()
|
||||
|
||||
# Only start background processing task - no response handler needed
|
||||
if not self._processing_task:
|
||||
self._processing_task = self.create_task(self._processing_task_handler())
|
||||
|
||||
async def stop(self, frame: EndFrame):
|
||||
"""Handle service stop."""
|
||||
await super().stop(frame)
|
||||
await self._stop_tasks()
|
||||
|
||||
async def cancel(self, frame: CancelFrame):
|
||||
"""Handle service cancellation."""
|
||||
await super().cancel(frame)
|
||||
await self._stop_tasks()
|
||||
|
||||
async def _stop_tasks(self):
|
||||
"""Stop background processing tasks."""
|
||||
self._processing_running = False
|
||||
await self._tts_queue.put(None) # Signal to stop processing
|
||||
|
||||
if self._processing_task:
|
||||
await self.cancel_task(self._processing_task)
|
||||
self._processing_task = None
|
||||
|
||||
def _tts_processor(self):
|
||||
"""Background processor that handles TTS generation calls."""
|
||||
try:
|
||||
while self._processing_running:
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(self._tts_queue.get(), self.get_event_loop())
|
||||
request = future.result()
|
||||
|
||||
if request is None: # Stop signal
|
||||
logger.debug("Received stop signal in TTS background processor")
|
||||
break
|
||||
|
||||
text, request_id = request
|
||||
logger.debug(f"Processing TTS request for text: [{text}]")
|
||||
|
||||
# Get the response queue for this request
|
||||
response_queue = None
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._get_response_queue(request_id), self.get_event_loop()
|
||||
)
|
||||
response_queue = future.result()
|
||||
|
||||
if response_queue is None:
|
||||
logger.warning(f"No response queue found for request {request_id}")
|
||||
continue
|
||||
|
||||
# Process TTS generation
|
||||
try:
|
||||
audio_result = self._generate_audio(text)
|
||||
|
||||
# Send result directly to the waiting request
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
response_queue.put(('success', audio_result)), self.get_event_loop()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in TTS generation: {e}")
|
||||
# Send error directly to the waiting request
|
||||
asyncio.run_coroutine_threadsafe(response_queue.put(('error', e)), self.get_event_loop())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in background TTS processor: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Background TTS processor fatal error: {e}")
|
||||
finally:
|
||||
logger.debug("Background TTS processor stopped")
|
||||
|
||||
async def _get_response_queue(self, request_id: str):
|
||||
"""Get the response queue for a specific request."""
|
||||
return self._pending_requests.get(request_id)
|
||||
|
||||
async def _processing_task_handler(self):
|
||||
"""Handler for background processing task."""
|
||||
try:
|
||||
self._processing_running = True
|
||||
logger.debug("Starting background TTS processing task")
|
||||
await asyncio.to_thread(self._tts_processor)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Background TTS processing task cancelled")
|
||||
self._processing_running = False
|
||||
raise
|
||||
finally:
|
||||
self._processing_running = False
|
||||
|
||||
def _handle_think_tokens(self, text: str) -> Optional[str]:
|
||||
"""
|
||||
Handle the thinking tokens for TTS.
|
||||
If the thinking tokens are not provided, return the text as it is.
|
||||
Otherwise:
|
||||
If both thinking tokens appear in the text, return the text after the end of thinking tokens.
|
||||
If the LLM is thinking, return None.
|
||||
If the LLM is done thinking, return the text after the end of thinking tokens.
|
||||
If the LLM starts thinking, return the text before the start of thinking tokens.
|
||||
If the LLM is not thinking, return the text as is.
|
||||
"""
|
||||
if not self._think_tokens or not text:
|
||||
return text
|
||||
elif self._think_tokens[0] in text and self._think_tokens[1] in text:
|
||||
# LLM finishes thinking in one chunk or outputs dummy thinking tokens
|
||||
logger.debug(f"LLM finishes thinking: {text}")
|
||||
idx = text.index(self._think_tokens[1])
|
||||
# only return the text after the end of thinking tokens
|
||||
text = text[idx + len(self._think_tokens[1]) :]
|
||||
self._have_seen_think_tokens = False
|
||||
logger.debug(f"Returning text after thinking: {text}")
|
||||
return text
|
||||
elif self._have_seen_think_tokens:
|
||||
# LLM is thinking
|
||||
if self._think_tokens[1] not in text:
|
||||
logger.debug(f"LLM is still thinking: {text}")
|
||||
# LLM is still thinking
|
||||
return None
|
||||
else:
|
||||
# LLM is done thinking
|
||||
logger.debug(f"LLM is done thinking: {text}")
|
||||
idx = text.index(self._think_tokens[1])
|
||||
# only return the text after the end of thinking tokens
|
||||
text = text[idx + len(self._think_tokens[1]) :]
|
||||
self._have_seen_think_tokens = False
|
||||
logger.debug(f"Returning text after thinking: {text}")
|
||||
return text
|
||||
elif self._think_tokens[0] in text:
|
||||
# LLM now starts thinking
|
||||
logger.debug(f"LLM starts thinking: {text}")
|
||||
self._have_seen_think_tokens = True
|
||||
# return text before the start of thinking tokens
|
||||
idx = text.index(self._think_tokens[0])
|
||||
text = text[:idx]
|
||||
logger.debug(f"Returning text before thinking: {text}")
|
||||
return text
|
||||
else:
|
||||
# LLM is not thinking
|
||||
return text
|
||||
|
||||
def _drop_special_tokens(self, text: str) -> Optional[str]:
|
||||
"""
|
||||
Drop the special tokens from the text.
|
||||
"""
|
||||
if self._ignore_strings is None:
|
||||
return text
|
||||
for ignore_string in self._ignore_strings:
|
||||
if ignore_string in text:
|
||||
logger.debug(f"Dropping string `{ignore_string}` from text: `{text}`")
|
||||
text = text.replace(ignore_string, "")
|
||||
return text
|
||||
|
||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
||||
"""Generate speech from text using the Nemo TTS model."""
|
||||
|
||||
if self._think_tokens is not None:
|
||||
text = self._handle_think_tokens(text)
|
||||
|
||||
if not text:
|
||||
yield None
|
||||
return
|
||||
|
||||
if self._ignore_strings is not None:
|
||||
text = self._drop_special_tokens(text)
|
||||
|
||||
logger.debug(f"{self}: Generating TTS [{text}]")
|
||||
|
||||
try:
|
||||
await self.start_ttfb_metrics()
|
||||
yield TTSStartedFrame()
|
||||
|
||||
# Increment turn index at the start of agent speaking (only if speaker changed)
|
||||
if self._audio_logger is not None:
|
||||
self._audio_logger.increment_turn_index(speaker="agent")
|
||||
|
||||
# Generate unique request ID
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
# Create response queue for this specific request
|
||||
request_queue = asyncio.Queue()
|
||||
self._pending_requests[request_id] = request_queue
|
||||
|
||||
try:
|
||||
# Queue the TTS request for background processing
|
||||
await self._tts_queue.put((text, request_id))
|
||||
|
||||
# Wait for the result directly from our request queue
|
||||
result = await request_queue.get()
|
||||
status, data = result
|
||||
|
||||
if status == 'error':
|
||||
logger.error(f"{self} TTS generation error: {data}")
|
||||
yield ErrorFrame(error=f"TTS generation error: {str(data)}")
|
||||
return
|
||||
|
||||
audio_result = data
|
||||
if audio_result is None:
|
||||
logger.error(f"{self} TTS model returned None for text: [{text}]")
|
||||
yield ErrorFrame(error="TTS generation failed - no audio returned")
|
||||
return
|
||||
|
||||
await self.start_tts_usage_metrics(text)
|
||||
|
||||
# Collect all audio for logging
|
||||
all_audio_bytes = b""
|
||||
# Capture the start time when TTS begins (not when it ends)
|
||||
if self._audio_logger is not None and self._audio_logger.first_audio_timestamp is None:
|
||||
self._audio_logger.first_audio_timestamp = datetime.now()
|
||||
|
||||
# Process the audio result (same as before)
|
||||
if (
|
||||
inspect.isgenerator(audio_result)
|
||||
or hasattr(audio_result, '__iter__')
|
||||
and hasattr(audio_result, '__next__')
|
||||
):
|
||||
# Handle generator case
|
||||
first_chunk = True
|
||||
for audio_chunk in audio_result:
|
||||
if first_chunk:
|
||||
await self.stop_ttfb_metrics()
|
||||
first_chunk = False
|
||||
# Capture start time on first chunk
|
||||
if self._audio_logger is not None:
|
||||
tts_start_time = self._audio_logger.get_time_from_start_of_session()
|
||||
|
||||
if audio_chunk is None:
|
||||
break
|
||||
|
||||
audio_bytes = self._convert_to_bytes(audio_chunk)
|
||||
all_audio_bytes += audio_bytes
|
||||
chunk_size = self.chunk_size
|
||||
for i in range(0, len(audio_bytes), chunk_size):
|
||||
audio_chunk_bytes = audio_bytes[i : i + chunk_size]
|
||||
if not audio_chunk_bytes:
|
||||
break
|
||||
|
||||
frame = TTSAudioRawFrame(
|
||||
audio=audio_chunk_bytes, sample_rate=self.sample_rate, num_channels=1
|
||||
)
|
||||
yield frame
|
||||
else:
|
||||
# Handle single result case
|
||||
await self.stop_ttfb_metrics()
|
||||
# Capture start time for single result
|
||||
if self._audio_logger is not None:
|
||||
tts_start_time = self._audio_logger.get_time_from_start_of_session()
|
||||
audio_bytes = self._convert_to_bytes(audio_result)
|
||||
all_audio_bytes = audio_bytes
|
||||
|
||||
chunk_size = self.chunk_size
|
||||
for i in range(0, len(audio_bytes), chunk_size):
|
||||
chunk = audio_bytes[i : i + chunk_size]
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
frame = TTSAudioRawFrame(audio=chunk, sample_rate=self.sample_rate, num_channels=1)
|
||||
yield frame
|
||||
|
||||
# Log the complete audio if logger is available
|
||||
if self._audio_logger is not None and all_audio_bytes:
|
||||
try:
|
||||
self._audio_logger.log_agent_audio(
|
||||
audio_data=all_audio_bytes,
|
||||
text=text,
|
||||
sample_rate=self.sample_rate,
|
||||
num_channels=1,
|
||||
additional_metadata={
|
||||
"model": self._model_name,
|
||||
},
|
||||
tts_generation_time=tts_start_time,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to log agent audio: {e}")
|
||||
|
||||
yield TTSStoppedFrame()
|
||||
|
||||
finally:
|
||||
# Clean up the pending request
|
||||
if request_id in self._pending_requests:
|
||||
del self._pending_requests[request_id]
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"{self} error generating TTS: {e}")
|
||||
error_message = f"TTS generation error: {str(e)}"
|
||||
yield ErrorFrame(error=error_message)
|
||||
|
||||
def _convert_to_bytes(self, audio_data) -> bytes:
|
||||
"""Convert various audio data formats to bytes."""
|
||||
if isinstance(audio_data, (bytes, bytearray)):
|
||||
return bytes(audio_data)
|
||||
|
||||
if isinstance(audio_data, np.ndarray):
|
||||
# Ensure it's in the right format (16-bit PCM)
|
||||
if audio_data.dtype in [np.float32, np.float64]:
|
||||
# Convert float [-1, 1] to int16 [-32768, 32767]
|
||||
audio_data = np.clip(audio_data, -1.0, 1.0) # Ensure values are in range
|
||||
audio_data = (audio_data * 32767).astype(np.int16)
|
||||
elif audio_data.dtype != np.int16:
|
||||
# Convert other integer types to int16
|
||||
audio_data = audio_data.astype(np.int16)
|
||||
return audio_data.tobytes()
|
||||
elif hasattr(audio_data, 'tobytes'):
|
||||
return audio_data.tobytes()
|
||||
else:
|
||||
return bytes(audio_data)
|
||||
|
||||
|
||||
class NeMoFastPitchHiFiGANTTSService(BaseNemoTTSService):
|
||||
"""Text-to-Speech service using NeMo FastPitch-Hifigan model.
|
||||
|
||||
More info: https://huggingface.co/nvidia/tts_en_fastpitch
|
||||
|
||||
Args:
|
||||
fastpitch_model: FastPitch model name
|
||||
hifigan_model: Hifigan model name
|
||||
device: Device to run on (default: 'cuda')
|
||||
**kwargs: Additional arguments passed to BaseNemoTTSService
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fastpitch_model: str = "nvidia/tts_en_fastpitch",
|
||||
hifigan_model: str = "nvidia/tts_hifigan",
|
||||
device: str = "cuda",
|
||||
**kwargs,
|
||||
):
|
||||
model_name = f"{fastpitch_model}+{hifigan_model}"
|
||||
self._fastpitch_model_name = fastpitch_model
|
||||
self._hifigan_model_name = hifigan_model
|
||||
super().__init__(model=model_name, device=device, **kwargs)
|
||||
self.setup_tool_calling()
|
||||
|
||||
def _setup_model(self):
|
||||
logger.info(
|
||||
f"Loading FastPitch model={self._fastpitch_model_name} and HiFiGAN model={self._hifigan_model_name}"
|
||||
)
|
||||
self._fastpitch_model = self._setup_fastpitch_model(self._fastpitch_model_name)
|
||||
self._hifigan_model = self._setup_hifigan_model(self._hifigan_model_name)
|
||||
return self._fastpitch_model, self._hifigan_model
|
||||
|
||||
def _setup_fastpitch_model(self, model_name: str):
|
||||
if model_name.endswith(".nemo"):
|
||||
fastpitch_model = FastPitchModel.restore_from(model_name, map_location=torch.device(self._device))
|
||||
else:
|
||||
fastpitch_model = FastPitchModel.from_pretrained(model_name, map_location=torch.device(self._device))
|
||||
fastpitch_model.eval()
|
||||
return fastpitch_model
|
||||
|
||||
def _setup_hifigan_model(self, model_name: str):
|
||||
if model_name.endswith(".nemo"):
|
||||
hifigan_model = HifiGanModel.restore_from(model_name, map_location=torch.device(self._device))
|
||||
else:
|
||||
hifigan_model = HifiGanModel.from_pretrained(model_name, map_location=torch.device(self._device))
|
||||
hifigan_model.eval()
|
||||
return hifigan_model
|
||||
|
||||
def _generate_audio(self, text: str) -> Iterator[np.ndarray]:
|
||||
with torch.no_grad():
|
||||
parsed = self._fastpitch_model.parse(text)
|
||||
spectrogram = self._fastpitch_model.generate_spectrogram(tokens=parsed)
|
||||
audio = self._hifigan_model.convert_spectrogram_to_audio(spec=spectrogram)
|
||||
audio = audio.detach().view(-1).cpu().numpy()
|
||||
yield audio
|
||||
|
||||
|
||||
class KokoroTTSService(BaseNemoTTSService):
|
||||
"""Text-to-Speech service using Kokoro-82M model.
|
||||
|
||||
Kokoro is an open-weight TTS model with 82 million parameters.
|
||||
More info: https://huggingface.co/hexgrad/Kokoro-82M
|
||||
|
||||
Args:
|
||||
lang_code: Language code for the model (default: 'a' for American English)
|
||||
voice: Voice to use (default: 'af_heart')
|
||||
device: Device to run on (default: 'cuda')
|
||||
sample_rate: Audio sample rate in Hz (default: 24000 for Kokoro)
|
||||
download_all: Download all models for different languages (default: True)
|
||||
cache_models: Cache models on GPU for faster switching between languages (default: True)
|
||||
**kwargs: Additional arguments passed to BaseNemoTTSService
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str = "hexgrad/Kokoro-82M",
|
||||
lang_code: str = "a",
|
||||
voice: str = "af_heart",
|
||||
device: str = "cuda",
|
||||
sample_rate: int = 24000,
|
||||
speed: float = 1.0,
|
||||
download_all: bool = True,
|
||||
cache_models: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
self._lang_code = lang_code
|
||||
self._voice = voice
|
||||
self._speed = speed
|
||||
assert speed > 0, "Speed must be greater than 0"
|
||||
self._original_speed = speed
|
||||
self._original_voice = voice
|
||||
self._gender = 'female' if voice[1] == 'f' else 'male'
|
||||
self._original_gender = self._gender
|
||||
self._original_lang_code = self._lang_code
|
||||
if download_all:
|
||||
self._model_maps = self._download_all_models(
|
||||
lang_code=["a", "b"], device=device, repo_id=model, cache_models=cache_models
|
||||
)
|
||||
else:
|
||||
self._model_maps = {}
|
||||
super().__init__(model=model, device=device, sample_rate=sample_rate, **kwargs)
|
||||
self.setup_tool_calling()
|
||||
|
||||
def _setup_model(self, lang_code: Optional[str] = None, voice: Optional[str] = None):
|
||||
"""Initialize the Kokoro pipeline."""
|
||||
try:
|
||||
from kokoro import KPipeline
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"kokoro package is required for KokoroTTSService. Install it with: `pip install kokoro>=0.9.2`"
|
||||
)
|
||||
if lang_code is None:
|
||||
lang_code = self._lang_code
|
||||
if voice is None:
|
||||
voice = self._voice
|
||||
logger.info(f"Loading Kokoro TTS model with model={self._model_name}, lang_code={lang_code}, voice={voice}")
|
||||
if lang_code in self._model_maps:
|
||||
pipeline = self._model_maps[lang_code]
|
||||
else:
|
||||
pipeline = KPipeline(lang_code=lang_code, device=self._device, repo_id=self._model_name)
|
||||
self._model_maps[lang_code] = pipeline
|
||||
return pipeline
|
||||
|
||||
def _download_all_models(
|
||||
self, lang_code: List[str] = ['a', 'b'], device="cuda", repo_id="hexgrad/Kokoro-82M", cache_models=True
|
||||
):
|
||||
"""Download all models for Kokoro TTS service."""
|
||||
logger.info(f"Downloading all models for Kokoro TTS service with lang_code={lang_code}")
|
||||
from kokoro import KPipeline
|
||||
|
||||
model_maps = {}
|
||||
|
||||
for lang in lang_code:
|
||||
pipeline = KPipeline(lang_code=lang, device=device, repo_id=repo_id)
|
||||
if cache_models:
|
||||
model_maps[lang] = pipeline
|
||||
torch.cuda.empty_cache()
|
||||
return model_maps
|
||||
|
||||
def _generate_audio(self, text: str) -> Iterator[np.ndarray]:
|
||||
"""Generate audio using the Kokoro pipeline.
|
||||
|
||||
Args:
|
||||
text: Text to convert to speech
|
||||
|
||||
Yields:
|
||||
Audio data as numpy arrays
|
||||
"""
|
||||
try:
|
||||
# Generate audio using Kokoro pipeline
|
||||
generator = self._model(text, voice=self._voice, speed=self._speed)
|
||||
|
||||
# The generator yields tuples of (gs, ps, audio)
|
||||
# We only need the audio component
|
||||
for i, (gs, ps, audio) in enumerate(generator):
|
||||
logger.debug(
|
||||
f"Kokoro generated audio chunk {i}: gs={gs}, ps={ps},"
|
||||
f"audio_shape={audio.shape if hasattr(audio, 'shape') else len(audio)}"
|
||||
)
|
||||
if isinstance(audio, torch.Tensor):
|
||||
audio = audio.detach().cpu().numpy()
|
||||
# Kokoro returns audio as numpy array in float32 format [-1, 1]
|
||||
# The base class will handle conversion to int16 bytes
|
||||
yield audio
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating audio with Kokoro: {e}")
|
||||
raise
|
||||
|
||||
async def tool_tts_set_speed(self, params: FunctionCallParams, speed_lambda: float):
|
||||
"""
|
||||
Set a specific speaking speed of the assistant's voice.
|
||||
This tool should be called only when the user specifies the speed explicitly,
|
||||
such as "speak twice as fast" or "speak half as slow" or "speak 1.5 times as fast".
|
||||
|
||||
Inform user of the result of this tool call. After calling this tool, continue the previous
|
||||
response if it was unfinished and was interrupted by the user, otherwise start a new response
|
||||
and ask if the user needs help on anything else. Avoid repeating previous responses.
|
||||
|
||||
Args:
|
||||
speed_lambda: positive float, the relative change of the speaking speed to the original speed.
|
||||
E.g., 1.0 for original speed, 1.25 for 25% faster than original speed,
|
||||
0.8 for 20% slower than original speed.
|
||||
|
||||
"""
|
||||
if speed_lambda <= 0:
|
||||
result = {
|
||||
"success": False,
|
||||
"message": f"Speed remains unchanged since the change is not a positive number: {speed_lambda}",
|
||||
}
|
||||
logger.debug(f"Speed remains unchanged since the change is not a positive number: {speed_lambda}")
|
||||
else:
|
||||
self._speed = speed_lambda * self._speed
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Speed set to {speed_lambda} of the previous speed",
|
||||
}
|
||||
logger.debug(f"Speed set to {speed_lambda} of the previous speed {self._original_speed}")
|
||||
await params.result_callback(result)
|
||||
|
||||
async def tool_tts_reset_speed(self, params: FunctionCallParams):
|
||||
"""
|
||||
Reset the speaking speed to the original speed.
|
||||
|
||||
Inform user of the result of this tool call. After calling this tool, continue the previous
|
||||
response if it was unfinished and was interrupted by the user, otherwise start a new response
|
||||
and ask if the user needs help on anything else. Avoid repeating previous responses.
|
||||
"""
|
||||
self._speed = self._original_speed
|
||||
result = {"success": True, "message": "Speaking speed is reset to the original one"}
|
||||
logger.debug(f"Speaking speed is reset to the original speed {self._original_speed}")
|
||||
await params.result_callback(result)
|
||||
|
||||
async def tool_tts_speak_faster(self, params: FunctionCallParams):
|
||||
"""
|
||||
Speak faster by increasing the speaking speed 15% faster each time this function is called.
|
||||
|
||||
Inform user of the result of this tool call. After calling this tool, continue the previous
|
||||
response if it was unfinished and was interrupted by the user, otherwise start a new response
|
||||
and ask if the user needs help on anything else. Avoid repeating previous responses.
|
||||
"""
|
||||
speed_lambda = 1.15
|
||||
self._speed = speed_lambda * self._speed
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Speaking speed is increased to {speed_lambda} of the previous speed",
|
||||
}
|
||||
logger.debug(f"Speed is set to {speed_lambda} of the previous speed, new speed is {self._speed}")
|
||||
await params.result_callback(result)
|
||||
|
||||
async def tool_tts_speak_slower(self, params: FunctionCallParams):
|
||||
"""
|
||||
Speak slower by decreasing the speaking speed 15% slower each time this function is called.
|
||||
|
||||
Inform user of the result of this tool call. After calling this tool, continue the previous
|
||||
response if it was unfinished and was interrupted by the user, otherwise start a new response
|
||||
and ask if the user needs help on anything else. Avoid repeating previous responses.
|
||||
"""
|
||||
speed_lambda = 0.85
|
||||
self._speed = speed_lambda * self._speed
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Speaking speed is decreased to {speed_lambda} of the previous speed",
|
||||
}
|
||||
logger.debug(f"Speed is set to {speed_lambda} of the previous speed, new speed is {self._speed}")
|
||||
await params.result_callback(result)
|
||||
|
||||
async def tool_tts_set_voice(self, params: FunctionCallParams, accent: str, gender: str):
|
||||
"""
|
||||
Set the accent and gender of the assistant's voice.
|
||||
This tool should be called only when the user specifies the accent and/or gender explicitly.
|
||||
|
||||
Inform user of the result of this tool call. After calling this tool, continue the previous
|
||||
response if it was unfinished and was interrupted by the user, otherwise start a new response
|
||||
and ask if the user needs help on anything else. Avoid repeating previous responses.
|
||||
|
||||
Args:
|
||||
accent: Accent for the TTS model. Must be one of 'American English', 'British English'
|
||||
or 'current' for keeping the current accent.
|
||||
gender: gender of the assistant's voice. Must be one of 'male', 'female',
|
||||
or 'current' for keeping the current gender.
|
||||
"""
|
||||
await params.llm.push_frame(LLMTextFrame("Just a moment."))
|
||||
|
||||
lang_code = "a" if accent == "American English" else "b" if accent == "British English" else "current"
|
||||
new_lang_code = self._lang_code
|
||||
new_gender = self._gender
|
||||
if lang_code != 'current':
|
||||
new_lang_code = lang_code
|
||||
if gender != 'current':
|
||||
new_gender = gender
|
||||
|
||||
if new_lang_code == 'a':
|
||||
new_voice = 'af_heart' if new_gender == 'female' else 'am_michael'
|
||||
elif new_lang_code == 'b':
|
||||
new_voice = 'bf_emma' if new_gender == 'female' else 'bm_george'
|
||||
else:
|
||||
await params.result_callback(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"Invalid language code: {new_lang_code} or gender: {new_gender}",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
new_model = await asyncio.to_thread(self._setup_model, new_lang_code, new_voice)
|
||||
self._model = new_model
|
||||
self._lang_code = new_lang_code
|
||||
self._gender = new_gender
|
||||
self._voice = new_voice
|
||||
logger.debug(f"Language and voice are set to {new_lang_code} and {new_voice}")
|
||||
await params.result_callback({"success": True, "message": "Voice has been updated."})
|
||||
|
||||
async def tool_tts_reset_voice(self, params: FunctionCallParams):
|
||||
"""
|
||||
Reset the accent and voice to the original ones.
|
||||
|
||||
Inform user of the result of this tool call. After calling this tool, continue the previous
|
||||
response if it was unfinished and was interrupted by the user, otherwise start a new response
|
||||
and ask if the user needs help on anything else. Avoid repeating previous responses.
|
||||
|
||||
"""
|
||||
await params.llm.push_frame(LLMTextFrame("Of course."))
|
||||
|
||||
new_model = await asyncio.to_thread(self._setup_model, self._original_lang_code, self._original_voice)
|
||||
self._model = new_model
|
||||
self._lang_code = self._original_lang_code
|
||||
self._gender = self._original_gender
|
||||
self._voice = self._original_voice
|
||||
logger.debug(
|
||||
f"Language and voice are reset to the original ones {self._original_lang_code} and {self._original_voice}"
|
||||
)
|
||||
await params.result_callback({"success": True, "message": "Voice has been reset to the original one."})
|
||||
|
||||
def setup_tool_calling(self):
|
||||
"""
|
||||
Setup the tool calling mixin by registering all available tools.
|
||||
"""
|
||||
self.register_direct_function("tool_tts_reset_speed", self.tool_tts_reset_speed)
|
||||
self.register_direct_function("tool_tts_speak_faster", self.tool_tts_speak_faster)
|
||||
self.register_direct_function("tool_tts_speak_slower", self.tool_tts_speak_slower)
|
||||
self.register_direct_function("tool_tts_set_speed", self.tool_tts_set_speed)
|
||||
self.register_direct_function("tool_tts_set_voice", self.tool_tts_set_voice)
|
||||
self.register_direct_function("tool_tts_reset_voice", self.tool_tts_reset_voice)
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
Reset the voice and speed to the original ones.
|
||||
"""
|
||||
self._text_aggregator.reset()
|
||||
self._speed = self._original_speed
|
||||
self._model = self._setup_model(self._original_lang_code, self._original_voice)
|
||||
self._lang_code = self._original_lang_code
|
||||
self._gender = self._original_gender
|
||||
self._voice = self._original_voice
|
||||
|
||||
|
||||
class MagpieTTSService(BaseNemoTTSService):
|
||||
"""Text-to-Speech service using Magpie TTS model.
|
||||
|
||||
Magpie is a multilingual TTS model with 357 million parameters.
|
||||
More info: https://huggingface.co/nvidia/magpie_tts_multilingual_357m
|
||||
|
||||
Args:
|
||||
model: Model name or path to the Magpie TTS model.
|
||||
language: Language code for the model (default: 'en' for English)
|
||||
speaker: Speaker to use for the model (default: 'Sofia')
|
||||
apply_TN: Whether to apply text normalization (default: False)
|
||||
device: Device to run on (default: 'cuda')
|
||||
**kwargs: Additional arguments passed to BaseNemoTTSService
|
||||
"""
|
||||
|
||||
SPEAKER_MAP = {"John": 0, "Sofia": 1, "Aria": 2, "Jason": 3, "Leo": 4}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str = "nvidia/magpie_tts_multilingual_357m",
|
||||
language: str = "en",
|
||||
speaker: str = "Sofia",
|
||||
apply_TN: bool = False,
|
||||
device: str = "cuda",
|
||||
**kwargs,
|
||||
):
|
||||
if speaker not in self.SPEAKER_MAP:
|
||||
raise ValueError(f"Invalid speaker: {speaker}, must be one of {list(self.SPEAKER_MAP.keys())}")
|
||||
self._language = language
|
||||
self._current_speaker = speaker
|
||||
self._apply_TN = apply_TN
|
||||
super().__init__(model=model, device=device, **kwargs)
|
||||
self.setup_tool_calling()
|
||||
|
||||
def _setup_model(self):
|
||||
from nemo.collections.tts.models import MagpieTTSModel
|
||||
|
||||
if self._model_name.endswith(".nemo"):
|
||||
model = MagpieTTSModel.restore_from(self._model_name, map_location=torch.device(self._device))
|
||||
else:
|
||||
model = MagpieTTSModel.from_pretrained(self._model_name, map_location=torch.device(self._device))
|
||||
model.eval()
|
||||
|
||||
text = "Warming up the Magpie TTS model, this will help the model to respond faster for later requests."
|
||||
with torch.no_grad():
|
||||
_, _ = model.do_tts(
|
||||
text,
|
||||
language=self._language,
|
||||
apply_TN=self._apply_TN,
|
||||
speaker_index=self.SPEAKER_MAP[self._current_speaker],
|
||||
)
|
||||
torch.cuda.empty_cache()
|
||||
return model
|
||||
|
||||
def _generate_audio(self, text: str) -> Iterator[np.ndarray]:
|
||||
audio, audio_len = self._model.do_tts(
|
||||
text,
|
||||
language=self._language,
|
||||
apply_TN=self._apply_TN,
|
||||
speaker_index=self.SPEAKER_MAP[self._current_speaker],
|
||||
)
|
||||
audio_len = audio_len.view(-1).item()
|
||||
audio = audio.detach().view(-1).cpu().numpy()
|
||||
yield audio[:audio_len]
|
||||
|
||||
def setup_tool_calling(self):
|
||||
"""No tools for now for Magpie TTS service."""
|
||||
pass
|
||||
|
||||
|
||||
def get_tts_service_from_config(config: DictConfig, audio_logger: Optional[AudioLogger] = None) -> BaseNemoTTSService:
|
||||
"""Get the TTS service from the configuration.
|
||||
|
||||
Args:
|
||||
config: The DictConfig object containing the TTS configuration.
|
||||
audio_logger: The audio logger to use for audio logging.
|
||||
Returns:
|
||||
The TTS service.
|
||||
"""
|
||||
if isinstance(config, DictConfig):
|
||||
config = OmegaConf.to_container(config, resolve=True)
|
||||
model = config.get("model", None)
|
||||
device = config.get("device", "cuda")
|
||||
if config.get("type", None) != "nemo":
|
||||
raise ValueError(f"Invalid TTS type: {config.get('type', None)}, only 'nemo' is supported")
|
||||
if model is None:
|
||||
raise ValueError("Model is required for Nemo TTS service")
|
||||
|
||||
text_aggregator = SimpleSegmentedTextAggregator(
|
||||
punctuation_marks=config.get("extra_separator", None),
|
||||
ignore_marks=config.get("ignore_strings", None),
|
||||
min_sentence_length=config.get("min_sentence_length", 5),
|
||||
use_legacy_eos_detection=config.get("use_legacy_eos_detection", False),
|
||||
)
|
||||
|
||||
if model == "fastpitch-hifigan":
|
||||
return NeMoFastPitchHiFiGANTTSService(
|
||||
fastpitch_model=config.get("main_model_id", None),
|
||||
hifigan_model=config.get("sub_model_id", None),
|
||||
device=device,
|
||||
text_aggregator=text_aggregator,
|
||||
think_tokens=config.get("think_tokens", None),
|
||||
audio_logger=audio_logger,
|
||||
ignore_strings=config.get("ignore_strings", None),
|
||||
)
|
||||
elif model == "magpie":
|
||||
return MagpieTTSService(
|
||||
model=config.get("main_model_id", None),
|
||||
language=config.get("language", "en"),
|
||||
speaker=config.get("speaker", "Sofia"),
|
||||
apply_TN=config.get("apply_TN", False),
|
||||
device=device,
|
||||
text_aggregator=text_aggregator,
|
||||
think_tokens=config.get("think_tokens", None),
|
||||
audio_logger=audio_logger,
|
||||
ignore_strings=config.get("ignore_strings", None),
|
||||
)
|
||||
elif model == "kokoro":
|
||||
return KokoroTTSService(
|
||||
model=config.get("main_model_id", "hexgrad/Kokoro-82M"),
|
||||
voice=config.get("sub_model_id", "af_heart"),
|
||||
device=device,
|
||||
speed=config.get("speed", 1.0),
|
||||
text_aggregator=text_aggregator,
|
||||
think_tokens=config.get("think_tokens", None),
|
||||
sample_rate=24000,
|
||||
audio_logger=audio_logger,
|
||||
ignore_strings=config.get("ignore_strings", None),
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid model: {model}, only 'fastpitch-hifigan', 'magpie' and 'kokoro' are supported")
|
||||
@@ -0,0 +1,441 @@
|
||||
# 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 time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union
|
||||
|
||||
import yaml
|
||||
from loguru import logger
|
||||
from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
Frame,
|
||||
InterimTranscriptionFrame,
|
||||
StartInterruptionFrame,
|
||||
TranscriptionFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
VADUserStartedSpeakingFrame,
|
||||
VADUserStoppedSpeakingFrame,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
from pipecat.transcriptions.language import Language
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from nemo.agents.voice_agent.pipecat.frames.frames import DiarResultFrame
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.audio_logger import AudioLogger
|
||||
|
||||
|
||||
class NeMoTurnTakingService(FrameProcessor):
|
||||
"""Service for handling turn-taking in voice conversations with backchannel detection."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backchannel_phrases: Union[str, List[str]] = None,
|
||||
eou_string: str = "<EOU>",
|
||||
eob_string: str = "<EOB>",
|
||||
language: Language = Language.EN_US,
|
||||
use_vad: bool = True,
|
||||
use_diar: bool = False,
|
||||
max_buffer_size: int = 2,
|
||||
bot_stop_delay: float = 0.5,
|
||||
audio_logger: Optional[AudioLogger] = None,
|
||||
can_create_user_frames: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self.eou_string = eou_string
|
||||
self.eob_string = eob_string
|
||||
self.language = language
|
||||
self.use_vad = use_vad
|
||||
self.use_diar = use_diar
|
||||
self.max_buffer_size = max_buffer_size
|
||||
|
||||
self.backchannel_phrases = self._load_backchannel_phrases(backchannel_phrases)
|
||||
self.backchannel_phrases_nopc = set([self.clean_text(phrase) for phrase in self.backchannel_phrases])
|
||||
self.bot_stop_delay = bot_stop_delay
|
||||
self.can_create_user_frames = can_create_user_frames
|
||||
# internal data
|
||||
self._current_speaker_id = None
|
||||
self._prev_speaker_id = None
|
||||
self._bot_stop_time = None
|
||||
self._bot_speaking = False
|
||||
self._vad_user_speaking = False
|
||||
self._have_sent_user_started_speaking = False
|
||||
self._user_speaking_buffer = ""
|
||||
self._audio_logger = audio_logger
|
||||
if not self.use_vad:
|
||||
# if vad is not used, we assume the user is always speaking
|
||||
self._vad_user_speaking = True
|
||||
|
||||
def _load_backchannel_phrases(self, backchannel_phrases: Optional[Union[str, List[str]]] = None):
|
||||
if not backchannel_phrases:
|
||||
return []
|
||||
|
||||
if isinstance(backchannel_phrases, str) and Path(backchannel_phrases).is_file():
|
||||
logger.info(f"Loading backchannel phrases from file: {backchannel_phrases}")
|
||||
if not Path(backchannel_phrases).exists():
|
||||
raise FileNotFoundError(f"Backchannel phrases file not found: {backchannel_phrases}")
|
||||
with open(backchannel_phrases, "r") as f:
|
||||
backchannel_phrases = yaml.safe_load(f)
|
||||
if not isinstance(backchannel_phrases, list):
|
||||
raise ValueError(f"Backchannel phrases must be a list, got {type(backchannel_phrases)}")
|
||||
logger.info(f"Loaded {len(backchannel_phrases)} backchannel phrases from file: {backchannel_phrases}")
|
||||
elif isinstance(backchannel_phrases, list):
|
||||
logger.info(f"Using backchannel phrases from list: {backchannel_phrases}")
|
||||
else:
|
||||
raise ValueError(f"Invalid backchannel phrases: {backchannel_phrases}")
|
||||
return backchannel_phrases
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
Reset the turn-taking service.
|
||||
"""
|
||||
self._current_speaker_id = None
|
||||
self._prev_speaker_id = None
|
||||
self._bot_stop_time = None
|
||||
self._bot_speaking = False
|
||||
self._vad_user_speaking = False
|
||||
self._have_sent_user_started_speaking = False
|
||||
self._user_speaking_buffer = ""
|
||||
if not self.use_vad:
|
||||
# if vad is not used, we assume the user is always speaking
|
||||
self._vad_user_speaking = True
|
||||
logger.debug("TurnTaking service reset complete")
|
||||
|
||||
def clean_text(self, text: str) -> str:
|
||||
"""
|
||||
Clean the text so that it can be used for backchannel detection.
|
||||
"""
|
||||
if self.language != Language.EN_US:
|
||||
raise ValueError(f"Language {self.language} not supported, currently only English is supported.")
|
||||
for eou_string in [self.eou_string, self.eob_string]:
|
||||
if text.endswith(eou_string):
|
||||
text = text[: -len(eou_string)].strip()
|
||||
text = text.lower()
|
||||
valid_chars = "abcdefghijklmnopqrstuvwxyz'"
|
||||
text = ''.join([c for c in text if c in valid_chars or c.isspace() or c == "'"])
|
||||
return " ".join(text.split()).strip()
|
||||
|
||||
def is_backchannel(self, text: str) -> bool:
|
||||
"""
|
||||
Check if the text is a backchannel phrase.
|
||||
"""
|
||||
if not self.backchannel_phrases:
|
||||
return False
|
||||
if text.startswith("<speaker_"):
|
||||
# if the text starts with a speaker tag, we remove it
|
||||
text = text[len("<speaker_0>") :]
|
||||
text = self.clean_text(text)
|
||||
return text in self.backchannel_phrases_nopc
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
"""Process incoming frames and handle turn-taking logic."""
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
if self._bot_stop_time is not None:
|
||||
# check if the bot has stopped speaking for more than the delay
|
||||
if time.time() - self._bot_stop_time > self.bot_stop_delay:
|
||||
# set the _bot_speaking flag to False to actually consider the bot as stopped speaking
|
||||
logger.debug(
|
||||
f"Bot stopped speaking for more than {self.bot_stop_delay} seconds, setting _bot_speaking to False"
|
||||
)
|
||||
self._bot_stop_time = None
|
||||
self._bot_speaking = False
|
||||
|
||||
if isinstance(frame, (TranscriptionFrame, InterimTranscriptionFrame)):
|
||||
await self._handle_transcription(frame, direction)
|
||||
elif isinstance(frame, VADUserStartedSpeakingFrame):
|
||||
await self._handle_vad_user_started_speaking(frame, direction)
|
||||
elif isinstance(frame, VADUserStoppedSpeakingFrame):
|
||||
await self._handle_vad_user_stopped_speaking(frame, direction)
|
||||
elif isinstance(frame, BotStartedSpeakingFrame):
|
||||
logger.debug("BotStartedSpeakingFrame received")
|
||||
self._bot_speaking = True
|
||||
# Capture the actual start time when audio starts playing
|
||||
# This is more accurate than capturing during TTS generation
|
||||
if self._audio_logger:
|
||||
self._audio_logger.set_agent_turn_start_time()
|
||||
elif isinstance(frame, BotStoppedSpeakingFrame):
|
||||
logger.debug("BotStoppedSpeakingFrame received")
|
||||
self._bot_stop_time = time.time()
|
||||
if self.bot_stop_delay is None or self.bot_stop_delay <= 0:
|
||||
# only set the flag if the delay is not set or is 0
|
||||
self._bot_speaking = False
|
||||
logger.debug("Setting _bot_speaking to False")
|
||||
elif isinstance(frame, DiarResultFrame):
|
||||
logger.debug("DiarResultFrame received")
|
||||
await self._handle_diar_result(frame, direction)
|
||||
else:
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
async def _handle_backchannel_text(self, text: str):
|
||||
# ignore the backchannel string while bot is speaking
|
||||
# push the backchannel string upstream, not downstream
|
||||
await self.push_frame(
|
||||
TranscriptionFrame(
|
||||
text=f"({text})",
|
||||
user_id="",
|
||||
timestamp=time_now_iso8601(),
|
||||
language=self.language if self.language else Language.EN_US,
|
||||
result={"text": f"Backchannel detected: {text}"},
|
||||
),
|
||||
direction=FrameDirection.UPSTREAM,
|
||||
)
|
||||
|
||||
async def _handle_transcription(
|
||||
self, frame: TranscriptionFrame | InterimTranscriptionFrame, direction: FrameDirection
|
||||
):
|
||||
text_segment = frame.text
|
||||
if self._vad_user_speaking:
|
||||
self._user_speaking_buffer += text_segment
|
||||
has_eou = self._user_speaking_buffer.endswith(self.eou_string)
|
||||
has_eob = self._user_speaking_buffer.endswith(self.eob_string)
|
||||
if has_eou:
|
||||
# EOU detected, user is done speaking - push completed text and interrupt bot
|
||||
logger.debug(f"<EOU> Detected: `{self._user_speaking_buffer}`")
|
||||
completed_text = self._user_speaking_buffer[: -len(self.eou_string)].strip()
|
||||
if self._bot_speaking and self.is_backchannel(completed_text):
|
||||
logger.debug(f"<EOU> detected for a backchannel phrase while bot is speaking: `{completed_text}`")
|
||||
await self._handle_backchannel_text(completed_text)
|
||||
if self._audio_logger:
|
||||
if self._audio_logger.staged_metadata is None:
|
||||
self._audio_logger.staged_metadata = {"is_backchannel": True, "start_time": datetime.now()}
|
||||
else:
|
||||
self._audio_logger.staged_metadata["is_backchannel"] = True
|
||||
|
||||
else:
|
||||
await self._handle_completed_text(completed_text, direction)
|
||||
await self._handle_user_interruption(UserStoppedSpeakingFrame())
|
||||
self._user_speaking_buffer = ""
|
||||
self._have_sent_user_started_speaking = False # user is done speaking, so we reset the flag
|
||||
elif has_eob and self._bot_speaking:
|
||||
logger.debug(f"<EOB> detected while bot is speaking: `{self._user_speaking_buffer}`")
|
||||
await self._handle_backchannel_text(str(self._user_speaking_buffer))
|
||||
if self._audio_logger:
|
||||
if self._audio_logger.staged_metadata is None:
|
||||
self._audio_logger.staged_metadata = {"is_backchannel": True, "start_time": datetime.now()}
|
||||
else:
|
||||
self._audio_logger.staged_metadata["is_backchannel"] = True
|
||||
self._user_speaking_buffer = ""
|
||||
self._have_sent_user_started_speaking = False # user is done speaking, so we reset the flag
|
||||
else:
|
||||
# if bot is not speaking, the backchannel string is not considered a backchannel phrase
|
||||
# user is still speaking, so we append the text segment to the buffer
|
||||
logger.debug(f"User is speaking: `{self._user_speaking_buffer}`")
|
||||
if has_eob:
|
||||
logger.debug(
|
||||
f"{self.eob_string} detected but ignored (bot NOT speaking): "
|
||||
f"`{self._user_speaking_buffer}`"
|
||||
)
|
||||
self._user_speaking_buffer = self._user_speaking_buffer[: -len(self.eob_string)].strip()
|
||||
# assume the last word is not completed
|
||||
completed_words = self._user_speaking_buffer.strip().split()[:-1]
|
||||
if len(completed_words) >= self.max_buffer_size:
|
||||
completed_text = " ".join(completed_words)
|
||||
await self._handle_completed_text(completed_text, direction, is_final=False)
|
||||
|
||||
else:
|
||||
# if vad is not detecting user speaking
|
||||
logger.debug(
|
||||
f"VAD is not detecting user speaking, but still received text segment from STT: `{text_segment}`"
|
||||
)
|
||||
is_backchannel = self.is_backchannel(text_segment)
|
||||
if text_segment.endswith(self.eob_string):
|
||||
is_backchannel = True
|
||||
logger.debug(f"Dropping EOB token: `{text_segment}`")
|
||||
text_segment = text_segment[: -len(self.eob_string)].strip()
|
||||
elif text_segment.endswith(self.eou_string):
|
||||
logger.debug(f"Dropping EOU token: `{text_segment}`")
|
||||
text_segment = text_segment[: -len(self.eou_string)].strip()
|
||||
|
||||
if not text_segment.strip():
|
||||
return
|
||||
if is_backchannel and self._bot_speaking:
|
||||
logger.debug(f"Backchannel detected while bot is speaking: `{text_segment}`")
|
||||
# push the backchannel string upstream, not downstream
|
||||
curr_text = str(self._user_speaking_buffer + text_segment)
|
||||
self._user_speaking_buffer = ""
|
||||
if self._audio_logger:
|
||||
if self._audio_logger.staged_metadata is None:
|
||||
self._audio_logger.staged_metadata = {"is_backchannel": True, "start_time": datetime.now()}
|
||||
else:
|
||||
self._audio_logger.staged_metadata["is_backchannel"] = True
|
||||
await self.push_frame(
|
||||
TranscriptionFrame(
|
||||
text=f"({curr_text})",
|
||||
user_id="",
|
||||
timestamp=time_now_iso8601(),
|
||||
language=self.language if self.language else Language.EN_US,
|
||||
result={"text": f"Backchannel detected: {self._user_speaking_buffer+text_segment}"},
|
||||
),
|
||||
direction=FrameDirection.UPSTREAM,
|
||||
)
|
||||
|
||||
else:
|
||||
# if the text segment is not empty and have non-space characters, we append it to the buffer
|
||||
self._user_speaking_buffer += text_segment
|
||||
if self.is_backchannel(self._user_speaking_buffer):
|
||||
logger.debug(f"Backchannel detected: `{self._user_speaking_buffer}`")
|
||||
self._user_speaking_buffer = ""
|
||||
self._have_sent_user_started_speaking = False
|
||||
return
|
||||
logger.debug(f"Appending text segment to user speaking buffer: `{self._user_speaking_buffer}`")
|
||||
|
||||
async def _handle_completed_text(self, completed_text: str, direction: FrameDirection, is_final: bool = True):
|
||||
if not self._have_sent_user_started_speaking:
|
||||
# if we haven't sent the user started speaking frame, we send it now
|
||||
# so that the bot can be interrupted and be ready to respond to the new user turn
|
||||
await self._handle_user_interruption(UserStartedSpeakingFrame())
|
||||
self._have_sent_user_started_speaking = True
|
||||
|
||||
completed_text = completed_text.strip()
|
||||
completed_text = completed_text.replace(self.eou_string, "").replace(self.eob_string, "")
|
||||
|
||||
if self.use_diar and not completed_text.startswith("<speaker_") and self._prev_speaker_id is not None:
|
||||
# Add the previous speaker tag to the beginning of the text
|
||||
completed_text = f"<speaker_{self._prev_speaker_id}> {completed_text}"
|
||||
|
||||
frame_type = TranscriptionFrame if is_final else InterimTranscriptionFrame
|
||||
text_frame = frame_type(
|
||||
text=completed_text,
|
||||
user_id="", # No speaker ID in this implementation
|
||||
timestamp=time_now_iso8601(),
|
||||
language=self.language if self.language else Language.EN_US,
|
||||
result={"text": completed_text},
|
||||
)
|
||||
logger.debug(f"Pushing text frame: {text_frame}")
|
||||
await self.push_frame(text_frame, direction)
|
||||
|
||||
def _contains_only_speaker_tags(self, text: str) -> bool:
|
||||
"""
|
||||
Check if the text contains only speaker tags.
|
||||
"""
|
||||
return text.strip().startswith("<speaker_") and text.strip().endswith(">")
|
||||
|
||||
async def _handle_vad_user_started_speaking(self, frame: VADUserStartedSpeakingFrame, direction: FrameDirection):
|
||||
"""
|
||||
Handle the user started speaking frame.
|
||||
|
||||
If there are no backchannel phrases and we haven't sent the user started speaking frame, we send it now
|
||||
so that the bot can be interrupted and be ready to respond to the new user turn
|
||||
"""
|
||||
self._vad_user_speaking = True
|
||||
logger.debug("NeMoTurnTakingService: VADUserStartedSpeakingFrame")
|
||||
await self.push_frame(frame, direction)
|
||||
if not self.backchannel_phrases and not self._have_sent_user_started_speaking:
|
||||
await self._handle_user_interruption(UserStartedSpeakingFrame())
|
||||
self._have_sent_user_started_speaking = True
|
||||
|
||||
async def _handle_vad_user_stopped_speaking(self, frame: VADUserStoppedSpeakingFrame, direction: FrameDirection):
|
||||
"""
|
||||
Handle the user stopped speaking frame.
|
||||
|
||||
If the buffer is not empty:
|
||||
- If bot is not speaking: push completed text regardless of backchannel
|
||||
- If bot is speaking: ignore backchannel strings
|
||||
If the buffer is empty, do nothing.
|
||||
"""
|
||||
if self.use_vad:
|
||||
self._vad_user_speaking = False
|
||||
logger.debug("NeMoTurnTakingService: VADUserStoppedSpeakingFrame")
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
# if user buffer only contains speaker tags, we don't push the completed text frame
|
||||
if self._contains_only_speaker_tags(self._user_speaking_buffer):
|
||||
logger.debug(f"User buffer only contains speaker tags: `{self._user_speaking_buffer}`, ignoring")
|
||||
return
|
||||
|
||||
is_backchannel = self.is_backchannel(self._user_speaking_buffer)
|
||||
if not self._user_speaking_buffer:
|
||||
return
|
||||
if not self._bot_speaking or not is_backchannel:
|
||||
logger.debug(f"Bot talking: {self._bot_speaking}, backchannel: {is_backchannel}")
|
||||
logger.debug(f"Pushing completed text frame for VAD user stopped speaking: {self._user_speaking_buffer}")
|
||||
await self._handle_completed_text(self._user_speaking_buffer, direction)
|
||||
self._user_speaking_buffer = ""
|
||||
if self._have_sent_user_started_speaking:
|
||||
await self._handle_user_interruption(UserStoppedSpeakingFrame())
|
||||
self._have_sent_user_started_speaking = False
|
||||
elif is_backchannel:
|
||||
logger.debug(f"Backchannel detected: `{self._user_speaking_buffer}`")
|
||||
if self._audio_logger:
|
||||
self._audio_logger.save_user_audio(is_backchannel=True)
|
||||
logger.debug(
|
||||
f"[TurnTakingService] Saved backchannel audio (VAD stopped): {self._user_speaking_buffer}"
|
||||
)
|
||||
|
||||
await self.push_frame(
|
||||
TranscriptionFrame(
|
||||
text=f"({self._user_speaking_buffer})",
|
||||
user_id="",
|
||||
timestamp=time_now_iso8601(),
|
||||
language=self.language if self.language else Language.EN_US,
|
||||
result={"text": f"Backchannel detected: {self._user_speaking_buffer}"},
|
||||
),
|
||||
direction=FrameDirection.UPSTREAM,
|
||||
)
|
||||
self._user_speaking_buffer = ""
|
||||
self._have_sent_user_started_speaking = False
|
||||
|
||||
async def _handle_user_interruption(self, frame: Frame):
|
||||
# Adapted from BaseInputTransport._handle_user_interruption
|
||||
if isinstance(frame, UserStartedSpeakingFrame):
|
||||
logger.debug("User started speaking")
|
||||
if self.can_create_user_frames:
|
||||
logger.debug("Pushing UserStartedSpeakingFrame and StartInterruptionFrame")
|
||||
await self.push_frame(frame)
|
||||
await self.push_frame(StartInterruptionFrame(), direction=FrameDirection.DOWNSTREAM)
|
||||
else:
|
||||
logger.debug(
|
||||
"Skipping UserStartedSpeakingFrame and StartInterruptionFrame because can_create_user_frames is False"
|
||||
)
|
||||
# Record cutoff time for agent audio when TTS is interrupted
|
||||
if self._audio_logger and self._bot_speaking:
|
||||
self._audio_logger.set_agent_cutoff_time()
|
||||
# Increment turn index when user starts speaking (only if speaker changed)
|
||||
if self._audio_logger:
|
||||
self._audio_logger.increment_turn_index(speaker="user")
|
||||
elif isinstance(frame, UserStoppedSpeakingFrame):
|
||||
logger.debug("User stopped speaking")
|
||||
if self.can_create_user_frames:
|
||||
logger.debug("Pushing UserStoppedSpeakingFrame")
|
||||
await self.push_frame(frame)
|
||||
else:
|
||||
logger.debug("Skipping UserStoppedSpeakingFrame because can_create_user_frames is False")
|
||||
else:
|
||||
logger.debug(f"Unknown frame type for _handle_user_interruption: {type(frame)}")
|
||||
|
||||
async def _handle_diar_result(self, frame: DiarResultFrame, direction: FrameDirection):
|
||||
if not self.use_diar:
|
||||
logger.debug("Diarization is disabled, skipping")
|
||||
return
|
||||
|
||||
new_speaker_id = frame.diar_result # speaker id of the dominant speaker
|
||||
|
||||
# logger.debug(f"Dominant speaker ID: {dominant_speaker_id}")
|
||||
self._prev_speaker_id = self._current_speaker_id
|
||||
last_speaker_id = self._current_speaker_id
|
||||
|
||||
if not self._user_speaking_buffer.startswith("<speaker_"):
|
||||
# add speaker tag <speaker_{speaker_id}> to the beginning of the current utterance
|
||||
self._user_speaking_buffer = f"<speaker_{new_speaker_id}> {self._user_speaking_buffer}"
|
||||
elif last_speaker_id != new_speaker_id:
|
||||
# change the speaker tag to the dominant speaker id
|
||||
self._user_speaking_buffer = self._user_speaking_buffer[len("<speaker_0>") :]
|
||||
self._user_speaking_buffer = f"<speaker_{new_speaker_id}> {self._user_speaking_buffer}"
|
||||
logger.debug(f"Speaker changed from {last_speaker_id} to {new_speaker_id}")
|
||||
self._current_speaker_id = new_speaker_id
|
||||
@@ -0,0 +1,197 @@
|
||||
# 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.
|
||||
# NOTE: This file will be deprecated in the future, as the new inference pipeline will replace it.
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
|
||||
import nemo.collections.asr as nemo_asr
|
||||
|
||||
LOG_MEL_ZERO = -16.635
|
||||
|
||||
|
||||
class AudioBufferer:
|
||||
def __init__(self, sample_rate: int, buffer_size_in_secs: float):
|
||||
self.buffer_size = int(buffer_size_in_secs * sample_rate)
|
||||
self.sample_buffer = torch.zeros(self.buffer_size, dtype=torch.float32)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
Reset the buffer to zero
|
||||
"""
|
||||
self.sample_buffer.zero_()
|
||||
|
||||
def update(self, audio: np.ndarray) -> None:
|
||||
"""
|
||||
Update the buffer with the new frame
|
||||
Args:
|
||||
frame (Frame): frame to update the buffer with
|
||||
"""
|
||||
if not isinstance(audio, torch.Tensor):
|
||||
audio = torch.from_numpy(audio)
|
||||
|
||||
audio_size = audio.shape[0]
|
||||
if audio_size > self.buffer_size:
|
||||
raise ValueError(f"Frame size ({audio_size}) exceeds buffer size ({self.buffer_size})")
|
||||
|
||||
shift = audio_size
|
||||
self.sample_buffer[:-shift] = self.sample_buffer[shift:].clone()
|
||||
self.sample_buffer[-shift:] = audio.clone()
|
||||
|
||||
def get_buffer(self) -> torch.Tensor:
|
||||
"""
|
||||
Get the current buffer
|
||||
Returns:
|
||||
torch.Tensor: current state of the buffer
|
||||
"""
|
||||
return self.sample_buffer.clone()
|
||||
|
||||
def is_buffer_empty(self) -> bool:
|
||||
"""
|
||||
Check if the buffer is empty
|
||||
Returns:
|
||||
bool: True if the buffer is empty, False otherwise
|
||||
"""
|
||||
return self.sample_buffer.sum() == 0
|
||||
|
||||
|
||||
class CacheFeatureBufferer:
|
||||
def __init__(
|
||||
self,
|
||||
sample_rate: int,
|
||||
buffer_size_in_secs: float,
|
||||
chunk_size_in_secs: float,
|
||||
preprocessor_cfg: DictConfig,
|
||||
device: torch.device,
|
||||
fill_value: float = LOG_MEL_ZERO,
|
||||
):
|
||||
|
||||
if buffer_size_in_secs < chunk_size_in_secs:
|
||||
raise ValueError(
|
||||
f"Buffer size ({buffer_size_in_secs}s) should be no less than chunk size ({chunk_size_in_secs}s)"
|
||||
)
|
||||
|
||||
self.sample_rate = sample_rate
|
||||
self.buffer_size_in_secs = buffer_size_in_secs
|
||||
self.chunk_size_in_secs = chunk_size_in_secs
|
||||
self.device = device
|
||||
|
||||
if hasattr(preprocessor_cfg, 'log') and preprocessor_cfg.log:
|
||||
self.ZERO_LEVEL_SPEC_DB_VAL = LOG_MEL_ZERO # Log-Mel spectrogram value for zero signals
|
||||
else:
|
||||
self.ZERO_LEVEL_SPEC_DB_VAL = fill_value
|
||||
|
||||
self.n_feat = preprocessor_cfg.features
|
||||
self.timestep_duration = preprocessor_cfg.window_stride
|
||||
self.n_chunk_look_back = int(self.timestep_duration * self.sample_rate)
|
||||
self.chunk_size = int(self.chunk_size_in_secs * self.sample_rate)
|
||||
self.sample_buffer = AudioBufferer(sample_rate, buffer_size_in_secs)
|
||||
|
||||
self.feature_buffer_len = int(buffer_size_in_secs / self.timestep_duration)
|
||||
self.feature_chunk_len = int(chunk_size_in_secs / self.timestep_duration)
|
||||
self.feature_buffer = torch.full(
|
||||
[self.n_feat, self.feature_buffer_len],
|
||||
self.ZERO_LEVEL_SPEC_DB_VAL,
|
||||
dtype=torch.float32,
|
||||
device=self.device,
|
||||
)
|
||||
|
||||
self.preprocessor = nemo_asr.models.ASRModel.from_config_dict(preprocessor_cfg)
|
||||
self.preprocessor.to(self.device)
|
||||
|
||||
def is_buffer_empty(self) -> bool:
|
||||
"""
|
||||
Check if the buffer is empty
|
||||
Returns:
|
||||
bool: True if the buffer is empty, False otherwise
|
||||
"""
|
||||
return self.sample_buffer.is_buffer_empty()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
Reset the buffer to zero
|
||||
"""
|
||||
self.sample_buffer.reset()
|
||||
self.feature_buffer.fill_(self.ZERO_LEVEL_SPEC_DB_VAL)
|
||||
|
||||
def _update_feature_buffer(self, feat_chunk: torch.Tensor) -> None:
|
||||
"""
|
||||
Add an extracted feature to `feature_buffer`
|
||||
"""
|
||||
self.feature_buffer[:, : -self.feature_chunk_len] = self.feature_buffer[:, self.feature_chunk_len :].clone()
|
||||
self.feature_buffer[:, -self.feature_chunk_len :] = feat_chunk.clone()
|
||||
|
||||
def preprocess(self, audio_signal: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Preprocess the audio signal using the preprocessor
|
||||
Args:
|
||||
audio_signal (torch.Tensor): audio signal
|
||||
Returns:
|
||||
torch.Tensor: preprocessed features
|
||||
"""
|
||||
audio_signal = audio_signal.unsqueeze_(0).to(self.device)
|
||||
audio_signal_len = torch.tensor([audio_signal.shape[1]], device=self.device)
|
||||
features, _ = self.preprocessor(
|
||||
input_signal=audio_signal,
|
||||
length=audio_signal_len,
|
||||
)
|
||||
features = features.squeeze()
|
||||
return features
|
||||
|
||||
def update(self, audio: np.ndarray) -> None:
|
||||
"""
|
||||
Update the sample anf feature buffers with the new frame
|
||||
Args:
|
||||
frame (Frame): frame to update the buffer with
|
||||
"""
|
||||
|
||||
# Update the sample buffer with the new frame
|
||||
self.sample_buffer.update(audio)
|
||||
|
||||
if math.isclose(self.buffer_size_in_secs, self.chunk_size_in_secs):
|
||||
# If the buffer size is equal to the chunk size, just take the whole buffer
|
||||
samples = self.sample_buffer.sample_buffer.clone()
|
||||
else:
|
||||
# Add look_back to have context for the first feature
|
||||
samples = self.sample_buffer.sample_buffer[-(self.n_chunk_look_back + self.chunk_size) :]
|
||||
|
||||
# Get the mel spectrogram
|
||||
features = self.preprocess(samples)
|
||||
|
||||
# If the features are longer than supposed to be, drop the last frames
|
||||
# Drop the last diff frames because they might be incomplete
|
||||
if (diff := features.shape[1] - self.feature_chunk_len - 1) > 0:
|
||||
features = features[:, :-diff]
|
||||
|
||||
# Update the feature buffer with the new features
|
||||
self._update_feature_buffer(features[:, -self.feature_chunk_len :])
|
||||
|
||||
def get_buffer(self) -> torch.Tensor:
|
||||
"""
|
||||
Get the current sample buffer
|
||||
Returns:
|
||||
torch.Tensor: current state of the buffer
|
||||
"""
|
||||
return self.sample_buffer.get_buffer()
|
||||
|
||||
def get_feature_buffer(self) -> torch.Tensor:
|
||||
"""
|
||||
Get the current feature buffer
|
||||
Returns:
|
||||
torch.Tensor: current state of the feature buffer
|
||||
"""
|
||||
return self.feature_buffer.clone()
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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.
|
||||
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.audio.vad.vad_analyzer import VADState
|
||||
from pipecat.frames.frames import (
|
||||
InputAudioRawFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
VADUserStartedSpeakingFrame,
|
||||
VADUserStoppedSpeakingFrame,
|
||||
)
|
||||
from pipecat.transports.base_input import BaseInputTransport as _BaseInputTransport
|
||||
|
||||
|
||||
class BaseInputTransport(_BaseInputTransport):
|
||||
async def _handle_vad(self, audio_frame: InputAudioRawFrame, vad_state: VADState):
|
||||
"""Handle Voice Activity Detection results and generate appropriate frames."""
|
||||
new_vad_state = await self._vad_analyze(audio_frame)
|
||||
if new_vad_state != vad_state and new_vad_state != VADState.STARTING and new_vad_state != VADState.STOPPING:
|
||||
frame = None
|
||||
# If the turn analyser is enabled, this will prevent:
|
||||
# - Creating the UserStoppedSpeakingFrame
|
||||
# - Creating the UserStartedSpeakingFrame multiple times
|
||||
can_create_user_frames = (
|
||||
self._params.turn_analyzer is None or not self._params.turn_analyzer.speech_triggered
|
||||
) and self._params.can_create_user_frames
|
||||
|
||||
if new_vad_state == VADState.SPEAKING:
|
||||
await self.push_frame(VADUserStartedSpeakingFrame())
|
||||
if can_create_user_frames:
|
||||
frame = UserStartedSpeakingFrame()
|
||||
else:
|
||||
logger.debug("base_input: VAD state changed to SPEAKING but can_create_user_frames is False")
|
||||
elif new_vad_state == VADState.QUIET:
|
||||
await self.push_frame(VADUserStoppedSpeakingFrame())
|
||||
if can_create_user_frames:
|
||||
frame = UserStoppedSpeakingFrame()
|
||||
else:
|
||||
logger.debug("base_input: VAD state changed to QUIET but can_create_user_frames is False")
|
||||
|
||||
if frame:
|
||||
await self._handle_user_interruption(frame)
|
||||
|
||||
vad_state = new_vad_state
|
||||
return vad_state
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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.
|
||||
|
||||
|
||||
from pipecat.transports.base_transport import TransportParams as _TransportParams
|
||||
|
||||
|
||||
class TransportParams(_TransportParams):
|
||||
can_create_user_frames: bool = True
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,304 @@
|
||||
# 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 asyncio
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.frames.frames import CancelFrame, EndFrame, InputAudioRawFrame, StartFrame
|
||||
from pipecat.serializers.base_serializer import FrameSerializer
|
||||
from pipecat.transports.base_transport import BaseTransport
|
||||
from pipecat.transports.network.websocket_server import (
|
||||
WebsocketServerCallbacks,
|
||||
WebsocketServerOutputTransport,
|
||||
WebsocketServerParams,
|
||||
)
|
||||
|
||||
from nemo.agents.voice_agent.pipecat.transports.base_input import BaseInputTransport
|
||||
from nemo.agents.voice_agent.pipecat.transports.base_transport import TransportParams
|
||||
|
||||
try:
|
||||
import websockets
|
||||
except ModuleNotFoundError as e:
|
||||
logger.error(f"Exception: {e}")
|
||||
logger.error("In order to use websockets, you need to `pip install pipecat-ai[websocket]`.")
|
||||
raise Exception(f"Missing module: {e}")
|
||||
|
||||
|
||||
class WebsocketServerParams(TransportParams):
|
||||
"""Configuration parameters for WebSocket server transport.
|
||||
|
||||
Parameters:
|
||||
add_wav_header: Whether to add WAV headers to audio frames.
|
||||
serializer: Frame serializer for message encoding/decoding.
|
||||
session_timeout: Timeout in seconds for client sessions.
|
||||
"""
|
||||
|
||||
add_wav_header: bool = False
|
||||
serializer: Optional[FrameSerializer] = None
|
||||
session_timeout: Optional[int] = None
|
||||
|
||||
|
||||
class WebsocketServerInputTransport(BaseInputTransport):
|
||||
"""WebSocket server input transport for receiving client data.
|
||||
|
||||
Handles incoming WebSocket connections, message processing, and client
|
||||
session management including timeout monitoring and connection lifecycle.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transport: BaseTransport,
|
||||
host: str,
|
||||
port: int,
|
||||
params: WebsocketServerParams,
|
||||
callbacks: WebsocketServerCallbacks,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the WebSocket server input transport.
|
||||
|
||||
Args:
|
||||
transport: The parent transport instance.
|
||||
host: Host address to bind the WebSocket server to.
|
||||
port: Port number to bind the WebSocket server to.
|
||||
params: WebSocket server configuration parameters.
|
||||
callbacks: Callback functions for WebSocket events.
|
||||
**kwargs: Additional arguments passed to parent class.
|
||||
"""
|
||||
super().__init__(params, **kwargs)
|
||||
|
||||
self._transport = transport
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._params = params
|
||||
self._callbacks = callbacks
|
||||
|
||||
self._websocket: Optional[websockets.WebSocketServerProtocol] = None
|
||||
|
||||
self._server_task = None
|
||||
|
||||
# This task will monitor the websocket connection periodically.
|
||||
self._monitor_task = None
|
||||
|
||||
self._stop_server_event = asyncio.Event()
|
||||
|
||||
# Whether we have seen a StartFrame already.
|
||||
self._initialized = False
|
||||
|
||||
async def start(self, frame: StartFrame):
|
||||
"""Start the WebSocket server and initialize components.
|
||||
|
||||
Args:
|
||||
frame: The start frame containing initialization parameters.
|
||||
"""
|
||||
await super().start(frame)
|
||||
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
|
||||
if self._params.serializer:
|
||||
await self._params.serializer.setup(frame)
|
||||
if not self._server_task:
|
||||
self._server_task = self.create_task(self._server_task_handler())
|
||||
await self.set_transport_ready(frame)
|
||||
|
||||
async def stop(self, frame: EndFrame):
|
||||
"""Stop the WebSocket server and cleanup resources.
|
||||
|
||||
Args:
|
||||
frame: The end frame signaling transport shutdown.
|
||||
"""
|
||||
await super().stop(frame)
|
||||
self._stop_server_event.set()
|
||||
if self._monitor_task:
|
||||
await self.cancel_task(self._monitor_task)
|
||||
self._monitor_task = None
|
||||
if self._server_task:
|
||||
await self.wait_for_task(self._server_task)
|
||||
self._server_task = None
|
||||
|
||||
async def cancel(self, frame: CancelFrame):
|
||||
"""Cancel the WebSocket server and stop all processing.
|
||||
|
||||
Args:
|
||||
frame: The cancel frame signaling immediate cancellation.
|
||||
"""
|
||||
await super().cancel(frame)
|
||||
if self._monitor_task:
|
||||
await self.cancel_task(self._monitor_task)
|
||||
self._monitor_task = None
|
||||
if self._server_task:
|
||||
await self.cancel_task(self._server_task)
|
||||
self._server_task = None
|
||||
|
||||
async def cleanup(self):
|
||||
"""Cleanup resources and parent transport."""
|
||||
await super().cleanup()
|
||||
await self._transport.cleanup()
|
||||
|
||||
async def _server_task_handler(self):
|
||||
"""Handle WebSocket server startup and client connections."""
|
||||
logger.info(f"Starting websocket server on {self._host}:{self._port}")
|
||||
async with websockets.serve(self._client_handler, self._host, self._port) as server:
|
||||
await self._callbacks.on_websocket_ready()
|
||||
await self._stop_server_event.wait()
|
||||
|
||||
async def _client_handler(self, websocket: websockets.WebSocketServerProtocol, path: Optional[str] = None):
|
||||
"""Handle individual client connections and message processing."""
|
||||
logger.info(f"New client connection from {websocket.remote_address}")
|
||||
if self._websocket:
|
||||
await self._websocket.close()
|
||||
logger.warning("Only one client connected, using new connection")
|
||||
|
||||
self._websocket = websocket
|
||||
|
||||
# Notify
|
||||
await self._callbacks.on_client_connected(websocket)
|
||||
|
||||
# Create a task to monitor the websocket connection
|
||||
if not self._monitor_task and self._params.session_timeout:
|
||||
self._monitor_task = self.create_task(self._monitor_websocket(websocket, self._params.session_timeout))
|
||||
|
||||
# Handle incoming messages
|
||||
try:
|
||||
async for message in websocket:
|
||||
if not self._params.serializer:
|
||||
continue
|
||||
|
||||
frame = await self._params.serializer.deserialize(message)
|
||||
|
||||
if not frame:
|
||||
continue
|
||||
|
||||
if isinstance(frame, InputAudioRawFrame):
|
||||
await self.push_audio_frame(frame)
|
||||
else:
|
||||
await self.push_frame(frame)
|
||||
except Exception as e:
|
||||
logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
|
||||
|
||||
# Notify disconnection
|
||||
await self._callbacks.on_client_disconnected(websocket)
|
||||
|
||||
await self._websocket.close()
|
||||
self._websocket = None
|
||||
|
||||
logger.info(f"Client {websocket.remote_address} disconnected")
|
||||
|
||||
async def _monitor_websocket(self, websocket: websockets.WebSocketServerProtocol, session_timeout: int):
|
||||
"""Monitor WebSocket connection for session timeout."""
|
||||
try:
|
||||
await asyncio.sleep(session_timeout)
|
||||
if not websocket.closed:
|
||||
await self._callbacks.on_session_timeout(websocket)
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"Monitoring task cancelled for: {websocket.remote_address}")
|
||||
raise
|
||||
|
||||
|
||||
class WebsocketServerTransport(BaseTransport):
|
||||
"""WebSocket server transport for bidirectional real-time communication.
|
||||
|
||||
Provides a complete WebSocket server implementation with separate input and
|
||||
output transports, client connection management, and event handling for
|
||||
real-time audio and data streaming applications.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
params: WebsocketServerParams,
|
||||
host: str = "localhost",
|
||||
port: int = 8765,
|
||||
input_name: Optional[str] = None,
|
||||
output_name: Optional[str] = None,
|
||||
):
|
||||
"""Initialize the WebSocket server transport.
|
||||
|
||||
Args:
|
||||
params: WebSocket server configuration parameters.
|
||||
host: Host address to bind the server to. Defaults to "localhost".
|
||||
port: Port number to bind the server to. Defaults to 8765.
|
||||
input_name: Optional name for the input processor.
|
||||
output_name: Optional name for the output processor.
|
||||
"""
|
||||
super().__init__(input_name=input_name, output_name=output_name)
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._params = params
|
||||
|
||||
self._callbacks = WebsocketServerCallbacks(
|
||||
on_client_connected=self._on_client_connected,
|
||||
on_client_disconnected=self._on_client_disconnected,
|
||||
on_session_timeout=self._on_session_timeout,
|
||||
on_websocket_ready=self._on_websocket_ready,
|
||||
)
|
||||
self._input: Optional[WebsocketServerInputTransport] = None
|
||||
self._output: Optional[WebsocketServerOutputTransport] = None
|
||||
self._websocket: Optional[websockets.WebSocketServerProtocol] = None
|
||||
|
||||
# Register supported handlers. The user will only be able to register
|
||||
# these handlers.
|
||||
self._register_event_handler("on_client_connected")
|
||||
self._register_event_handler("on_client_disconnected")
|
||||
self._register_event_handler("on_session_timeout")
|
||||
self._register_event_handler("on_websocket_ready")
|
||||
|
||||
def input(self) -> WebsocketServerInputTransport:
|
||||
"""Get the input transport for receiving client data.
|
||||
|
||||
Returns:
|
||||
The WebSocket server input transport instance.
|
||||
"""
|
||||
if not self._input:
|
||||
self._input = WebsocketServerInputTransport(
|
||||
self, self._host, self._port, self._params, self._callbacks, name=self._input_name
|
||||
)
|
||||
return self._input
|
||||
|
||||
def output(self) -> WebsocketServerOutputTransport:
|
||||
"""Get the output transport for sending data to clients.
|
||||
|
||||
Returns:
|
||||
The WebSocket server output transport instance.
|
||||
"""
|
||||
if not self._output:
|
||||
self._output = WebsocketServerOutputTransport(self, self._params, name=self._output_name)
|
||||
return self._output
|
||||
|
||||
async def _on_client_connected(self, websocket):
|
||||
"""Handle client connection events."""
|
||||
if self._output:
|
||||
await self._output.set_client_connection(websocket)
|
||||
await self._call_event_handler("on_client_connected", websocket)
|
||||
else:
|
||||
logger.error("A WebsocketServerTransport output is missing in the pipeline")
|
||||
|
||||
async def _on_client_disconnected(self, websocket):
|
||||
"""Handle client disconnection events."""
|
||||
if self._output:
|
||||
await self._output.set_client_connection(None)
|
||||
await self._call_event_handler("on_client_disconnected", websocket)
|
||||
else:
|
||||
logger.error("A WebsocketServerTransport output is missing in the pipeline")
|
||||
|
||||
async def _on_session_timeout(self, websocket):
|
||||
"""Handle client session timeout events."""
|
||||
await self._call_event_handler("on_session_timeout", websocket)
|
||||
|
||||
async def _on_websocket_ready(self):
|
||||
"""Handle WebSocket server ready events."""
|
||||
await self._call_event_handler("on_websocket_ready")
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,238 @@
|
||||
# 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
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.utils.string import match_endofsentence
|
||||
from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType
|
||||
from pipecat.utils.text.simple_text_aggregator import SimpleTextAggregator
|
||||
|
||||
|
||||
def has_partial_decimal(text: str) -> bool:
|
||||
"""Check if the text ends with a partial decimal.
|
||||
|
||||
Returns True if the text ends with a number that looks like it could
|
||||
be a partial decimal (e.g., "3.", "3.14", "($3.14)"), but NOT if it's
|
||||
clearly a complete sentence (e.g., "It costs $3.14.") or a bullet point
|
||||
(e.g., "1. Alpha; 2.").
|
||||
"""
|
||||
|
||||
# Check for bullet point pattern: ends with 1-3 digits followed by period
|
||||
# Examples: "1.", "12.", "123.", or "text; 2."
|
||||
# Bullet points are typically small numbers (1-999) at the end
|
||||
bullet_match = re.search(r'(?:^|[\s;,]|[^\d])(\d{1,3})\.$', text)
|
||||
if bullet_match:
|
||||
# It's likely a bullet point, not a partial decimal
|
||||
return False
|
||||
|
||||
# Pattern to find decimal numbers near the end, allowing for trailing
|
||||
# non-word characters like ), ], ", ', etc.
|
||||
# Match: digit(s) + period + optional digit(s) + optional trailing non-word chars
|
||||
match = re.search(r'\d+\.(?:\d+)?([^\w\s]*)$', text)
|
||||
|
||||
if not match:
|
||||
return False
|
||||
|
||||
trailing = match.group(1) # e.g., ")" or "" or "."
|
||||
|
||||
# If trailing contains a period, it's sentence-ending punctuation
|
||||
# e.g., "3.14." means complete sentence
|
||||
if '.' in trailing:
|
||||
return False
|
||||
|
||||
# Otherwise, it's a partial decimal (either incomplete like "3."
|
||||
# or complete number but sentence not finished like "($3.14)")
|
||||
return True
|
||||
|
||||
|
||||
def find_last_period_index(text: str) -> int:
|
||||
"""
|
||||
Find the last occurrence of a period in the text,
|
||||
but return -1 if the text doesn't seem to be a complete sentence.
|
||||
"""
|
||||
num_periods = text.count(".")
|
||||
if num_periods == 0:
|
||||
return -1
|
||||
|
||||
if num_periods == 1:
|
||||
if has_partial_decimal(text):
|
||||
# if the only period in the text is part of a number, return -1
|
||||
return -1
|
||||
# Check if the only period is a bullet point (e.g., "1. Alpha" or incomplete "1.")
|
||||
if re.search(r'(?:^|[\s;,]|[^\d])(\d{1,3})\.(?:\s+\w|\s*$)', text):
|
||||
# The period is after a bullet point number, either:
|
||||
# - followed by content (e.g., "1. Alpha")
|
||||
# - or at the end with optional whitespace (e.g., "1." or "1. ")
|
||||
return -1
|
||||
|
||||
# Check if any of the abbreviations "e.", "i." "g.", "etc." are present in the text
|
||||
if re.search(r'\b(e\.|i\.|g\.)\b', text):
|
||||
# The period is after a character/word that is likely to be a abbreviation, return -1
|
||||
return -1
|
||||
|
||||
# otherwise, check the last occurrence of a period
|
||||
idx = text.rfind(".")
|
||||
if idx <= 0:
|
||||
return idx
|
||||
if text[idx - 1].isdigit():
|
||||
# if the period is after a digit, it's likely a partial decimal, return -1
|
||||
return -1
|
||||
elif text[idx - 1].isupper():
|
||||
# if the period is after a capital letter (e.g., "Washington, D.C."), it's likely a abbreviation, return -1
|
||||
return -1
|
||||
elif idx > 1 and text[idx - 2 : idx + 1].lower() in ["a.m.", "p.m."]:
|
||||
# if the period is after a.m. or p.m., it's likely a time, return -1
|
||||
return -1
|
||||
elif idx > 2 and text[idx - 3 : idx + 1] in ["e.g.", "i.e.", "etc."]:
|
||||
# The period is after a character/word that is likely to be a abbreviation, return -1
|
||||
return -1
|
||||
elif idx >= 2 and text[idx - 2 : idx + 1].lower() in ["st.", "mr.", "mrs.", "ms.", "dr."]:
|
||||
# if the period is after a character/word that is likely to be a abbreviation, return -1
|
||||
return -1
|
||||
|
||||
# the text seems to have a complete sentence, return the index of the last period
|
||||
return idx
|
||||
|
||||
|
||||
def find_last_comma_index(text: str, min_residual_length: int = 5) -> int:
|
||||
"""
|
||||
Find the last occurrence of a valid comma in the text,
|
||||
ignoring the commas in the numbers (e.g., "1,234,567").
|
||||
If the leftover text after the comma is too short, it may be an abbreviation, return -1.
|
||||
|
||||
Args:
|
||||
text: The text to find the last occurrence of a valid comma.
|
||||
min_residual_length: The minimum length of the leftover text after the rightmost comma
|
||||
to be considered as a valid sentence (e.g., "Santa Clara, CA, US.").
|
||||
Returns:
|
||||
The index of the last occurrence of a valid comma, or -1 if no valid comma is found.
|
||||
"""
|
||||
# find the last occurrence of a comma in the text
|
||||
idx = text.rfind(",")
|
||||
if idx == -1:
|
||||
return -1
|
||||
# check if the comma is in a number
|
||||
if re.search(r'\d+,\d+', text[: idx + 1]):
|
||||
# the comma is in a number, return -1
|
||||
return -1
|
||||
|
||||
# check if the leftover text after the comma is too short
|
||||
if len(text[idx + 1 :]) <= min_residual_length:
|
||||
# the leftover text is too short, it may be an abbreviation, return -1
|
||||
return -1
|
||||
|
||||
# the comma is not in a number, return the index of the comma
|
||||
return idx
|
||||
|
||||
|
||||
class SimpleSegmentedTextAggregator(SimpleTextAggregator):
|
||||
"""A simple text aggregator that segments the text into sentences based on punctuation marks."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
punctuation_marks: str | list[str] = ".,!?;:\n",
|
||||
ignore_marks: str | list[str] = "*",
|
||||
min_sentence_length: int = 0,
|
||||
use_legacy_eos_detection: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
punctuation_marks: The punctuation marks to use for sentence detection.
|
||||
ignore_marks: The strings to ignore in the text (e.g., "*").
|
||||
min_sentence_length: The minimum length of a sentence to be considered.
|
||||
use_legacy_eos_detection: Whether to use the legacy EOS detection from pipecat.
|
||||
**kwargs: Additional arguments to pass to the SimpleTextAggregator constructor.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._use_legacy_eos_detection = use_legacy_eos_detection
|
||||
self._min_sentence_length = min_sentence_length
|
||||
self._ignore_marks = set(["*"] if ignore_marks is None else set(ignore_marks))
|
||||
if not punctuation_marks:
|
||||
self._punctuation_marks = list()
|
||||
else:
|
||||
punctuation_marks = (
|
||||
[c for c in punctuation_marks] if isinstance(punctuation_marks, str) else punctuation_marks
|
||||
)
|
||||
if "." in punctuation_marks:
|
||||
punctuation_marks.remove(".")
|
||||
# put period at the end of the list to ensure it's the last punctuation mark to be matched
|
||||
punctuation_marks += ["."]
|
||||
self._punctuation_marks = punctuation_marks
|
||||
|
||||
def _find_segment_end(self, text: str) -> Optional[int]:
|
||||
"""find the end of text segment.
|
||||
|
||||
Args:
|
||||
text: The text to find the end of the segment.
|
||||
|
||||
Returns:
|
||||
The index of the end of the segment, or None if the text is too short.
|
||||
"""
|
||||
# drop leading whitespace but keep trailing whitespace to
|
||||
# allow "\n" to trigger the end of the sentence
|
||||
text_len = len(text)
|
||||
text = text.lstrip()
|
||||
offset = text_len - len(text)
|
||||
if len(text) < self._min_sentence_length:
|
||||
return None
|
||||
|
||||
for punc in self._punctuation_marks:
|
||||
if punc == ".":
|
||||
idx = find_last_period_index(text)
|
||||
elif punc == ",":
|
||||
idx = find_last_comma_index(text)
|
||||
else:
|
||||
idx = text.find(punc)
|
||||
if idx != -1:
|
||||
# add the offset to the index to account for the leading whitespace
|
||||
return idx + 1 + offset
|
||||
return None
|
||||
|
||||
async def aggregate(self, text: str) -> AsyncIterator[Aggregation]:
|
||||
"""Aggregate the input text and return the first complete sentence in the text.
|
||||
|
||||
Args:
|
||||
text: The text to aggregate.
|
||||
|
||||
Returns:
|
||||
The first complete sentence in the text, or None if none is found.
|
||||
"""
|
||||
result: Optional[str] = None
|
||||
self._text += str(text)
|
||||
|
||||
eos_end_index = self._find_segment_end(self._text)
|
||||
|
||||
if not eos_end_index and not has_partial_decimal(self._text) and self._use_legacy_eos_detection:
|
||||
# if the text doesn't have partial decimal, and no punctuation marks,
|
||||
# we use match_endofsentence to find the end of the sentence
|
||||
eos_end_index = match_endofsentence(self._text)
|
||||
|
||||
if eos_end_index:
|
||||
result = self._text[:eos_end_index]
|
||||
if len(result.strip()) < self._min_sentence_length:
|
||||
logger.debug(
|
||||
f"Text is too short, skipping: `{result}`, full text: `{self._text}`, input text: `{text}`"
|
||||
)
|
||||
result = None
|
||||
else:
|
||||
logger.debug(f"Text Aggregator Result: `{result}`, full text: `{self._text}`, input text: `{text}`")
|
||||
self._text = self._text[eos_end_index:]
|
||||
|
||||
if result:
|
||||
for ignore_mark in self._ignore_marks:
|
||||
result = result.replace(ignore_mark, "")
|
||||
yield Aggregation(text=result, type=AggregationType.SENTENCE)
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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.
|
||||
|
||||
from nemo.agents.voice_agent.utils.config_manager import ConfigManager
|
||||
@@ -0,0 +1,312 @@
|
||||
# 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 os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from loguru import logger
|
||||
from omegaconf import OmegaConf
|
||||
from pipecat.audio.vad.silero import VADParams
|
||||
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.diar import NeMoDiarInputParams
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.stt import NeMoSTTInputParams
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""
|
||||
Manages configuration for the voice agent server.
|
||||
Handles loading, merging, and providing access to all configuration parameters.
|
||||
"""
|
||||
|
||||
def __init__(self, server_base_path: str, server_config_path: Optional[str] = None):
|
||||
"""
|
||||
Initialize the configuration manager.
|
||||
|
||||
Args:
|
||||
config_path: Path to the main server configuration file.
|
||||
If None, uses default path from environment variable.
|
||||
"""
|
||||
if not os.path.exists(server_base_path):
|
||||
raise FileNotFoundError(f"Server base path not found at {server_base_path}")
|
||||
|
||||
self._server_base_path = server_base_path
|
||||
if server_config_path is not None:
|
||||
self._server_config_path = server_config_path
|
||||
else:
|
||||
self._server_config_path = f"{os.path.abspath(self._server_base_path)}/server_configs/default.yaml"
|
||||
|
||||
if not os.path.exists(self._server_config_path):
|
||||
raise FileNotFoundError(f"Server configuration file not found at {self._server_config_path}")
|
||||
|
||||
# Load model registry
|
||||
self.model_registry_path = f"{os.path.abspath(self._server_base_path)}/model_registry.yaml"
|
||||
self.model_registry = self._load_model_registry()
|
||||
|
||||
# Load and process main configuration
|
||||
self.server_config = self._load_server_config()
|
||||
|
||||
# Initialize configuration parameters
|
||||
self._initialize_config_parameters()
|
||||
|
||||
self._generic_hf_llm_model_id = "hf_llm_generic"
|
||||
|
||||
logger.info(f"Configuration loaded from: {self._server_config_path}")
|
||||
logger.info(f"Model registry loaded from: {self.model_registry_path}")
|
||||
|
||||
def _load_model_registry(self) -> Dict[str, Any]:
|
||||
"""Load model registry from YAML file."""
|
||||
try:
|
||||
return OmegaConf.load(self.model_registry_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load model registry: {e}")
|
||||
raise ValueError(f"Failed to load model registry: {e}")
|
||||
|
||||
def _load_server_config(self) -> OmegaConf:
|
||||
"""Load and process the main server configuration."""
|
||||
server_config = OmegaConf.load(self._server_config_path)
|
||||
server_config = OmegaConf.to_container(server_config, resolve=True)
|
||||
server_config = OmegaConf.create(server_config)
|
||||
return server_config
|
||||
|
||||
def _initialize_config_parameters(self):
|
||||
"""Initialize all configuration parameters from the loaded config."""
|
||||
# Default constants
|
||||
self.SAMPLE_RATE = 16000
|
||||
self.RAW_AUDIO_FRAME_LEN_IN_SECS = 0.016
|
||||
self.SYSTEM_PROMPT = " ".join(
|
||||
[
|
||||
"You are a helpful AI agent named Lisa.",
|
||||
"Begin by warmly greeting the user and introducing yourself in one sentence.",
|
||||
"Keep your answers concise and to the point.",
|
||||
]
|
||||
)
|
||||
|
||||
# Transport configuration
|
||||
self.TRANSPORT_AUDIO_OUT_10MS_CHUNKS = self.server_config.transport.audio_out_10ms_chunks
|
||||
|
||||
# VAD configuration
|
||||
self.vad_params = VADParams(
|
||||
confidence=self.server_config.vad.confidence,
|
||||
start_secs=self.server_config.vad.start_secs,
|
||||
stop_secs=self.server_config.vad.stop_secs,
|
||||
min_volume=self.server_config.vad.min_volume,
|
||||
)
|
||||
# STT configuration
|
||||
self._configure_stt()
|
||||
|
||||
# Diarization configuration
|
||||
self._configure_diarization()
|
||||
|
||||
# Turn taking configuration
|
||||
self._configure_turn_taking()
|
||||
|
||||
# LLM configuration
|
||||
self._configure_llm()
|
||||
|
||||
# TTS configuration
|
||||
self._configure_tts()
|
||||
|
||||
def _configure_stt(self):
|
||||
"""Configure STT parameters."""
|
||||
self.STT_MODEL = self.server_config.stt.model
|
||||
self.STT_DEVICE = self.server_config.stt.device
|
||||
# Apply STT-specific configuration based on model type
|
||||
# Try to get STT config file name from server config first
|
||||
if self.server_config.stt.get("model_config", None) is not None:
|
||||
yaml_file_name = os.path.basename(self.server_config.stt.model_config)
|
||||
else:
|
||||
# Get STT configuration from registry
|
||||
if str(self.STT_MODEL).endswith(".nemo"):
|
||||
model_name = os.path.splitext(os.path.basename(self.STT_MODEL))[0]
|
||||
else:
|
||||
model_name = self.STT_MODEL
|
||||
if model_name in self.model_registry.stt_models:
|
||||
yaml_file_name = self.model_registry.stt_models[model_name].yaml_id
|
||||
else:
|
||||
error_msg = f"STT model {model_name} is not in model registry: {self.model_registry.stt_models}."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
stt_config_path = f"{os.path.abspath(self._server_base_path)}/server_configs/stt_configs/{yaml_file_name}"
|
||||
if not os.path.exists(stt_config_path):
|
||||
raise FileNotFoundError(f"STT config file not found at {stt_config_path}")
|
||||
stt_config = OmegaConf.load(stt_config_path)
|
||||
|
||||
# merge stt config with server config
|
||||
for key in stt_config:
|
||||
if key in self.server_config.stt and self.server_config.stt[key] != stt_config[key]:
|
||||
logger.info(
|
||||
f"STT config field `{key}` is overridden from `{self.server_config.stt[key]}` "
|
||||
f"to `{stt_config[key]}` by {stt_config_path}"
|
||||
)
|
||||
self.server_config.stt[key] = stt_config[key]
|
||||
|
||||
logger.info(f"Final STT config: {self.server_config.stt}")
|
||||
|
||||
audio_chunk_size_in_secs = self.server_config.stt.get("audio_chunk_size_in_secs", 0.08)
|
||||
buffer_size = audio_chunk_size_in_secs // self.RAW_AUDIO_FRAME_LEN_IN_SECS
|
||||
self.stt_params = NeMoSTTInputParams(
|
||||
att_context_size=self.server_config.stt.att_context_size,
|
||||
frame_len_in_secs=self.server_config.stt.frame_len_in_secs,
|
||||
raw_audio_frame_len_in_secs=self.RAW_AUDIO_FRAME_LEN_IN_SECS,
|
||||
buffer_size=buffer_size,
|
||||
)
|
||||
|
||||
def _configure_diarization(self):
|
||||
"""
|
||||
Configure diarization parameters.
|
||||
Currently only NeMo End-to-End Diarization is supported.
|
||||
"""
|
||||
self.DIAR_MODEL = self.server_config.diar.model
|
||||
self.USE_DIAR = self.server_config.diar.enabled
|
||||
self.diar_params = NeMoDiarInputParams(
|
||||
frame_len_in_secs=self.server_config.diar.frame_len_in_secs,
|
||||
threshold=self.server_config.diar.threshold,
|
||||
)
|
||||
|
||||
def _configure_turn_taking(self):
|
||||
"""Configure turn taking parameters."""
|
||||
self.TURN_TAKING_BACKCHANNEL_PHRASES_PATH = self.server_config.turn_taking.backchannel_phrases_path
|
||||
self.TURN_TAKING_MAX_BUFFER_SIZE = self.server_config.turn_taking.max_buffer_size
|
||||
self.TURN_TAKING_BOT_STOP_DELAY = self.server_config.turn_taking.bot_stop_delay
|
||||
|
||||
def _configure_llm(self):
|
||||
"""Configure LLM parameters."""
|
||||
llm_model_id = self.server_config.llm.model
|
||||
is_registry_model = False
|
||||
|
||||
# Try to get LLM config file name from server config first
|
||||
if self.server_config.llm.get("model_config", None) is not None:
|
||||
yaml_file_name = os.path.basename(self.server_config.llm.model_config)
|
||||
else:
|
||||
# Get LLM configuration from registry
|
||||
if llm_model_id in self.model_registry.llm_models:
|
||||
yaml_file_name = self.model_registry.llm_models[llm_model_id].yaml_id
|
||||
is_registry_model = True
|
||||
else:
|
||||
logger.warning(
|
||||
f"LLM model {llm_model_id} is not included in the model registry. "
|
||||
"Using a generic HuggingFace LLM config instead."
|
||||
)
|
||||
yaml_file_name = self.model_registry.llm_models[self._generic_hf_llm_model_id].yaml_id
|
||||
|
||||
# Load and merge LLM configuration
|
||||
llm_config_path = f"{os.path.abspath(self._server_base_path)}/server_configs/llm_configs/{yaml_file_name}"
|
||||
|
||||
if (
|
||||
is_registry_model
|
||||
and self.model_registry.llm_models[llm_model_id].get("reasoning_supported", False)
|
||||
and self.server_config.llm.get("enable_reasoning", False)
|
||||
):
|
||||
llm_config_path = llm_config_path.replace(".yaml", "_think.yaml")
|
||||
|
||||
if not os.path.exists(llm_config_path):
|
||||
raise FileNotFoundError(f"LLM config file not found at {llm_config_path}")
|
||||
logger.info(f"Loading LLM config from: {llm_config_path}")
|
||||
|
||||
llm_config = OmegaConf.load(llm_config_path)
|
||||
# merge llm config with server config
|
||||
# print the override keys
|
||||
for key in llm_config:
|
||||
if key in self.server_config.llm and self.server_config.llm[key] != llm_config[key]:
|
||||
logger.info(
|
||||
f"LLM config field `{key}` is overridden from `{self.server_config.llm[key]}` to "
|
||||
f"`{llm_config[key]}` by {llm_config_path}"
|
||||
)
|
||||
self.server_config.llm[key] = llm_config[key]
|
||||
|
||||
logger.info(f"Final LLM config: {self.server_config.llm}")
|
||||
|
||||
# Configure system prompt
|
||||
self.SYSTEM_ROLE = self.server_config.llm.get("system_role", "system")
|
||||
if self.server_config.llm.get("system_prompt", None) is not None:
|
||||
system_prompt = self.server_config.llm.system_prompt
|
||||
if os.path.isfile(system_prompt):
|
||||
with open(system_prompt, "r") as f:
|
||||
system_prompt = f.read()
|
||||
self.SYSTEM_PROMPT = system_prompt
|
||||
else:
|
||||
logger.info(f"No system prompt provided, using default system prompt: {self.SYSTEM_PROMPT}")
|
||||
|
||||
if self.server_config.llm.get("system_prompt_suffix", None) is not None:
|
||||
self.SYSTEM_PROMPT += "\n" + self.server_config.llm.system_prompt_suffix
|
||||
logger.info(f"Adding system prompt suffix: {self.server_config.llm.system_prompt_suffix}")
|
||||
|
||||
logger.info(f"System prompt: {self.SYSTEM_PROMPT}")
|
||||
|
||||
def _configure_tts(self):
|
||||
"""Configure TTS parameters."""
|
||||
tts_model_id = self.server_config.tts.model
|
||||
|
||||
# Try to get TTS config file name from server config first
|
||||
if self.server_config.tts.get("model_config", None) is not None:
|
||||
yaml_file_name = os.path.basename(self.server_config.tts.model_config)
|
||||
else:
|
||||
# Get TTS configuration from registry
|
||||
if tts_model_id in self.model_registry.tts_models:
|
||||
yaml_file_name = self.model_registry.tts_models[tts_model_id].yaml_id
|
||||
else:
|
||||
error_msg = f"TTS model {tts_model_id} is not in model registry: {self.model_registry.tts_models}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
tts_config_path = f"{os.path.abspath(self._server_base_path)}/server_configs/tts_configs/{yaml_file_name}"
|
||||
if not os.path.exists(tts_config_path):
|
||||
raise FileNotFoundError(f"Default TTS config file not found at {tts_config_path}")
|
||||
tts_config = OmegaConf.load(tts_config_path)
|
||||
|
||||
# merge tts config with server config
|
||||
for key in tts_config:
|
||||
if key in self.server_config.tts and self.server_config.tts[key] != tts_config[key]:
|
||||
logger.info(
|
||||
f"TTS config field `{key}` is overridden from `{self.server_config.tts[key]}` to "
|
||||
f"`{tts_config[key]}` by {tts_config_path}"
|
||||
)
|
||||
self.server_config.tts[key] = tts_config[key]
|
||||
|
||||
logger.info(f"Final TTS config: {self.server_config.tts}")
|
||||
|
||||
# Extract TTS parameters
|
||||
self.TTS_MAIN_MODEL_ID = self.server_config.tts.get("main_model_id", None)
|
||||
self.TTS_SUB_MODEL_ID = self.server_config.tts.get("sub_model_id", None)
|
||||
self.TTS_DEVICE = self.server_config.tts.get("device", None)
|
||||
|
||||
# Handle optional TTS parameters
|
||||
self.TTS_THINK_TOKENS = self.server_config.tts.get("think_tokens", None)
|
||||
if self.TTS_THINK_TOKENS is not None:
|
||||
self.TTS_THINK_TOKENS = OmegaConf.to_container(self.TTS_THINK_TOKENS)
|
||||
|
||||
self.TTS_EXTRA_SEPARATOR = self.server_config.tts.get("extra_separator", None)
|
||||
if self.TTS_EXTRA_SEPARATOR is not None:
|
||||
self.TTS_EXTRA_SEPARATOR = OmegaConf.to_container(self.TTS_EXTRA_SEPARATOR)
|
||||
|
||||
def get_server_config(self) -> OmegaConf:
|
||||
"""Get the complete server configuration."""
|
||||
return self.server_config
|
||||
|
||||
def get_model_registry(self) -> Dict[str, Any]:
|
||||
"""Get the model registry configuration."""
|
||||
return self.model_registry
|
||||
|
||||
def get_vad_params(self) -> VADParams:
|
||||
"""Get VAD parameters."""
|
||||
return self.vad_params
|
||||
|
||||
def get_stt_params(self) -> NeMoSTTInputParams:
|
||||
"""Get STT parameters."""
|
||||
return self.stt_params
|
||||
|
||||
def get_diar_params(self) -> NeMoDiarInputParams:
|
||||
"""Get diarization parameters."""
|
||||
return self.diar_params
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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 asyncio
|
||||
import python_weather
|
||||
from loguru import logger
|
||||
from pipecat.frames.frames import LLMTextFrame, TTSSpeakFrame
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
|
||||
HTTP_REQUEST_TIMEOUT = 10.0
|
||||
|
||||
|
||||
async def tool_get_city_weather(params: FunctionCallParams, city_name: str):
|
||||
"""Get the current weather of a city. The result includes city name, weather description,
|
||||
temperature, wind speed, wind direction, precipitation, humidity, visibility, and UV index.
|
||||
|
||||
Args:
|
||||
city_name: The name of the city to get the weather of. For example, "London", "Beijing", "Paris".
|
||||
Other examples are: "Paris, TX, US", "Paris, FR" and "Tokyo, JP".
|
||||
"""
|
||||
message = f"Looking up weather data for {city_name}. Please wait a moment..."
|
||||
# Send the message to upstream so that RTVI can log it while doesn't block the actual tool call.
|
||||
await params.llm.push_frame(LLMTextFrame(message), direction=FrameDirection.UPSTREAM)
|
||||
# Send the message to TTS directly so that the user can hear it immediately.
|
||||
await params.llm.push_frame(TTSSpeakFrame(message))
|
||||
|
||||
# The measuring unit defaults to metric (Celsius)
|
||||
# Use imperial for Fahrenheit: python_weather.IMPERIAL
|
||||
async with python_weather.Client(unit=python_weather.METRIC) as client:
|
||||
# Fetch a weather forecast from a city
|
||||
logger.debug(f"Fetching weather forecast for `{city_name}`")
|
||||
try:
|
||||
weather: python_weather.Forecast = await asyncio.wait_for(
|
||||
client.get(city_name),
|
||||
timeout=HTTP_REQUEST_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
error_msg = f"python_weather API request timed out after {HTTP_REQUEST_TIMEOUT} seconds for `{city_name}`"
|
||||
logger.error(error_msg)
|
||||
await params.result_callback({"error": error_msg})
|
||||
return
|
||||
except Exception as e:
|
||||
error_msg = f"Error fetching weather forecast for `{city_name}`: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
await params.result_callback({"error": error_msg})
|
||||
return
|
||||
|
||||
results = {
|
||||
"city": city_name,
|
||||
"description": str(weather.description),
|
||||
"temperature": f"{weather.temperature} degrees Celsius",
|
||||
"wind_speed": f"{weather.wind_speed} kilometers per hour",
|
||||
"wind_direction": str(weather.wind_direction.name),
|
||||
"precipitation": f"{weather.precipitation} millimeters",
|
||||
"humidity": f"{weather.humidity} percent",
|
||||
"visibility": f"{weather.visibility} kilometers",
|
||||
"uv_index": str(weather.ultraviolet),
|
||||
}
|
||||
logger.debug(f"Weather results for {city_name}: {results}")
|
||||
await params.result_callback(results)
|
||||
@@ -0,0 +1,106 @@
|
||||
# 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.
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.adapters.schemas.direct_function import DirectFunction
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
|
||||
|
||||
class ToolCallingMixin:
|
||||
"""
|
||||
A mixin class for tool calling.
|
||||
Subclasses must implement the `setup_tool_calling` method to register all available tools
|
||||
using `self.register_direct_function()`. Then the `__init__` method of the subclass should
|
||||
call the `setup_tool_calling` method to register the tools.
|
||||
"""
|
||||
|
||||
def setup_tool_calling(self):
|
||||
"""
|
||||
Setup the tool calling mixin by registering all available tools using self.register_direct_function().
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Subclasses must implement this method to register all available functions "
|
||||
"using self.register_direct_function()"
|
||||
)
|
||||
|
||||
def register_direct_function(self, function_name: str, function: DirectFunction):
|
||||
"""
|
||||
Register a direct function to be called by the LLM.
|
||||
|
||||
Args:
|
||||
function_name: The name of the function to register.
|
||||
function: The direct function to register.
|
||||
"""
|
||||
if not hasattr(self, "direct_functions"):
|
||||
self.direct_functions = {}
|
||||
logger.info(
|
||||
f"[{self.__class__.__name__}] Registering direct function name {function_name} to "
|
||||
f"{function.__module__ + '.' + function.__qualname__}"
|
||||
)
|
||||
self.direct_functions[function_name] = function
|
||||
|
||||
@property
|
||||
def available_tools(self) -> dict[str, DirectFunction]:
|
||||
"""
|
||||
Return a dictionary of available tools, where the key is the tool name and the value is the direct function.
|
||||
"""
|
||||
tools = {}
|
||||
if not hasattr(self, "direct_functions"):
|
||||
return tools
|
||||
for function_name, function in self.direct_functions.items():
|
||||
tools[function_name] = function
|
||||
return tools
|
||||
|
||||
|
||||
def register_direct_tools_to_llm(
|
||||
*,
|
||||
llm: OpenAILLMService,
|
||||
context: OpenAILLMContext,
|
||||
tool_mixins: list[ToolCallingMixin] = [],
|
||||
tools: list[DirectFunction] = [],
|
||||
cancel_on_interruption: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Register direct tools to the LLM.
|
||||
Args:
|
||||
llm: The LLM service to use.
|
||||
context: The LLM context to use.
|
||||
tools: The list of tools (instances of either `DirectFunction` or `ToolCallingMixin`) to use.
|
||||
"""
|
||||
all_tools = []
|
||||
for tool in tool_mixins:
|
||||
if not isinstance(tool, ToolCallingMixin):
|
||||
logger.warning(f"Tool {tool.__class__.__name__} is not a ToolCallingMixin, skipping.")
|
||||
continue
|
||||
for function_name, function in tool.available_tools.items():
|
||||
logger.info(f"Registering direct function {function_name} from {tool.__class__.__name__}")
|
||||
all_tools.append(function)
|
||||
|
||||
for tool in tools:
|
||||
logger.info(f"Registering direct function: {tool.__module__ + '.' + tool.__qualname__}")
|
||||
all_tools.append(tool)
|
||||
|
||||
if not all_tools:
|
||||
logger.warning("No direct tools provided.")
|
||||
return
|
||||
else:
|
||||
logger.info(f"Registering {len(all_tools)} direct tools to the LLM.")
|
||||
|
||||
tools_schema = ToolsSchema(standard_tools=all_tools)
|
||||
context.set_tools(tools_schema)
|
||||
|
||||
for tool in all_tools:
|
||||
llm.register_direct_function(tool, cancel_on_interruption=cancel_on_interruption)
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) 2020, 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.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Automatic Speech Recognition (ASR)
|
||||
|
||||
## Key Features
|
||||
|
||||
* [HuggingFace Space for Audio Transcription (File, Microphone and YouTube)](https://huggingface.co/spaces/smajumdar/nemo_multilingual_language_id)
|
||||
* [Pretrained models](https://ngc.nvidia.com/catalog/collections/nvidia:nemo_asr) available in 14+ languages
|
||||
* [Automatic Speech Recognition (ASR)](https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/main/asr/intro.html)
|
||||
* Supported ASR [models](https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/stable/asr/models.html):
|
||||
* Jasper, QuartzNet, CitriNet, ContextNet
|
||||
* Conformer-CTC, Conformer-Transducer, FastConformer-CTC, FastConformer-Transducer
|
||||
* Squeezeformer-CTC and Squeezeformer-Transducer
|
||||
* LSTM-Transducer (RNNT) and LSTM-CTC
|
||||
* Supports the following decoders/losses:
|
||||
* CTC
|
||||
* Transducer/RNNT
|
||||
* Hybrid Transducer/CTC
|
||||
* NeMo Original [Multi-blank Transducers](https://arxiv.org/abs/2211.03541) and [Token-and-Duration Transducers (TDT)](https://arxiv.org/abs/2304.06795)
|
||||
* Streaming/Buffered ASR (CTC/Transducer) - [Chunked Inference Examples](https://github.com/NVIDIA/NeMo/tree/stable/examples/asr/asr_chunked_inference)
|
||||
* [Cache-aware Streaming Conformer](https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/stable/asr/models.html#cache-aware-streaming-conformer) with multiple lookaheads (including microphone streaming [tutorial](https://github.com/NVIDIA/NeMo/blob/main/tutorials/asr/Online_ASR_Microphone_Demo_Cache_Aware_Streaming.ipynb).
|
||||
* Beam Search decoding
|
||||
* [Language Modelling for ASR (CTC and RNNT)](https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/main/asr/asr_language_modeling.html): N-gram LM in fusion with Beam Search decoding, Neural Rescoring with Transformer
|
||||
* [Support of long audios for Conformer with memory efficient local attention](https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/main/asr/results.html#inference-on-long-audio)
|
||||
* [Speech Classification, Speech Command Recognition and Language Identification](https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/main/asr/speech_classification/intro.html): MatchboxNet (Command Recognition), AmberNet (LangID)
|
||||
* [Voice activity Detection (VAD)](https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/stable/asr/speech_classification/models.html#marblenet-vad): MarbleNet
|
||||
* ASR with VAD Inference - [Example](https://github.com/NVIDIA/NeMo/tree/stable/examples/asr/asr_vad)
|
||||
* [Speaker Recognition](https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/main/asr/speaker_recognition/intro.html): TitaNet, ECAPA_TDNN, SpeakerNet
|
||||
* [Speaker Diarization](https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/main/asr/speaker_diarization/intro.html)
|
||||
* Clustering Diarizer: TitaNet, ECAPA_TDNN, SpeakerNet
|
||||
* Neural Diarizer: Sortformer
|
||||
* [Speech Intent Detection and Slot Filling](https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/main/asr/speech_intent_slot/intro.html): Conformer-Transformer
|
||||
|
||||
You can also get a high-level overview of NeMo ASR by watching the talk *NVIDIA NeMo: Toolkit for Conversational AI*, presented at PyData Yerevan 2022:
|
||||
|
||||
|
||||
[](https://www.youtube.com/embed/J-P6Sczmas8?mute=0&start=14&autoplay=0
|
||||
"NeMo presentation at PyData@Yerevan 2022")
|
||||
@@ -0,0 +1,25 @@
|
||||
# Copyright (c) 2020, 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.
|
||||
|
||||
from nemo.collections.asr import data, losses, models, modules
|
||||
from nemo.package_info import __version__
|
||||
|
||||
# Set collection version equal to NeMo version.
|
||||
__version = __version__
|
||||
|
||||
# Authorship.
|
||||
__author__ = "NVIDIA Corporation"
|
||||
|
||||
# Set collection name.
|
||||
__description__ = "Automatic Speech Recognition collection"
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) 2020, 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.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Copyright (c) 2022, 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 json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, List, Tuple
|
||||
|
||||
from nemo.collections.asr.data.audio_to_text_dataset import ASRPredictionWriter
|
||||
from nemo.utils import logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lightning.pytorch import LightningModule
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameCtmUnit:
|
||||
"""A container class for one CTM unit with start and length countable in frames."""
|
||||
|
||||
label: str
|
||||
start_frame: int
|
||||
length: int
|
||||
probability: float
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.label}\t({self.probability:1.3f}): [{self.start_frame:6d}, {self.length:6d}]"
|
||||
|
||||
@property
|
||||
def end_frame(self):
|
||||
return self.start_frame + self.length
|
||||
|
||||
def to_ctm_str(self, time_per_frame: int) -> str:
|
||||
"""Represents the data as part of the CTM line.
|
||||
|
||||
The CTM line format is
|
||||
<utterance_name> <channel> <start_time> <duration> <label_str> <probability>
|
||||
This method prepares the last four entities."""
|
||||
return f"{self.start_frame * time_per_frame :.3f} {self.length * time_per_frame :.3f} {self.label} {self.probability :1.3f}"
|
||||
|
||||
|
||||
class ASRCTMPredictionWriter(ASRPredictionWriter):
|
||||
def __init__(self, dataset, output_file: str, output_ctm_dir: str, time_per_frame: float):
|
||||
super().__init__(dataset, output_file)
|
||||
self.output_ctm_dir = output_ctm_dir
|
||||
self.time_per_frame = time_per_frame
|
||||
os.makedirs(self.output_ctm_dir, exist_ok=True)
|
||||
|
||||
def write_ctm(self, name, filepath, frameCtmUnits):
|
||||
with open(filepath, "tw", encoding="utf-8") as f:
|
||||
for unit in frameCtmUnits:
|
||||
f.write(f"{name} 1 {unit.to_ctm_str(self.time_per_frame)}\n")
|
||||
|
||||
def write_on_batch_end(
|
||||
self,
|
||||
trainer,
|
||||
pl_module: 'LightningModule',
|
||||
prediction: Tuple[int, List[FrameCtmUnit]],
|
||||
batch_indices: List[int],
|
||||
batch: Any,
|
||||
batch_idx: int,
|
||||
dataloader_idx: int,
|
||||
):
|
||||
for sample_id, units in prediction:
|
||||
sample = self.dataset.get_manifest_sample(sample_id)
|
||||
with_ctm = True
|
||||
if len(units) == 0:
|
||||
logging.warning(
|
||||
f"""Do not producing CTM output for item `{sample.audio_file}`.
|
||||
Check if text is empty or if duration is too short: `{sample.text_raw}`, {sample.duration}"""
|
||||
)
|
||||
with_ctm = False
|
||||
item = {}
|
||||
item["audio_filepath"] = sample.audio_file
|
||||
item["duration"] = sample.duration
|
||||
item["text"] = sample.text_raw
|
||||
if with_ctm:
|
||||
utt_name = Path(sample.audio_file).stem
|
||||
ctm_filepath = os.path.join(self.output_ctm_dir, utt_name) + ".ctm"
|
||||
self.write_ctm(utt_name, ctm_filepath, units)
|
||||
item["ctm_filepath"] = ctm_filepath
|
||||
else:
|
||||
item["ctm_filepath"] = ""
|
||||
self.outf.write(json.dumps(item) + "\n")
|
||||
self.samples_num += 1
|
||||
return
|
||||
@@ -0,0 +1,562 @@
|
||||
# Copyright (c) 2022, 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 os
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.parts.utils.speaker_utils import convert_rttm_line, get_subsegments
|
||||
from nemo.collections.common.parts.preprocessing.collections import EndtoEndDiarizationSpeechLabel
|
||||
from nemo.core.classes import Dataset
|
||||
from nemo.core.neural_types import AudioSignal, LengthsType, NeuralType, ProbsType
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
def get_subsegments_to_timestamps(
|
||||
subsegments: List[Tuple[float, float]], feat_per_sec: int = 100, max_end_ts: float = None, decimals=2
|
||||
):
|
||||
"""
|
||||
Convert subsegment timestamps to scale timestamps by multiplying with the feature rate (`feat_per_sec`)
|
||||
and rounding. Segment is consisted of many subsegments and sugsegments are equivalent to `frames`
|
||||
in end-to-end speaker diarization models.
|
||||
|
||||
Args:
|
||||
subsegments (List[Tuple[float, float]]):
|
||||
A list of tuples where each tuple contains the start and end times of a subsegment
|
||||
(frames in end-to-end models).
|
||||
>>> subsegments = [[t0_start, t0_duration], [t1_start, t1_duration],..., [tN_start, tN_duration]]
|
||||
feat_per_sec (int, optional):
|
||||
The number of feature frames per second. Defaults to 100.
|
||||
max_end_ts (float, optional):
|
||||
The maximum end timestamp to clip the results. If None, no clipping is applied. Defaults to None.
|
||||
decimals (int, optional):
|
||||
The number of decimal places to round the timestamps. Defaults to 2.
|
||||
|
||||
Example:
|
||||
Segments starting from 0.0 and ending at 69.2 seconds.
|
||||
If hop-length is 0.08 and the subsegment (frame) length is 0.16 seconds,
|
||||
there are 864 = (69.2 - 0.16)/0.08 + 1 subsegments (frames in end-to-end models) in this segment.
|
||||
>>> subsegments = [[[0.0, 0.16], [0.08, 0.16], ..., [69.04, 0.16], [69.12, 0.08]]
|
||||
|
||||
Returns:
|
||||
ts (torch.tensor):
|
||||
A tensor containing the scaled and rounded timestamps for each subsegment.
|
||||
"""
|
||||
seg_ts = (torch.tensor(subsegments) * feat_per_sec).float()
|
||||
ts_round = torch.round(seg_ts, decimals=decimals)
|
||||
ts = ts_round.long()
|
||||
ts[:, 1] = ts[:, 0] + ts[:, 1]
|
||||
if max_end_ts is not None:
|
||||
ts = np.clip(ts, 0, int(max_end_ts * feat_per_sec))
|
||||
return ts
|
||||
|
||||
|
||||
def extract_frame_info_from_rttm(offset, duration, rttm_lines, round_digits=3):
|
||||
"""
|
||||
Extracts RTTM lines containing speaker labels, start time, and end time for a given audio segment.
|
||||
|
||||
Args:
|
||||
uniq_id (str): Unique identifier for the audio file and corresponding RTTM file.
|
||||
offset (float): The starting time offset for the segment of interest.
|
||||
duration (float): The duration of the segment of interest.
|
||||
rttm_lines (list): List of RTTM lines in string format.
|
||||
round_digits (int, optional): Number of decimal places to round the start and end times. Defaults to 3.
|
||||
|
||||
Returns:
|
||||
rttm_mat (tuple): A tuple containing lists of start times, end times, and speaker labels.
|
||||
sess_to_global_spkids (dict): A mapping from session-specific speaker indices to global speaker identifiers.
|
||||
"""
|
||||
rttm_stt, rttm_end = offset, offset + duration
|
||||
stt_list, end_list, speaker_list, speaker_set = [], [], [], []
|
||||
sess_to_global_spkids = dict()
|
||||
|
||||
for rttm_line in rttm_lines:
|
||||
start, end, speaker = convert_rttm_line(rttm_line)
|
||||
|
||||
# Skip invalid RTTM lines where the start time is greater than the end time.
|
||||
if start > end:
|
||||
continue
|
||||
|
||||
# Check if the RTTM segment overlaps with the specified segment of interest.
|
||||
if (end > rttm_stt and start < rttm_end) or (start < rttm_end and end > rttm_stt):
|
||||
# Adjust the start and end times to fit within the segment of interest.
|
||||
start, end = max(start, rttm_stt), min(end, rttm_end)
|
||||
else:
|
||||
continue
|
||||
|
||||
# Round the start and end times to the specified number of decimal places.
|
||||
end_list.append(round(end, round_digits))
|
||||
stt_list.append(round(start, round_digits))
|
||||
|
||||
# Assign a unique index to each speaker and maintain a mapping.
|
||||
if speaker not in speaker_set:
|
||||
speaker_set.append(speaker)
|
||||
speaker_list.append(speaker_set.index(speaker))
|
||||
sess_to_global_spkids.update({speaker_set.index(speaker): speaker})
|
||||
|
||||
rttm_mat = (stt_list, end_list, speaker_list)
|
||||
return rttm_mat, sess_to_global_spkids
|
||||
|
||||
|
||||
def get_frame_targets_from_rttm(
|
||||
rttm_timestamps: list,
|
||||
offset: float,
|
||||
duration: float,
|
||||
round_digits: int,
|
||||
feat_per_sec: int,
|
||||
max_spks: int,
|
||||
):
|
||||
"""
|
||||
Create a multi-dimensional vector sequence containing speaker timestamp information in RTTM.
|
||||
The unit-length is the frame shift length of the acoustic feature. The feature-level annotations
|
||||
`feat_level_target` will later be converted to base-segment level diarization label.
|
||||
|
||||
Args:
|
||||
rttm_timestamps (list):
|
||||
List containing start and end time for each speaker segment label.
|
||||
stt_list, end_list and speaker_list are contained.
|
||||
feat_per_sec (int):
|
||||
Number of feature frames per second.
|
||||
This quantity is determined by window_stride variable in preprocessing module.
|
||||
target_spks (tuple):
|
||||
Speaker indices that are generated from combinations. If there are only one or two speakers,
|
||||
only a single target_spks variable is generated.
|
||||
|
||||
Returns:
|
||||
feat_level_target (torch.tensor):
|
||||
Tensor containing label for each feature level frame.
|
||||
"""
|
||||
stt_list, end_list, speaker_list = rttm_timestamps
|
||||
sorted_speakers = sorted(list(set(speaker_list)))
|
||||
total_fr_len = int(duration * feat_per_sec)
|
||||
if len(sorted_speakers) > max_spks:
|
||||
logging.warning(
|
||||
f"Number of speakers in RTTM file {len(sorted_speakers)} exceeds the maximum number of speakers: "
|
||||
f"{max_spks}! Only {max_spks} first speakers remain, and this will affect frame metrics!"
|
||||
)
|
||||
feat_level_target = torch.zeros(total_fr_len, max_spks)
|
||||
for count, (stt, end, spk_rttm_key) in enumerate(zip(stt_list, end_list, speaker_list)):
|
||||
if end < offset or stt > offset + duration:
|
||||
continue
|
||||
stt, end = max(offset, stt), min(offset + duration, end)
|
||||
spk = spk_rttm_key
|
||||
if spk < max_spks:
|
||||
stt_fr, end_fr = int((stt - offset) * feat_per_sec), int((end - offset) * feat_per_sec)
|
||||
feat_level_target[stt_fr:end_fr, spk] = 1
|
||||
return feat_level_target
|
||||
|
||||
|
||||
class _AudioToSpeechE2ESpkDiarDataset(Dataset):
|
||||
"""
|
||||
Dataset class that loads a json file containing paths to audio files,
|
||||
RTTM files and number of speakers. This Dataset class is designed for
|
||||
training or fine-tuning speaker embedding extractor and diarization decoder
|
||||
at the same time.
|
||||
|
||||
Example:
|
||||
{"audio_filepath": "/path/to/audio_0.wav", "num_speakers": 2,
|
||||
"rttm_filepath": "/path/to/diar_label_0.rttm}
|
||||
...
|
||||
{"audio_filepath": "/path/to/audio_n.wav", "num_speakers": 2,
|
||||
"rttm_filepath": "/path/to/diar_label_n.rttm}
|
||||
|
||||
Args:
|
||||
manifest_filepath (str):
|
||||
Path to input manifest json files.
|
||||
multiargs_dict (dict):
|
||||
Dictionary containing the parameters for multiscale segmentation and clustering.
|
||||
soft_label_thres (float):
|
||||
Threshold that determines the label of each segment based on RTTM file information.
|
||||
featurizer:
|
||||
Featurizer instance for generating audio_signal from the raw waveform.
|
||||
window_stride (float):
|
||||
Window stride for acoustic feature. This value is used for calculating the numbers of feature-level frames.
|
||||
"""
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
"""Returns definitions of module output ports."""
|
||||
output_types = {
|
||||
"audio_signal": NeuralType(('B', 'T'), AudioSignal()),
|
||||
"audio_length": NeuralType(('B'), LengthsType()),
|
||||
"targets": NeuralType(('B', 'T', 'C'), ProbsType()),
|
||||
"target_len": NeuralType(('B'), LengthsType()),
|
||||
}
|
||||
|
||||
return output_types
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
manifest_filepath: str,
|
||||
soft_label_thres: float,
|
||||
session_len_sec: float,
|
||||
num_spks: int,
|
||||
featurizer,
|
||||
fb_featurizer,
|
||||
window_stride: float,
|
||||
min_subsegment_duration: float = 0.03,
|
||||
global_rank: int = 0,
|
||||
dtype=torch.float16,
|
||||
round_digits: int = 2,
|
||||
soft_targets: bool = False,
|
||||
subsampling_factor: int = 8,
|
||||
device: str = 'cpu',
|
||||
):
|
||||
super().__init__()
|
||||
self.collection = EndtoEndDiarizationSpeechLabel(
|
||||
manifests_files=manifest_filepath.split(','),
|
||||
round_digits=round_digits,
|
||||
)
|
||||
self.featurizer = featurizer
|
||||
self.fb_featurizer = fb_featurizer
|
||||
# STFT and subsampling factor parameters
|
||||
self.n_fft = self.fb_featurizer.n_fft
|
||||
self.hop_length = self.fb_featurizer.hop_length
|
||||
self.stft_pad_amount = self.fb_featurizer.stft_pad_amount
|
||||
self.subsampling_factor = subsampling_factor
|
||||
# Annotation and target length parameters
|
||||
self.round_digits = round_digits
|
||||
self.feat_per_sec = int(1 / window_stride)
|
||||
self.diar_frame_length = round(subsampling_factor * window_stride, round_digits)
|
||||
self.session_len_sec = session_len_sec
|
||||
self.soft_label_thres = soft_label_thres
|
||||
self.max_spks = num_spks
|
||||
self.min_subsegment_duration = min_subsegment_duration
|
||||
self.dtype = dtype
|
||||
self.use_asr_style_frame_count = True
|
||||
self.soft_targets = soft_targets
|
||||
self.round_digits = 2
|
||||
self.floor_decimal = 10**self.round_digits
|
||||
self.device = device
|
||||
self.global_rank = global_rank
|
||||
|
||||
def __len__(self):
|
||||
return len(self.collection)
|
||||
|
||||
def get_frame_count_from_time_series_length(self, seq_len):
|
||||
"""
|
||||
This function is used to get the sequence length of the audio signal. This is required to match
|
||||
the feature frame length with ASR (STT) models. This function is copied from
|
||||
NeMo/nemo/collections/asr/parts/preprocessing/features.py::FilterbankFeatures::get_seq_len.
|
||||
|
||||
Args:
|
||||
seq_len (int):
|
||||
The sequence length of the time-series data.
|
||||
|
||||
Returns:
|
||||
seq_len (int):
|
||||
The sequence length of the feature frames.
|
||||
"""
|
||||
pad_amount = self.stft_pad_amount * 2 if self.stft_pad_amount is not None else self.n_fft // 2 * 2
|
||||
seq_len = torch.floor_divide((seq_len + pad_amount - self.n_fft), self.hop_length).to(dtype=torch.long)
|
||||
frame_count = int(np.ceil(seq_len / self.subsampling_factor))
|
||||
return frame_count
|
||||
|
||||
def get_uniq_id_with_range(self, sample, deci=3):
|
||||
"""
|
||||
Generate unique training sample ID from unique file ID, offset and duration. The start-end time added
|
||||
unique ID is required for identifying the sample since multiple short audio samples are generated from a single
|
||||
audio file. The start time and end time of the audio stream uses millisecond units if `deci=3`.
|
||||
|
||||
Args:
|
||||
sample:
|
||||
`EndtoEndDiarizationSpeechLabel` instance from collections.
|
||||
|
||||
Returns:
|
||||
uniq_id (str):
|
||||
Unique sample ID which includes start and end time of the audio stream.
|
||||
Example: abc1001_3122_6458
|
||||
"""
|
||||
bare_uniq_id = os.path.splitext(os.path.basename(sample.rttm_file))[0]
|
||||
offset = str(int(round(sample.offset, deci) * pow(10, deci)))
|
||||
endtime = str(int(round(sample.offset + sample.duration, deci) * pow(10, deci)))
|
||||
uniq_id = f"{bare_uniq_id}_{offset}_{endtime}"
|
||||
return uniq_id
|
||||
|
||||
def parse_rttm_for_targets_and_lens(self, rttm_file, offset, duration, target_len):
|
||||
"""
|
||||
Generate target tensor variable by extracting groundtruth diarization labels from an RTTM file.
|
||||
This function converts (start, end, speaker_id) format into base-scale (the finest scale) segment level
|
||||
diarization label in a matrix form.
|
||||
|
||||
Example of seg_target:
|
||||
[[0., 1.], [0., 1.], [1., 1.], [1., 0.], [1., 0.], ..., [0., 1.]]
|
||||
"""
|
||||
if rttm_file in [None, '']:
|
||||
num_seg = torch.max(target_len)
|
||||
targets = torch.zeros(num_seg, self.max_spks)
|
||||
return targets
|
||||
|
||||
with open(rttm_file, 'r') as f:
|
||||
rttm_lines = f.readlines()
|
||||
|
||||
rttm_timestamps, sess_to_global_spkids = extract_frame_info_from_rttm(offset, duration, rttm_lines)
|
||||
|
||||
fr_level_target = get_frame_targets_from_rttm(
|
||||
rttm_timestamps=rttm_timestamps,
|
||||
offset=offset,
|
||||
duration=duration,
|
||||
round_digits=self.round_digits,
|
||||
feat_per_sec=self.feat_per_sec,
|
||||
max_spks=self.max_spks,
|
||||
)
|
||||
|
||||
soft_target_seg = self.get_soft_targets_seg(feat_level_target=fr_level_target, target_len=target_len)
|
||||
if self.soft_targets:
|
||||
step_target = soft_target_seg
|
||||
else:
|
||||
step_target = (soft_target_seg >= self.soft_label_thres).float()
|
||||
return step_target
|
||||
|
||||
def get_soft_targets_seg(self, feat_level_target, target_len):
|
||||
"""
|
||||
Generate the final targets for the actual diarization step.
|
||||
Here, frame level means step level which is also referred to as segments.
|
||||
We follow the original paper and refer to the step level as "frames".
|
||||
|
||||
Args:
|
||||
feat_level_target (torch.tensor):
|
||||
Tensor variable containing hard-labels of speaker activity in each feature-level segment.
|
||||
target_len (torch.tensor):
|
||||
Numbers of ms segments
|
||||
|
||||
Returns:
|
||||
soft_target_seg (torch.tensor):
|
||||
Tensor variable containing soft-labels of speaker activity in each step-level segment.
|
||||
"""
|
||||
num_seg = torch.max(target_len)
|
||||
targets = torch.zeros(num_seg, self.max_spks)
|
||||
stride = int(self.feat_per_sec * self.diar_frame_length)
|
||||
for index in range(num_seg):
|
||||
if index == 0:
|
||||
seg_stt_feat = 0
|
||||
else:
|
||||
seg_stt_feat = stride * index - 1 - int(stride / 2)
|
||||
if index == num_seg - 1:
|
||||
seg_end_feat = feat_level_target.shape[0]
|
||||
else:
|
||||
seg_end_feat = stride * index - 1 + int(stride / 2)
|
||||
targets[index] = torch.mean(feat_level_target[seg_stt_feat : seg_end_feat + 1, :], axis=0)
|
||||
return targets
|
||||
|
||||
def get_segment_timestamps(
|
||||
self,
|
||||
duration: float,
|
||||
offset: float = 0,
|
||||
sample_rate: int = 16000,
|
||||
):
|
||||
"""
|
||||
Get start and end time of segments in each scale.
|
||||
|
||||
Args:
|
||||
sample:
|
||||
`EndtoEndDiarizationSpeechLabel` instance from preprocessing.collections
|
||||
Returns:
|
||||
segment_timestamps (torch.tensor):
|
||||
Tensor containing Multiscale segment timestamps.
|
||||
target_len (torch.tensor):
|
||||
Number of segments for each scale. This information is used for reshaping embedding batch
|
||||
during forward propagation.
|
||||
"""
|
||||
subsegments = get_subsegments(
|
||||
offset=offset,
|
||||
window=round(self.diar_frame_length * 2, self.round_digits),
|
||||
shift=self.diar_frame_length,
|
||||
duration=duration,
|
||||
min_subsegment_duration=self.min_subsegment_duration,
|
||||
use_asr_style_frame_count=self.use_asr_style_frame_count,
|
||||
sample_rate=sample_rate,
|
||||
feat_per_sec=self.feat_per_sec,
|
||||
)
|
||||
if self.use_asr_style_frame_count:
|
||||
effective_dur = (
|
||||
np.ceil((1 + duration * sample_rate) / int(sample_rate / self.feat_per_sec)).astype(int)
|
||||
/ self.feat_per_sec
|
||||
)
|
||||
else:
|
||||
effective_dur = duration
|
||||
ts_tensor = get_subsegments_to_timestamps(
|
||||
subsegments, self.feat_per_sec, decimals=2, max_end_ts=(offset + effective_dur)
|
||||
)
|
||||
target_len = torch.tensor([ts_tensor.shape[0]])
|
||||
return target_len
|
||||
|
||||
def __getitem__(self, index):
|
||||
sample = self.collection[index]
|
||||
if sample.offset is None:
|
||||
sample.offset = 0
|
||||
offset = sample.offset
|
||||
if self.session_len_sec < 0:
|
||||
session_len_sec = sample.duration
|
||||
else:
|
||||
session_len_sec = min(sample.duration, self.session_len_sec)
|
||||
|
||||
audio_signal = self.featurizer.process(sample.audio_file, offset=offset, duration=session_len_sec)
|
||||
|
||||
# We should resolve the length mis-match from the round-off errors between these two variables:
|
||||
# `session_len_sec` and `audio_signal.shape[0]`
|
||||
session_len_sec = (
|
||||
np.floor(audio_signal.shape[0] / self.featurizer.sample_rate * self.floor_decimal) / self.floor_decimal
|
||||
)
|
||||
audio_signal = audio_signal[: round(self.featurizer.sample_rate * session_len_sec)]
|
||||
audio_signal_length = torch.tensor(audio_signal.shape[0]).long()
|
||||
|
||||
# Target length should be following the ASR feature extraction convention: Use self.get_frame_count_from_time_series_length.
|
||||
target_len = self.get_segment_timestamps(duration=session_len_sec, sample_rate=self.featurizer.sample_rate)
|
||||
target_len = torch.clamp(target_len, max=self.get_frame_count_from_time_series_length(audio_signal.shape[0]))
|
||||
|
||||
targets = self.parse_rttm_for_targets_and_lens(
|
||||
rttm_file=sample.rttm_file, offset=offset, duration=session_len_sec, target_len=target_len
|
||||
)
|
||||
targets = targets[:target_len, :]
|
||||
return audio_signal, audio_signal_length, targets, target_len
|
||||
|
||||
|
||||
def _eesd_train_collate_fn(self, batch):
|
||||
"""
|
||||
Collate a batch of variables needed for training the end-to-end speaker diarization (EESD) model
|
||||
from raw waveforms to diarization labels. The following variables are included in the training/validation batch:
|
||||
|
||||
Args:
|
||||
batch (tuple):
|
||||
A tuple containing the variables for diarization training.
|
||||
|
||||
Returns:
|
||||
audio_signal (torch.Tensor):
|
||||
A tensor containing the raw waveform samples (time series) loaded from the `audio_filepath`
|
||||
in the input manifest file.
|
||||
feature_length (torch.Tensor):
|
||||
A tensor containing the lengths of the raw waveform samples.
|
||||
targets (torch.Tensor):
|
||||
Groundtruth speaker labels for the given input embedding sequence.
|
||||
target_lens (torch.Tensor):
|
||||
A tensor containing the number of segments for each sample in the batch, necessary for
|
||||
reshaping inputs to the EESD model.
|
||||
"""
|
||||
packed_batch = list(zip(*batch))
|
||||
audio_signal, feature_length, targets, target_len = packed_batch
|
||||
audio_signal_list, feature_length_list = [], []
|
||||
target_len_list, targets_list = [], []
|
||||
|
||||
max_raw_feat_len = max([x.shape[0] for x in audio_signal])
|
||||
max_target_len = max([x.shape[0] for x in targets])
|
||||
if max([len(feat.shape) for feat in audio_signal]) > 1:
|
||||
max_ch = max([feat.shape[1] for feat in audio_signal])
|
||||
else:
|
||||
max_ch = 1
|
||||
for feat, feat_len, tgt, segment_ct in batch:
|
||||
seq_len = tgt.shape[0]
|
||||
if len(feat.shape) > 1:
|
||||
pad_feat = (0, 0, 0, max_raw_feat_len - feat.shape[0])
|
||||
else:
|
||||
pad_feat = (0, max_raw_feat_len - feat.shape[0])
|
||||
if feat.shape[0] < feat_len:
|
||||
feat_len_pad = feat_len - feat.shape[0]
|
||||
feat = torch.nn.functional.pad(feat, (0, feat_len_pad))
|
||||
pad_tgt = (0, 0, 0, max_target_len - seq_len)
|
||||
padded_feat = torch.nn.functional.pad(feat, pad_feat)
|
||||
padded_tgt = torch.nn.functional.pad(tgt, pad_tgt)
|
||||
if max_ch > 1 and padded_feat.shape[1] < max_ch:
|
||||
feat_ch_pad = max_ch - padded_feat.shape[1]
|
||||
padded_feat = torch.nn.functional.pad(padded_feat, (0, feat_ch_pad))
|
||||
audio_signal_list.append(padded_feat)
|
||||
feature_length_list.append(feat_len.clone().detach())
|
||||
target_len_list.append(segment_ct.clone().detach())
|
||||
targets_list.append(padded_tgt)
|
||||
audio_signal = torch.stack(audio_signal_list)
|
||||
feature_length = torch.stack(feature_length_list)
|
||||
target_lens = torch.stack(target_len_list).squeeze(1)
|
||||
targets = torch.stack(targets_list)
|
||||
return audio_signal, feature_length, targets, target_lens
|
||||
|
||||
|
||||
class AudioToSpeechE2ESpkDiarDataset(_AudioToSpeechE2ESpkDiarDataset):
|
||||
"""
|
||||
Dataset class for loading a JSON file containing paths to audio files,
|
||||
RTTM (Rich Transcription Time Marked) files, and the number of speakers.
|
||||
This class is designed for training or fine-tuning a speaker embedding
|
||||
extractor and diarization decoder simultaneously.
|
||||
|
||||
The JSON manifest file should have entries in the following format:
|
||||
|
||||
Example:
|
||||
{
|
||||
"audio_filepath": "/path/to/audio_0.wav",
|
||||
"num_speakers": 2,
|
||||
"rttm_filepath": "/path/to/diar_label_0.rttm"
|
||||
}
|
||||
...
|
||||
{
|
||||
"audio_filepath": "/path/to/audio_n.wav",
|
||||
"num_speakers": 2,
|
||||
"rttm_filepath": "/path/to/diar_label_n.rttm"
|
||||
}
|
||||
|
||||
Args:
|
||||
manifest_filepath (str):
|
||||
Path to the input manifest JSON file containing paths to audio and RTTM files.
|
||||
soft_label_thres (float):
|
||||
Threshold for assigning soft labels to segments based on RTTM file information.
|
||||
session_len_sec (float):
|
||||
Duration of each session (in seconds) for training or fine-tuning.
|
||||
num_spks (int):
|
||||
Number of speakers in the audio files.
|
||||
featurizer:
|
||||
Instance of a featurizer for generating features from the raw waveform.
|
||||
window_stride (float):
|
||||
Window stride (in seconds) for extracting acoustic features, used to calculate
|
||||
the number of feature frames.
|
||||
global_rank (int):
|
||||
Global rank of the current process (used for distributed training).
|
||||
soft_targets (bool):
|
||||
Whether or not to use soft targets during training.
|
||||
|
||||
Methods:
|
||||
eesd_train_collate_fn(batch):
|
||||
Collates a batch of data for end-to-end speaker diarization training.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
manifest_filepath: str,
|
||||
soft_label_thres: float,
|
||||
session_len_sec: float,
|
||||
num_spks: int,
|
||||
featurizer,
|
||||
fb_featurizer,
|
||||
window_stride,
|
||||
global_rank: int,
|
||||
soft_targets: bool,
|
||||
device: str,
|
||||
):
|
||||
super().__init__(
|
||||
manifest_filepath=manifest_filepath,
|
||||
soft_label_thres=soft_label_thres,
|
||||
session_len_sec=session_len_sec,
|
||||
num_spks=num_spks,
|
||||
featurizer=featurizer,
|
||||
fb_featurizer=fb_featurizer,
|
||||
window_stride=window_stride,
|
||||
global_rank=global_rank,
|
||||
soft_targets=soft_targets,
|
||||
device=device,
|
||||
)
|
||||
|
||||
def eesd_train_collate_fn(self, batch):
|
||||
"""Collate a batch of data for end-to-end speaker diarization training."""
|
||||
return _eesd_train_collate_fn(self, batch)
|
||||
@@ -0,0 +1,114 @@
|
||||
# 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.
|
||||
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import torch.utils.data
|
||||
from lhotse.dataset import AudioSamples
|
||||
from lhotse.dataset.collation import collate_matrices
|
||||
|
||||
from nemo.collections.asr.parts.utils.asr_multispeaker_utils import (
|
||||
get_hidden_length_from_sample_length,
|
||||
speaker_to_target,
|
||||
)
|
||||
from nemo.core.neural_types import AudioSignal, LabelsType, LengthsType, NeuralType
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
class LhotseAudioToSpeechE2ESpkDiarDataset(torch.utils.data.Dataset):
|
||||
"""
|
||||
This dataset is a Lhotse version of diarization dataset in audio_to_diar_label.py.
|
||||
Unlike native NeMo datasets, Lhotse dataset defines only the mapping from
|
||||
a CutSet (meta-data) to a mini-batch with PyTorch tensors.
|
||||
Specifically, it performs tokenization, I/O, augmentation, and feature extraction (if any).
|
||||
Managing data, sampling, de-duplication across workers/nodes etc. is all handled
|
||||
by Lhotse samplers instead.
|
||||
"""
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
"""Define the output types of the dataset."""
|
||||
return {
|
||||
'audio_signal': NeuralType(('B', 'T'), AudioSignal()),
|
||||
'a_sig_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'targets': NeuralType(('B', 'T', 'N'), LabelsType()),
|
||||
'target_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'sample_id': NeuralType(tuple('B'), LengthsType(), optional=True),
|
||||
}
|
||||
|
||||
def __init__(self, cfg):
|
||||
super().__init__()
|
||||
self.load_audio = AudioSamples(fault_tolerant=True)
|
||||
self.cfg = cfg
|
||||
self.num_speakers = self.cfg.get('num_speakers', 4)
|
||||
self.num_sample_per_mel_frame = int(
|
||||
self.cfg.get('window_stride', 0.01) * self.cfg.get('sample_rate', 16000)
|
||||
) # 160 samples for every 1ms by default
|
||||
self.num_mel_frame_per_target_frame = int(self.cfg.get('subsampling_factor', 8))
|
||||
|
||||
def __getitem__(self, cuts) -> Tuple[torch.Tensor, ...]:
|
||||
# NOTE: This end-to-end diarization dataloader only loads the 1st ch of the audio file.
|
||||
# Process cuts in a single loop: convert to mono and compute speaker activities
|
||||
mono_cuts = []
|
||||
speaker_activities = []
|
||||
for cut in cuts:
|
||||
if cut.num_channels is not None and cut.num_channels > 1:
|
||||
logging.warning(
|
||||
"Multiple channels detected in cut '%s' (%d channels). "
|
||||
"Only the first channel will be used; remaining channels are ignored.",
|
||||
cut.id,
|
||||
cut.num_channels,
|
||||
)
|
||||
mono_cut = cut.with_channels(channels=[0])
|
||||
mono_cuts.append(mono_cut)
|
||||
|
||||
speaker_activity = speaker_to_target(
|
||||
a_cut=mono_cut,
|
||||
num_speakers=self.num_speakers,
|
||||
num_sample_per_mel_frame=self.num_sample_per_mel_frame,
|
||||
num_mel_frame_per_asr_frame=self.num_mel_frame_per_target_frame,
|
||||
boundary_segments=True,
|
||||
)
|
||||
# This line prevents dimension mismatch error in the collate_matrices function.
|
||||
if speaker_activity.shape[1] > self.num_speakers:
|
||||
logging.warning(
|
||||
"Number of speakers in the target %s is greater than "
|
||||
"the maximum number of speakers %s. Truncating extra speakers. "
|
||||
"Set the `num_speakers` to higher value to avoid this warning.",
|
||||
speaker_activity.shape[1],
|
||||
self.num_speakers,
|
||||
)
|
||||
speaker_activity = speaker_activity[:, : self.num_speakers]
|
||||
speaker_activities.append(speaker_activity)
|
||||
|
||||
cuts = type(cuts).from_cuts(mono_cuts)
|
||||
audio, audio_lens, cuts = self.load_audio(cuts)
|
||||
targets = collate_matrices(speaker_activities).to(audio.dtype) # (B, T, N)
|
||||
|
||||
if targets.shape[2] > self.num_speakers:
|
||||
targets = targets[:, :, : self.num_speakers]
|
||||
elif targets.shape[2] < self.num_speakers:
|
||||
targets = torch.nn.functional.pad(
|
||||
targets, (0, self.num_speakers - targets.shape[2]), mode='constant', value=0
|
||||
)
|
||||
|
||||
target_lens_list = []
|
||||
for audio_len in audio_lens:
|
||||
target_fr_len = get_hidden_length_from_sample_length(
|
||||
audio_len, self.num_sample_per_mel_frame, self.num_mel_frame_per_target_frame
|
||||
)
|
||||
target_lens_list.append(target_fr_len)
|
||||
target_lens = torch.tensor(target_lens_list)
|
||||
|
||||
return audio, audio_lens, targets, target_lens
|
||||
@@ -0,0 +1,524 @@
|
||||
# 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 math
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import torch.utils.data
|
||||
from lhotse.cut import Cut, CutSet, MixedCut
|
||||
from lhotse.dataset import AudioSamples
|
||||
from lhotse.dataset.collation import collate_vectors
|
||||
from omegaconf import DictConfig, OmegaConf
|
||||
|
||||
from nemo.collections.asr.parts.preprocessing.perturb import process_augmentations
|
||||
from nemo.collections.asr.parts.preprocessing.segment import AudioSegment
|
||||
from nemo.collections.common.tokenizers.aggregate_tokenizer import TokenizerWrapper
|
||||
from nemo.collections.common.tokenizers.tokenizer_spec import TokenizerSpec
|
||||
from nemo.core.neural_types import AudioSignal, LabelsType, LengthsType, NeuralType
|
||||
from nemo.utils import logging
|
||||
|
||||
NON_SPEECH_LABEL = 0
|
||||
SPEECH_LABEL = 1
|
||||
EOU_LABEL = 2
|
||||
EOB_LABEL = 3
|
||||
EOU_STRING = '<EOU>'
|
||||
EOB_STRING = '<EOB>'
|
||||
|
||||
# These augmentations are not supported yet, since they will need to change the SOU/EOU timestamps
|
||||
EOU_INVALID_AUGMENTATIONS = ['random_segment', 'speed', 'time_stretch']
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioToTextEOUBatch:
|
||||
"""
|
||||
Data class for ASR-EOU batch.
|
||||
"""
|
||||
|
||||
sample_ids: List | None = None
|
||||
audio_filepaths: List | None = None
|
||||
audio_signal: torch.Tensor | None = None
|
||||
audio_lengths: torch.Tensor | None = None
|
||||
text_tokens: torch.Tensor | None = None
|
||||
text_token_lengths: torch.Tensor | None = None
|
||||
eou_targets: torch.Tensor | None = None
|
||||
eou_target_lengths: torch.Tensor | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RandomPaddingConfig:
|
||||
prob: float = 0.9 # probability of applying padding
|
||||
min_pad_duration: float = 0.0 # minimum duration of pre/post padding in seconds
|
||||
max_pad_duration: float = 5.0 # maximum duration of pre/post padding in seconds
|
||||
max_total_duration: float = 40.0 # maximum total duration of the padded audio in seconds
|
||||
min_pre_pad_duration: float = 0.0 # minimum duration of pre-padding in seconds
|
||||
min_post_pad_duration: float = 2.0 # minimum duration of post-padding in seconds
|
||||
pad_distribution: str = 'uniform' # distribution of padding duration, 'uniform' or 'normal' or 'constant'
|
||||
normal_mean: float = 0.5 # mean of normal distribution for padding duration
|
||||
normal_std: float = 2.0 # standard deviation of normal distribution for padding duration
|
||||
pre_pad_duration: float = 0.2 # amount of left-padding when pad_distribution='constant'
|
||||
post_pad_duration: float = 3.0 # amount of right-padding when pad_distribution='constant'
|
||||
|
||||
|
||||
class LhotseSpeechToTextBpeEOUDataset(torch.utils.data.Dataset):
|
||||
"""
|
||||
This dataset processes the audio data and the corresponding text data to generate the ASR labels,
|
||||
along with EOU labels for each frame. The audios used in this dataset should only contain speech with
|
||||
NO precedding or following silence. The dataset also randomly pads non-speech frames before and after
|
||||
the audio signal for training EOU prediction task.
|
||||
|
||||
To generate EOU labels, the last frame of utterance will be marked as "end of utterance" (labeled as `2`),
|
||||
while if it's a backchannel utterance it'll be marked asd "end of backchannel" (labeled as `3`).
|
||||
The rest of the speech frames will be marked as "speech" (labeled as `1`).
|
||||
The padded non-speech signals will be marked as "non-speech" (labeled as 0).
|
||||
|
||||
Args:
|
||||
cfg: DictConfig object container following keys, usually taken from your `model.train_ds`
|
||||
or `model.validation_ds` config:
|
||||
```
|
||||
sample_rate: # int, Sample rate of the audio signal
|
||||
window_stride: # float, Window stride for audio encoder
|
||||
subsampling_factor: # Subsampling factor for audio encoder
|
||||
random_padding: # Random padding configuration
|
||||
prob: 0.9 # probability of applying padding
|
||||
min_pad_duration: 0.5 # minimum duration of pre/post padding in seconds
|
||||
max_pad_duration: 2.0 # maximum duration of pre/post padding in seconds
|
||||
max_total_duration: 30.0 # maximum total duration of the padded audio in seconds
|
||||
pad_distribution: 'uniform' # distribution of padding duration, 'uniform' or 'normal' or 'constant'
|
||||
normal_mean: 0.5 # mean of normal distribution for padding duration
|
||||
normal_std: 2.0 # standard deviation of normal distribution for padding duration
|
||||
pre_pad_duration: 0.2 # amount of left-padding when pad_distribution='constant'
|
||||
post_pad_duration: 3.0 # amount of right-padding when pad_distribution='constant'
|
||||
```
|
||||
|
||||
Returns:
|
||||
audio: torch.Tensor of audio signal
|
||||
audio_lens: torch.Tensor of audio signal length
|
||||
text_tokens: torch.Tensor of text text_tokens
|
||||
text_token_lens: torch.Tensor of text token length
|
||||
eou_targets (optional): torch.Tensor of EOU labels
|
||||
eou_target_lens (optional): torch.Tensor of EOU label length
|
||||
|
||||
The input manifest should be a jsonl file where each line is a python dictionary.
|
||||
Example manifest sample:
|
||||
{
|
||||
"audio_filepath": "/path/to/audio.wav",
|
||||
"offset": 0.0,
|
||||
"duration": 6.0,
|
||||
"sou_time": [0.3, 4.0],
|
||||
"eou_time": [1.3, 4.5],
|
||||
"utterances": ["Tell me a joke", "Ah-ha"],
|
||||
"is_backchannel": [False, True],
|
||||
}
|
||||
|
||||
Padding logic:
|
||||
0. Don't pad when `random_padding` is None or during validation/test
|
||||
1. randomly draw a probability to decide whether to apply padding
|
||||
2. if not padding or audio duration is longer than the maximum duration,
|
||||
1) return the original audio and EOU labels
|
||||
3. if apply padding,
|
||||
1) get the max padding duration based on the maximum total duration and the audio duration
|
||||
2) randomly draw a total padding duration based on the given distribution
|
||||
3) randomly split the total padding duration into pre-padding and post-padding
|
||||
4) randomly generate the non-speech signal (audio signal=0) for pre-padding and post-padding
|
||||
5) concatenate the pre-padding, audio, and post-padding to get the padded audio signal
|
||||
6) update the EOU labels accordingly
|
||||
|
||||
"""
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
"""Define the output types of the dataset."""
|
||||
return {
|
||||
'audio': NeuralType(('B', 'T'), AudioSignal()),
|
||||
'audio_lens': NeuralType(tuple('B'), LengthsType()),
|
||||
'eou_targets': NeuralType(('B', 'T'), LabelsType()),
|
||||
'eou_target_lens': NeuralType(tuple('B'), LengthsType()),
|
||||
'text_tokens': NeuralType(tuple('B', 'T'), LengthsType(), optional=True),
|
||||
'text_token_lens': NeuralType(tuple('B'), LengthsType(), optional=True),
|
||||
}
|
||||
|
||||
def __init__(self, cfg: DictConfig, tokenizer: TokenizerSpec, return_cuts: bool = False):
|
||||
super().__init__()
|
||||
self.cfg = cfg
|
||||
self.return_cuts = return_cuts
|
||||
self.eou_string = self.cfg.get('eou_string', EOU_STRING)
|
||||
self.eob_string = self.cfg.get('eob_string', EOB_STRING)
|
||||
if cfg.get('check_tokenizer', True):
|
||||
self._check_special_tokens(tokenizer)
|
||||
|
||||
self.tokenizer = TokenizerWrapper(tokenizer)
|
||||
self.load_audio = AudioSamples(fault_tolerant=True)
|
||||
self.sample_rate = self.cfg.get('sample_rate', 16000)
|
||||
self.window_stride = self.cfg.get('window_stride', 0.01)
|
||||
self.num_sample_per_mel_frame = int(
|
||||
self.window_stride * self.sample_rate
|
||||
) # 160 samples for every 1ms by default
|
||||
self.num_mel_frame_per_target_frame = int(self.cfg.get('subsampling_factor', 8))
|
||||
self.add_sep_before_eou = self.cfg.get('add_sep_before_eou', False)
|
||||
self.add_eou_to_text = self.cfg.get('add_eou_to_text', True)
|
||||
self.pad_eou_label_secs = self.cfg.get('pad_eou_label_secs', 0.0)
|
||||
self.padding_cfg = self.cfg.get('random_padding', None)
|
||||
if self.padding_cfg is not None:
|
||||
self.padding_cfg = OmegaConf.to_container(self.padding_cfg, resolve=True)
|
||||
self.padding_cfg = RandomPaddingConfig(**self.padding_cfg)
|
||||
self.ignore_eob_label = self.cfg.get('ignore_eob_label', False)
|
||||
self.augmentor = None
|
||||
if self.cfg.get('augmentor', None) is not None:
|
||||
augmentor = {}
|
||||
aug_cfg = OmegaConf.to_container(self.cfg.augmentor, resolve=True)
|
||||
for k, v in aug_cfg.items():
|
||||
if k in EOU_INVALID_AUGMENTATIONS:
|
||||
logging.warning(f"EOU dataset does not support {k} augmentation yet, skipping.")
|
||||
continue
|
||||
augmentor[k] = v
|
||||
|
||||
if len(augmentor) > 0:
|
||||
logging.info(f"EOU dataset will apply augmentations: {augmentor}")
|
||||
self.augmentor = process_augmentations(augmentor)
|
||||
|
||||
def _check_special_tokens(self, tokenizer: TokenizerSpec):
|
||||
"""
|
||||
Check if the special tokens are in the tokenizer vocab.
|
||||
"""
|
||||
special_tokens = set([self.eou_string, self.eob_string])
|
||||
vocab_size = tokenizer.vocab_size
|
||||
special_tokens_in_vocab = set([tokenizer.ids_to_text(vocab_size - 1), tokenizer.ids_to_text(vocab_size - 2)])
|
||||
if special_tokens != special_tokens_in_vocab:
|
||||
raise ValueError(
|
||||
f"Input special tokens {special_tokens} don't match with the tokenizer vocab {special_tokens_in_vocab}. "
|
||||
f"Please add them to tokenizer or change input `eou_string` and/or `eob_string` accordingly. "
|
||||
"Special tokens should be added as the last two tokens in the new tokenizer. "
|
||||
"Please refer to scripts/asr_end_of_utterance/tokenizers/add_special_tokens_to_sentencepiece.py for details."
|
||||
)
|
||||
|
||||
def __getitem__(self, cuts: CutSet) -> AudioToTextEOUBatch:
|
||||
audio, audio_lens, cuts = self.load_audio(cuts)
|
||||
audio_signals = []
|
||||
audio_lengths = []
|
||||
eou_targets = []
|
||||
text_tokens = []
|
||||
sample_ids = []
|
||||
audio_filepaths = []
|
||||
|
||||
for i in range(len(cuts)):
|
||||
c = cuts[i]
|
||||
if isinstance(c, MixedCut):
|
||||
c = c.first_non_padding_cut
|
||||
|
||||
sample_ids.append(c.id)
|
||||
audio_filepaths.append(c.recording.sources[0].source)
|
||||
|
||||
audio_i = audio[i]
|
||||
audio_len_i = audio_lens[i]
|
||||
|
||||
# Get EOU labels and text tokens
|
||||
eou_targets_i = self._get_frame_labels(c, audio_len_i)
|
||||
text_tokens_i = self._get_text_tokens(c)
|
||||
|
||||
# Maybe apply random padding to both sides of the audio
|
||||
audio_i, audio_len_i, eou_targets_i = self._random_pad_audio(audio_i, audio_len_i, eou_targets_i)
|
||||
|
||||
# Maybe apply augmentations to the audio signal after padding
|
||||
audio_i, audio_len_i = self._maybe_augment_audio(audio_i, audio_len_i)
|
||||
|
||||
# Append the processed audio, EOU labels, and text tokens to the lists
|
||||
audio_signals.append(audio_i)
|
||||
audio_lengths.append(audio_len_i)
|
||||
eou_targets.append(eou_targets_i)
|
||||
text_tokens.append(text_tokens_i)
|
||||
|
||||
audio_signals = collate_vectors(audio_signals, padding_value=0)
|
||||
audio_lengths = torch.tensor(audio_lengths, dtype=torch.long)
|
||||
eou_target_lens = torch.tensor([t.size(0) for t in eou_targets], dtype=torch.long)
|
||||
eou_targets = collate_vectors(eou_targets, padding_value=0)
|
||||
text_token_lens = torch.tensor([t.size(0) for t in text_tokens], dtype=torch.long)
|
||||
text_tokens = collate_vectors(text_tokens, padding_value=0)
|
||||
|
||||
if self.return_cuts:
|
||||
return audio_signals, audio_lengths, cuts
|
||||
|
||||
return AudioToTextEOUBatch(
|
||||
sample_ids=sample_ids,
|
||||
audio_filepaths=audio_filepaths,
|
||||
audio_signal=audio_signals,
|
||||
audio_lengths=audio_lengths,
|
||||
text_tokens=text_tokens,
|
||||
text_token_lengths=text_token_lens,
|
||||
eou_targets=eou_targets,
|
||||
eou_target_lengths=eou_target_lens,
|
||||
)
|
||||
|
||||
def _audio_len_to_frame_len(self, num_samples: int):
|
||||
"""
|
||||
Convert the raw audio length to the number of frames after audio encoder.
|
||||
|
||||
self.num_sample_per_mel_frame = int(
|
||||
self.cfg.get('window_stride', 0.01) * self.cfg.get('sample_rate', 16000)
|
||||
) # 160 samples for every 1ms by default
|
||||
self.num_mel_frame_per_target_frame = int(self.cfg.get('subsampling_factor', 8))
|
||||
"""
|
||||
mel_frame_count = math.ceil((num_samples + 1) / self.num_sample_per_mel_frame)
|
||||
hidden_length = math.ceil(mel_frame_count / self.num_mel_frame_per_target_frame)
|
||||
return hidden_length
|
||||
|
||||
def _repeat_eou_labels(self, eou_targets: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Repeat EOU labels according to self.pad_eou_label_secs
|
||||
Args:
|
||||
eou_targets: torch.Tensor of EOU labels, shape [T]
|
||||
Returns:
|
||||
eou_targets: torch.Tensor of padded EOU labels, shape [T]
|
||||
"""
|
||||
if not self.pad_eou_label_secs or self.pad_eou_label_secs <= 0:
|
||||
return eou_targets
|
||||
|
||||
eou_len = self._audio_len_to_frame_len(int(self.pad_eou_label_secs * self.sample_rate))
|
||||
|
||||
i = 0
|
||||
while i < eou_targets.size(0):
|
||||
if eou_targets[i] == EOU_LABEL or eou_targets[i] == EOB_LABEL:
|
||||
# repeat the label for the next eou_len samples
|
||||
start = i
|
||||
end = min(i + eou_len, eou_targets.size(0))
|
||||
j = start + 1
|
||||
while j < end:
|
||||
if eou_targets[j] != NON_SPEECH_LABEL:
|
||||
# do not overwrite the label if it's not non-speech
|
||||
break
|
||||
j += 1
|
||||
end = min(j, end)
|
||||
# fill the non-speech label with the current EOU/EOB label
|
||||
eou_targets[start:end] = eou_targets[i]
|
||||
i = end
|
||||
else:
|
||||
i += 1
|
||||
return eou_targets
|
||||
|
||||
def _get_frame_labels(self, cut: Cut, num_samples: int):
|
||||
"""
|
||||
Get the frame-level EOU labels for a single audio segment.
|
||||
Args:
|
||||
cut: Cut object
|
||||
num_samples: int, the number of samples in the audio segment
|
||||
Returns:
|
||||
eou_targets: torch.Tensor of EOU labels, shape [T]
|
||||
"""
|
||||
hidden_length = self._audio_len_to_frame_len(num_samples)
|
||||
if not "sou_time" in cut.custom or not "eou_time" in cut.custom:
|
||||
# assume only single speech segment
|
||||
text = cut.supervisions[0].text
|
||||
if not text:
|
||||
# skip empty utterances
|
||||
return torch.zeros(hidden_length).long()
|
||||
eou_targets = torch.ones(hidden_length).long() # speech label
|
||||
eou_targets[-1] = EOU_LABEL # by default it's end of utterance
|
||||
if cut.has_custom("is_backchannel") and cut.custom["is_backchannel"] and not self.ignore_eob_label:
|
||||
eou_targets[-1] = EOB_LABEL # end of backchannel
|
||||
return eou_targets
|
||||
|
||||
sou_time = cut.custom["sou_time"]
|
||||
eou_time = cut.custom["eou_time"]
|
||||
if not isinstance(sou_time, list):
|
||||
sou_time = [sou_time]
|
||||
if not isinstance(eou_time, list):
|
||||
eou_time = [eou_time]
|
||||
|
||||
assert len(sou_time) == len(
|
||||
eou_time
|
||||
), f"Number of SOU time and EOU time do not match: SOU ({sou_time}) vs EOU ({eou_time})"
|
||||
|
||||
if cut.has_custom("is_backchannel"):
|
||||
is_backchannel = cut.custom["is_backchannel"]
|
||||
if not isinstance(is_backchannel, list):
|
||||
is_backchannel = [is_backchannel]
|
||||
assert len(sou_time) == len(
|
||||
is_backchannel
|
||||
), f"Number of SOU and backchannel do not match: SOU ({len(sou_time)}) vs backchannel ({len(is_backchannel)})"
|
||||
else:
|
||||
is_backchannel = [False] * len(sou_time)
|
||||
|
||||
eou_targets = torch.zeros(hidden_length).long()
|
||||
for i in range(len(sou_time)):
|
||||
if sou_time[i] is None or eou_time[i] is None or sou_time[i] < 0 or eou_time[i] < 0:
|
||||
# skip empty utterances
|
||||
continue
|
||||
sou_idx = self._audio_len_to_frame_len(int((sou_time[i] - cut.start) * self.sample_rate))
|
||||
seg_len_in_secs = eou_time[i] - sou_time[i]
|
||||
seg_len = self._audio_len_to_frame_len(int(seg_len_in_secs * self.sample_rate))
|
||||
eou_targets[sou_idx : sou_idx + seg_len] = SPEECH_LABEL
|
||||
last_idx = min(sou_idx + seg_len - 1, hidden_length - 1)
|
||||
if is_backchannel[i] and not self.ignore_eob_label:
|
||||
eou_targets[last_idx] = EOB_LABEL # end of backchannel
|
||||
else:
|
||||
eou_targets[last_idx] = EOU_LABEL # end of utterance
|
||||
|
||||
return eou_targets
|
||||
|
||||
def _get_text_tokens(self, cut: Cut):
|
||||
"""
|
||||
Add EOU labels to the text and get the text tokens for a single audio segment.
|
||||
Args:
|
||||
cut: Cut object
|
||||
Returns:
|
||||
text_tokens: torch.Tensor of text tokens, shape [T]
|
||||
"""
|
||||
if not cut.has_custom("sou_time") or not cut.has_custom("eou_time") or not cut.has_custom("utterances"):
|
||||
# assume only single speech segment
|
||||
utterances = [cut.supervisions[0].text]
|
||||
else:
|
||||
utterances = cut.custom["utterances"]
|
||||
|
||||
if not isinstance(utterances, list):
|
||||
utterances = [utterances]
|
||||
|
||||
if cut.has_custom("is_backchannel"):
|
||||
is_backchannel = cut.custom["is_backchannel"]
|
||||
if not isinstance(is_backchannel, list):
|
||||
is_backchannel = [is_backchannel]
|
||||
assert len(utterances) == len(
|
||||
is_backchannel
|
||||
), f"Number of utterances and backchannel do not match: utterance ({len(utterances)}) vs backchannel ({len(is_backchannel)})"
|
||||
else:
|
||||
is_backchannel = [False] * len(utterances)
|
||||
|
||||
total_text = ""
|
||||
for i, text in enumerate(utterances):
|
||||
if not text:
|
||||
# skip empty utterances
|
||||
continue
|
||||
if self.add_eou_to_text:
|
||||
eou_string = self.eob_string if is_backchannel[i] and not self.ignore_eob_label else self.eou_string
|
||||
if self.add_sep_before_eou:
|
||||
eou_string = " " + eou_string
|
||||
else:
|
||||
eou_string = ""
|
||||
total_text += text + eou_string + " "
|
||||
total_text = total_text.strip()
|
||||
return torch.as_tensor(self.tokenizer(total_text))
|
||||
|
||||
def _random_pad_audio(self, audio: torch.Tensor, audio_len: torch.Tensor, eou_targets: torch.Tensor):
|
||||
"""
|
||||
Randomly pad the audio signal with non-speech signal before and after the audio signal.
|
||||
Args:
|
||||
audio: torch.Tensor of a single audio signal, shape [T]
|
||||
audio_len: torch.Tensor of audio signal length, shape [1]
|
||||
eou_targets: torch.Tensor of EOU labels, shape [T]
|
||||
Returns:
|
||||
padded_audio: torch.Tensor of padded audio signal, shape [T+padding]
|
||||
padded_audio_len: torch.Tensor of padded audio signal length, shape [1]
|
||||
padded_eou_targets: torch.Tensor of padded EOU labels, shape [T+padding]
|
||||
padded_eou_targets_len: torch.Tensor of padded EOU label length, shape [1]
|
||||
"""
|
||||
p = np.random.rand()
|
||||
if self.padding_cfg is None or p > self.padding_cfg.prob:
|
||||
# don't apply padding
|
||||
eou_targets = self._repeat_eou_labels(eou_targets)
|
||||
return audio, audio_len, eou_targets
|
||||
|
||||
duration = audio_len.item() / self.cfg.sample_rate
|
||||
# if already longer than the maximum duration, return the original audio
|
||||
if duration >= self.padding_cfg.max_total_duration:
|
||||
return audio, audio_len, eou_targets
|
||||
|
||||
# apply padding
|
||||
audio = audio[:audio_len]
|
||||
|
||||
self.padding_cfg.min_pre_pad_duration = max(
|
||||
self.padding_cfg.min_pre_pad_duration, self.padding_cfg.min_pad_duration
|
||||
)
|
||||
self.padding_cfg.min_post_pad_duration = max(
|
||||
self.padding_cfg.min_post_pad_duration, self.padding_cfg.min_pad_duration
|
||||
)
|
||||
|
||||
max_padding_duration = max(0, self.padding_cfg.max_total_duration - duration)
|
||||
if max_padding_duration <= self.padding_cfg.min_pre_pad_duration + self.padding_cfg.min_post_pad_duration:
|
||||
min_padding_duration = 0
|
||||
else:
|
||||
min_padding_duration = self.padding_cfg.min_pre_pad_duration + self.padding_cfg.min_post_pad_duration
|
||||
|
||||
pre_padding_duration = None
|
||||
post_padding_duration = None
|
||||
|
||||
if self.padding_cfg.pad_distribution == 'uniform':
|
||||
total_padding_duration = np.random.uniform(min_padding_duration, max_padding_duration)
|
||||
elif self.padding_cfg.pad_distribution == 'normal':
|
||||
total_padding_duration = np.random.normal(self.padding_cfg.normal_mean, self.padding_cfg.normal_std)
|
||||
total_padding_duration = max(min_padding_duration, min(max_padding_duration, total_padding_duration))
|
||||
elif self.padding_cfg.pad_distribution == 'constant':
|
||||
pass
|
||||
else:
|
||||
raise ValueError(f"Unknown padding distribution: {self.padding_cfg.pad_distribution}")
|
||||
|
||||
if self.padding_cfg.pad_distribution == 'constant':
|
||||
pre_padding_duration = self.padding_cfg.pre_pad_duration
|
||||
post_padding_duration = self.padding_cfg.post_pad_duration
|
||||
elif min_padding_duration == 0:
|
||||
pre_padding_duration = total_padding_duration / 2
|
||||
post_padding_duration = total_padding_duration / 2
|
||||
else:
|
||||
post_padding_duration = np.random.uniform(
|
||||
self.padding_cfg.min_post_pad_duration, total_padding_duration - self.padding_cfg.min_pre_pad_duration
|
||||
)
|
||||
pre_padding_duration = total_padding_duration - post_padding_duration
|
||||
|
||||
if self.padding_cfg.max_pad_duration is not None:
|
||||
pre_padding_duration = min(pre_padding_duration, self.padding_cfg.max_pad_duration)
|
||||
post_padding_duration = min(post_padding_duration, self.padding_cfg.max_pad_duration)
|
||||
|
||||
pre_padding_len = math.ceil(pre_padding_duration * self.cfg.sample_rate)
|
||||
post_padding_len = math.ceil(post_padding_duration * self.cfg.sample_rate)
|
||||
|
||||
# pad the audio signal
|
||||
pre_padding = torch.zeros(pre_padding_len, dtype=audio.dtype)
|
||||
post_padding = torch.zeros(post_padding_len, dtype=audio.dtype)
|
||||
padded_audio = torch.cat((pre_padding, audio, post_padding), dim=0)
|
||||
padded_audio_len = audio_len + pre_padding_len + post_padding_len
|
||||
|
||||
# pad the EOU labels
|
||||
pre_padding_eou_len = self._audio_len_to_frame_len(pre_padding_len)
|
||||
post_padding_eou_len = self._audio_len_to_frame_len(post_padding_len)
|
||||
pre_padding_eou = torch.zeros(pre_padding_eou_len, dtype=eou_targets.dtype)
|
||||
post_padding_eou = torch.zeros(post_padding_eou_len, dtype=eou_targets.dtype)
|
||||
padded_eou_targets = torch.cat((pre_padding_eou, eou_targets, post_padding_eou), dim=0)
|
||||
padded_eou_targets = self._repeat_eou_labels(padded_eou_targets)
|
||||
return padded_audio, padded_audio_len, padded_eou_targets
|
||||
|
||||
def _maybe_augment_audio(self, audio: torch.Tensor, audio_len: torch.Tensor):
|
||||
"""
|
||||
Apply augmentation to the audio signal if augmentor is provided.
|
||||
Args:
|
||||
audio: torch.Tensor of a single audio signal, shape [T]
|
||||
audio_len: torch.Tensor of audio signal length, shape [1]
|
||||
Returns:
|
||||
augmented_audio: torch.Tensor of augmented audio signal, shape [T]
|
||||
augmented_audio_len: torch.Tensor of augmented audio signal length, shape [1]
|
||||
"""
|
||||
if self.augmentor is None:
|
||||
return audio, audio_len
|
||||
|
||||
# Cast to AudioSegment
|
||||
audio_segment = AudioSegment(
|
||||
samples=audio[:audio_len].numpy(),
|
||||
sample_rate=self.sample_rate,
|
||||
offset=0,
|
||||
duration=audio_len.item() / self.sample_rate,
|
||||
)
|
||||
# Apply augmentation
|
||||
self.augmentor.perturb(audio_segment)
|
||||
audio = torch.from_numpy(audio_segment.samples).float()
|
||||
audio_len = audio.size(0)
|
||||
|
||||
return audio, audio_len
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
# Copyright (c) 2020, 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 copy
|
||||
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.data import audio_to_label
|
||||
from nemo.collections.asr.data.audio_to_text_dataset import convert_to_config_list, get_chain_dataset
|
||||
from nemo.collections.asr.parts.preprocessing.perturb import process_augmentations
|
||||
from nemo.collections.common.data.dataset import ConcatDataset
|
||||
|
||||
|
||||
def get_classification_label_dataset(featurizer, config: dict) -> audio_to_label.AudioToClassificationLabelDataset:
|
||||
"""
|
||||
Instantiates a Classification AudioLabelDataset.
|
||||
|
||||
Args:
|
||||
config: Config of the AudioToClassificationLabelDataset.
|
||||
|
||||
Returns:
|
||||
An instance of AudioToClassificationLabelDataset.
|
||||
"""
|
||||
dataset = audio_to_label.AudioToClassificationLabelDataset(
|
||||
manifest_filepath=config['manifest_filepath'],
|
||||
labels=config['labels'],
|
||||
featurizer=featurizer,
|
||||
max_duration=config.get('max_duration', None),
|
||||
min_duration=config.get('min_duration', None),
|
||||
trim=config.get('trim_silence', False),
|
||||
is_regression_task=config.get('is_regression_task', False),
|
||||
cal_labels_occurrence=config.get('cal_labels_occurrence', False),
|
||||
)
|
||||
return dataset
|
||||
|
||||
|
||||
def get_speech_label_dataset(featurizer, config: dict) -> audio_to_label.AudioToSpeechLabelDataset:
|
||||
"""
|
||||
Instantiates a Speech Label (e.g. VAD, speaker recognition) AudioLabelDataset.
|
||||
|
||||
Args:
|
||||
config: Config of the AudioToSpeechLabelDataSet.
|
||||
|
||||
Returns:
|
||||
An instance of AudioToSpeechLabelDataset.
|
||||
"""
|
||||
dataset = audio_to_label.AudioToSpeechLabelDataset(
|
||||
manifest_filepath=config['manifest_filepath'],
|
||||
labels=config['labels'],
|
||||
featurizer=featurizer,
|
||||
max_duration=config.get('max_duration', None),
|
||||
min_duration=config.get('min_duration', None),
|
||||
trim=config.get('trim_silence', False),
|
||||
window_length_in_sec=config.get('window_length_in_sec', 0.31),
|
||||
shift_length_in_sec=config.get('shift_length_in_sec', 0.01),
|
||||
normalize_audio=config.get('normalize_audio', False),
|
||||
cal_labels_occurrence=config.get('cal_labels_occurrence', False),
|
||||
)
|
||||
return dataset
|
||||
|
||||
|
||||
def get_tarred_classification_label_dataset(
|
||||
featurizer, config: dict, shuffle_n: int, global_rank: int, world_size: int
|
||||
) -> audio_to_label.TarredAudioToClassificationLabelDataset:
|
||||
"""
|
||||
Instantiates a Classification TarredAudioLabelDataset.
|
||||
|
||||
Args:
|
||||
config: Config of the TarredAudioToClassificationLabelDataset.
|
||||
shuffle_n: How many samples to look ahead and load to be shuffled.
|
||||
See WebDataset documentation for more details.
|
||||
global_rank: Global rank of this device.
|
||||
world_size: Global world size in the training method.
|
||||
|
||||
Returns:
|
||||
An instance of TarredAudioToClassificationLabelDataset.
|
||||
"""
|
||||
tarred_audio_filepaths = config['tarred_audio_filepaths']
|
||||
manifest_filepaths = config['manifest_filepath']
|
||||
datasets = []
|
||||
tarred_audio_filepaths = convert_to_config_list(tarred_audio_filepaths)
|
||||
manifest_filepaths = convert_to_config_list(manifest_filepaths)
|
||||
|
||||
bucketing_weights = config.get('bucketing_weights', None) # For upsampling buckets
|
||||
if bucketing_weights:
|
||||
for idx, weight in enumerate(bucketing_weights):
|
||||
if not isinstance(weight, int) or weight <= 0:
|
||||
raise ValueError("bucket weights must be positive integers")
|
||||
|
||||
if len(manifest_filepaths) != len(tarred_audio_filepaths):
|
||||
raise ValueError(
|
||||
f"manifest_filepaths (length={len(manifest_filepaths)}) and tarred_audio_filepaths (length={len(tarred_audio_filepaths)}) need to have the same number of buckets."
|
||||
)
|
||||
|
||||
for dataset_idx, (tarred_audio_filepath, manifest_filepath) in enumerate(
|
||||
zip(tarred_audio_filepaths, manifest_filepaths)
|
||||
):
|
||||
if len(tarred_audio_filepath) == 1:
|
||||
tarred_audio_filepath = tarred_audio_filepath[0]
|
||||
dataset = audio_to_label.TarredAudioToClassificationLabelDataset(
|
||||
audio_tar_filepaths=tarred_audio_filepath,
|
||||
manifest_filepath=manifest_filepath,
|
||||
labels=config['labels'],
|
||||
featurizer=featurizer,
|
||||
shuffle_n=shuffle_n,
|
||||
max_duration=config.get('max_duration', None),
|
||||
min_duration=config.get('min_duration', None),
|
||||
trim=config.get('trim_silence', False),
|
||||
shard_strategy=config.get('tarred_shard_strategy', 'scatter'),
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
is_regression_task=config.get('is_regression_task', False),
|
||||
)
|
||||
|
||||
if bucketing_weights:
|
||||
[datasets.append(dataset) for _ in range(bucketing_weights[dataset_idx])]
|
||||
else:
|
||||
datasets.append(dataset)
|
||||
|
||||
return get_chain_dataset(datasets=datasets, ds_config=config, rank=global_rank)
|
||||
|
||||
|
||||
def get_concat_tarred_speech_label_dataset(
|
||||
featurizer,
|
||||
config: dict,
|
||||
shuffle_n: int,
|
||||
global_rank: int,
|
||||
world_size: int,
|
||||
):
|
||||
tarred_audio_filepaths = config['tarred_audio_filepaths']
|
||||
manifest_filepaths = config['manifest_filepath']
|
||||
datasets = []
|
||||
for dataset_idx, (tarred_audio_filepath, manifest_filepath) in enumerate(
|
||||
zip(tarred_audio_filepaths, manifest_filepaths)
|
||||
):
|
||||
conf = copy.deepcopy(config)
|
||||
conf['manifest_filepath'] = manifest_filepath
|
||||
conf['tarred_audio_filepaths'] = tarred_audio_filepath
|
||||
dataset = get_tarred_speech_label_dataset(
|
||||
config=conf,
|
||||
featurizer=featurizer,
|
||||
shuffle_n=shuffle_n,
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
)
|
||||
datasets.append(dataset)
|
||||
|
||||
dataset = ConcatDataset(
|
||||
datasets,
|
||||
sampling_technique=config.get('concat_sampling_technique', 'temperature'),
|
||||
sampling_temperature=config.get('concat_sampling_temperature', 5),
|
||||
sampling_probabilities=config.get('concat_sampling_probabilities', None),
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
shuffle=config['shuffle'],
|
||||
)
|
||||
return dataset
|
||||
|
||||
|
||||
def get_tarred_speech_label_dataset(
|
||||
featurizer,
|
||||
config: dict,
|
||||
shuffle_n: int,
|
||||
global_rank: int,
|
||||
world_size: int,
|
||||
) -> audio_to_label.TarredAudioToSpeechLabelDataset:
|
||||
"""
|
||||
InInstantiates a Speech Label (e.g. VAD, speaker recognition) TarredAudioLabelDataset.
|
||||
|
||||
Args:
|
||||
config: Config of the TarredAudioToSpeechLabelDataset.
|
||||
shuffle_n: How many samples to look ahead and load to be shuffled.
|
||||
See WebDataset documentation for more details.
|
||||
global_rank: Global rank of this device.
|
||||
world_size: Global world size in the training method.
|
||||
|
||||
Returns:
|
||||
An instance of TarredAudioToSpeechLabelDataset.
|
||||
"""
|
||||
tarred_audio_filepaths = config['tarred_audio_filepaths']
|
||||
manifest_filepaths = config['manifest_filepath']
|
||||
datasets = []
|
||||
tarred_audio_filepaths = convert_to_config_list(tarred_audio_filepaths)
|
||||
manifest_filepaths = convert_to_config_list(manifest_filepaths)
|
||||
|
||||
bucketing_weights = config.get('bucketing_weights', None) # For upsampling buckets
|
||||
if bucketing_weights:
|
||||
for idx, weight in enumerate(bucketing_weights):
|
||||
if not isinstance(weight, int) or weight <= 0:
|
||||
raise ValueError("bucket weights must be positive integers")
|
||||
|
||||
if len(manifest_filepaths) != len(tarred_audio_filepaths):
|
||||
raise ValueError(
|
||||
f"manifest_filepaths (length={len(manifest_filepaths)}) and tarred_audio_filepaths (length={len(tarred_audio_filepaths)}) need to have the same number of buckets."
|
||||
)
|
||||
|
||||
for dataset_idx, (tarred_audio_filepath, manifest_filepath) in enumerate(
|
||||
zip(tarred_audio_filepaths, manifest_filepaths)
|
||||
):
|
||||
if len(tarred_audio_filepath) == 1:
|
||||
tarred_audio_filepath = tarred_audio_filepath[0]
|
||||
dataset = audio_to_label.TarredAudioToSpeechLabelDataset(
|
||||
audio_tar_filepaths=tarred_audio_filepath,
|
||||
manifest_filepath=manifest_filepath,
|
||||
labels=config['labels'],
|
||||
featurizer=featurizer,
|
||||
shuffle_n=shuffle_n,
|
||||
max_duration=config.get('max_duration', None),
|
||||
min_duration=config.get('min_duration', None),
|
||||
trim=config.get('trim_silence', False),
|
||||
window_length_in_sec=config.get('window_length_in_sec', 8),
|
||||
shift_length_in_sec=config.get('shift_length_in_sec', 0.075),
|
||||
normalize_audio=config.get('normalize_audio', False),
|
||||
shard_strategy=config.get('tarred_shard_strategy', 'scatter'),
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
)
|
||||
|
||||
if bucketing_weights:
|
||||
[datasets.append(dataset) for _ in range(bucketing_weights[dataset_idx])]
|
||||
else:
|
||||
datasets.append(dataset)
|
||||
|
||||
return get_chain_dataset(datasets=datasets, ds_config=config, rank=global_rank)
|
||||
|
||||
|
||||
def get_audio_multi_label_dataset(cfg: DictConfig) -> audio_to_label.AudioToMultiLabelDataset:
|
||||
if "augmentor" in cfg:
|
||||
augmentor = process_augmentations(cfg.augmentor)
|
||||
else:
|
||||
augmentor = None
|
||||
|
||||
dataset = audio_to_label.AudioToMultiLabelDataset(
|
||||
manifest_filepath=cfg.get("manifest_filepath"),
|
||||
sample_rate=cfg.get("sample_rate"),
|
||||
labels=cfg.get("labels", None),
|
||||
int_values=cfg.get("int_values", False),
|
||||
augmentor=augmentor,
|
||||
min_duration=cfg.get("min_duration", None),
|
||||
max_duration=cfg.get("max_duration", None),
|
||||
trim_silence=cfg.get("trim_silence", False),
|
||||
is_regression_task=cfg.get("is_regression_task", False),
|
||||
cal_labels_occurrence=cfg.get("cal_labels_occurrence", False),
|
||||
delimiter=cfg.get("delimiter", None),
|
||||
normalize_audio_db=cfg.get("normalize_audio_db", None),
|
||||
)
|
||||
return dataset
|
||||
|
||||
|
||||
def get_tarred_audio_multi_label_dataset(
|
||||
cfg: DictConfig, shuffle_n: int, global_rank: int, world_size: int
|
||||
) -> audio_to_label.TarredAudioToMultiLabelDataset:
|
||||
|
||||
if "augmentor" in cfg:
|
||||
augmentor = process_augmentations(cfg.augmentor)
|
||||
else:
|
||||
augmentor = None
|
||||
|
||||
tarred_audio_filepaths = cfg['tarred_audio_filepaths']
|
||||
manifest_filepaths = cfg['manifest_filepath']
|
||||
datasets = []
|
||||
tarred_audio_filepaths = convert_to_config_list(tarred_audio_filepaths)
|
||||
manifest_filepaths = convert_to_config_list(manifest_filepaths)
|
||||
|
||||
bucketing_weights = cfg.get('bucketing_weights', None) # For upsampling buckets
|
||||
if bucketing_weights:
|
||||
for idx, weight in enumerate(bucketing_weights):
|
||||
if not isinstance(weight, int) or weight <= 0:
|
||||
raise ValueError("bucket weights must be positive integers")
|
||||
|
||||
if len(manifest_filepaths) != len(tarred_audio_filepaths):
|
||||
raise ValueError(
|
||||
f"manifest_filepaths (length={len(manifest_filepaths)}) and tarred_audio_filepaths (length={len(tarred_audio_filepaths)}) need to have the same number of buckets."
|
||||
)
|
||||
|
||||
for dataset_idx, (tarred_audio_filepath, manifest_filepath) in enumerate(
|
||||
zip(tarred_audio_filepaths, manifest_filepaths)
|
||||
):
|
||||
if len(tarred_audio_filepath) == 1:
|
||||
tarred_audio_filepath = tarred_audio_filepath[0]
|
||||
|
||||
dataset = audio_to_label.TarredAudioToMultiLabelDataset(
|
||||
audio_tar_filepaths=tarred_audio_filepath,
|
||||
manifest_filepath=manifest_filepath,
|
||||
sample_rate=cfg["sample_rate"],
|
||||
labels=cfg['labels'],
|
||||
shuffle_n=shuffle_n,
|
||||
int_values=cfg.get("int_values", False),
|
||||
augmentor=augmentor,
|
||||
min_duration=cfg.get('min_duration', None),
|
||||
max_duration=cfg.get('max_duration', None),
|
||||
trim_silence=cfg.get('trim_silence', False),
|
||||
is_regression_task=cfg.get('is_regression_task', False),
|
||||
delimiter=cfg.get("delimiter", None),
|
||||
shard_strategy=cfg.get('tarred_shard_strategy', 'scatter'),
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
normalize_audio_db=cfg.get("normalize_audio_db", None),
|
||||
)
|
||||
|
||||
if bucketing_weights:
|
||||
[datasets.append(dataset) for _ in range(bucketing_weights[dataset_idx])]
|
||||
else:
|
||||
datasets.append(dataset)
|
||||
|
||||
return get_chain_dataset(datasets=datasets, ds_config=cfg, rank=global_rank)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,777 @@
|
||||
# Copyright (c) 2020, 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 math
|
||||
import operator
|
||||
import os.path
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from typing import Callable, List, Optional, Union
|
||||
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.data.audio_to_text import ASRManifestProcessor, expand_sharded_filepaths
|
||||
from nemo.collections.common.parts.preprocessing import parsers
|
||||
from nemo.utils import logging, model_utils
|
||||
|
||||
try:
|
||||
import nvidia.dali as dali
|
||||
from nvidia.dali.pipeline import Pipeline
|
||||
from nvidia.dali.plugin.pytorch import DALIGenericIterator as DALIPytorchIterator
|
||||
from nvidia.dali.plugin.pytorch import LastBatchPolicy as LastBatchPolicy
|
||||
|
||||
HAVE_DALI = True
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
HAVE_DALI = False
|
||||
|
||||
__all__ = [
|
||||
'AudioToCharDALIDataset',
|
||||
'AudioToBPEDALIDataset',
|
||||
]
|
||||
|
||||
"""
|
||||
Below minimum version is required to access the "read_idxs" argument in
|
||||
dali.fn.readers.nemo_asr
|
||||
"""
|
||||
__DALI_MINIMUM_VERSION__ = "1.11"
|
||||
|
||||
DALI_INSTALLATION_MESSAGE = (
|
||||
"Could not import `nvidia.dali`.\n"
|
||||
"Please install DALI by following the steps provided here - \n"
|
||||
"https://docs.nvidia.com/deeplearning/dali/user-guide/docs/installation.html"
|
||||
)
|
||||
|
||||
|
||||
def is_dali_supported(min_version: str, verbose: bool = False) -> bool:
|
||||
"""
|
||||
Checks if DALI in installed, and version is >= min_verion.
|
||||
|
||||
Args:
|
||||
min_version: A semver str that is the minimum requirement.
|
||||
verbose: Whether to log the installation instructions if DALI is not found.
|
||||
|
||||
Returns:
|
||||
bool - whether DALI could be imported or not.
|
||||
"""
|
||||
module_available, _ = model_utils.check_lib_version(
|
||||
'nvidia.dali', checked_version=min_version, operator=operator.ge
|
||||
)
|
||||
|
||||
# If DALI is not installed
|
||||
if module_available is None:
|
||||
if verbose:
|
||||
logging.info(DALI_INSTALLATION_MESSAGE)
|
||||
|
||||
return False
|
||||
|
||||
return module_available
|
||||
|
||||
|
||||
class DALIOutputs(object):
|
||||
def __init__(self, out_dict):
|
||||
self._has_processed_signal = 'processed_signal' in out_dict and 'processed_signal_len' in out_dict
|
||||
if not self._has_processed_signal:
|
||||
assert 'audio' in out_dict and 'audio_len' in out_dict
|
||||
assert 'transcript' in out_dict and 'transcript_len' in out_dict
|
||||
if self._has_processed_signal:
|
||||
self._outs = (
|
||||
out_dict['processed_signal'],
|
||||
out_dict['processed_signal_len'].reshape(-1),
|
||||
out_dict['transcript'],
|
||||
out_dict['transcript_len'].reshape(-1),
|
||||
)
|
||||
else:
|
||||
self._outs = (
|
||||
out_dict['audio'],
|
||||
out_dict['audio_len'].reshape(-1),
|
||||
out_dict['transcript'],
|
||||
out_dict['transcript_len'].reshape(-1),
|
||||
)
|
||||
|
||||
@property
|
||||
def has_processed_signal(self):
|
||||
return self._has_processed_signal
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._outs[key]
|
||||
|
||||
def __len__(self):
|
||||
return len(self._outs)
|
||||
|
||||
|
||||
class _AudioTextDALIDataset(Iterator):
|
||||
"""
|
||||
NVIDIA DALI pipeline that loads tensors via one or more manifest files where each line containing a sample descriptor in JSON,
|
||||
including audio files, transcripts, and durations (in seconds).
|
||||
Here's an example:
|
||||
{"audio_filepath": "/path/to/audio.wav", "text_filepath": "/path/to/audio.txt", "duration": 23.147}
|
||||
...
|
||||
{"audio_filepath": "/path/to/audio.wav", "text": "the transcription", "offset": 301.75, "duration": 0.82, "utt":
|
||||
"utterance_id", "ctm_utt": "en_4156", "side": "A"}
|
||||
|
||||
Args:
|
||||
manifest_filepath: Path to manifest file with the format described above. Can be comma-separated paths.
|
||||
device (str): Determines the device type to be used for preprocessing. Allowed values are: 'cpu', 'gpu'.
|
||||
batch_size (int): Number of samples in a batch.
|
||||
parser (str, callable): A str for an inbuilt parser, or a callable with signature f(str) -> List[int].
|
||||
sample_rate (int): Sample rate to resample loaded audio to.
|
||||
num_threads (int): Number of CPU processing threads to be created by the DALI pipeline.
|
||||
max_duration (float): Determines the maximum allowed duration, in seconds, of the loaded audio files.
|
||||
min_duration (float): Determines the minimum allowed duration, in seconds, of the loaded audio files.
|
||||
bos_id (int): Id of beginning of sequence symbol to append if not None
|
||||
eos_id (int): Id of end of sequence symbol to append if not None
|
||||
pad_id (int): Id used to pad the input. Defaults to 0 if not provided.
|
||||
trim (bool): If True, it will extract the nonsilent region of the loaded audio signal.
|
||||
shuffle (bool): If set to True, the dataset will shuffled after loading.
|
||||
drop_last (bool): If set to True, the last batch will be dropped if incomplete. This will be the case when the shard size is not divisible by the batch size.
|
||||
If set to False and the size of dataset is not divisible by the batch size, then the last batch will be smaller.
|
||||
device_id (int): Index of the GPU to be used (local_rank). Only applicable when device == 'gpu'. Defaults to 0.
|
||||
global_rank (int): Worker rank, used for partitioning shards. Defaults to 0.
|
||||
world_size (int): Total number of processes, used for partitioning shards. Defaults to 1.
|
||||
preprocessor_cfg (DictConfig): Preprocessor configuration. Supports AudioToMelSpectrogramPreprocessor and AudioToMFCCPreprocessor.
|
||||
return_sample_id (bool): whether to return the sample_id as a part of each sample (not supported yet).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manifest_filepath: str,
|
||||
device: str,
|
||||
batch_size: int,
|
||||
parser: Union[str, Callable],
|
||||
audio_tar_filepaths: Optional[Union[str, List[str]]] = None,
|
||||
audio_tar_index_filepaths: Optional[Union[str, List[str]]] = None,
|
||||
sample_rate: int = 16000,
|
||||
num_threads: int = 4,
|
||||
max_duration: float = 0.0,
|
||||
min_duration: float = 0.0,
|
||||
bos_id: Optional[int] = None,
|
||||
eos_id: Optional[int] = None,
|
||||
pad_id: int = 0,
|
||||
trim: bool = False,
|
||||
shuffle: bool = False,
|
||||
drop_last: bool = False,
|
||||
shard_strategy: str = "scatter",
|
||||
device_id: int = 0,
|
||||
global_rank: int = 0,
|
||||
world_size: int = 1,
|
||||
preprocessor_cfg: DictConfig = None,
|
||||
return_sample_id: bool = False,
|
||||
):
|
||||
self.drop_last = drop_last # used by lr_scheduler
|
||||
if return_sample_id:
|
||||
raise ValueError(
|
||||
"Currently DALI data layers don't support returning the sample_id and return_sample_id can not be enabled."
|
||||
)
|
||||
self.return_sample_id = return_sample_id
|
||||
|
||||
if not HAVE_DALI:
|
||||
raise ModuleNotFoundError(
|
||||
f"{self} requires NVIDIA DALI to be installed. "
|
||||
f"See: https://docs.nvidia.com/deeplearning/dali/user-guide/docs/installation.html#id1"
|
||||
)
|
||||
|
||||
if device not in ('cpu', 'gpu'):
|
||||
raise ValueError(
|
||||
f"{self} received an unexpected device argument {device}. Supported values are: 'cpu', 'gpu'"
|
||||
)
|
||||
|
||||
device_id = device_id if device == 'gpu' else None
|
||||
|
||||
self.batch_size = batch_size # Used by NeMo
|
||||
|
||||
self.device = device
|
||||
self.device_id = device_id
|
||||
|
||||
if world_size > 1:
|
||||
self.shard_id = global_rank
|
||||
self.num_shards = world_size
|
||||
else:
|
||||
self.shard_id = None
|
||||
self.num_shards = None
|
||||
|
||||
self.eos_id = eos_id
|
||||
self.bos_id = bos_id
|
||||
self.sample_rate = sample_rate
|
||||
|
||||
self.pipe = Pipeline(
|
||||
batch_size=batch_size,
|
||||
num_threads=num_threads,
|
||||
device_id=self.device_id,
|
||||
exec_async=True,
|
||||
exec_pipelined=True,
|
||||
)
|
||||
|
||||
has_preprocessor = preprocessor_cfg is not None
|
||||
if has_preprocessor:
|
||||
if preprocessor_cfg._target_ == "nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor":
|
||||
feature_type = "mel_spectrogram"
|
||||
elif preprocessor_cfg._target_ == "nemo.collections.asr.modules.AudioToMFCCPreprocessor":
|
||||
feature_type = "mfcc"
|
||||
else:
|
||||
raise ValueError(
|
||||
f"{self} received an unexpected preprocessor configuration: {preprocessor_cfg._target_}."
|
||||
f" Supported preprocessors are: AudioToMelSpectrogramPreprocessor, AudioToMFCCPreprocessor"
|
||||
)
|
||||
|
||||
# Default values taken from AudioToMelSpectrogramPreprocessor
|
||||
params = preprocessor_cfg
|
||||
self.dither = params['dither'] if 'dither' in params else 0.0
|
||||
self.preemph = params['preemph'] if 'preemph' in params else 0.97
|
||||
self.window_size_sec = params['window_size'] if 'window_size' in params else 0.02
|
||||
self.window_stride_sec = params['window_stride'] if 'window_stride' in params else 0.01
|
||||
self.sample_rate = params['sample_rate'] if 'sample_rate' in params else sample_rate
|
||||
self.window_size = int(self.window_size_sec * self.sample_rate)
|
||||
self.window_stride = int(self.window_stride_sec * self.sample_rate)
|
||||
|
||||
normalize = params['normalize'] if 'normalize' in params else 'per_feature'
|
||||
if normalize == 'per_feature': # Each freq channel independently
|
||||
self.normalization_axes = (1,)
|
||||
elif normalize == 'all_features':
|
||||
self.normalization_axes = (0, 1)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"{self} received {normalize} for the normalize parameter."
|
||||
f" It must be either 'per_feature' or 'all_features'."
|
||||
)
|
||||
|
||||
self.window = None
|
||||
window_name = params['window'] if 'window' in params else 'hann'
|
||||
torch_windows = {
|
||||
'hann': torch.hann_window,
|
||||
'hamming': torch.hamming_window,
|
||||
'blackman': torch.blackman_window,
|
||||
'bartlett': torch.bartlett_window,
|
||||
'none': None,
|
||||
}
|
||||
|
||||
if window_name == 'ones':
|
||||
window_tensor = torch.ones(self.window_size)
|
||||
else:
|
||||
try:
|
||||
window_fn = torch_windows.get(window_name, None)
|
||||
except:
|
||||
raise ValueError(
|
||||
f"{self} received '{window_name}' for the window parameter."
|
||||
f" It must be one of: ('hann', 'ones', 'hamming', 'blackman', 'bartlett', None)."
|
||||
f" None is equivalent to 'hann'."
|
||||
)
|
||||
window_tensor = window_fn(self.window_size, periodic=False) if window_fn else None
|
||||
self.window = window_tensor.numpy().tolist() if window_tensor is not None else None
|
||||
|
||||
self.n_fft = params['n_fft'] if 'n_fft' in params else 2 ** math.ceil(math.log2(self.window_size))
|
||||
self.n_mels = params['n_mels'] if 'n_mels' in params else 64
|
||||
self.n_mfcc = params['n_mfcc'] if 'n_mfcc' in params else 64
|
||||
|
||||
features = params['features'] if 'features' in params else 0
|
||||
if features > 0:
|
||||
if feature_type == 'mel_spectrogram':
|
||||
self.n_mels = features
|
||||
elif feature_type == 'mfcc':
|
||||
self.n_mfcc = features
|
||||
|
||||
# TODO Implement frame splicing
|
||||
if 'frame_splicing' in params:
|
||||
assert params['frame_splicing'] == 1, "Frame splicing is not implemented"
|
||||
|
||||
self.freq_low = params['lowfreq'] if 'lowfreq' in params else 0.0
|
||||
self.freq_high = params['highfreq'] if 'highfreq' in params else self.sample_rate / 2.0
|
||||
self.log_features = params['log'] if 'log' in params else True
|
||||
|
||||
# We want to avoid taking the log of zero
|
||||
# There are two options: either adding or clamping to a small value
|
||||
|
||||
self.log_zero_guard_type = params['log_zero_guard_type'] if 'log_zero_guard_type' in params else 'add'
|
||||
if self.log_zero_guard_type not in ["add", "clamp"]:
|
||||
raise ValueError(
|
||||
f"{self} received {self.log_zero_guard_type} for the "
|
||||
f"log_zero_guard_type parameter. It must be either 'add' or "
|
||||
f"'clamp'."
|
||||
)
|
||||
|
||||
self.log_zero_guard_value = params['log_zero_guard_value'] if 'log_zero_guard_value' in params else 2**-24
|
||||
if isinstance(self.log_zero_guard_value, str):
|
||||
if self.log_zero_guard_value == "tiny":
|
||||
self.log_zero_guard_value = torch.finfo(torch.float32).tiny
|
||||
elif self.log_zero_guard_value == "eps":
|
||||
self.log_zero_guard_value = torch.finfo(torch.float32).eps
|
||||
else:
|
||||
raise ValueError(
|
||||
f"{self} received {self.log_zero_guard_value} for the log_zero_guard_type parameter."
|
||||
f"It must be either a number, 'tiny', or 'eps'"
|
||||
)
|
||||
|
||||
self.mag_power = params['mag_power'] if 'mag_power' in params else 2
|
||||
if self.mag_power != 1.0 and self.mag_power != 2.0:
|
||||
raise ValueError(
|
||||
f"{self} received {self.mag_power} for the mag_power parameter." f" It must be either 1.0 or 2.0."
|
||||
)
|
||||
|
||||
self.pad_to = max(params['pad_to'], 1) if 'pad_to' in params else 16
|
||||
self.pad_value = params['pad_value'] if 'pad_value' in params else 0.0
|
||||
|
||||
with self.pipe:
|
||||
if audio_tar_filepaths is None and audio_tar_index_filepaths is None:
|
||||
audio, indices = dali.fn.readers.nemo_asr(
|
||||
name="Reader",
|
||||
manifest_filepaths=manifest_filepath.split(','),
|
||||
dtype=dali.types.FLOAT,
|
||||
downmix=True,
|
||||
sample_rate=float(self.sample_rate),
|
||||
min_duration=min_duration,
|
||||
max_duration=max_duration,
|
||||
read_sample_rate=False,
|
||||
read_text=False,
|
||||
read_idxs=True,
|
||||
random_shuffle=shuffle,
|
||||
shard_id=self.shard_id,
|
||||
num_shards=self.num_shards,
|
||||
pad_last_batch=True,
|
||||
)
|
||||
|
||||
self.is_tarred_dataset = False
|
||||
|
||||
elif audio_tar_filepaths is not None and audio_tar_index_filepaths is not None:
|
||||
audio_tar_filepaths = expand_sharded_filepaths(
|
||||
audio_tar_filepaths,
|
||||
shard_strategy=shard_strategy,
|
||||
world_size=world_size,
|
||||
global_rank=global_rank,
|
||||
)
|
||||
|
||||
audio_tar_index_filepaths = expand_sharded_filepaths(
|
||||
audio_tar_index_filepaths,
|
||||
shard_strategy=shard_strategy,
|
||||
world_size=world_size,
|
||||
global_rank=global_rank,
|
||||
)
|
||||
|
||||
if len(audio_tar_filepaths) != len(audio_tar_index_filepaths) and len(audio_tar_index_filepaths) != 0:
|
||||
raise ValueError(
|
||||
f"Number of filepaths provided for `audio_tar_filepaths` must match "
|
||||
f"`audio_tar_index_filepaths`. Got {len(audio_tar_filepaths)} audio_tar_filepaths and "
|
||||
f"{len(audio_tar_index_filepaths)} audio_tar_index_filepaths."
|
||||
)
|
||||
|
||||
tar_file = dali.fn.readers.webdataset(
|
||||
paths=audio_tar_filepaths,
|
||||
index_paths=audio_tar_index_filepaths,
|
||||
name="Reader",
|
||||
ext=["wav"],
|
||||
missing_component_behavior="error",
|
||||
random_shuffle=shuffle,
|
||||
shard_id=self.shard_id,
|
||||
num_shards=self.num_shards,
|
||||
pad_last_batch=True,
|
||||
)
|
||||
audio, _ = dali.fn.decoders.audio(
|
||||
tar_file,
|
||||
dtype=dali.types.FLOAT,
|
||||
downmix=True,
|
||||
sample_rate=float(self.sample_rate),
|
||||
)
|
||||
indices = dali.fn.get_property(tar_file, key="source_info")
|
||||
indices = dali.fn.pad(indices)
|
||||
|
||||
self.is_tarred_dataset = True
|
||||
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"When using DALI datasets, either `audio_tar_filepaths` "
|
||||
"and `audio_tar_index_filepaths` should either both be None (sequential dataset)"
|
||||
"or provided (tarred dataset)."
|
||||
)
|
||||
|
||||
# Extract nonsilent region, if necessary
|
||||
if trim:
|
||||
# Need to extract non-silent region before moving to the GPU
|
||||
roi_start, roi_len = dali.fn.nonsilent_region(audio, cutoff_db=-60)
|
||||
audio = audio.gpu() if self.device == 'gpu' else audio
|
||||
audio = dali.fn.slice(
|
||||
audio, roi_start, roi_len, normalized_anchor=False, normalized_shape=False, axes=[0]
|
||||
)
|
||||
else:
|
||||
audio = audio.gpu() if self.device == 'gpu' else audio
|
||||
|
||||
if not has_preprocessor:
|
||||
# No preprocessing, the output is the audio signal
|
||||
audio_len = dali.fn.shapes(dali.fn.reshape(audio, shape=[-1]))
|
||||
audio = dali.fn.pad(audio)
|
||||
self.pipe.set_outputs(audio, audio_len, indices)
|
||||
else:
|
||||
# Additive gaussian noise (dither)
|
||||
if self.dither > 0.0:
|
||||
gaussian_noise = dali.fn.random.normal(audio)
|
||||
audio = audio + self.dither * gaussian_noise
|
||||
|
||||
# Preemphasis filter
|
||||
if self.preemph > 0.0:
|
||||
audio = dali.fn.preemphasis_filter(audio, preemph_coeff=self.preemph, border='zero')
|
||||
|
||||
# Power spectrogram
|
||||
spec = dali.fn.spectrogram(
|
||||
audio,
|
||||
nfft=self.n_fft,
|
||||
window_length=self.window_size,
|
||||
window_step=self.window_stride,
|
||||
window_fn=self.window,
|
||||
)
|
||||
|
||||
if feature_type == 'mel_spectrogram' or feature_type == 'mfcc':
|
||||
# Spectrogram to Mel Spectrogram
|
||||
spec = dali.fn.mel_filter_bank(
|
||||
spec,
|
||||
sample_rate=self.sample_rate,
|
||||
nfilter=self.n_mels,
|
||||
normalize=True,
|
||||
freq_low=self.freq_low,
|
||||
freq_high=self.freq_high,
|
||||
)
|
||||
# Mel Spectrogram to MFCC
|
||||
if feature_type == 'mfcc':
|
||||
spec = dali.fn.mfcc(spec, n_mfcc=self.n_mfcc)
|
||||
|
||||
# Logarithm
|
||||
if self.log_zero_guard_type == 'add':
|
||||
spec = spec + self.log_zero_guard_value
|
||||
|
||||
spec = dali.fn.to_decibels(
|
||||
spec, multiplier=math.log(10), reference=1.0, cutoff_db=math.log(self.log_zero_guard_value)
|
||||
)
|
||||
|
||||
# Normalization
|
||||
spec = dali.fn.normalize(spec, axes=self.normalization_axes, epsilon=1e-5**2, ddof=1)
|
||||
|
||||
# Extracting the length of the spectrogram
|
||||
spec_len = dali.fn.slice(dali.fn.shapes(spec), 1, 1, axes=(0,))
|
||||
|
||||
# Pads feature dimension to be a multiple of `pad_to` and the temporal dimension to be as big as the largest sample (shape -1)
|
||||
spec = dali.fn.pad(spec, fill_value=self.pad_value, axes=(0, 1), align=(self.pad_to, 1), shape=(1, -1))
|
||||
self.pipe.set_outputs(spec, spec_len, indices)
|
||||
|
||||
x = time.time()
|
||||
# Building DALI pipeline
|
||||
self.pipe.build()
|
||||
y = time.time()
|
||||
|
||||
logging.info(f"Time for pipe.build() : {(y - x)} seconds")
|
||||
|
||||
if has_preprocessor:
|
||||
output_names = ['processed_signal', 'processed_signal_len', 'manifest_indices']
|
||||
else:
|
||||
output_names = ['audio', 'audio_len', 'manifest_indices']
|
||||
|
||||
x = time.time()
|
||||
last_batch_policy = LastBatchPolicy.DROP if drop_last else LastBatchPolicy.PARTIAL
|
||||
self._iter = DALIPytorchIterator(
|
||||
[self.pipe],
|
||||
output_map=output_names,
|
||||
reader_name="Reader",
|
||||
last_batch_policy=last_batch_policy,
|
||||
dynamic_shape=True,
|
||||
auto_reset=True,
|
||||
)
|
||||
y = time.time()
|
||||
logging.info(f"Time for DALIPytorchIterator to initialize : {(y - x)} seconds")
|
||||
|
||||
# TODO come up with a better solution
|
||||
class DummyDataset:
|
||||
def __init__(self, parent):
|
||||
self.parent = parent
|
||||
|
||||
def __len__(self):
|
||||
return self.parent.size
|
||||
|
||||
self.dataset = DummyDataset(self) # Used by NeMo
|
||||
|
||||
x = time.time()
|
||||
self.manifest_processor = ASRManifestProcessor(
|
||||
manifest_filepath=manifest_filepath,
|
||||
parser=parser,
|
||||
max_duration=max_duration,
|
||||
min_duration=min_duration,
|
||||
max_utts=0,
|
||||
bos_id=bos_id,
|
||||
eos_id=eos_id,
|
||||
pad_id=pad_id,
|
||||
index_by_file_id=self.is_tarred_dataset,
|
||||
)
|
||||
y = time.time()
|
||||
logging.info(f"Time to build nemo manifest processor - {(y - x)} seconds")
|
||||
|
||||
def reset(self):
|
||||
self._iter.reset()
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def next(self):
|
||||
return self.__next__()
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return self._iter.size
|
||||
|
||||
def __len__(self):
|
||||
return len(self._iter)
|
||||
|
||||
def __next__(self):
|
||||
outputs = self._iter.next()
|
||||
assert len(outputs) == 1
|
||||
dali_out = outputs[0]
|
||||
manifest_indices = dali_out['manifest_indices'].numpy()
|
||||
|
||||
out = {}
|
||||
out_names = ['processed_signal', 'processed_signal_len', 'audio', 'audio_len']
|
||||
for out_name in out_names:
|
||||
if out_name in dali_out:
|
||||
out[out_name] = dali_out[out_name].detach().clone()
|
||||
|
||||
text_tokens = []
|
||||
text_tokens_len = []
|
||||
max_len = 0
|
||||
batch_size = manifest_indices.shape[0]
|
||||
for i, manifest_index in enumerate(manifest_indices):
|
||||
|
||||
if not self.is_tarred_dataset:
|
||||
# Loose-file dataset. Index is integer based.
|
||||
manifest_index = manifest_index[0]
|
||||
text, text_length = self.manifest_processor.process_text_by_id(manifest_index)
|
||||
else:
|
||||
# Tarred-file dataset. Index is filename based.
|
||||
resolved_manifest_indices = manifest_index.tobytes().decode().split(":")
|
||||
resolved_manifest_index = resolved_manifest_indices[2] # we require just the filename segment
|
||||
resolved_manifest_index = os.path.splitext(resolved_manifest_index)[0] # we dont need file extension
|
||||
text, text_length = self.manifest_processor.process_text_by_file_id(resolved_manifest_index)
|
||||
|
||||
text_tokens_len.append(text_length)
|
||||
text_tokens.append(text)
|
||||
if text_length > max_len:
|
||||
max_len = text_length
|
||||
|
||||
transcript_out = torch.full([batch_size, max_len], fill_value=self.manifest_processor.pad_id, dtype=torch.long)
|
||||
for i, n in enumerate(text_tokens_len):
|
||||
transcript_out[i, :n] = torch.tensor(text_tokens[i], dtype=torch.long)
|
||||
transcript_len_out = torch.tensor(text_tokens_len, dtype=torch.long)
|
||||
|
||||
out['transcript'] = transcript_out
|
||||
out['transcript_len'] = transcript_len_out
|
||||
return DALIOutputs(out)
|
||||
|
||||
|
||||
class AudioToCharDALIDataset(_AudioTextDALIDataset):
|
||||
"""
|
||||
Character based NVIDIA DALI pipeline that loads tensors via one or more manifest files where each line containing a
|
||||
sample descriptor in JSON, including audio files, transcripts, and durations (in seconds).
|
||||
Here's an example:
|
||||
{"audio_filepath": "/path/to/audio.wav", "text_filepath": "/path/to/audio.txt", "duration": 23.147}
|
||||
...
|
||||
{"audio_filepath": "/path/to/audio.wav", "text": "the transcription", "offset": 301.75, "duration": 0.82, "utt":
|
||||
"utterance_id", "ctm_utt": "en_4156", "side": "A"}
|
||||
|
||||
Args:
|
||||
manifest_filepath: Path to manifest file with the format described above. Can be comma-separated paths.
|
||||
device (str): Determines the device type to be used for preprocessing. Allowed values are: 'cpu', 'gpu'.
|
||||
batch_size (int): Number of samples in a batch.
|
||||
labels (List[str]): String containing all the possible characters to map to.
|
||||
sample_rate (int): Sample rate to resample loaded audio to.
|
||||
num_threads (int): Number of CPU processing threads to be created by the DALI pipeline.
|
||||
max_duration (float): Determines the maximum allowed duration, in seconds, of the loaded audio files.
|
||||
min_duration (float): Determines the minimum allowed duration, in seconds, of the loaded audio files.
|
||||
blank_index (int): blank character index, default = -1
|
||||
unk_index (int): unk_character index, default = -1
|
||||
normalize (bool): whether to normalize transcript text (default): True
|
||||
bos_id (int): Id of beginning of sequence symbol to append if not None
|
||||
eos_id (int): Id of end of sequence symbol to append if not None
|
||||
pad_id (int): Id used to pad the input. Defaults to 0 if not provided.
|
||||
trim (bool): If True, it will extract the nonsilent region of the loaded audio signal.
|
||||
shuffle (bool): If set to True, the dataset will shuffled after loading.
|
||||
drop_last (bool): If set to True, the last batch will be dropped if incomplete. This will be the case when the shard size is not divisible by the batch size.
|
||||
If set to False and the size of dataset is not divisible by the batch size, then the last batch will be smaller.
|
||||
parser (str, callable): A str for an inbuilt parser, or a callable with signature f(str) -> List[int].
|
||||
device_id (int): Index of the GPU to be used (local_rank). Only applicable when device == 'gpu'. Defaults to 0.
|
||||
global_rank (int): Worker rank, used for partitioning shards. Defaults to 0.
|
||||
world_size (int): Total number of processes, used for partitioning shards. Defaults to 1.
|
||||
preprocessor_cfg (DictConfig): Preprocessor configuration. Supports AudioToMelSpectrogramPreprocessor and AudioToMFCCPreprocessor.
|
||||
return_sample_id (bool): whether to return the sample_id as a part of each sample (not supported yet).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manifest_filepath: str,
|
||||
device: str,
|
||||
batch_size: int,
|
||||
labels: Union[str, List[str]],
|
||||
sample_rate: int = 16000,
|
||||
audio_tar_filepaths: Optional[Union[str, List[str]]] = None,
|
||||
audio_tar_index_filepaths: Optional[Union[str, List[str]]] = None,
|
||||
num_threads: int = 4,
|
||||
max_duration: float = 0.0,
|
||||
min_duration: float = 0.0,
|
||||
blank_index: int = -1,
|
||||
unk_index: int = -1,
|
||||
normalize: bool = True,
|
||||
bos_id: Optional[int] = None,
|
||||
eos_id: Optional[int] = None,
|
||||
pad_id: int = 0,
|
||||
trim: bool = False,
|
||||
shuffle: bool = False,
|
||||
drop_last: bool = False,
|
||||
parser: Union[str, Callable] = 'en',
|
||||
shard_strategy: str = "scatter",
|
||||
device_id: int = 0,
|
||||
global_rank: int = 0,
|
||||
world_size: int = 1,
|
||||
preprocessor_cfg: DictConfig = None,
|
||||
return_sample_id: bool = False,
|
||||
):
|
||||
self.labels = labels
|
||||
|
||||
parser = parsers.make_parser(
|
||||
labels=labels, name=parser, unk_id=unk_index, blank_id=blank_index, do_normalize=normalize
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
manifest_filepath=manifest_filepath,
|
||||
device=device,
|
||||
batch_size=batch_size,
|
||||
audio_tar_filepaths=audio_tar_filepaths,
|
||||
audio_tar_index_filepaths=audio_tar_index_filepaths,
|
||||
sample_rate=sample_rate,
|
||||
num_threads=num_threads,
|
||||
max_duration=max_duration,
|
||||
min_duration=min_duration,
|
||||
bos_id=bos_id,
|
||||
eos_id=eos_id,
|
||||
pad_id=pad_id,
|
||||
trim=trim,
|
||||
shuffle=shuffle,
|
||||
drop_last=drop_last,
|
||||
parser=parser,
|
||||
shard_strategy=shard_strategy,
|
||||
device_id=device_id,
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
preprocessor_cfg=preprocessor_cfg,
|
||||
return_sample_id=return_sample_id,
|
||||
)
|
||||
|
||||
|
||||
class AudioToBPEDALIDataset(_AudioTextDALIDataset):
|
||||
"""
|
||||
Subword based NVIDIA DALI pipeline that loads tensors via one or more manifest files where each line containing a
|
||||
sample descriptor in JSON, including audio files, transcripts, and durations (in seconds).
|
||||
Here's an example:
|
||||
{"audio_filepath": "/path/to/audio.wav", "text_filepath": "/path/to/audio.txt", "duration": 23.147}
|
||||
...
|
||||
{"audio_filepath": "/path/to/audio.wav", "text": "the transcription", "offset": 301.75, "duration": 0.82, "utt":
|
||||
"utterance_id", "ctm_utt": "en_4156", "side": "A"}
|
||||
|
||||
Args:
|
||||
manifest_filepath: Path to manifest file with the format described above. Can be comma-separated paths.
|
||||
tokenizer (TokenizerSpec): A TokenizerSpec implementation that wraps a tokenization implementation.
|
||||
device (str): Determines the device type to be used for preprocessing. Allowed values are: 'cpu', 'gpu'.
|
||||
batch_size (int): Number of samples in a batch.
|
||||
sample_rate (int): Sample rate to resample loaded audio to.
|
||||
num_threads (int): Number of CPU processing threads to be created by the DALI pipeline.
|
||||
max_duration (float): Determines the maximum allowed duration, in seconds, of the loaded audio files.
|
||||
min_duration (float): Determines the minimum allowed duration, in seconds, of the loaded audio files.
|
||||
bos_id (int): Id of beginning of sequence symbol to append if not None. Injected from the tokenizer.
|
||||
eos_id (int): Id of end of sequence symbol to append if not None. Injected from the tokenizer.
|
||||
pad_id (int): Id used to pad the input. Defaults to 0 if not provided. Injected from the tokenizer.
|
||||
trim (bool): If True, it will extract the nonsilent region of the loaded audio signal.
|
||||
shuffle (bool): If set to True, the dataset will shuffled after loading.
|
||||
drop_last (bool): If set to True, the last batch will be dropped if incomplete. This will be the case when the shard size is not divisible by the batch size.
|
||||
If set to False and the size of dataset is not divisible by the batch size, then the last batch will be smaller.
|
||||
|
||||
device_id (int): Index of the GPU to be used (local_rank). Only applicable when device == 'gpu'. Defaults to 0.
|
||||
global_rank (int): Worker rank, used for partitioning shards. Defaults to 0.
|
||||
world_size (int): Total number of processes, used for partitioning shards. Defaults to 1.
|
||||
preprocessor_cfg (DictConfig): Preprocessor configuration. Supports AudioToMelSpectrogramPreprocessor and AudioToMFCCPreprocessor.
|
||||
use_start_end_token (bool): Boolean which dictates whether to add [BOS] and [EOS] tokens to beginning and
|
||||
ending of speech respectively.
|
||||
return_sample_id (bool): whether to return the sample_id as a part of each sample (not supported yet).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manifest_filepath: str,
|
||||
tokenizer: 'nemo.collections.common.tokenizers.TokenizerSpec',
|
||||
device: str,
|
||||
batch_size: int,
|
||||
sample_rate: int = 16000,
|
||||
audio_tar_filepaths: Optional[Union[str, List[str]]] = None,
|
||||
audio_tar_index_filepaths: Optional[Union[str, List[str]]] = None,
|
||||
num_threads: int = 4,
|
||||
max_duration: float = 0.0,
|
||||
min_duration: float = 0.0,
|
||||
trim: bool = False,
|
||||
shuffle: bool = False,
|
||||
drop_last: bool = False,
|
||||
shard_strategy: str = "scatter",
|
||||
device_id: int = 0,
|
||||
global_rank: int = 0,
|
||||
world_size: int = 1,
|
||||
preprocessor_cfg: DictConfig = None,
|
||||
use_start_end_token: bool = True,
|
||||
return_sample_id: bool = False,
|
||||
):
|
||||
|
||||
if use_start_end_token and hasattr(tokenizer, 'bos_token'):
|
||||
bos_id = tokenizer.bos_id
|
||||
else:
|
||||
bos_id = None
|
||||
|
||||
if use_start_end_token and hasattr(tokenizer, 'eos_token'):
|
||||
eos_id = tokenizer.eos_id
|
||||
else:
|
||||
eos_id = None
|
||||
|
||||
if hasattr(tokenizer, 'pad_token'):
|
||||
pad_id = tokenizer.pad_id
|
||||
else:
|
||||
pad_id = 0
|
||||
|
||||
class TokenizerWrapper:
|
||||
def __init__(self, tokenizer):
|
||||
self._tokenizer = tokenizer
|
||||
|
||||
def __call__(self, text):
|
||||
t = self._tokenizer.text_to_ids(text)
|
||||
return t
|
||||
|
||||
super().__init__(
|
||||
manifest_filepath=manifest_filepath,
|
||||
device=device,
|
||||
batch_size=batch_size,
|
||||
sample_rate=sample_rate,
|
||||
audio_tar_filepaths=audio_tar_filepaths,
|
||||
audio_tar_index_filepaths=audio_tar_index_filepaths,
|
||||
num_threads=num_threads,
|
||||
max_duration=max_duration,
|
||||
min_duration=min_duration,
|
||||
bos_id=bos_id,
|
||||
eos_id=eos_id,
|
||||
pad_id=pad_id,
|
||||
trim=trim,
|
||||
shuffle=shuffle,
|
||||
drop_last=drop_last,
|
||||
parser=TokenizerWrapper(tokenizer),
|
||||
shard_strategy=shard_strategy,
|
||||
device_id=device_id,
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
preprocessor_cfg=preprocessor_cfg,
|
||||
return_sample_id=return_sample_id,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
# 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 os
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import torch.utils.data
|
||||
from lhotse.dataset import AudioSamples
|
||||
from lhotse.dataset.collation import collate_vectors
|
||||
|
||||
from nemo.collections.common.tokenizers.aggregate_tokenizer import TokenizerWrapper
|
||||
from nemo.collections.common.tokenizers.tokenizer_spec import TokenizerSpec
|
||||
from nemo.core.neural_types import AudioSignal, LabelsType, LengthsType, NeuralType
|
||||
|
||||
|
||||
class LhotseSpeechToTextBpeDataset(torch.utils.data.Dataset):
|
||||
"""
|
||||
This dataset is based on BPE datasets from audio_to_text.py.
|
||||
Unlike native NeMo datasets, Lhotse dataset defines only the mapping from
|
||||
a CutSet (meta-data) to a mini-batch with PyTorch tensors.
|
||||
Specifically, it performs tokenization, I/O, augmentation, and feature extraction (if any).
|
||||
Managing data, sampling, de-duplication across workers/nodes etc. is all handled
|
||||
by Lhotse samplers instead.
|
||||
|
||||
NOTE:
|
||||
If the environment variable ``USE_AIS_GET_BATCH`` is set to ``true`` (case-insensitive),
|
||||
then batch audio loading from AIStore will be enabled for this dataset. This will use the
|
||||
AISBatchLoader to load the audio from AIStore. This can improve data loading efficiency in some setups.
|
||||
"""
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
return {
|
||||
'audio_signal': NeuralType(('B', 'T'), AudioSignal()),
|
||||
'a_sig_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'transcripts': NeuralType(('B', 'T'), LabelsType()),
|
||||
'transcript_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'sample_id': NeuralType(tuple('B'), LengthsType(), optional=True),
|
||||
}
|
||||
|
||||
def __init__(self, tokenizer: TokenizerSpec, return_cuts: bool = False):
|
||||
super().__init__()
|
||||
self.tokenizer = TokenizerWrapper(tokenizer)
|
||||
self.use_ais_get_batch = os.environ.get("USE_AIS_GET_BATCH", "False").lower() == "true"
|
||||
|
||||
# Try to use use_batch_loader if available (Lhotse >= 1.32.0)
|
||||
try:
|
||||
self.load_audio = AudioSamples(fault_tolerant=True, use_batch_loader=self.use_ais_get_batch)
|
||||
except TypeError:
|
||||
# Lhotse < 1.32.0 doesn't support use_batch_loader
|
||||
if self.use_ais_get_batch:
|
||||
import logging
|
||||
|
||||
logging.warning(
|
||||
"AIS batch loading requested but not supported by this Lhotse version. "
|
||||
"Please upgrade to Lhotse >= 1.32.0"
|
||||
)
|
||||
self.load_audio = AudioSamples(fault_tolerant=True)
|
||||
|
||||
self.return_cuts = return_cuts
|
||||
|
||||
def __getitem__(self, cuts) -> Tuple[torch.Tensor, ...]:
|
||||
audio, audio_lens, cuts = self.load_audio(cuts)
|
||||
tokens = [
|
||||
torch.cat(
|
||||
[
|
||||
torch.as_tensor(s.tokens if hasattr(s, "tokens") else self.tokenizer(s.text or "", s.language))
|
||||
for s in c.supervisions
|
||||
],
|
||||
dim=0,
|
||||
)
|
||||
for c in cuts
|
||||
]
|
||||
token_lens = torch.tensor([t.size(0) for t in tokens], dtype=torch.long)
|
||||
tokens = collate_vectors(tokens, padding_value=0)
|
||||
if self.return_cuts:
|
||||
return audio, audio_lens, tokens, token_lens, cuts.drop_in_memory_data()
|
||||
return audio, audio_lens, tokens, token_lens
|
||||
@@ -0,0 +1,177 @@
|
||||
# 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 math
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.utils.data
|
||||
from lhotse.dataset import AudioSamples
|
||||
from lhotse.dataset.collation import collate_matrices, collate_vectors
|
||||
|
||||
from nemo.collections.common.tokenizers.aggregate_tokenizer import AggregateTokenizer
|
||||
from nemo.collections.common.tokenizers.tokenizer_spec import TokenizerSpec
|
||||
from nemo.core.neural_types import AudioSignal, LabelsType, LengthsType, NeuralType
|
||||
|
||||
|
||||
class LhotseSpeechToTextBpeDatasetWithPrompt(torch.utils.data.Dataset):
|
||||
"""
|
||||
Dataset class for speech-to-text with prompt vectors.
|
||||
Supports both language ID and custom prompts.
|
||||
"""
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
return {
|
||||
'audio_signal': NeuralType(('B', 'T'), AudioSignal()),
|
||||
'audio_signal_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'transcripts': NeuralType(('B', 'T'), LabelsType()),
|
||||
'transcript_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'prompt': NeuralType(('B', 'T', 'D'), LabelsType()),
|
||||
}
|
||||
|
||||
def __init__(self, tokenizer, cfg):
|
||||
super().__init__()
|
||||
self.tokenizer = TokenizerWrapper(tokenizer)
|
||||
self.load_audio = AudioSamples(fault_tolerant=True)
|
||||
self.cfg = cfg
|
||||
|
||||
# Calculate num_sample_per_mel_frame from config
|
||||
sample_rate = cfg.get('sample_rate', 16000)
|
||||
window_stride = cfg.get('window_stride', 0.01)
|
||||
self.num_sample_per_mel_frame = int(sample_rate * window_stride)
|
||||
|
||||
self.subsampling_factor = cfg.get('subsampling_factor', 8)
|
||||
|
||||
# Load prompt dictionary from config if provided
|
||||
self.prompt_dict = cfg.get('prompt_dictionary')
|
||||
if self.prompt_dict:
|
||||
# Set num_prompts based on the length of prompt_dictionary or a minimum value
|
||||
# This ensures we have enough dimensions in our embedding space to add scale up without changing the model
|
||||
self.num_prompts = cfg.get('num_prompts', 128)
|
||||
|
||||
# Field to use for prompt key (default to 'language')
|
||||
self.prompt_field = cfg.get('prompt_field', 'language')
|
||||
|
||||
def _get_prompt_index(self, prompt_key: str) -> int:
|
||||
"""
|
||||
Maps prompt keys to indices using the prompt dictionary.
|
||||
"""
|
||||
if not self.prompt_dict:
|
||||
raise ValueError("Prompt dictionary is empty. Please provide a valid prompt_dictionary in the config.")
|
||||
|
||||
if prompt_key not in self.prompt_dict:
|
||||
available_keys = list(self.prompt_dict.keys())
|
||||
raise ValueError(
|
||||
f"Unknown prompt key: '{prompt_key}'. Available prompts: {available_keys[:10]}{'...' if len(available_keys) > 10 else ''}"
|
||||
)
|
||||
|
||||
return self.prompt_dict[prompt_key]
|
||||
|
||||
def prompt_to_target(self, cut, num_prompts: int, window_stride: int, subsampling_factor: int):
|
||||
"""
|
||||
Create prompt target tensor for the sequence.
|
||||
"""
|
||||
# Calculate encoder output length based on subsampling factor
|
||||
encoder_hidden_len = self.get_hidden_length_from_sample_length(cut.num_samples)
|
||||
|
||||
# Initialize prompt target matrix
|
||||
mask = np.zeros((num_prompts, encoder_hidden_len))
|
||||
|
||||
# Get prompt index - default to language if prompt not specified
|
||||
# revise supervisions to include prompt key
|
||||
# prompt_key = getattr(cut.supervisions[0].custom_fields, cut.supervisions[0].language)cut.supervisions[0].custom_fields,
|
||||
prompt_id = self._get_prompt_index(cut.supervisions[0].language)
|
||||
|
||||
# Set the corresponding prompt ID to 1 for all time steps
|
||||
mask[prompt_id, :] = 1
|
||||
|
||||
return mask
|
||||
|
||||
def get_hidden_length_from_sample_length(self, num_samples: int) -> int:
|
||||
"""
|
||||
Calculate the hidden length from the given number of samples.
|
||||
|
||||
Parameters:
|
||||
num_samples (int): The total number of audio samples.
|
||||
|
||||
Returns:
|
||||
hidden_length (int): The calculated hidden length in terms of the number of frames.
|
||||
"""
|
||||
mel_frame_count = math.ceil((num_samples + 1) / self.num_sample_per_mel_frame)
|
||||
hidden_length = math.ceil(mel_frame_count / self.subsampling_factor)
|
||||
return int(hidden_length)
|
||||
|
||||
def __getitem__(self, cuts) -> Tuple[torch.Tensor, ...]:
|
||||
audio, audio_lens, cuts = self.load_audio(cuts)
|
||||
tokens = [torch.as_tensor(self.tokenizer(c.supervisions[0].text, c.supervisions[0].language)) for c in cuts]
|
||||
|
||||
# Create prompt targets
|
||||
prompt_targets = [
|
||||
torch.transpose(
|
||||
torch.as_tensor(
|
||||
self.prompt_to_target(
|
||||
c,
|
||||
self.num_prompts,
|
||||
self.num_sample_per_mel_frame,
|
||||
self.subsampling_factor,
|
||||
),
|
||||
dtype=torch.float32,
|
||||
),
|
||||
0,
|
||||
1,
|
||||
)
|
||||
for c in cuts
|
||||
]
|
||||
|
||||
# Create final tensors
|
||||
token_lens = torch.tensor([t.size(0) for t in tokens], dtype=torch.long)
|
||||
tokens = collate_vectors(tokens, padding_value=0)
|
||||
prompt_targets = collate_matrices(prompt_targets)
|
||||
|
||||
return (
|
||||
audio, # Audio signal
|
||||
audio_lens, # Audio lengths
|
||||
tokens, # Text tokens
|
||||
token_lens, # Token lengths
|
||||
prompt_targets, # Prompt targets
|
||||
)
|
||||
|
||||
|
||||
class TokenizerWrapper:
|
||||
"""
|
||||
Provide a unified interface for NeMo Tokenizer, AggregateTokenizer, and (char) Parser.
|
||||
"""
|
||||
|
||||
def __init__(self, tokenizer):
|
||||
self._tokenizer = tokenizer
|
||||
if isinstance(tokenizer, AggregateTokenizer):
|
||||
self._impl = self._call_agg_tokenizer
|
||||
elif isinstance(tokenizer, TokenizerSpec):
|
||||
self._impl = self._call_tokenizer
|
||||
else:
|
||||
self._impl = self._call_parser
|
||||
|
||||
def __call__(self, text: str, lang: Optional[str] = None):
|
||||
return self._impl(text, lang)
|
||||
|
||||
def _call_agg_tokenizer(self, text: str, lang: Optional[str] = None):
|
||||
assert lang is not None, "Expected 'lang' to be set for AggregateTokenizer."
|
||||
return self._tokenizer.text_to_ids(text, lang)
|
||||
|
||||
def _call_tokenizer(self, text: str, lang: Optional[str] = None):
|
||||
return self._tokenizer.text_to_ids(text)
|
||||
|
||||
def _call_parser(self, text: str, lang: Optional[str] = None):
|
||||
return self._tokenizer(text)
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Simplified Lhotse dataset that returns language ID indices instead of full prompt tensors.
|
||||
The model creates the prompt tensor using the actual encoded length.
|
||||
"""
|
||||
|
||||
import random
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import torch
|
||||
import torch.utils.data
|
||||
from lhotse.dataset import AudioSamples
|
||||
from lhotse.dataset.collation import collate_vectors
|
||||
|
||||
from nemo.collections.common.tokenizers.aggregate_tokenizer import TokenizerWrapper
|
||||
from nemo.collections.common.tokenizers.tokenizer_spec import TokenizerSpec
|
||||
from nemo.core.neural_types import AudioSignal, LabelsType, LengthsType, NeuralType
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
class LhotseSpeechToTextBpeDatasetWithPromptIndex(torch.utils.data.Dataset):
|
||||
"""
|
||||
Simplified dataset class for speech-to-text with prompt support.
|
||||
|
||||
Instead of computing full prompt tensors, this dataset returns just the
|
||||
language ID index per sample. The model creates the prompt tensor using
|
||||
the actual encoder output length, guaranteeing no size mismatch.
|
||||
|
||||
Returns:
|
||||
audio_signal: Audio waveform [B, T]
|
||||
audio_signal_length: Audio lengths [B]
|
||||
transcripts: Token IDs [B, T]
|
||||
transcript_length: Token lengths [B]
|
||||
prompt_indices: Language ID indices [B] (NOT full tensors)
|
||||
"""
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
return {
|
||||
'audio_signal': NeuralType(('B', 'T'), AudioSignal()),
|
||||
'audio_signal_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'transcripts': NeuralType(('B', 'T'), LabelsType()),
|
||||
'transcript_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'prompt_indices': NeuralType(tuple('B'), LabelsType()), # Just indices, not full tensors
|
||||
}
|
||||
|
||||
def __init__(self, tokenizer: TokenizerSpec, cfg: Dict) -> None:
|
||||
super().__init__()
|
||||
self.tokenizer = TokenizerWrapper(tokenizer)
|
||||
self.load_audio = AudioSamples(fault_tolerant=True)
|
||||
self.cfg = cfg
|
||||
|
||||
# Load prompt dictionary from config
|
||||
self.prompt_dict = cfg.get('prompt_dictionary')
|
||||
if not self.prompt_dict:
|
||||
raise ValueError("prompt_dictionary is required in config")
|
||||
|
||||
self.num_prompts = cfg.get('num_prompts', 128)
|
||||
|
||||
# Per-dataset prompt mode is read from cut.custom["prompt_mode"] at runtime.
|
||||
# Supported values:
|
||||
# "langID" — always pass the real language ID
|
||||
# "auto" — always pass auto (101)
|
||||
# "unified" — randomize: auto with probability unified_auto_ratio, else lang ID
|
||||
# Set via lhotse input_cfg tags, e.g. tags: { prompt_mode: langID }
|
||||
self.prompt_mode_field = cfg.get('prompt_mode_field', 'prompt_mode')
|
||||
self.default_prompt_mode = cfg.get('default_prompt_mode', 'unified')
|
||||
self.unified_auto_ratio = cfg.get('unified_auto_ratio', 0.5)
|
||||
|
||||
# Index used for the language-agnostic / auto prompt
|
||||
self.auto_index = self.prompt_dict.get('auto', 101)
|
||||
|
||||
logging.info(
|
||||
f"LhotseSpeechToTextBpeDatasetWithPromptIndex: "
|
||||
f"default_prompt_mode={self.default_prompt_mode}, "
|
||||
f"unified_auto_ratio={self.unified_auto_ratio}"
|
||||
)
|
||||
|
||||
def _get_prompt_index(self, prompt_key: str) -> int:
|
||||
"""Maps prompt keys to indices using the prompt dictionary."""
|
||||
if prompt_key not in self.prompt_dict:
|
||||
available_keys = list(self.prompt_dict.keys())
|
||||
raise ValueError(
|
||||
f"Unknown prompt key: '{prompt_key}'. Available: {available_keys[:10]}{'...' if len(available_keys) > 10 else ''}"
|
||||
)
|
||||
return self.prompt_dict[prompt_key]
|
||||
|
||||
def _get_prompt_mode(self, cut) -> str:
|
||||
"""Resolve the prompt_mode for a cut from its custom tags."""
|
||||
if cut.custom is not None:
|
||||
mode = cut.custom.get(self.prompt_mode_field)
|
||||
if mode is not None:
|
||||
return mode
|
||||
return self.default_prompt_mode
|
||||
|
||||
def _get_prompt_index_for_cut(self, cut) -> int:
|
||||
"""
|
||||
Determine the prompt index for a cut based on its prompt_mode tag.
|
||||
|
||||
Behaviour depends on prompt_mode (set per-dataset via lhotse
|
||||
input_cfg tags, falling back to ``default_prompt_mode``):
|
||||
"langID" — always return the real language ID
|
||||
(use for inference / language-forced tasks)
|
||||
"auto" — always return auto index (language-agnostic)
|
||||
"unified" — return auto with probability unified_auto_ratio,
|
||||
otherwise the real language ID
|
||||
"""
|
||||
mode = self._get_prompt_mode(cut)
|
||||
|
||||
if mode == 'langID':
|
||||
return self._get_prompt_index(cut.supervisions[0].language)
|
||||
elif mode == 'auto':
|
||||
return self.auto_index
|
||||
elif mode == 'unified':
|
||||
if random.random() < self.unified_auto_ratio:
|
||||
return self.auto_index
|
||||
return self._get_prompt_index(cut.supervisions[0].language)
|
||||
else:
|
||||
logging.warning(f"Unknown prompt_mode '{mode}', falling back to unified")
|
||||
if random.random() < self.unified_auto_ratio:
|
||||
return self.auto_index
|
||||
return self._get_prompt_index(cut.supervisions[0].language)
|
||||
|
||||
def __getitem__(self, cuts) -> Tuple[torch.Tensor, ...]:
|
||||
audio, audio_lens, cuts = self.load_audio(cuts)
|
||||
tokens = [torch.as_tensor(self.tokenizer(c.supervisions[0].text, c.supervisions[0].language)) for c in cuts]
|
||||
|
||||
# Get prompt indices (just the language ID per sample, NOT full tensors)
|
||||
prompt_indices = torch.tensor([self._get_prompt_index_for_cut(c) for c in cuts], dtype=torch.long)
|
||||
|
||||
# Create final tensors
|
||||
token_lens = torch.tensor([t.size(0) for t in tokens], dtype=torch.long)
|
||||
tokens = collate_vectors(tokens, padding_value=0)
|
||||
|
||||
return (
|
||||
audio, # Audio signal [B, T]
|
||||
audio_lens, # Audio lengths [B]
|
||||
tokens, # Text tokens [B, T]
|
||||
token_lens, # Token lengths [B]
|
||||
prompt_indices, # Language ID indices [B] - model creates full tensor
|
||||
)
|
||||
@@ -0,0 +1,269 @@
|
||||
# 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 os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Union
|
||||
|
||||
import torch.utils.data
|
||||
from lhotse import CutSet
|
||||
from lhotse.cut import MixedCut
|
||||
from lhotse.dataset import AudioSamples
|
||||
from lhotse.dataset.collation import collate_vectors
|
||||
|
||||
from nemo.collections.common.data import apply_prompt_format_fn
|
||||
from nemo.collections.common.prompts import PromptFormatter
|
||||
from nemo.collections.common.tokenizers import TokenizerSpec
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptedAudioToTextMiniBatch:
|
||||
audio: torch.Tensor
|
||||
audio_lens: torch.Tensor
|
||||
transcript: torch.Tensor
|
||||
transcript_lens: torch.Tensor
|
||||
prompt: torch.Tensor
|
||||
prompt_lens: torch.Tensor
|
||||
prompted_transcript: torch.Tensor
|
||||
prompted_transcript_lens: torch.Tensor
|
||||
cuts: Optional[CutSet] = None
|
||||
|
||||
def get_decoder_inputs_outputs(self) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""
|
||||
Returns the inputs and outputs of transformer decoder for training.
|
||||
The input is ``prompted_transcript`` (minus last token),
|
||||
and the output is ``prompted_transcript`` (minus first token).
|
||||
"""
|
||||
return self.prompted_transcript[:, :-1], self.prompted_transcript[:, 1:]
|
||||
|
||||
|
||||
class PromptedAudioToTextLhotseDataset(torch.utils.data.Dataset):
|
||||
"""
|
||||
This dataset is based on :class:`~nemo.collections.asr.data.audio_to_text_lhotse.LhotseSpeechToTextBpeDataset`.
|
||||
It is a Lhotse-style dataset that converts a mini-batch of Cuts into tensors.
|
||||
The main difference from ``LhotseSpeechToTextBpeDataset`` is that we introduce
|
||||
a special prompt format for multitask encoder-decoder models.
|
||||
|
||||
To perform the prompt formatting, we accept a ``prompt_format_fn``.
|
||||
It's expected to accept:
|
||||
* a ``CutSet`` which it will internally iterate over for utterances, and
|
||||
* a ``TokenizerWrapper`` object that will be internally used to tokenize the utterances
|
||||
|
||||
Tokenized utterances will be extended with special prompt tokens according to ``prompt_format_fn`` logic.
|
||||
We support cuts with multiple supervision segments -- their tokenized texts will be concatenated before we add the prompt tokens.
|
||||
This is useful, for example, in code-switched scenarios where each segment is spoken in a different language.
|
||||
|
||||
Chunking:
|
||||
If `enable_chunking` is True, each audio sample is split into optimally sized chunks
|
||||
(see `find_optimal_chunk_size` and `chunk_waveform`). This is useful for long audio inputs,
|
||||
allowing the model to process them in manageable segments.
|
||||
|
||||
NOTE:
|
||||
If the environment variable `USE_AIS_GET_BATCH` is set to `true` (case-insensitive),
|
||||
then batch audio loading from AIStore will be enabled for this dataset. This will use the
|
||||
AISBatchLoader to load the audio from AIStore. This can improve data loading efficiency in some setups.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tokenizer: TokenizerSpec,
|
||||
prompt: PromptFormatter,
|
||||
enable_chunking: bool = False,
|
||||
):
|
||||
super().__init__()
|
||||
self.tokenizer = tokenizer
|
||||
self.use_ais_get_batch = os.environ.get("USE_AIS_GET_BATCH", "False").lower() == "true"
|
||||
|
||||
# Try to use use_batch_loader if available (Lhotse >= 1.32.0)
|
||||
try:
|
||||
self.load_audio = AudioSamples(fault_tolerant=True, use_batch_loader=self.use_ais_get_batch)
|
||||
except TypeError:
|
||||
# Lhotse < 1.32.0 doesn't support use_batch_loader
|
||||
if self.use_ais_get_batch:
|
||||
import logging
|
||||
|
||||
logging.warning(
|
||||
"AIS batch loading requested but not supported by this Lhotse version. "
|
||||
"Please upgrade to Lhotse >= 1.32.0"
|
||||
)
|
||||
self.load_audio = AudioSamples(fault_tolerant=True)
|
||||
|
||||
self.padding_value = self.tokenizer.pad_id
|
||||
self.prompt = prompt
|
||||
self.enable_chunking = enable_chunking
|
||||
|
||||
def __getitem__(self, cuts: CutSet) -> PromptedAudioToTextMiniBatch:
|
||||
# Load the audio's from AIS and add them to the CutSet
|
||||
audio, audio_lens, cuts = self.load_audio(cuts)
|
||||
|
||||
# Will work if batch_size is set to 1.
|
||||
if self.enable_chunking:
|
||||
# If dynamic chunking is enabled, split each audio sample into chunks.
|
||||
new_audio = []
|
||||
new_audio_lens = []
|
||||
for i in range(audio.shape[0]):
|
||||
waveform = audio[i, : audio_lens[i]]
|
||||
# Split the waveform into chunks and get their lengths.
|
||||
chunks, chunk_lens = self._chunk_waveform(waveform)
|
||||
new_audio.extend(chunks)
|
||||
new_audio_lens.extend(chunk_lens)
|
||||
# Stack all chunks into a batch.
|
||||
audio = torch.stack(new_audio)
|
||||
audio_lens = torch.tensor(new_audio_lens, dtype=torch.long)
|
||||
# Adding this to allow gathering results of the same audio from different batches
|
||||
if cuts[0].start != 0:
|
||||
cuts[0].id = cuts[0].id + '_cut_segmented'
|
||||
# Fast-path: the tokenization and prompt format ting was already done before sampling.
|
||||
attrs = ("input_ids", "context_ids", "answer_ids")
|
||||
pre_formatted = all(hasattr(c, a) for c in cuts for a in attrs)
|
||||
if pre_formatted:
|
||||
prompts_with_answers, prompts, answers = zip(*((c.input_ids, c.context_ids, c.answer_ids) for c in cuts))
|
||||
else:
|
||||
formatted = [apply_prompt_format_fn(cut, self.prompt) for cut in cuts]
|
||||
prompts_with_answers = [ex["input_ids"] for ex in formatted]
|
||||
prompts = [ex["context_ids"] for ex in formatted]
|
||||
answers = [ex["answer_ids"] for ex in formatted]
|
||||
|
||||
transcript, transcript_lens = self._collate_tokens(answers)
|
||||
prompts_with_answers, prompts_with_answers_lens = self._collate_tokens(prompts_with_answers)
|
||||
prompts, prompt_lens = self._collate_tokens(prompts)
|
||||
|
||||
return PromptedAudioToTextMiniBatch(
|
||||
audio=audio,
|
||||
audio_lens=audio_lens,
|
||||
transcript=transcript,
|
||||
transcript_lens=transcript_lens,
|
||||
prompt=prompts,
|
||||
prompt_lens=prompt_lens,
|
||||
prompted_transcript=prompts_with_answers,
|
||||
prompted_transcript_lens=prompts_with_answers_lens,
|
||||
cuts=_drop_in_memory_data(cuts),
|
||||
)
|
||||
|
||||
def _collate_tokens(self, tokens: list[Union[list[int], torch.Tensor]]) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
tokens = [torch.as_tensor(t) for t in tokens]
|
||||
token_lens = torch.tensor([t.size(0) for t in tokens], dtype=torch.long)
|
||||
tokens = collate_vectors(tokens, padding_value=self.padding_value)
|
||||
return tokens, token_lens
|
||||
|
||||
def _find_optimal_chunk_size(
|
||||
self, total_len: int, min_sec: int = 30, max_sec: int = 40, sample_rate: int = 16000, overlap_sec: float = 1.0
|
||||
) -> int:
|
||||
"""
|
||||
Find the optimal chunk size for audio processing that minimizes paddings to the last chunk.
|
||||
|
||||
Args:
|
||||
total_len (int): Total length of the audio waveform in samples
|
||||
min_sec (int, optional): Minimum chunk size in seconds. Defaults to 30.
|
||||
max_sec (int, optional): Maximum chunk size in seconds. Defaults to 40.
|
||||
sample_rate (int, optional): Audio sample rate in Hz. Defaults to 16000.
|
||||
overlap_sec (float, optional): Overlap duration between consecutive chunks in seconds.
|
||||
Defaults to 1.0.
|
||||
|
||||
Returns:
|
||||
int: Optimal chunk size in samples that maximizes the last chunk length
|
||||
"""
|
||||
best_chunk_size = min_sec * sample_rate
|
||||
best_last_chunk_len = 0
|
||||
if total_len < max_sec * sample_rate:
|
||||
return total_len
|
||||
# Try each possible chunk duration in the range
|
||||
for sec in range(min_sec, max_sec + 1):
|
||||
chunk_size = sec * sample_rate
|
||||
overlap_size = int(overlap_sec * sample_rate)
|
||||
step_size = chunk_size - overlap_size
|
||||
|
||||
if step_size <= 0: # Invalid overlap
|
||||
continue
|
||||
if chunk_size > total_len:
|
||||
continue
|
||||
|
||||
# Calculate how many chunks we'd need and the last chunk's length
|
||||
n_chunks = (total_len + step_size - 1) // step_size
|
||||
last_chunk_len = total_len - step_size * (n_chunks - 1)
|
||||
|
||||
if last_chunk_len > best_last_chunk_len:
|
||||
best_last_chunk_len = last_chunk_len
|
||||
best_chunk_size = chunk_size
|
||||
|
||||
return best_chunk_size
|
||||
|
||||
def _chunk_waveform(
|
||||
self, waveform: torch.Tensor, chunk_size: int = None, overlap_sec: float = 1.0, sample_rate: int = 16000
|
||||
) -> tuple[list[torch.Tensor], list[int]]:
|
||||
"""
|
||||
Split a waveform tensor into overlapping chunks.
|
||||
|
||||
Args:
|
||||
waveform (torch.Tensor): Input audio waveform tensor of shape (time_samples,)
|
||||
chunk_size (int, optional): Size of each chunk in samples. If None, automatically
|
||||
determines optimal chunk size using find_optimal_chunk_size().
|
||||
Defaults to None.
|
||||
sample_rate (int, optional): Audio sample rate in Hz. Defaults to 16000.
|
||||
overlap_sec (float, optional): Overlap duration between consecutive chunks in seconds.
|
||||
Used to calculate step size. Defaults to 2.
|
||||
|
||||
Returns:
|
||||
tuple[list[torch.Tensor], list[int]]: A tuple containing:
|
||||
- List of chunk tensors, each of shape (chunk_size,)
|
||||
- List of original lengths for each chunk before padding (useful for masking
|
||||
padded regions during processing.
|
||||
"""
|
||||
# If chunk_size is None, find the optimal chunk size for this waveform
|
||||
total_len = waveform.shape[0]
|
||||
if chunk_size is None:
|
||||
chunk_size = self._find_optimal_chunk_size(total_len, overlap_sec=overlap_sec)
|
||||
if chunk_size >= total_len:
|
||||
return [waveform], [total_len]
|
||||
overlap_size = int(overlap_sec * sample_rate)
|
||||
step_size = chunk_size - overlap_size
|
||||
chunks = []
|
||||
chunk_lens = []
|
||||
start = 0
|
||||
while start + overlap_size < total_len:
|
||||
end = min(start + chunk_size, total_len)
|
||||
chunk = waveform[start:end]
|
||||
length = chunk.shape[0]
|
||||
if length < chunk_size:
|
||||
pad = torch.zeros(chunk_size - length, dtype=chunk.dtype, device=chunk.device)
|
||||
chunk = torch.cat([chunk, pad], dim=0)
|
||||
chunks.append(chunk)
|
||||
chunk_lens.append(length)
|
||||
start += step_size
|
||||
|
||||
return chunks, chunk_lens
|
||||
|
||||
|
||||
class ProbablyIncorrectLanguageKeyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _drop_in_memory_data(
|
||||
cuts: CutSet,
|
||||
_fields=frozenset(MixedCut.__dataclass_fields__.keys()),
|
||||
) -> CutSet:
|
||||
"""Workaround for an edge case in cuts.drop_in_memory_data() on MixedCut with Lhotse<1.29.0"""
|
||||
ans = []
|
||||
for c in cuts:
|
||||
# Not a mixed cut or a mixed cut that wasn't assigned any extra attributes.
|
||||
if not isinstance(c, MixedCut) or _fields.issuperset(c.__dict__.keys()):
|
||||
ans.append(c.drop_in_memory_data())
|
||||
else:
|
||||
extra_attrs = {k: v for k, v in c.__dict__.items() if k not in _fields}
|
||||
for k in extra_attrs:
|
||||
delattr(c, k)
|
||||
ans.append(c.drop_in_memory_data())
|
||||
for k, v in extra_attrs.items():
|
||||
setattr(ans[-1], k, v)
|
||||
setattr(c, k, v)
|
||||
return CutSet(ans)
|
||||
@@ -0,0 +1,97 @@
|
||||
# Copyright (c) 2024, 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
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import torch.utils.data
|
||||
from lhotse.dataset import AudioSamples
|
||||
from lhotse.dataset.collation import collate_vectors
|
||||
|
||||
from nemo.collections.asr.data.audio_to_text_lhotse import TokenizerWrapper
|
||||
from nemo.collections.asr.parts.utils.asr_multispeaker_utils import speaker_to_target
|
||||
from nemo.collections.common.tokenizers.tokenizer_spec import TokenizerSpec
|
||||
from nemo.core.neural_types import AudioSignal, LabelsType, LengthsType, NeuralType
|
||||
|
||||
|
||||
class LhotseSpeechToTextSpkBpeDataset(torch.utils.data.Dataset):
|
||||
"""
|
||||
This dataset is based on BPE datasets from audio_to_text.py. It has the same functionality of LhotseSpeechToTextBpeDataset but also yield speaker target tensor.
|
||||
Unlike native NeMo datasets, Lhotse dataset defines only the mapping from
|
||||
a CutSet (meta-data) to a mini-batch with PyTorch tensors.
|
||||
Specifically, it performs tokenization, I/O, augmentation, and feature extraction (if any).
|
||||
Managing data, sampling, de-duplication across workers/nodes etc. is all handled
|
||||
by Lhotse samplers instead.
|
||||
"""
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
return {
|
||||
'audio_signal': NeuralType(('B', 'T'), AudioSignal()),
|
||||
'a_sig_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'transcripts': NeuralType(('B', 'T'), LabelsType()),
|
||||
'transcript_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'spk_targets': NeuralType(('B', 'T'), LabelsType()),
|
||||
'bg_spk_targets': NeuralType(('B', 'T'), LabelsType()),
|
||||
}
|
||||
|
||||
def __init__(self, cfg, tokenizer: TokenizerSpec):
|
||||
super().__init__()
|
||||
self.tokenizer = TokenizerWrapper(tokenizer)
|
||||
self.load_audio = AudioSamples(fault_tolerant=True)
|
||||
self.cfg = cfg
|
||||
self.num_speakers = self.cfg.get('num_speakers', 4)
|
||||
self.num_sample_per_mel_frame = self.cfg.get('num_sample_per_mel_frame', 160)
|
||||
self.num_mel_frame_per_asr_frame = self.cfg.get('num_mel_frame_per_asr_frame', 8)
|
||||
self.fixed_spk_id = self.cfg.get('fixed_spk_id', None)
|
||||
self.inference_mode = self.cfg.get('inference_mode', False)
|
||||
|
||||
def __getitem__(self, cuts) -> Tuple[torch.Tensor, ...]:
|
||||
|
||||
audio, audio_lens, cuts = self.load_audio(cuts)
|
||||
|
||||
tokens = []
|
||||
spk_targets = []
|
||||
bg_spk_targets = []
|
||||
|
||||
if self.inference_mode:
|
||||
return audio, audio_lens, None, None, None, None
|
||||
|
||||
for idx, cut in enumerate(cuts):
|
||||
|
||||
speaker_targets, texts = speaker_to_target(
|
||||
a_cut=cut,
|
||||
num_speakers=self.num_speakers,
|
||||
num_sample_per_mel_frame=self.num_sample_per_mel_frame,
|
||||
num_mel_frame_per_asr_frame=self.num_mel_frame_per_asr_frame,
|
||||
return_text=True,
|
||||
)
|
||||
speaker_targets = speaker_targets.transpose(0, 1)[: len(texts)]
|
||||
|
||||
target_speaker_id = random.choice(range(len(texts)))
|
||||
non_target_speaker_ids = [i for i in range(len(texts)) if i != target_speaker_id]
|
||||
text = texts[target_speaker_id]
|
||||
speaker_target = speaker_targets[target_speaker_id]
|
||||
bg_speaker_target = speaker_targets[non_target_speaker_ids].sum(dim=0) > 0
|
||||
|
||||
tokens.append(torch.as_tensor(self.tokenizer(text, cut.supervisions[0].language)))
|
||||
spk_targets.append(speaker_target)
|
||||
bg_spk_targets.append(bg_speaker_target)
|
||||
|
||||
token_lens = torch.tensor([t.size(0) for t in tokens], dtype=torch.long)
|
||||
tokens = collate_vectors(tokens, padding_value=0)
|
||||
spk_targets = collate_vectors(spk_targets, padding_value=0)
|
||||
bg_spk_targets = collate_vectors(bg_spk_targets, padding_value=0)
|
||||
|
||||
return audio, audio_lens, tokens, token_lens, spk_targets, bg_spk_targets
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,507 @@
|
||||
# Copyright (c) 2020, 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.
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.parts.preprocessing.feature_loader import ExternalFeatureLoader
|
||||
from nemo.collections.common.parts.preprocessing import collections
|
||||
from nemo.core.classes import Dataset
|
||||
from nemo.core.neural_types import AcousticEncodedRepresentation, LabelsType, LengthsType, NeuralType
|
||||
from nemo.utils import logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nemo.collections.asr.parts.preprocessing.perturb import AudioAugmentor
|
||||
|
||||
|
||||
def _feature_collate_fn(batch):
|
||||
"""collate batch of feat sig, feat len, labels, labels len, assuming all features have the same shape.
|
||||
Args:
|
||||
batch (FloatTensor, LongTensor, LongTensor, LongTensor): A tuple of tuples of feature, feature lengths,
|
||||
encoded labels, and encoded labels length.
|
||||
"""
|
||||
packed_batch = list(zip(*batch))
|
||||
if len(packed_batch) == 5:
|
||||
_, feat_lengths, _, labels_lengths, sample_ids = packed_batch
|
||||
elif len(packed_batch) == 4:
|
||||
sample_ids = None
|
||||
_, feat_lengths, _, labels_lengths = packed_batch
|
||||
else:
|
||||
raise ValueError("Expects 4 or 5 tensors in the batch!")
|
||||
|
||||
features, labels = [], []
|
||||
for b in batch:
|
||||
feat_i, labels_i = b[0], b[2]
|
||||
features.append(feat_i)
|
||||
labels.append(labels_i)
|
||||
|
||||
features = torch.stack(features)
|
||||
feat_lengths = torch.stack(feat_lengths)
|
||||
|
||||
labels = torch.stack(labels)
|
||||
labels_lengths = torch.stack(labels_lengths)
|
||||
|
||||
if sample_ids is None:
|
||||
return features, feat_lengths, labels, labels_lengths
|
||||
else:
|
||||
sample_ids = torch.tensor(sample_ids, dtype=torch.int32)
|
||||
return features, feat_lengths, labels, labels_lengths, sample_ids
|
||||
|
||||
|
||||
def _audio_feature_collate_fn(batch, feat_pad_val, label_pad_id):
|
||||
"""collate batch of audio feature, audio len, labels, labels len
|
||||
Args:
|
||||
batch (Optional[FloatTensor], Optional[LongTensor], LongTensor,
|
||||
LongTensor): A tuple of tuples of feature, feature lengths,
|
||||
labels, and label lengths. This collate func assumes the
|
||||
features are torch tensors of Log-Melspectrogram (i.e. [N_MEL, T]).
|
||||
"""
|
||||
packed_batch = list(zip(*batch))
|
||||
if len(packed_batch) == 5:
|
||||
_, feat_lengths, _, labels_lengths, sample_ids = packed_batch
|
||||
elif len(packed_batch) == 4:
|
||||
sample_ids = None
|
||||
_, feat_lengths, _, labels_lengths = packed_batch
|
||||
else:
|
||||
raise ValueError("Expects 4 or 5 tensors in the batch!")
|
||||
max_feat_len = 0
|
||||
has_feat = feat_lengths[0] is not None
|
||||
if has_feat:
|
||||
max_feat_len = max(feat_lengths).item()
|
||||
max_labels_len = max(labels_lengths).item()
|
||||
|
||||
features, labels = [], []
|
||||
for b in batch:
|
||||
feat_i, feat_i_len, label_i, label_i_len = b[0], b[1], b[2], b[3]
|
||||
|
||||
if has_feat:
|
||||
feat_i_len = feat_i_len.item()
|
||||
if feat_i_len < max_feat_len:
|
||||
pad = (0, max_feat_len - feat_i_len)
|
||||
feat_i = torch.nn.functional.pad(feat_i, pad, value=feat_pad_val)
|
||||
features.append(feat_i)
|
||||
|
||||
label_i_len = label_i_len.item()
|
||||
if label_i_len < max_labels_len:
|
||||
pad = (0, max_labels_len - label_i_len)
|
||||
label_i = torch.nn.functional.pad(label_i, pad, value=label_pad_id)
|
||||
labels.append(label_i)
|
||||
|
||||
if has_feat:
|
||||
features = torch.stack(features)
|
||||
feature_lengths = torch.stack(feat_lengths)
|
||||
else:
|
||||
features, feat_lengths = None, None
|
||||
labels = torch.stack(labels)
|
||||
labels_lengths = torch.stack(labels_lengths)
|
||||
|
||||
if sample_ids is None:
|
||||
return features, feature_lengths, labels, labels_lengths
|
||||
else:
|
||||
sample_ids = torch.tensor(sample_ids, dtype=torch.int32)
|
||||
return features, feature_lengths, labels, labels_lengths, sample_ids
|
||||
|
||||
|
||||
def _vad_feature_segment_collate_fn(batch, window_length_in_sec, shift_length_in_sec, frame_unit_in_sec):
|
||||
"""collate batch of audio features, features len, tokens, tokens len
|
||||
Args:
|
||||
batch (Optional[FloatTensor], Optional[LongTensor], LongTensor,
|
||||
LongTensor): A tuple of tuples of signal, signal lengths,
|
||||
encoded tokens, and encoded tokens length. This collate func
|
||||
assumes the signals are 1d torch tensors (i.e. mono audio).
|
||||
batch size equals to 1.
|
||||
"""
|
||||
slice_length = int(window_length_in_sec / frame_unit_in_sec)
|
||||
audio_features, feat_lengths, _, tokens_lengths = zip(*batch)
|
||||
|
||||
slice_length = int(min(slice_length, max(feat_lengths)))
|
||||
shift = int(shift_length_in_sec / frame_unit_in_sec)
|
||||
has_audio = feat_lengths[0] is not None
|
||||
|
||||
f_dim = audio_features[0].shape[0]
|
||||
audio_features, num_slices, tokens, feat_lengths = [], [], [], []
|
||||
append_len_start = torch.div(slice_length, 2, rounding_mode='trunc')
|
||||
append_len_end = slice_length - torch.div(slice_length, 2, rounding_mode='trunc')
|
||||
for feat_i, feat_i_len, tokens_i, _ in batch:
|
||||
start = torch.zeros(f_dim, append_len_start)
|
||||
end = torch.zeros(f_dim, append_len_end)
|
||||
feat_i = torch.cat((start, feat_i, end), dim=1)
|
||||
feat_i_len += slice_length
|
||||
|
||||
if has_audio:
|
||||
slices = max(1, torch.div(feat_i_len - slice_length, shift, rounding_mode='trunc'))
|
||||
|
||||
for slice_id in range(slices):
|
||||
start_idx = slice_id * shift
|
||||
end_idx = start_idx + slice_length
|
||||
feat_slice = feat_i[:, start_idx:end_idx]
|
||||
audio_features.append(feat_slice)
|
||||
|
||||
num_slices.append(slices)
|
||||
tokens.extend([tokens_i] * slices)
|
||||
feat_lengths.extend([slice_length] * slices)
|
||||
|
||||
if has_audio:
|
||||
audio_features = torch.stack(audio_features)
|
||||
feat_lengths = torch.tensor(feat_lengths)
|
||||
else:
|
||||
audio_features, feat_lengths = None, None
|
||||
|
||||
tokens = torch.stack(tokens)
|
||||
tokens_lengths = torch.tensor(num_slices)
|
||||
return audio_features, feat_lengths, tokens, tokens_lengths
|
||||
|
||||
|
||||
class _FeatureSeqSpeakerLabelDataset(Dataset):
|
||||
"""
|
||||
Dataset that loads tensors via a json file containing paths to feature files, sequences of labels.
|
||||
Each new line is a different sample. Example below:
|
||||
and their target labels. JSON files should be of the following format:
|
||||
{"feature_filepath": "/path/to/feature_0.p", "seq_label": speakerA speakerB SpeakerA ....} \
|
||||
...
|
||||
{"feature_filepath": "/path/to/feature_n.p", "seq_label": target_seq_label_n}
|
||||
target_seq_label_n is the string of sequence of speaker label, separated by space.
|
||||
|
||||
Args:
|
||||
manifest_filepath (str): Dataset parameter. Path to JSON containing data.
|
||||
labels (Optional[list]): Dataset parameter. List of unique labels collected from all samples.
|
||||
feature_loader : Dataset parameter. Feature loader to load (external) feature.
|
||||
"""
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
"""Returns definitions of module output ports."""
|
||||
# TODO output type for external features
|
||||
output_types = {
|
||||
'external_feat': NeuralType(('B', 'D', 'T'), AcousticEncodedRepresentation()),
|
||||
'feat_length': NeuralType(tuple('B'), LengthsType()),
|
||||
}
|
||||
|
||||
if self.is_speaker_emb:
|
||||
output_types.update(
|
||||
{
|
||||
'embs': NeuralType(('B', 'D', 'T'), AcousticEncodedRepresentation()),
|
||||
'embs_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'label': NeuralType(('B', 'T'), LabelsType()),
|
||||
'label_length': NeuralType(tuple('B'), LengthsType()),
|
||||
}
|
||||
)
|
||||
else:
|
||||
output_types.update(
|
||||
{
|
||||
'label': NeuralType(('B', 'T'), LabelsType()),
|
||||
'label_length': NeuralType(tuple('B'), LengthsType()),
|
||||
}
|
||||
)
|
||||
|
||||
return output_types
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
manifest_filepath: str,
|
||||
labels: List[str],
|
||||
feature_loader,
|
||||
is_speaker_emb: bool = False,
|
||||
):
|
||||
super().__init__()
|
||||
self.collection = collections.ASRFeatureSequenceLabel(
|
||||
manifests_files=manifest_filepath.split(','),
|
||||
)
|
||||
|
||||
self.feature_loader = feature_loader
|
||||
self.labels = labels if labels else self.collection.uniq_labels
|
||||
self.is_speaker_emb = is_speaker_emb
|
||||
|
||||
self.label2id, self.id2label = {}, {}
|
||||
for label_id, label in enumerate(self.labels):
|
||||
self.label2id[label] = label_id
|
||||
self.id2label[label_id] = label
|
||||
|
||||
for idx in range(len(self.labels[:5])):
|
||||
logging.debug(" label id {} and its mapped label {}".format(idx, self.id2label[idx]))
|
||||
|
||||
def __len__(self):
|
||||
return len(self.collection)
|
||||
|
||||
def __getitem__(self, index):
|
||||
sample = self.collection[index]
|
||||
|
||||
features = self.feature_loader.process(sample.feature_file)
|
||||
f, fl = features, torch.tensor(features.shape[0]).long()
|
||||
|
||||
t = torch.tensor(sample.seq_label).float()
|
||||
tl = torch.tensor(len(sample.seq_label)).long()
|
||||
|
||||
return f, fl, t, tl
|
||||
|
||||
|
||||
class FeatureToSeqSpeakerLabelDataset(_FeatureSeqSpeakerLabelDataset):
|
||||
"""
|
||||
Dataset that loads tensors via a json file containing paths to feature
|
||||
files and sequence of speakers. Each new line is a
|
||||
different sample. Example below:
|
||||
{"feature_filepath": "/path/to/feature_0.p", "seq_label": speakerA speakerB SpeakerA ....} \
|
||||
...
|
||||
{"feature_filepath": "/path/to/feature_n.p", "seq_label": target_seq_label_n}
|
||||
target_seq_label_n is the string of sequence of speaker label, separated by space.
|
||||
|
||||
Args:
|
||||
manifest_filepath (str): Path to manifest json as described above. Canbe comma-separated paths.
|
||||
labels (Optional[list]): String containing all the possible labels to map to
|
||||
if None then automatically picks from ASRFeatureSequenceLabel collection.
|
||||
feature_loader, Feature load to loader (external) feature.
|
||||
|
||||
"""
|
||||
|
||||
def _collate_fn(self, batch):
|
||||
return _feature_collate_fn(batch)
|
||||
|
||||
|
||||
class FeatureToLabelDataset(Dataset):
|
||||
"""
|
||||
Dataset that loads tensors via a json file containing paths to feature files and their labels.
|
||||
Each new line is a different sample. Example below:
|
||||
and their target labels. JSON files should be of the following format:
|
||||
{"feature_filepath": "/path/to/audio_feature.pt", "label": "1"}
|
||||
...
|
||||
{"feature_filepath": "/path/to/audio_feature.pt", "label": "0"}
|
||||
Args:
|
||||
manifest_filepath (str): Path to JSON containing data.
|
||||
labels (Optional[list]): List of unique labels collected from all samples.
|
||||
augmentor (Optional): feature augmentation
|
||||
window_length_in_sec (float): Window length in seconds.
|
||||
shift_length_in_sec (float): Shift length in seconds.
|
||||
is_regression_task (bool): if True, the labels are treated as for a regression task.
|
||||
cal_labels_occurrence (bool): if True, the labels occurrence will be calculated.
|
||||
zero_spec_db_val (float): Value to replace non-speech signals in log-melspectrogram.
|
||||
min_duration (float): Minimum duration of the audio file in seconds.
|
||||
max_duration (float): Maximum duration of the audio file in seconds.
|
||||
"""
|
||||
|
||||
ZERO_LEVEL_SPEC_DB_VAL = -16.635 # Log-Melspectrogram value for zero signal
|
||||
FRAME_UNIT_TIME_SECS = 0.01
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
"""Returns definitions of module output ports."""
|
||||
output_types = {
|
||||
'audio_feat': NeuralType(('B', 'D', 'T'), AcousticEncodedRepresentation()),
|
||||
'feat_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'labels': NeuralType(('B'), LabelsType()),
|
||||
'labels_length': NeuralType(tuple('B'), LengthsType()),
|
||||
}
|
||||
|
||||
return output_types
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
manifest_filepath: str,
|
||||
labels: List[str] = None,
|
||||
augmentor: 'AudioAugmentor' = None,
|
||||
window_length_in_sec: float = 0.63,
|
||||
shift_length_in_sec: float = 0.01,
|
||||
is_regression_task: bool = False,
|
||||
cal_labels_occurrence: Optional[bool] = False,
|
||||
zero_spec_db_val: float = -16.635,
|
||||
min_duration: Optional[float] = None,
|
||||
max_duration: Optional[float] = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.window_length_in_sec = window_length_in_sec
|
||||
self.shift_length_in_sec = shift_length_in_sec
|
||||
self.zero_spec_db_val = zero_spec_db_val
|
||||
|
||||
if isinstance(manifest_filepath, str):
|
||||
manifest_filepath = manifest_filepath.split(',')
|
||||
|
||||
self.collection = collections.ASRFeatureLabel(
|
||||
manifests_files=manifest_filepath,
|
||||
is_regression_task=is_regression_task,
|
||||
cal_labels_occurrence=cal_labels_occurrence,
|
||||
min_duration=min_duration,
|
||||
max_duration=max_duration,
|
||||
)
|
||||
|
||||
self.feature_loader = ExternalFeatureLoader(augmentor=augmentor)
|
||||
self.labels = labels if labels else self.collection.uniq_labels
|
||||
|
||||
self.is_regression_task = is_regression_task
|
||||
|
||||
if not is_regression_task:
|
||||
self.labels = labels if labels else self.collection.uniq_labels
|
||||
self.num_classes = len(self.labels) if self.labels is not None else 1
|
||||
self.label2id, self.id2label = {}, {}
|
||||
self.id2occurrence, self.labels_occurrence = {}, []
|
||||
|
||||
for label_id, label in enumerate(self.labels):
|
||||
self.label2id[label] = label_id
|
||||
self.id2label[label_id] = label
|
||||
if cal_labels_occurrence:
|
||||
self.id2occurrence[label_id] = self.collection.labels_occurrence[label]
|
||||
|
||||
if cal_labels_occurrence:
|
||||
self.labels_occurrence = [self.id2occurrence[k] for k in sorted(self.id2occurrence)]
|
||||
|
||||
for idx in range(len(self.labels[:5])):
|
||||
logging.debug(" label id {} and its mapped label {}".format(idx, self.id2label[idx]))
|
||||
else:
|
||||
self.labels = []
|
||||
self.num_classes = 1
|
||||
|
||||
def __len__(self):
|
||||
return len(self.collection)
|
||||
|
||||
def __getitem__(self, index):
|
||||
sample = self.collection[index]
|
||||
|
||||
features = self.feature_loader.process(sample.feature_file)
|
||||
f, fl = features, torch.tensor(features.shape[1]).long()
|
||||
|
||||
t = torch.tensor(self.label2id[sample.label])
|
||||
tl = torch.tensor(1).long()
|
||||
|
||||
return f, fl, t, tl
|
||||
|
||||
def _collate_fn(self, batch):
|
||||
return _audio_feature_collate_fn(batch, self.zero_spec_db_val, 0)
|
||||
|
||||
def _vad_segment_collate_fn(self, batch):
|
||||
return _vad_feature_segment_collate_fn(
|
||||
batch, self.window_length_in_sec, self.shift_length_in_sec, self.FRAME_UNIT_TIME_SECS
|
||||
)
|
||||
|
||||
|
||||
class FeatureToMultiLabelDataset(Dataset):
|
||||
"""
|
||||
Dataset that loads tensors via a json file containing paths to feature files and their labels.
|
||||
Each new line is a different sample. Example below:
|
||||
and their target labels. JSON files should be of the following format:
|
||||
{"feature_filepath": "/path/to/audio_feature.pt", "label": "1 1 0 0 1"}
|
||||
...
|
||||
{"feature_filepath": "/path/to/audio_feature.pt", "label": "0 1 0 0"}
|
||||
Args:
|
||||
manifest_filepath (str): Path to JSON containing data.
|
||||
labels (Optional[list]): List of unique labels collected from all samples.
|
||||
augmentor (Optional): feature augmentation
|
||||
delimiter (str): delimiter to split the labels.
|
||||
is_regression_task (bool): if True, the labels are treated as for a regression task.
|
||||
cal_labels_occurrence (bool): if True, the labels occurrence will be calculated.
|
||||
zero_spec_db_val (float): Value to replace non-speech signals in log-melspectrogram.
|
||||
min_duration (float): Minimum duration of the audio file in seconds.
|
||||
max_duration (float): Maximum duration of the audio file in seconds.
|
||||
"""
|
||||
|
||||
ZERO_LEVEL_SPEC_DB_VAL = -16.635 # Log-Melspectrogram value for zero signal
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
"""Returns definitions of module output ports."""
|
||||
output_types = {
|
||||
'audio_feat': NeuralType(('B', 'D', 'T'), AcousticEncodedRepresentation()),
|
||||
'feat_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'labels': NeuralType(('B', 'T'), LabelsType()),
|
||||
'labels_length': NeuralType(tuple('B'), LengthsType()),
|
||||
}
|
||||
|
||||
return output_types
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
manifest_filepath: str,
|
||||
labels: List[str] = None,
|
||||
augmentor: 'AudioAugmentor' = None,
|
||||
delimiter: Optional[str] = None,
|
||||
is_regression_task: bool = False,
|
||||
cal_labels_occurrence: Optional[bool] = False,
|
||||
zero_spec_db_val: float = -16.635,
|
||||
min_duration: Optional[float] = None,
|
||||
max_duration: Optional[float] = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.delimiter = delimiter
|
||||
self.zero_spec_db_val = zero_spec_db_val
|
||||
|
||||
if isinstance(manifest_filepath, str):
|
||||
manifest_filepath = manifest_filepath.split(',')
|
||||
|
||||
self.collection = collections.ASRFeatureLabel(
|
||||
manifests_files=manifest_filepath,
|
||||
is_regression_task=is_regression_task,
|
||||
cal_labels_occurrence=cal_labels_occurrence,
|
||||
delimiter=delimiter,
|
||||
min_duration=min_duration,
|
||||
max_duration=max_duration,
|
||||
)
|
||||
|
||||
self.is_regression_task = is_regression_task
|
||||
self.feature_loader = ExternalFeatureLoader(augmentor=augmentor)
|
||||
self.labels = labels if labels else self.collection.uniq_labels
|
||||
|
||||
self.label2id, self.id2label = {}, {}
|
||||
if not is_regression_task:
|
||||
self.labels = labels if labels else self._get_label_set()
|
||||
self.num_classes = len(self.labels) if self.labels is not None else 1
|
||||
self.label2id, self.id2label = {}, {}
|
||||
for label_id, label in enumerate(self.labels):
|
||||
self.label2id[label] = label_id
|
||||
self.id2label[label_id] = label
|
||||
if cal_labels_occurrence:
|
||||
self.id2occurrence[label_id] = self.collection.labels_occurrence[label]
|
||||
self.labels_occurrence.append(self.id2occurrence[label_id])
|
||||
|
||||
for idx in range(len(self.labels[:5])):
|
||||
logging.debug(" label id {} and its mapped label {}".format(idx, self.id2label[idx]))
|
||||
else:
|
||||
self.labels = []
|
||||
self.num_classes = 1
|
||||
|
||||
def _get_label_set(self):
|
||||
labels = []
|
||||
for sample in self.collection:
|
||||
label_str = sample.label
|
||||
if label_str:
|
||||
label_str_list = label_str.split(self.delimiter) if self.delimiter else label_str.split()
|
||||
labels.extend(label_str_list)
|
||||
return sorted(set(labels))
|
||||
|
||||
def _label_str_to_tensor(self, label_str: str):
|
||||
labels = label_str.split(self.delimiter) if self.delimiter else label_str.split()
|
||||
|
||||
if self.is_regression_task:
|
||||
labels = [float(s) for s in labels]
|
||||
labels = torch.tensor(labels).float()
|
||||
else:
|
||||
labels = [self.label2id[s] for s in labels]
|
||||
labels = torch.tensor(labels).long()
|
||||
return labels
|
||||
|
||||
def __len__(self):
|
||||
return len(self.collection)
|
||||
|
||||
def __getitem__(self, index):
|
||||
sample = self.collection[index]
|
||||
|
||||
features = self.feature_loader.process(sample.feature_file)
|
||||
f, fl = features, torch.tensor(features.shape[1]).long()
|
||||
|
||||
t = self._label_str_to_tensor(sample.label)
|
||||
tl = torch.tensor(t.size(0)).long()
|
||||
|
||||
return f, fl, t, tl
|
||||
|
||||
def _collate_fn(self, batch):
|
||||
return _audio_feature_collate_fn(batch, self.zero_spec_db_val, 0)
|
||||
@@ -0,0 +1,68 @@
|
||||
# Copyright (c) 2020, 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.
|
||||
from typing import Any, Optional
|
||||
|
||||
from nemo.collections.asr.data import feature_to_label
|
||||
|
||||
|
||||
def get_feature_seq_speakerlabel_dataset(
|
||||
feature_loader, config: dict
|
||||
) -> feature_to_label.FeatureToSeqSpeakerLabelDataset:
|
||||
"""
|
||||
Instantiates a FeatureSeqSpeakerLabelDataset.
|
||||
Args:
|
||||
config: Config of the FeatureToSeqSpeakerLabelDataset.
|
||||
|
||||
Returns:
|
||||
An instance of FeatureToSeqSpeakerLabelDataset.
|
||||
"""
|
||||
dataset = feature_to_label.FeatureToSeqSpeakerLabelDataset(
|
||||
manifest_filepath=config['manifest_filepath'],
|
||||
labels=config['labels'],
|
||||
feature_loader=feature_loader,
|
||||
)
|
||||
return dataset
|
||||
|
||||
|
||||
def get_feature_label_dataset(config: dict, augmentor: Optional[Any] = None) -> feature_to_label.FeatureToLabelDataset:
|
||||
dataset = feature_to_label.FeatureToLabelDataset(
|
||||
manifest_filepath=config['manifest_filepath'],
|
||||
labels=config['labels'],
|
||||
augmentor=augmentor,
|
||||
window_length_in_sec=config.get("window_length_in_sec", 0.63),
|
||||
shift_length_in_sec=config.get("shift_length_in_sec", 0.08),
|
||||
is_regression_task=config.get("is_regression_task", False),
|
||||
cal_labels_occurrence=config.get("cal_labels_occurrence", False),
|
||||
zero_spec_db_val=config.get("zero_spec_db_val", -16.635),
|
||||
max_duration=config.get('max_duration', None),
|
||||
min_duration=config.get('min_duration', None),
|
||||
)
|
||||
return dataset
|
||||
|
||||
|
||||
def get_feature_multi_label_dataset(
|
||||
config: dict, augmentor: Optional[Any] = None
|
||||
) -> feature_to_label.FeatureToMultiLabelDataset:
|
||||
dataset = feature_to_label.FeatureToMultiLabelDataset(
|
||||
manifest_filepath=config['manifest_filepath'],
|
||||
labels=config['labels'],
|
||||
augmentor=augmentor,
|
||||
delimiter=config.get('delimiter', None),
|
||||
is_regression_task=config.get("is_regression_task", False),
|
||||
cal_labels_occurrence=config.get("cal_labels_occurrence", False),
|
||||
zero_spec_db_val=config.get("zero_spec_db_val", -16.635),
|
||||
max_duration=config.get('max_duration', None),
|
||||
min_duration=config.get('min_duration', None),
|
||||
)
|
||||
return dataset
|
||||
@@ -0,0 +1,487 @@
|
||||
# Copyright (c) 2022, 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.
|
||||
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.data.feature_to_label import _audio_feature_collate_fn
|
||||
from nemo.collections.asr.parts.preprocessing.feature_loader import ExternalFeatureLoader
|
||||
from nemo.collections.asr.parts.preprocessing.features import normalize_batch
|
||||
from nemo.collections.asr.parts.preprocessing.segment import ChannelSelectorType
|
||||
from nemo.collections.asr.parts.utils.vad_utils import load_speech_segments_from_rttm
|
||||
from nemo.collections.common import tokenizers
|
||||
from nemo.collections.common.parts.preprocessing import collections, parsers
|
||||
from nemo.core.classes import Dataset
|
||||
from nemo.core.neural_types import AcousticEncodedRepresentation, LabelsType, LengthsType, NeuralType
|
||||
|
||||
|
||||
class ASRFeatureManifestProcessor:
|
||||
def __init__(
|
||||
self,
|
||||
manifest_filepath: str,
|
||||
parser: Union[str, Callable],
|
||||
max_duration: Optional[float] = None,
|
||||
min_duration: Optional[float] = None,
|
||||
max_utts: int = 0,
|
||||
bos_id: Optional[int] = None,
|
||||
eos_id: Optional[int] = None,
|
||||
pad_id: int = 0,
|
||||
index_by_file_id: bool = False,
|
||||
):
|
||||
self.parser = parser
|
||||
self.collection = collections.ASRFeatureText(
|
||||
manifests_files=manifest_filepath,
|
||||
parser=parser,
|
||||
min_duration=min_duration,
|
||||
max_duration=max_duration,
|
||||
max_number=max_utts,
|
||||
index_by_file_id=index_by_file_id,
|
||||
)
|
||||
|
||||
self.eos_id = eos_id
|
||||
self.bos_id = bos_id
|
||||
self.pad_id = pad_id
|
||||
|
||||
def process_text_by_id(self, index: int) -> Tuple[List[int], int]:
|
||||
sample = self.collection[index]
|
||||
return self.process_text_by_sample(sample)
|
||||
|
||||
def process_text_by_file_id(self, file_id: str) -> Tuple[List[int], int]:
|
||||
manifest_idx = self.collection.mapping[file_id][0]
|
||||
sample = self.collection[manifest_idx]
|
||||
return self.process_text_by_sample(sample)
|
||||
|
||||
def process_text_by_sample(self, sample: collections.ASRAudioText.OUTPUT_TYPE) -> Tuple[List[int], int]:
|
||||
t, tl = sample.text_tokens, len(sample.text_tokens)
|
||||
|
||||
if self.bos_id is not None:
|
||||
t = [self.bos_id] + t
|
||||
tl += 1
|
||||
if self.eos_id is not None:
|
||||
t = t + [self.eos_id]
|
||||
tl += 1
|
||||
|
||||
return t, tl
|
||||
|
||||
|
||||
class _FeatureTextDataset(Dataset):
|
||||
"""
|
||||
Dataset that loads tensors via a json file containing paths to audio feature files, transcripts,
|
||||
durations (in seconds) and optional RTTM files. Each new line is a different sample. Example below:
|
||||
{"feature_filepath": "/path/to/audio_feature.pt", "text_filepath": "/path/to/audio.txt",
|
||||
"rttm_filepath": "/path/to/audio_rttm.rttm", "duration": 23.147}
|
||||
...
|
||||
{"feature_filepath": "/path/to/audio_feature.pt", "text": "the transcription", "offset": 301.75, "duration": 0.82, "utt":
|
||||
"utterance_id", "ctm_utt": "en_4156", "side": "A"}
|
||||
Args:
|
||||
manifest_filepath (str): Path to manifest json as described above. Can be comma-separated paths.
|
||||
parser: Str for a language specific preprocessor or a callable.
|
||||
normalize (bool): whether and where to normalize feature, must be one of [None, "post_norm", "pre_norm"]
|
||||
normalize_type (Union[str, dict]): how to normalize feature, see `nemo.collections.asr.parts.preprocessing.features.normalize_batch`
|
||||
use_rttm (bool): whether to use RTTM files if there is any, default to False
|
||||
rttm_mode (str): how to use RTTM files, must be one of ['mask', 'drop'], default to 'mask'
|
||||
feat_min_len (int): minimum length of feature when rttm_mode=deop, default to 4.
|
||||
feat_mask_val (Optional[float]): value used to mask features with RTTM files, default to None to use zero mel-spectralgram
|
||||
frame_unit_time_secs (float): time in seconds for each frame
|
||||
sample_rate (int): Sample rate to resample loaded audio to
|
||||
int_values (bool): If true, load samples as 32-bit integers. Defauts to False.
|
||||
augmentor (nemo.collections.asr.parts.perturb.AudioAugmentor): An AudioAugmentor object used to augment loaded audio
|
||||
max_duration (float): If audio exceeds this length, do not include in dataset
|
||||
min_duration (float): If audio is less than this length, do not include in dataset
|
||||
max_utts (int): Limit number of utterances
|
||||
trim (bool): whether or not to trim silence. Defaults to False
|
||||
bos_id (int): Id of beginning of sequence symbol to append if not None
|
||||
eos_id (int): Id of end of sequence symbol to append if not None
|
||||
pad_id (int): Id of pad symbol. Defaults to 0
|
||||
return_sample_id (bool): whether to return the sample_id as a part of each sample
|
||||
channel_selector (int | Iterable[int] | str): select a single channel or a subset of channels from multi-channel audio. If set to `'average'`, it performs averaging across channels. Disabled if set to `None`. Defaults to `None`. Uses zero-based indexing.
|
||||
"""
|
||||
|
||||
ZERO_LEVEL_SPEC_DB_VAL = -16.635 # Log-Melspectrogram value for zero signal
|
||||
NORM_MODES = ["pre_norm", "post_norm"]
|
||||
RTTM_MODES = ["mask", "drop"]
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
"""Returns definitions of module output ports."""
|
||||
return {
|
||||
'features': NeuralType(('B', 'D', 'T'), AcousticEncodedRepresentation()),
|
||||
'feature_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'transcripts': NeuralType(('B', 'T'), LabelsType()),
|
||||
'transcript_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'sample_id': NeuralType(tuple('B'), LengthsType(), optional=True),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manifest_filepath: str,
|
||||
parser: Union[str, Callable],
|
||||
normalize: Optional[str] = "post_norm",
|
||||
normalize_type: Union[str, dict] = "per_feature",
|
||||
use_rttm: bool = False,
|
||||
rttm_mode: str = "mask",
|
||||
feat_min_len: int = 4,
|
||||
feat_mask_val: Optional[float] = None,
|
||||
frame_unit_time_secs: float = 0.01,
|
||||
sample_rate: Optional[int] = 16000,
|
||||
augmentor: 'nemo.collections.asr.parts.perturb.FeatureAugmentor' = None,
|
||||
max_duration: Optional[int] = None,
|
||||
min_duration: Optional[int] = None,
|
||||
max_utts: int = 0,
|
||||
trim: bool = False,
|
||||
bos_id: Optional[int] = None,
|
||||
eos_id: Optional[int] = None,
|
||||
pad_id: int = 0,
|
||||
return_sample_id: bool = False,
|
||||
channel_selector: Optional[ChannelSelectorType] = None,
|
||||
):
|
||||
if type(manifest_filepath) == str:
|
||||
manifest_filepath = manifest_filepath.split(",")
|
||||
|
||||
self.sample_rate = sample_rate
|
||||
self.normalize = normalize
|
||||
self.normalize_type = normalize_type
|
||||
self.use_rttm = use_rttm
|
||||
self.rttm_mode = rttm_mode
|
||||
if self.use_rttm and self.rttm_mode not in self.RTTM_MODES:
|
||||
raise ValueError(f"`rttm_mode` must be one of {self.RTTM_MODES}, got `{rttm_mode}` instead")
|
||||
|
||||
self.feat_min_len = feat_min_len
|
||||
if feat_mask_val is not None:
|
||||
self.feat_mask_val = feat_mask_val
|
||||
elif normalize == "pre_norm":
|
||||
self.feat_mask_val = 0.0 # similar to SpectralAugmentation
|
||||
else:
|
||||
self.feat_mask_val = self.ZERO_LEVEL_SPEC_DB_VAL
|
||||
|
||||
if normalize is not None and normalize not in self.NORM_MODES:
|
||||
raise ValueError(f"`normalize` must be one of {self.NORM_MODES}, got `{normalize}` instead")
|
||||
|
||||
self.frame_unit_time_secs = frame_unit_time_secs
|
||||
|
||||
self.manifest_processor = ASRFeatureManifestProcessor(
|
||||
manifest_filepath=manifest_filepath,
|
||||
parser=parser,
|
||||
max_duration=max_duration,
|
||||
min_duration=min_duration,
|
||||
max_utts=max_utts,
|
||||
bos_id=bos_id,
|
||||
eos_id=eos_id,
|
||||
pad_id=pad_id,
|
||||
)
|
||||
self.featurizer = ExternalFeatureLoader(augmentor=augmentor)
|
||||
self.trim = trim
|
||||
self.return_sample_id = return_sample_id
|
||||
self.channel_selector = channel_selector
|
||||
|
||||
def get_manifest_sample(self, sample_id):
|
||||
return self.manifest_processor.collection[sample_id]
|
||||
|
||||
def __getitem__(self, index):
|
||||
sample = self.manifest_processor.collection[index]
|
||||
offset = sample.offset
|
||||
|
||||
if offset is None:
|
||||
offset = 0
|
||||
|
||||
features = self.featurizer.process(sample.feature_file)
|
||||
|
||||
f, fl = features, torch.tensor(features.shape[1]).long()
|
||||
|
||||
t, tl = self.manifest_processor.process_text_by_sample(sample=sample)
|
||||
|
||||
# Feature normalization
|
||||
if self.normalize is None:
|
||||
if self.use_rttm and sample.rttm_file:
|
||||
f = self.process_features_with_rttm(f, offset, sample.rttm_file, self.feat_mask_val)
|
||||
elif self.normalize == "post_norm":
|
||||
# (Optional) Masking based on RTTM file
|
||||
if self.use_rttm and sample.rttm_file:
|
||||
f = self.process_features_with_rttm(f, offset, sample.rttm_file, self.feat_mask_val)
|
||||
|
||||
f = self.normalize_feature(f)
|
||||
else: # pre-norm
|
||||
f = self.normalize_feature(f)
|
||||
# (Optional) Masking based on RTTM file
|
||||
if self.use_rttm and sample.rttm_file:
|
||||
f = self.process_features_with_rttm(f, offset, sample.rttm_file, self.feat_mask_val)
|
||||
|
||||
if self.return_sample_id:
|
||||
output = f, fl, torch.tensor(t).long(), torch.tensor(tl).long(), index
|
||||
else:
|
||||
output = f, fl, torch.tensor(t).long(), torch.tensor(tl).long()
|
||||
|
||||
return output
|
||||
|
||||
def process_features_with_rttm(self, features, offset, rttm_file, mask_val):
|
||||
segments = load_speech_segments_from_rttm(rttm_file)
|
||||
new_features = features.clone()
|
||||
sid, fid = 0, 0
|
||||
for i in range(features.size(1)):
|
||||
t = offset + i * self.frame_unit_time_secs
|
||||
while sid < len(segments) - 1 and segments[sid][1] < t:
|
||||
sid += 1
|
||||
if segments[sid][1] == 0 or t < segments[sid][0] or t > segments[sid][1]:
|
||||
# not in speech segment
|
||||
if self.rttm_mode == "drop":
|
||||
# drop the frame
|
||||
continue
|
||||
else:
|
||||
# mask the frame with specified value
|
||||
new_features[:, i] = mask_val
|
||||
fid += 1
|
||||
else:
|
||||
# in speech segment
|
||||
new_features[:, fid] = features[:, i]
|
||||
fid += 1
|
||||
|
||||
if fid < self.feat_min_len and self.rttm_mode == "drop":
|
||||
new_features[:, : self.feat_min_len] = mask_val
|
||||
return new_features[:, : self.feat_min_len]
|
||||
return new_features[:, :fid]
|
||||
|
||||
def __len__(self):
|
||||
return len(self.manifest_processor.collection)
|
||||
|
||||
def _collate_fn(self, batch):
|
||||
return _audio_feature_collate_fn(
|
||||
batch, feat_pad_val=self.feat_mask_val, label_pad_id=self.manifest_processor.pad_id
|
||||
)
|
||||
|
||||
def normalize_feature(self, feat):
|
||||
"""
|
||||
Args:
|
||||
feat: feature tensor of shape [M, T]
|
||||
"""
|
||||
feat = feat.unsqueeze(0) # add batch dim
|
||||
feat, _, _ = normalize_batch(feat, torch.tensor([feat.size(-1)]), self.normalize_type)
|
||||
return feat.squeeze(0) # delete batch dim
|
||||
|
||||
|
||||
class FeatureToCharDataset(_FeatureTextDataset):
|
||||
"""
|
||||
Dataset that loads tensors via a json file containing paths to audio feature
|
||||
files, transcripts, durations (in seconds) and optional RTTM files. Each new line is a
|
||||
different sample. Example below:
|
||||
{"feature_filepath": "/path/to/audio_feature.pt", "text_filepath":
|
||||
"/path/to/audio.txt", "duration": 23.147, "rttm_filepath": "/path/to/audio_rttm.rttm",}
|
||||
...
|
||||
{"feature_filepath": "/path/to/audio_feature.pt", "text": "the
|
||||
transcription", "offset": 301.75, "duration": 0.82, "utt":
|
||||
"utterance_id", "ctm_utt": "en_4156", "side": "A"}
|
||||
|
||||
Args:
|
||||
manifest_filepath (str): Path to manifest json as described above. Can
|
||||
be comma-separated paths.
|
||||
labels (str): String containing all the possible characters to map to
|
||||
normalize (str): how to normalize feature, must be one of [None, "post_norm", "pre_norm"]
|
||||
normalize_type (Union[str, dict]): how to normalize feature, see `nemo.collections.asr.parts.preprocessing.features.normalize_batch`
|
||||
use_rttm (bool): whether to use RTTM files if there is any, default to False
|
||||
rttm_mode (str): how to use RTTM files, must be one of ['mask', 'drop'], default to 'mask'
|
||||
feat_min_len (int): minimum length of feature, default to 4
|
||||
feat_mask_val (Optional[float]): value used to mask features with RTTM files, default to None to use zero mel-spectralgram
|
||||
frame_unit_time_secs: time in seconds for each frame
|
||||
sample_rate (int): Sample rate to resample loaded audio to
|
||||
int_values (bool): If true, load samples as 32-bit integers. Defauts to False.
|
||||
augmentor (nemo.collections.asr.parts.perturb.AudioAugmentor): An AudioAugmentor
|
||||
object used to augment loaded audio
|
||||
max_duration: If audio exceeds this length, do not include in dataset
|
||||
min_duration: If audio is less than this length, do not include
|
||||
in dataset
|
||||
max_utts: Limit number of utterances
|
||||
blank_index: blank character index, default = -1
|
||||
unk_index: unk_character index, default = -1
|
||||
bos_id: Id of beginning of sequence symbol to append if not None
|
||||
eos_id: Id of end of sequence symbol to append if not None
|
||||
return_sample_id (bool): whether to return the sample_id as a part of each sample
|
||||
channel_selector (int | Iterable[int] | str): select a single channel or a subset of channels from multi-channel audio. If set to `'average'`, it performs averaging across channels. Disabled if set to `None`. Defaults to `None`. Uses zero-based indexing.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manifest_filepath: str,
|
||||
labels: Union[str, List[str]],
|
||||
normalize: Optional[str] = "post_norm",
|
||||
normalize_type: Union[str, dict] = "per_feature",
|
||||
use_rttm: bool = False,
|
||||
rttm_mode: str = "mask",
|
||||
feat_min_len: int = 4,
|
||||
feat_mask_val: Optional[float] = None,
|
||||
frame_unit_time_secs: float = 0.01,
|
||||
sample_rate: Optional[int] = 16000,
|
||||
augmentor: 'nemo.collections.asr.parts.perturb.FeatureAugmentor' = None,
|
||||
max_duration: Optional[int] = None,
|
||||
min_duration: Optional[int] = None,
|
||||
max_utts: int = 0,
|
||||
blank_index: int = -1,
|
||||
unk_index: int = -1,
|
||||
trim: bool = False,
|
||||
bos_id: Optional[int] = None,
|
||||
eos_id: Optional[int] = None,
|
||||
pad_id: int = 0,
|
||||
parser: Union[str, Callable] = 'en',
|
||||
return_sample_id: bool = False,
|
||||
channel_selector: Optional[ChannelSelectorType] = None,
|
||||
):
|
||||
self.labels = labels
|
||||
|
||||
parser = parsers.make_parser(
|
||||
labels=labels, name=parser, unk_id=unk_index, blank_id=blank_index, do_normalize=normalize
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
manifest_filepath=manifest_filepath,
|
||||
parser=parser,
|
||||
normalize=normalize,
|
||||
normalize_type=normalize_type,
|
||||
use_rttm=use_rttm,
|
||||
rttm_mode=rttm_mode,
|
||||
feat_min_len=feat_min_len,
|
||||
feat_mask_val=feat_mask_val,
|
||||
frame_unit_time_secs=frame_unit_time_secs,
|
||||
sample_rate=sample_rate,
|
||||
augmentor=augmentor,
|
||||
max_duration=max_duration,
|
||||
min_duration=min_duration,
|
||||
max_utts=max_utts,
|
||||
trim=trim,
|
||||
bos_id=bos_id,
|
||||
eos_id=eos_id,
|
||||
pad_id=pad_id,
|
||||
return_sample_id=return_sample_id,
|
||||
channel_selector=channel_selector,
|
||||
)
|
||||
|
||||
|
||||
class FeatureToBPEDataset(_FeatureTextDataset):
|
||||
"""
|
||||
Dataset that loads tensors via a json file containing paths to audio feature
|
||||
files, transcripts, durations (in seconds) and optional RTTM files. Each new line is a different sample.
|
||||
Example below:
|
||||
{"audio_filepath": "/path/to/audio.wav", "text_filepath":
|
||||
"/path/to/audio.txt", "duration": 23.147, "rttm_filepath": "/path/to/audio_rttm.rttm",}
|
||||
...
|
||||
{"audio_filepath": "/path/to/audio.wav", "text": "the
|
||||
transcription", "offset": 301.75, "duration": 0.82, "utt":
|
||||
"utterance_id", "ctm_utt": "en_4156", "side": "A"}
|
||||
|
||||
In practice, the dataset and manifest used for character encoding and byte pair encoding
|
||||
are exactly the same. The only difference lies in how the dataset tokenizes the text in
|
||||
the manifest.
|
||||
|
||||
Args:
|
||||
manifest_filepath (str): Path to manifest json as described above. Can
|
||||
be comma-separated paths.
|
||||
tokenizer: A subclass of the Tokenizer wrapper found in the common collection,
|
||||
nemo.collections.common.tokenizers.TokenizerSpec. ASR Models support a subset of
|
||||
all available tokenizers.
|
||||
normalize (str): how to normalize feature, must be one of [None, "post_norm", "pre_norm"]
|
||||
normalize_type (Union[str, dict]): how to normalize feature, see `nemo.collections.asr.parts.preprocessing.features.normalize_batch`
|
||||
use_rttm (bool): whether to use RTTM files if there is any, default to False
|
||||
rttm_mode (str): how to use RTTM files, must be one of ['mask', 'drop'], default to 'mask'
|
||||
feat_min_len (int): minimum length of feature, default to 4
|
||||
feat_mask_val (Optional[float]): value used to mask features with RTTM files, default to None to use zero mel-spectralgram
|
||||
frame_unit_time_secs: time in seconds for each frame
|
||||
sample_rate (int): Sample rate to resample loaded audio to
|
||||
int_values (bool): If true, load samples as 32-bit integers. Defauts to False.
|
||||
augmentor (nemo.collections.asr.parts.perturb.AudioAugmentor): An AudioAugmentor
|
||||
object used to augment loaded audio
|
||||
max_duration: If audio exceeds this length, do not include in dataset
|
||||
min_duration: If audio is less than this length, do not include
|
||||
in dataset
|
||||
max_utts: Limit number of utterances
|
||||
trim: Whether to trim silence segments
|
||||
use_start_end_token: Boolean which dictates whether to add [BOS] and [EOS]
|
||||
tokens to beginning and ending of speech respectively.
|
||||
return_sample_id (bool): whether to return the sample_id as a part of each sample
|
||||
channel_selector (int | Iterable[int] | str): select a single channel or a subset of channels from multi-channel audio. If set to `'average'`, it performs averaging across channels. Disabled if set to `None`. Defaults to `None`. Uses zero-based indexing.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manifest_filepath: str,
|
||||
tokenizer: 'nemo.collections.common.tokenizers.TokenizerSpec',
|
||||
normalize: Optional[str] = "post_norm",
|
||||
normalize_type: Union[str, dict] = "per_feature",
|
||||
use_rttm: bool = False,
|
||||
rttm_mode: str = "mask",
|
||||
feat_min_len: int = 4,
|
||||
feat_mask_val: Optional[float] = None,
|
||||
frame_unit_time_secs: float = 0.01,
|
||||
sample_rate: Optional[int] = 16000,
|
||||
augmentor: 'nemo.collections.asr.parts.perturb.FeatureAugmentor' = None,
|
||||
max_duration: Optional[int] = None,
|
||||
min_duration: Optional[int] = None,
|
||||
max_utts: int = 0,
|
||||
use_start_end_token: bool = True,
|
||||
trim: bool = False,
|
||||
return_sample_id: bool = False,
|
||||
channel_selector: Optional[ChannelSelectorType] = None,
|
||||
):
|
||||
if use_start_end_token and hasattr(tokenizer, "bos_id") and tokenizer.bos_id > 0:
|
||||
bos_id = tokenizer.bos_id
|
||||
else:
|
||||
bos_id = None
|
||||
|
||||
if use_start_end_token and hasattr(tokenizer, "eos_id") and tokenizer.eos_id > 0:
|
||||
eos_id = tokenizer.eos_id
|
||||
else:
|
||||
eos_id = None
|
||||
|
||||
if hasattr(tokenizer, "pad_id") and tokenizer.pad_id > 0:
|
||||
pad_id = tokenizer.pad_id
|
||||
else:
|
||||
pad_id = 0
|
||||
|
||||
class TokenizerWrapper:
|
||||
def __init__(self, tokenizer):
|
||||
if isinstance(tokenizer, tokenizers.aggregate_tokenizer.AggregateTokenizer):
|
||||
self.is_aggregate = True
|
||||
else:
|
||||
self.is_aggregate = False
|
||||
self._tokenizer = tokenizer
|
||||
|
||||
def __call__(self, *args):
|
||||
if isinstance(args[0], List) and self.is_aggregate:
|
||||
t = []
|
||||
for span in args[0]:
|
||||
t.extend(self._tokenizer.text_to_ids(span['str'], span['lang']))
|
||||
return t
|
||||
|
||||
t = self._tokenizer.text_to_ids(*args)
|
||||
return t
|
||||
|
||||
super().__init__(
|
||||
manifest_filepath=manifest_filepath,
|
||||
parser=TokenizerWrapper(tokenizer),
|
||||
normalize=normalize,
|
||||
normalize_type=normalize_type,
|
||||
use_rttm=use_rttm,
|
||||
rttm_mode=rttm_mode,
|
||||
feat_min_len=feat_min_len,
|
||||
feat_mask_val=feat_mask_val,
|
||||
frame_unit_time_secs=frame_unit_time_secs,
|
||||
sample_rate=sample_rate,
|
||||
augmentor=augmentor,
|
||||
max_duration=max_duration,
|
||||
min_duration=min_duration,
|
||||
max_utts=max_utts,
|
||||
trim=trim,
|
||||
bos_id=bos_id,
|
||||
eos_id=eos_id,
|
||||
pad_id=pad_id,
|
||||
return_sample_id=return_sample_id,
|
||||
channel_selector=channel_selector,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
# Copyright (c) 2022, 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.
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from nemo.collections.asr.data.feature_to_text import FeatureToBPEDataset, FeatureToCharDataset
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
def get_char_dataset(config: dict, augmentor: Optional['FeatureAugmentor'] = None) -> FeatureToCharDataset:
|
||||
"""
|
||||
Instantiates a Character Encoding based FeatureToCharDataset.
|
||||
|
||||
Args:
|
||||
config: Config of the FeatureToCharDataset.
|
||||
augmentor: Optional AudioAugmentor object for augmentations on audio data.
|
||||
|
||||
Returns:
|
||||
An instance of FeatureToCharDataset.
|
||||
"""
|
||||
if 'labels' not in config:
|
||||
logging.warning(f"dataset does not have explicitly defined labels")
|
||||
|
||||
dataset = FeatureToCharDataset(
|
||||
manifest_filepath=config['manifest_filepath'],
|
||||
labels=config.get('labels', None),
|
||||
normalize=config.get('normalize', 'post_norm'),
|
||||
normalize_type=config.get('normalize_type', 'per_feature'),
|
||||
use_rttm=config.get('use_rttm', False),
|
||||
rttm_mode=config.get('rttm_mode', 'mask'),
|
||||
feat_min_len=config.get('feat_min_len', 4),
|
||||
feat_mask_val=config.get('feat_mask_val', None),
|
||||
frame_unit_time_secs=config.get('frame_unit_time_secs', 0.01),
|
||||
sample_rate=config.get('sample_rate', 16000),
|
||||
augmentor=augmentor,
|
||||
max_duration=config.get('max_duration', None),
|
||||
min_duration=config.get('min_duration', None),
|
||||
max_utts=config.get('max_utts', 0),
|
||||
blank_index=config.get('blank_index', -1),
|
||||
unk_index=config.get('unk_index', -1),
|
||||
trim=config.get('trim_silence', False),
|
||||
parser=config.get('parser', 'en'),
|
||||
return_sample_id=config.get('return_sample_id', False),
|
||||
channel_selector=config.get('channel_selector', None),
|
||||
)
|
||||
return dataset
|
||||
|
||||
|
||||
def get_bpe_dataset(
|
||||
config: dict, tokenizer: 'TokenizerSpec', augmentor: Optional['FeatureAugmentor'] = None
|
||||
) -> FeatureToBPEDataset:
|
||||
"""
|
||||
Instantiates a Byte Pair Encoding / Word Piece Encoding based FeatureoToBPEDataset.
|
||||
|
||||
Args:
|
||||
config: Config of the FeatureToBPEDataset.
|
||||
tokenizer: An instance of a TokenizerSpec object.
|
||||
augmentor: Optional FeatureAugmentor object for augmentations on audio features.
|
||||
|
||||
Returns:
|
||||
An instance of FeatureToBPEDataset.
|
||||
"""
|
||||
dataset = FeatureToBPEDataset(
|
||||
manifest_filepath=config['manifest_filepath'],
|
||||
tokenizer=tokenizer,
|
||||
normalize=config.get('normalize', 'post_norm'),
|
||||
normalize_type=config.get('normalize_type', 'per_feature'),
|
||||
use_rttm=config.get('use_rttm', False),
|
||||
rttm_mode=config.get('rttm_mode', 'mask'),
|
||||
feat_min_len=config.get('feat_min_len', 4),
|
||||
feat_mask_val=config.get('feat_mask_val', None),
|
||||
frame_unit_time_secs=config.get('frame_unit_time_secs', 0.01),
|
||||
sample_rate=config.get('sample_rate', 16000),
|
||||
augmentor=augmentor,
|
||||
max_duration=config.get('max_duration', None),
|
||||
min_duration=config.get('min_duration', None),
|
||||
max_utts=config.get('max_utts', 0),
|
||||
trim=config.get('trim_silence', False),
|
||||
use_start_end_token=config.get('use_start_end_token', True),
|
||||
return_sample_id=config.get('return_sample_id', False),
|
||||
channel_selector=config.get('channel_selector', None),
|
||||
)
|
||||
return dataset
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) 2023, 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.
|
||||
@@ -0,0 +1,709 @@
|
||||
# Copyright (c) 2023, 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import datasets as hf_datasets
|
||||
import torch
|
||||
from datasets import concatenate_datasets
|
||||
from datasets.distributed import split_dataset_by_node
|
||||
from omegaconf import DictConfig, ListConfig, open_dict
|
||||
|
||||
from nemo.collections.asr.data.audio_to_text import _speech_collate_fn
|
||||
from nemo.collections.asr.parts.preprocessing.perturb import AudioAugmentor
|
||||
from nemo.collections.asr.parts.preprocessing.segment import AudioSegment, ChannelSelectorType
|
||||
from nemo.collections.common import tokenizers
|
||||
from nemo.collections.common.parts.preprocessing import parsers
|
||||
from nemo.core.classes import Dataset, IterableDataset
|
||||
from nemo.core.neural_types import AudioSignal, LabelsType, LengthsType, NeuralType
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
class HFTextProcessor:
|
||||
"""
|
||||
Text processor for huggingface datasets, mimicing the behavior of
|
||||
`nemo.collections.asr.data.audio_to_text.ASRManifestProcessor`.
|
||||
Basic text cleaning is also supported.
|
||||
Args:
|
||||
parser: Str for a language specific preprocessor or a callable.
|
||||
bos_id: BOS token id to add to the beginning of the transcript.
|
||||
eos_id: EOS token id to add to the end of the transcript.
|
||||
pad_id: PAD token id to pad transcripts to the same length.
|
||||
normalize_text: If true, normalizes text in HFTextProcessor
|
||||
symbols_to_keep: If not None, only keeps symbols in this list when normalizing text
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parser: Union[str, Callable],
|
||||
bos_id: Optional[int] = None,
|
||||
eos_id: Optional[int] = None,
|
||||
pad_id: int = 0,
|
||||
normalize_text: bool = False,
|
||||
symbols_to_keep: Optional[str | List[str]] = None,
|
||||
):
|
||||
self.parser = parser
|
||||
self.eos_id = eos_id
|
||||
self.bos_id = bos_id
|
||||
self.pad_id = pad_id
|
||||
self.normalize_text = normalize_text
|
||||
self.symbols_to_keep = [x for x in symbols_to_keep] if symbols_to_keep is not None else []
|
||||
|
||||
def process_text(self, text: str, lang: Optional[str] = None) -> List[int]:
|
||||
|
||||
if self.normalize_text:
|
||||
text = text.lower()
|
||||
# only keep alphanumeric characters, spaces and symbols defined in self.symbols_to_keep
|
||||
text = ''.join([c for c in text if c.isalnum() or c.isspace() or c in self.symbols_to_keep])
|
||||
|
||||
if hasattr(self.parser, "is_aggregate") and self.parser.is_aggregate and isinstance(text, str):
|
||||
if lang is not None:
|
||||
text_tokens = self.parser(text, lang)
|
||||
# for future use if want to add language bypass to audio_to_text classes
|
||||
# elif hasattr(parser, "lang") and parser.lang is not None:
|
||||
# text_tokens = parser(text, parser.lang)
|
||||
else:
|
||||
raise ValueError("lang required in manifest when using aggregate tokenizers")
|
||||
else:
|
||||
text_tokens = self.parser(text)
|
||||
text_tokens_length = len(text_tokens)
|
||||
if self.bos_id is not None:
|
||||
text_tokens = [self.bos_id] + text_tokens
|
||||
text_tokens_length += 1
|
||||
if self.eos_id is not None:
|
||||
text_tokens = text_tokens + [self.eos_id]
|
||||
text_tokens_length += 1
|
||||
return text_tokens, text_tokens_length
|
||||
|
||||
|
||||
def get_nested_dict_value(dictionary: dict, key: str):
|
||||
"""
|
||||
the key should be a string of nested keys separated by `.`, e.g. `key1.key2.key3`,
|
||||
then the returned value will be `dictionary[key1][key2][key3]`
|
||||
"""
|
||||
nested_keys = key.split(".")
|
||||
result = dictionary
|
||||
for k in nested_keys:
|
||||
if k not in result:
|
||||
raise KeyError(
|
||||
f"Key `{key}` not found in [{result.keys()}], target is {nested_keys}, input is {dictionary}"
|
||||
)
|
||||
result = result[k]
|
||||
return result
|
||||
|
||||
|
||||
class _HFAudioTextDataset(Dataset):
|
||||
"""
|
||||
A Dataset wrapper that loads from HuggingFace datasets and converts to NeMo compatible format.
|
||||
Args:
|
||||
audio_key: key to access audio data from the dataset
|
||||
text_key: key to access text data from the dataset
|
||||
sample_rate_key: key to access sample rate data from the dataset
|
||||
hf_data_cfg: HuggingFace dataset config, params are passed to `hf_datasets.load_dataset` (trust_remote_code is always stripped for security)
|
||||
parser: Str for a language specific preprocessor or a callable.
|
||||
augmentor: An instance of `nemo.collections.asr.parts.perturb.AudioAugmentor` to apply on audio.
|
||||
trim: If true, trims silence using `nemo.collections.asr.parts.preprocessing.segment.AudioSegment`
|
||||
bos_id: BOS token id to add to the beginning of the transcript.
|
||||
eos_id: EOS token id to add to the end of the transcript.
|
||||
pad_id: PAD token id to pad transcripts to the same length.
|
||||
return_sample_id: If true, returns sample id from the dataset.
|
||||
channel_selector: ChannelSelectorType, which channel(s) to use for audio.
|
||||
normalize_db: Target RMS value for audio normalization.
|
||||
ref_channel: Reference channel for normalization.
|
||||
id_key: key to access sample id from the dataset
|
||||
normalize_text: If true, normalizes text in HFTextProcessor
|
||||
symbols_to_keep: If not None, only keeps symbols in this list when normalizing text
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audio_key: str,
|
||||
text_key: str,
|
||||
sample_rate_key: str,
|
||||
hf_data_cfg: Union[DictConfig, ListConfig],
|
||||
parser: Union[str, Callable],
|
||||
sample_rate: int,
|
||||
augmentor: Optional[AudioAugmentor] = None,
|
||||
trim: bool = False,
|
||||
bos_id: Optional[int] = None,
|
||||
eos_id: Optional[int] = None,
|
||||
pad_id: int = 0,
|
||||
return_sample_id: bool = False,
|
||||
channel_selector: Optional[ChannelSelectorType] = None,
|
||||
normalize_db: Optional[float] = None,
|
||||
ref_channel: Optional[int] = None,
|
||||
id_key: Optional[str] = None,
|
||||
normalize_text: bool = False,
|
||||
symbols_to_keep: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.audio_key = audio_key
|
||||
self.text_key = text_key
|
||||
self.sample_rate_key = sample_rate_key
|
||||
self.id_key = id_key
|
||||
self.sample_rate = sample_rate
|
||||
self.augmentor = augmentor if augmentor is not None else AudioAugmentor()
|
||||
self.trim = trim
|
||||
self.return_sample_id = return_sample_id
|
||||
self.channel_selector = channel_selector
|
||||
self.normalize_db = normalize_db
|
||||
self.ref_channel = ref_channel
|
||||
|
||||
self.text_processor = HFTextProcessor(parser, bos_id, eos_id, pad_id, normalize_text, symbols_to_keep)
|
||||
|
||||
data_config_list = [hf_data_cfg] if isinstance(hf_data_cfg, DictConfig) else hf_data_cfg
|
||||
dataset_list = []
|
||||
for data_cfg in data_config_list:
|
||||
with open_dict(data_cfg):
|
||||
if data_cfg.pop("trust_remote_code", False):
|
||||
logging.warning(
|
||||
"trust_remote_code=True was found in hf_data_cfg but has been removed for security. "
|
||||
"This key is always stripped from hf_data_cfg before calling load_dataset."
|
||||
)
|
||||
if "streaming" in data_cfg and data_cfg.streaming:
|
||||
logging.warning(
|
||||
"streaming must be False for random access dataset, but you use streaming=True. "
|
||||
"Forcing streaming=False"
|
||||
)
|
||||
data_cfg.streaming = False
|
||||
logging.info(f"Loading HuggingFace Dataset with cfg: {data_cfg}")
|
||||
dataset_list.append(hf_datasets.load_dataset(**data_cfg))
|
||||
logging.info(f"Dataset loaded with {len(dataset_list[-1])} samples")
|
||||
self.dataset = concatenate_datasets(dataset_list)
|
||||
|
||||
logging.info(f"Total number of samples loaded: {len(self.dataset)}")
|
||||
|
||||
def __len__(self):
|
||||
return len(self.dataset)
|
||||
|
||||
def __getitem__(self, index) -> Tuple:
|
||||
item = self.dataset[index]
|
||||
|
||||
audio_array = get_nested_dict_value(item, self.audio_key)
|
||||
origin_sr = get_nested_dict_value(item, self.sample_rate_key)
|
||||
audio_segment = AudioSegment(
|
||||
samples=audio_array,
|
||||
sample_rate=origin_sr,
|
||||
target_sr=self.sample_rate,
|
||||
trim=self.trim,
|
||||
channel_selector=self.channel_selector,
|
||||
normalize_db=self.normalize_db,
|
||||
ref_channel=self.ref_channel,
|
||||
)
|
||||
self.augmentor.perturb(audio_segment)
|
||||
f = torch.tensor(audio_segment.samples, dtype=torch.float)
|
||||
fl = torch.tensor(f.shape[0], dtype=torch.long)
|
||||
|
||||
text = get_nested_dict_value(item, self.text_key)
|
||||
t, tl = self.text_processor.process_text(text)
|
||||
|
||||
index = get_nested_dict_value(item, self.id_key) if self.id_key else index
|
||||
if self.return_sample_id:
|
||||
output = f, fl, torch.tensor(t).long(), torch.tensor(tl).long(), index
|
||||
else:
|
||||
output = f, fl, torch.tensor(t).long(), torch.tensor(tl).long()
|
||||
|
||||
return output
|
||||
|
||||
def _collate_fn(self, batch):
|
||||
return _speech_collate_fn(batch, pad_id=self.text_processor.pad_id)
|
||||
|
||||
|
||||
class HFAudioToCharDataset(_HFAudioTextDataset):
|
||||
"""
|
||||
Wrapper class for loading HuggingFace dataset for a char-based ASR model
|
||||
"""
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
"""Returns definitions of module output ports."""
|
||||
return {
|
||||
'audio_signal': NeuralType(('B', 'T'), AudioSignal()),
|
||||
'a_sig_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'transcripts': NeuralType(('B', 'T'), LabelsType()),
|
||||
'transcript_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'sample_id': NeuralType(tuple('B'), LengthsType(), optional=True),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audio_key: str,
|
||||
text_key: str,
|
||||
sample_rate_key: str,
|
||||
hf_data_cfg: DictConfig,
|
||||
labels: List[str],
|
||||
sample_rate: int,
|
||||
augmentor: Optional[AudioAugmentor] = None,
|
||||
trim: bool = False,
|
||||
bos_id: Optional[int] = None,
|
||||
eos_id: Optional[int] = None,
|
||||
pad_id: int = 0,
|
||||
return_sample_id: bool = False,
|
||||
channel_selector: Optional[ChannelSelectorType] = None,
|
||||
normalize_db: Optional[float] = None,
|
||||
ref_channel: Optional[int] = None,
|
||||
parser: Union[str, Callable] = 'en',
|
||||
blank_index: int = -1,
|
||||
unk_index: int = -1,
|
||||
normalize: bool = True,
|
||||
id_key: Optional[str] = None,
|
||||
normalize_text: bool = False,
|
||||
symbols_to_keep: Optional[str] = None,
|
||||
):
|
||||
self.labels = labels
|
||||
|
||||
parser = parsers.make_parser(
|
||||
labels=labels, name=parser, unk_id=unk_index, blank_id=blank_index, do_normalize=normalize
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
audio_key=audio_key,
|
||||
text_key=text_key,
|
||||
sample_rate_key=sample_rate_key,
|
||||
hf_data_cfg=hf_data_cfg,
|
||||
parser=parser,
|
||||
sample_rate=sample_rate,
|
||||
augmentor=augmentor,
|
||||
trim=trim,
|
||||
bos_id=bos_id,
|
||||
eos_id=eos_id,
|
||||
pad_id=pad_id,
|
||||
return_sample_id=return_sample_id,
|
||||
channel_selector=channel_selector,
|
||||
normalize_db=normalize_db,
|
||||
ref_channel=ref_channel,
|
||||
id_key=id_key,
|
||||
normalize_text=normalize_text,
|
||||
symbols_to_keep=symbols_to_keep,
|
||||
)
|
||||
|
||||
|
||||
class HFAudioToBPEDataset(_HFAudioTextDataset):
|
||||
"""
|
||||
Wrapper class for loading a HuggingFace dataset for a BPE-based ASR model
|
||||
"""
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
"""Returns definitions of module output ports."""
|
||||
return {
|
||||
'audio_signal': NeuralType(('B', 'T'), AudioSignal()),
|
||||
'a_sig_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'transcripts': NeuralType(('B', 'T'), LabelsType()),
|
||||
'transcript_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'sample_id': NeuralType(tuple('B'), LengthsType(), optional=True),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audio_key: str,
|
||||
text_key: str,
|
||||
sample_rate_key: str,
|
||||
hf_data_cfg: DictConfig,
|
||||
tokenizer: tokenizers.TokenizerSpec,
|
||||
sample_rate: int,
|
||||
augmentor: Optional[AudioAugmentor] = None,
|
||||
trim: bool = False,
|
||||
return_sample_id: bool = False,
|
||||
channel_selector: Optional[ChannelSelectorType] = None,
|
||||
normalize_db: Optional[float] = None,
|
||||
ref_channel: Optional[int] = None,
|
||||
use_start_end_token: bool = True,
|
||||
id_key: Optional[str] = None,
|
||||
normalize_text: bool = False,
|
||||
symbols_to_keep: Optional[str] = None,
|
||||
):
|
||||
if use_start_end_token and hasattr(tokenizer, "bos_id") and tokenizer.bos_id > 0:
|
||||
bos_id = tokenizer.bos_id
|
||||
else:
|
||||
bos_id = None
|
||||
|
||||
if use_start_end_token and hasattr(tokenizer, "eos_id") and tokenizer.eos_id > 0:
|
||||
eos_id = tokenizer.eos_id
|
||||
else:
|
||||
eos_id = None
|
||||
|
||||
if hasattr(tokenizer, "pad_id") and tokenizer.pad_id > 0:
|
||||
pad_id = tokenizer.pad_id
|
||||
else:
|
||||
pad_id = 0
|
||||
|
||||
class TokenizerWrapper:
|
||||
def __init__(self, tokenizer):
|
||||
if isinstance(tokenizer, tokenizers.aggregate_tokenizer.AggregateTokenizer):
|
||||
self.is_aggregate = True
|
||||
else:
|
||||
self.is_aggregate = False
|
||||
self._tokenizer = tokenizer
|
||||
|
||||
def __call__(self, *args):
|
||||
if isinstance(args[0], List) and self.is_aggregate:
|
||||
t = []
|
||||
for span in args[0]:
|
||||
t.extend(self._tokenizer.text_to_ids(span['str'], span['lang']))
|
||||
return t
|
||||
|
||||
t = self._tokenizer.text_to_ids(*args)
|
||||
return t
|
||||
|
||||
super().__init__(
|
||||
audio_key=audio_key,
|
||||
text_key=text_key,
|
||||
sample_rate_key=sample_rate_key,
|
||||
hf_data_cfg=hf_data_cfg,
|
||||
parser=TokenizerWrapper(tokenizer),
|
||||
sample_rate=sample_rate,
|
||||
augmentor=augmentor,
|
||||
trim=trim,
|
||||
bos_id=bos_id,
|
||||
eos_id=eos_id,
|
||||
pad_id=pad_id,
|
||||
return_sample_id=return_sample_id,
|
||||
channel_selector=channel_selector,
|
||||
normalize_db=normalize_db,
|
||||
ref_channel=ref_channel,
|
||||
id_key=id_key,
|
||||
normalize_text=normalize_text,
|
||||
symbols_to_keep=symbols_to_keep,
|
||||
)
|
||||
|
||||
|
||||
class _HFIterableAudioTextDataset(IterableDataset):
|
||||
"""
|
||||
Wrapper class for loading HuggingFace IterableDataset and converts to NeMo compatible format.
|
||||
Args:
|
||||
audio_key: key to access audio data from the dataset
|
||||
text_key: key to access text data from the dataset
|
||||
sample_rate_key: key to access sample rate data from the dataset
|
||||
hf_data_cfg: HuggingFace dataset config, params are passed to `hf_datasets.load_dataset` (trust_remote_code is always stripped for security)
|
||||
parser: Str for a language specific preprocessor or a callable.
|
||||
augmentor: An instance of `nemo.collections.asr.parts.perturb.AudioAugmentor` to apply on audio.
|
||||
trim: If true, trims silence using `nemo.collections.asr.parts.preprocessing.segment.AudioSegment`
|
||||
bos_id: BOS token id to add to the beginning of the transcript.
|
||||
eos_id: EOS token id to add to the end of the transcript.
|
||||
pad_id: PAD token id to pad transcripts to the same length.
|
||||
return_sample_id: If true, returns sample id from the dataset.
|
||||
channel_selector: ChannelSelectorType, which channel(s) to use for audio.
|
||||
normalize_db: Target RMS value for audio normalization.
|
||||
ref_channel: Reference channel for normalization.
|
||||
id_key: key to access sample id from the dataset
|
||||
global_rank: global rank of the current worker
|
||||
world_size: total number of workers
|
||||
shuffle_n: buffer size for shuffling
|
||||
shuffle_seed: seed for shuffling
|
||||
normalize_text: If true, normalizes text in HFTextProcessor
|
||||
symbols_to_keep: If not None, only keeps symbols in this list when normalizing text
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audio_key: str,
|
||||
text_key: str,
|
||||
sample_rate_key: str,
|
||||
hf_data_cfg: Union[DictConfig, ListConfig],
|
||||
parser: Union[str, Callable],
|
||||
sample_rate: int,
|
||||
augmentor: Optional[AudioAugmentor] = None,
|
||||
trim: bool = False,
|
||||
bos_id: Optional[int] = None,
|
||||
eos_id: Optional[int] = None,
|
||||
pad_id: int = 0,
|
||||
return_sample_id: bool = False,
|
||||
channel_selector: Optional[ChannelSelectorType] = None,
|
||||
normalize_db: Optional[float] = None,
|
||||
ref_channel: Optional[int] = None,
|
||||
id_key: Optional[str] = None,
|
||||
global_rank: int = 0,
|
||||
world_size: int = 0,
|
||||
shuffle_n: int = 0,
|
||||
shuffle_seed: Optional[int] = None,
|
||||
normalize_text: bool = False,
|
||||
symbols_to_keep: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
if return_sample_id and id_key is None:
|
||||
raise ValueError("return_sample_id is True, but id_key is None")
|
||||
|
||||
self.audio_key = audio_key
|
||||
self.text_key = text_key
|
||||
self.sample_rate_key = sample_rate_key
|
||||
self.id_key = id_key
|
||||
self.sample_rate = sample_rate
|
||||
self.augmentor = augmentor if augmentor is not None else AudioAugmentor()
|
||||
self.trim = trim
|
||||
self.return_sample_id = return_sample_id
|
||||
self.channel_selector = channel_selector
|
||||
self.normalize_db = normalize_db
|
||||
self.ref_channel = ref_channel
|
||||
|
||||
self.text_processor = HFTextProcessor(parser, bos_id, eos_id, pad_id, normalize_text, symbols_to_keep)
|
||||
|
||||
data_config_list = [hf_data_cfg] if isinstance(hf_data_cfg, DictConfig) else hf_data_cfg
|
||||
dataset_list = []
|
||||
for data_cfg in data_config_list:
|
||||
with open_dict(data_cfg):
|
||||
if data_cfg.pop("trust_remote_code", False):
|
||||
logging.warning(
|
||||
"trust_remote_code=True was found in hf_data_cfg but has been removed for security. "
|
||||
"This key is always stripped from hf_data_cfg before calling load_dataset."
|
||||
)
|
||||
if "streaming" in data_cfg and not data_cfg.streaming:
|
||||
logging.warning(
|
||||
"streaming must be True for streaming dataset, but you use streaming=False. "
|
||||
"Forcing streaming=True"
|
||||
)
|
||||
# streaming must be True for iterable dataset
|
||||
data_cfg.streaming = True
|
||||
logging.info(f"Streaming HuggingFace IterableDataset with cfg: {data_cfg}")
|
||||
dataset_list.append(hf_datasets.load_dataset(**data_cfg))
|
||||
|
||||
self.dataset = concatenate_datasets(dataset_list)
|
||||
logging.info("Total number of samples cannot be extracted from HF streaming dataset")
|
||||
|
||||
if shuffle_n > 0:
|
||||
self.dataset = self.dataset.shuffle(seed=shuffle_seed, buffer_size=shuffle_n)
|
||||
|
||||
self.dataset = split_dataset_by_node(self.dataset, global_rank, world_size)
|
||||
self.dataset = self.dataset.map(self._build_sample)
|
||||
|
||||
def __len__(self):
|
||||
raise NotImplementedError(
|
||||
f"len() is not supported for {self.__class__.__name__}. "
|
||||
"Please set `trainer.max_steps` to explicitly set the number of steps to train for."
|
||||
)
|
||||
|
||||
def __iter__(self):
|
||||
return self.dataset.__iter__()
|
||||
|
||||
def _collate_fn(self, batch):
|
||||
a_signal = [b['audio_signal'] for b in batch]
|
||||
a_sig_length = [b['a_sig_length'] for b in batch]
|
||||
transcripts = [b['transcripts'] for b in batch]
|
||||
transcript_length = [b['transcript_length'] for b in batch]
|
||||
if self.return_sample_id:
|
||||
sample_id = [b['sample_id'] for b in batch]
|
||||
batch_list = list(zip(a_signal, a_sig_length, transcripts, transcript_length, sample_id))
|
||||
else:
|
||||
batch_list = list(zip(a_signal, a_sig_length, transcripts, transcript_length))
|
||||
|
||||
return _speech_collate_fn(batch_list, pad_id=self.text_processor.pad_id)
|
||||
|
||||
def _build_sample(self, sample):
|
||||
audio_array = get_nested_dict_value(sample, self.audio_key)
|
||||
origin_sr = get_nested_dict_value(sample, self.sample_rate_key)
|
||||
audio_segment = AudioSegment(
|
||||
samples=audio_array,
|
||||
sample_rate=origin_sr,
|
||||
target_sr=self.sample_rate,
|
||||
trim=self.trim,
|
||||
channel_selector=self.channel_selector,
|
||||
normalize_db=self.normalize_db,
|
||||
ref_channel=self.ref_channel,
|
||||
)
|
||||
self.augmentor.perturb(audio_segment)
|
||||
f = torch.tensor(audio_segment.samples, dtype=torch.float)
|
||||
fl = torch.tensor(f.shape[0], dtype=torch.long)
|
||||
|
||||
text = get_nested_dict_value(sample, self.text_key)
|
||||
t, tl = self.text_processor.process_text(text)
|
||||
|
||||
output = {
|
||||
'audio_signal': f,
|
||||
'a_sig_length': fl,
|
||||
'transcripts': torch.tensor(t).long(),
|
||||
'transcript_length': torch.tensor(tl).long(),
|
||||
}
|
||||
|
||||
if self.return_sample_id:
|
||||
output['sample_id'] = get_nested_dict_value(sample, self.id_key)
|
||||
return output
|
||||
|
||||
|
||||
class HFIterableAudioToCharDataset(_HFIterableAudioTextDataset):
|
||||
"""
|
||||
Wrapper class for loading HuggingFace IterableDataset for a char-based ASR model
|
||||
"""
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
"""Returns definitions of module output ports."""
|
||||
return {
|
||||
'audio_signal': NeuralType(('B', 'T'), AudioSignal()),
|
||||
'a_sig_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'transcripts': NeuralType(('B', 'T'), LabelsType()),
|
||||
'transcript_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'sample_id': NeuralType(tuple('B'), LengthsType(), optional=True),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
labels: List[str],
|
||||
audio_key: str,
|
||||
text_key: str,
|
||||
sample_rate_key: str,
|
||||
hf_data_cfg: DictConfig,
|
||||
sample_rate: int,
|
||||
augmentor: Optional[AudioAugmentor] = None,
|
||||
trim: bool = False,
|
||||
bos_id: int | None = None,
|
||||
eos_id: int | None = None,
|
||||
pad_id: int = 0,
|
||||
return_sample_id: bool = False,
|
||||
id_key: str | None = None,
|
||||
channel_selector: ChannelSelectorType | None = None,
|
||||
normalize_db: float | None = None,
|
||||
ref_channel: int | None = None,
|
||||
global_rank: int = 0,
|
||||
world_size: int = 0,
|
||||
shuffle_n: int = 0,
|
||||
shuffle_seed: Optional[int] = None,
|
||||
parser: Union[str, Callable] = 'en',
|
||||
blank_index: int = -1,
|
||||
unk_index: int = -1,
|
||||
normalize: bool = True,
|
||||
normalize_text: bool = False,
|
||||
symbols_to_keep: Optional[str] = None,
|
||||
) -> None:
|
||||
self.labels = labels
|
||||
|
||||
parser = parsers.make_parser(
|
||||
labels=labels, name=parser, unk_id=unk_index, blank_id=blank_index, do_normalize=normalize
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
audio_key=audio_key,
|
||||
text_key=text_key,
|
||||
sample_rate_key=sample_rate_key,
|
||||
hf_data_cfg=hf_data_cfg,
|
||||
parser=parser,
|
||||
sample_rate=sample_rate,
|
||||
augmentor=augmentor,
|
||||
trim=trim,
|
||||
bos_id=bos_id,
|
||||
eos_id=eos_id,
|
||||
pad_id=pad_id,
|
||||
return_sample_id=return_sample_id,
|
||||
id_key=id_key,
|
||||
channel_selector=channel_selector,
|
||||
normalize_db=normalize_db,
|
||||
ref_channel=ref_channel,
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
shuffle_n=shuffle_n,
|
||||
shuffle_seed=shuffle_seed,
|
||||
normalize_text=normalize_text,
|
||||
symbols_to_keep=symbols_to_keep,
|
||||
)
|
||||
|
||||
|
||||
class HFIterableAudioToBPEDataset(_HFIterableAudioTextDataset):
|
||||
"""
|
||||
Wrapper class for loading HuggingFace IterableDataset for a BPE-based ASR model
|
||||
"""
|
||||
|
||||
@property
|
||||
def output_types(self) -> Optional[Dict[str, NeuralType]]:
|
||||
"""Returns definitions of module output ports."""
|
||||
return {
|
||||
'audio_signal': NeuralType(('B', 'T'), AudioSignal()),
|
||||
'a_sig_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'transcripts': NeuralType(('B', 'T'), LabelsType()),
|
||||
'transcript_length': NeuralType(tuple('B'), LengthsType()),
|
||||
'sample_id': NeuralType(tuple('B'), LengthsType(), optional=True),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audio_key: str,
|
||||
text_key: str,
|
||||
sample_rate_key: str,
|
||||
hf_data_cfg: DictConfig,
|
||||
tokenizer: tokenizers.TokenizerSpec,
|
||||
sample_rate: int,
|
||||
augmentor: Optional[AudioAugmentor] = None,
|
||||
trim: bool = False,
|
||||
return_sample_id: bool = False,
|
||||
id_key: str | None = None,
|
||||
channel_selector: ChannelSelectorType | None = None,
|
||||
normalize_db: float | None = None,
|
||||
ref_channel: int | None = None,
|
||||
global_rank: int = 0,
|
||||
world_size: int = 0,
|
||||
shuffle_n: int = 0,
|
||||
shuffle_seed: Optional[int] = None,
|
||||
use_start_end_token: bool = True,
|
||||
normalize_text: bool = False,
|
||||
symbols_to_keep: Optional[str] = None,
|
||||
) -> None:
|
||||
|
||||
if use_start_end_token and hasattr(tokenizer, "bos_id") and tokenizer.bos_id > 0:
|
||||
bos_id = tokenizer.bos_id
|
||||
else:
|
||||
bos_id = None
|
||||
|
||||
if use_start_end_token and hasattr(tokenizer, "eos_id") and tokenizer.eos_id > 0:
|
||||
eos_id = tokenizer.eos_id
|
||||
else:
|
||||
eos_id = None
|
||||
|
||||
if hasattr(tokenizer, "pad_id") and tokenizer.pad_id > 0:
|
||||
pad_id = tokenizer.pad_id
|
||||
else:
|
||||
pad_id = 0
|
||||
|
||||
class TokenizerWrapper:
|
||||
def __init__(self, tokenizer):
|
||||
if isinstance(tokenizer, tokenizers.aggregate_tokenizer.AggregateTokenizer):
|
||||
self.is_aggregate = True
|
||||
else:
|
||||
self.is_aggregate = False
|
||||
self._tokenizer = tokenizer
|
||||
|
||||
def __call__(self, *args):
|
||||
if isinstance(args[0], List) and self.is_aggregate:
|
||||
t = []
|
||||
for span in args[0]:
|
||||
t.extend(self._tokenizer.text_to_ids(span['str'], span['lang']))
|
||||
return t
|
||||
|
||||
t = self._tokenizer.text_to_ids(*args)
|
||||
return t
|
||||
|
||||
super().__init__(
|
||||
audio_key=audio_key,
|
||||
text_key=text_key,
|
||||
sample_rate_key=sample_rate_key,
|
||||
hf_data_cfg=hf_data_cfg,
|
||||
parser=TokenizerWrapper(tokenizer),
|
||||
sample_rate=sample_rate,
|
||||
augmentor=augmentor,
|
||||
trim=trim,
|
||||
bos_id=bos_id,
|
||||
eos_id=eos_id,
|
||||
pad_id=pad_id,
|
||||
return_sample_id=return_sample_id,
|
||||
id_key=id_key,
|
||||
channel_selector=channel_selector,
|
||||
normalize_db=normalize_db,
|
||||
ref_channel=ref_channel,
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
shuffle_n=shuffle_n,
|
||||
shuffle_seed=shuffle_seed,
|
||||
normalize_text=normalize_text,
|
||||
symbols_to_keep=symbols_to_keep,
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
# Copyright (c) 2023, 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.
|
||||
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.data.huggingface.hf_audio_to_text import (
|
||||
HFAudioToBPEDataset,
|
||||
HFAudioToCharDataset,
|
||||
HFIterableAudioToBPEDataset,
|
||||
HFIterableAudioToCharDataset,
|
||||
)
|
||||
|
||||
|
||||
def get_hf_audio_to_text_bpe_dataset(
|
||||
config: DictConfig,
|
||||
global_rank: int,
|
||||
world_size: int,
|
||||
tokenizer,
|
||||
augmentor=None,
|
||||
):
|
||||
if "streaming" in config and config["streaming"]:
|
||||
dataset = HFIterableAudioToBPEDataset(
|
||||
audio_key=config.get('audio_key', 'audio.array'),
|
||||
text_key=config["text_key"],
|
||||
sample_rate_key=config.get('sample_rate_key', 'audio.sampling_rate'),
|
||||
tokenizer=tokenizer,
|
||||
hf_data_cfg=config["hf_data_cfg"],
|
||||
sample_rate=config["sample_rate"],
|
||||
augmentor=augmentor,
|
||||
trim=config.get('trim_silence', False),
|
||||
return_sample_id=config.get('return_sample_id', False),
|
||||
id_key=config.get("id_key", None),
|
||||
channel_selector=config.get('channel_selector', None),
|
||||
normalize_db=config.get('normalize_db', None),
|
||||
ref_channel=config.get('ref_channel', None),
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
shuffle_n=config.get("shuffle_n", 2048),
|
||||
shuffle_seed=config.get("shuffle_seed", None),
|
||||
use_start_end_token=config.get('use_start_end_token', True),
|
||||
normalize_text=config.get('normalize_text', False),
|
||||
symbols_to_keep=config.get('symbols_to_keep', None),
|
||||
)
|
||||
else:
|
||||
dataset = HFAudioToBPEDataset(
|
||||
audio_key=config.get('audio_key', 'audio.array'),
|
||||
text_key=config["text_key"],
|
||||
sample_rate_key=config.get('sample_rate_key', 'audio.sampling_rate'),
|
||||
tokenizer=tokenizer,
|
||||
hf_data_cfg=config["hf_data_cfg"],
|
||||
sample_rate=config["sample_rate"],
|
||||
augmentor=augmentor,
|
||||
trim=config.get('trim_silence', False),
|
||||
return_sample_id=config.get('return_sample_id', False),
|
||||
id_key=config.get("id_key", None),
|
||||
channel_selector=config.get('channel_selector', None),
|
||||
normalize_db=config.get('normalize_db', None),
|
||||
ref_channel=config.get('ref_channel', None),
|
||||
use_start_end_token=config.get('use_start_end_token', True),
|
||||
normalize_text=config.get('normalize_text', False),
|
||||
symbols_to_keep=config.get('symbols_to_keep', None),
|
||||
)
|
||||
|
||||
return dataset
|
||||
|
||||
|
||||
def get_hf_audio_to_text_char_dataset(
|
||||
config: DictConfig,
|
||||
global_rank: int,
|
||||
world_size: int,
|
||||
augmentor=None,
|
||||
):
|
||||
if "streaming" in config and config["streaming"]:
|
||||
dataset = HFIterableAudioToCharDataset(
|
||||
labels=config["labels"],
|
||||
audio_key=config.get('audio_key', 'audio.array'),
|
||||
text_key=config["text_key"],
|
||||
sample_rate_key=config.get('sample_rate_key', 'audio.sampling_rate'),
|
||||
hf_data_cfg=config["hf_data_cfg"],
|
||||
sample_rate=config["sample_rate"],
|
||||
augmentor=augmentor,
|
||||
trim=config.get('trim_silence', False),
|
||||
return_sample_id=config.get('return_sample_id', False),
|
||||
id_key=config.get("id_key", None),
|
||||
channel_selector=config.get('channel_selector', None),
|
||||
normalize_db=config.get('normalize_db', None),
|
||||
ref_channel=config.get('ref_channel', None),
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
shuffle_n=config.get("shuffle_n", 2048),
|
||||
shuffle_seed=config.get("shuffle_seed", None),
|
||||
parser=config.get("parser", "en"),
|
||||
blank_index=config.get("blank_index", -1),
|
||||
unk_index=config.get("unk_index", -1),
|
||||
normalize=config.get("normalize", False),
|
||||
normalize_text=config.get('normalize_text', False),
|
||||
symbols_to_keep=config.get('symbols_to_keep', None),
|
||||
pad_id=config.get('pad_id', 0),
|
||||
bos_id=config.get('bos_id', None),
|
||||
eos_id=config.get('eos_id', None),
|
||||
)
|
||||
else:
|
||||
dataset = HFAudioToCharDataset(
|
||||
labels=config["labels"],
|
||||
audio_key=config.get('audio_key', 'audio.array'),
|
||||
text_key=config["text_key"],
|
||||
sample_rate_key=config.get('sample_rate_key', 'audio.sampling_rate'),
|
||||
hf_data_cfg=config["hf_data_cfg"],
|
||||
sample_rate=config["sample_rate"],
|
||||
augmentor=augmentor,
|
||||
trim=config.get('trim_silence', False),
|
||||
bos_id=config.get('bos_id', None),
|
||||
eos_id=config.get('eos_id', None),
|
||||
pad_id=config.get('pad_id', 0),
|
||||
return_sample_id=config.get('return_sample_id', False),
|
||||
id_key=config.get("id_key", None),
|
||||
channel_selector=config.get('channel_selector', None),
|
||||
normalize_db=config.get('normalize_db', None),
|
||||
ref_channel=config.get('ref_channel', None),
|
||||
parser=config.get("parser", "en"),
|
||||
blank_index=config.get("blank_index", -1),
|
||||
unk_index=config.get("unk_index", -1),
|
||||
normalize=config.get("normalize", False),
|
||||
normalize_text=config.get('normalize_text', False),
|
||||
symbols_to_keep=config.get('symbols_to_keep', None),
|
||||
)
|
||||
|
||||
return dataset
|
||||
@@ -0,0 +1,697 @@
|
||||
# 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 copy
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from math import isclose
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from lhotse.dataset import AudioSamples
|
||||
from omegaconf import DictConfig, ListConfig, open_dict
|
||||
from torch import Tensor
|
||||
|
||||
from nemo.collections.asr.data import audio_to_text, audio_to_text_dataset
|
||||
from nemo.collections.asr.parts.preprocessing.perturb import WhiteNoisePerturbation, process_augmentations
|
||||
from nemo.collections.asr.parts.preprocessing.segment import AudioSegment
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest
|
||||
from nemo.collections.common.data.dataset import ConcatDataset
|
||||
from nemo.collections.common.parts.preprocessing.manifest import get_full_path
|
||||
from nemo.core.classes import Serialization
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioNoiseItem:
|
||||
sample_id: str | None = None
|
||||
audio: Union[Tensor, None] = None
|
||||
audio_len: Union[Tensor, None] = None
|
||||
noise: Union[Tensor, None] = None
|
||||
noise_len: Union[Tensor, None] = None
|
||||
noisy_audio: Union[Tensor, None] = None
|
||||
noisy_audio_len: Union[Tensor, None] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioNoiseBatch:
|
||||
sample_id: list | None = None
|
||||
audio: Union[Tensor, None] = None
|
||||
audio_len: Union[Tensor, None] = None
|
||||
noise: Union[Tensor, None] = None
|
||||
noise_len: Union[Tensor, None] = None
|
||||
noisy_audio: Union[Tensor, None] = None
|
||||
noisy_audio_len: Union[Tensor, None] = None
|
||||
|
||||
|
||||
def _parse_manifest_item(line: str, manifest_file: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Specialized function to parse the manifest file by ignoring text,
|
||||
such that nemo dataset can save time on tokenizing text.
|
||||
"""
|
||||
item = json.loads(line)
|
||||
|
||||
# Audio file
|
||||
if 'audio_filename' in item:
|
||||
item['audio_file'] = item.pop('audio_filename')
|
||||
elif 'audio_filepath' in item:
|
||||
item['audio_file'] = item.pop('audio_filepath')
|
||||
else:
|
||||
raise KeyError(f"No 'audio_filename' or 'audio_filepath' in manifest item: {item}")
|
||||
|
||||
item['audio_file'] = get_full_path(audio_file=item['audio_file'], manifest_file=manifest_file)
|
||||
|
||||
# Duration.
|
||||
if 'duration' not in item:
|
||||
item['duration'] = None
|
||||
|
||||
# dummy text
|
||||
item['text'] = ""
|
||||
|
||||
item = dict(
|
||||
audio_file=item['audio_file'],
|
||||
duration=item['duration'],
|
||||
text=item['text'],
|
||||
offset=item.get('offset', None),
|
||||
speaker=item.get('speaker', None),
|
||||
orig_sr=item.get('orig_sample_rate', None),
|
||||
token_labels=item.get('token_labels', None),
|
||||
lang=item.get('lang', None),
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def _audio_noise_collate_fn(batch: List[AudioNoiseItem], batch_augmentor: Any = None) -> AudioNoiseBatch:
|
||||
audios = [x.audio for x in batch]
|
||||
audio_lengths = [x.audio_len for x in batch]
|
||||
max_audio_len = max(audio_lengths).item()
|
||||
|
||||
noises = [x.noise for x in batch]
|
||||
noise_lengths = [x.noise_len for x in batch]
|
||||
|
||||
audio_signal_list = []
|
||||
noise_signal_list = []
|
||||
for i, audio in enumerate(audios):
|
||||
audio_len = audio.size(0)
|
||||
if audio_len < max_audio_len:
|
||||
pad = (0, max_audio_len - audio_len)
|
||||
audio = torch.nn.functional.pad(audio, pad)
|
||||
audio_signal_list.append(audio)
|
||||
|
||||
noise = noises[i]
|
||||
noise_len = noise.size(0)
|
||||
if noise_len < max_audio_len:
|
||||
pad = (0, max_audio_len - noise_len)
|
||||
noise = torch.nn.functional.pad(noise, pad)
|
||||
noise_signal_list.append(noise[:max_audio_len])
|
||||
|
||||
audio_signal = torch.stack(audio_signal_list).float()
|
||||
audio_lengths = torch.stack(audio_lengths).long()
|
||||
noise_signal = torch.stack(noise_signal_list).float()
|
||||
noise_lengths = torch.stack(noise_lengths).long()
|
||||
|
||||
output = AudioNoiseBatch(
|
||||
audio=audio_signal,
|
||||
audio_len=audio_lengths,
|
||||
noise=noise_signal,
|
||||
noise_len=noise_lengths,
|
||||
)
|
||||
|
||||
if batch_augmentor is not None:
|
||||
output = batch_augmentor(output)
|
||||
else:
|
||||
output.noisy_audio = output.audio + output.noise
|
||||
output.noisy_audio_len = output.audio_len
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def load_noise_manifest(noise_manifest: Union[str, ListConfig, None]):
|
||||
"""
|
||||
load noise manifest from a single or a list of manifest files
|
||||
"""
|
||||
if noise_manifest is None:
|
||||
return []
|
||||
|
||||
if isinstance(noise_manifest, str):
|
||||
noise_manifest = noise_manifest.split(',')
|
||||
|
||||
noise_data = []
|
||||
for manifest in noise_manifest:
|
||||
curr_data = read_manifest(manifest)
|
||||
for i in range(len(curr_data)):
|
||||
curr_data[i]['audio_filepath'] = get_full_path(curr_data[i]['audio_filepath'], manifest)
|
||||
noise_data.extend(curr_data)
|
||||
return noise_data
|
||||
|
||||
|
||||
def load_noise_audio(
|
||||
sample: Dict[str, Any],
|
||||
sample_rate: int,
|
||||
max_audio_len: Optional[int] = None,
|
||||
pad_to_max: bool = True,
|
||||
min_white_noise_db: int = -90,
|
||||
max_white_noise_db: int = -46,
|
||||
max_trial: int = 100,
|
||||
):
|
||||
"""
|
||||
Load noise audio from the manifest item, and apply white noise if the loaded noise audio is empty.
|
||||
Args:
|
||||
sample: a sample from the noise manifest
|
||||
sample_rate: target sample rate to resample the noise audio
|
||||
max_audio_len: the maximum audio length to load
|
||||
pad_to_max: whether to pad the audio to max_audio_len
|
||||
min_white_noise_db: the minimum white noise level in dB
|
||||
max_white_noise_db: the maximum white noise level in dB
|
||||
max_trial: the maximum number of trials to load noise audio before giving up
|
||||
Returns:
|
||||
noise: the loaded noise audio
|
||||
noise_len: the length of the loaded noise audio
|
||||
"""
|
||||
max_dur = None if max_audio_len is None else max_audio_len / sample_rate
|
||||
duration = sample.get('duration', None)
|
||||
offset = sample.get('offset', 0.0)
|
||||
|
||||
if max_dur is not None and duration is not None and duration > max_dur:
|
||||
cnt = 0
|
||||
while cnt < max_trial:
|
||||
# randomly sample a segment of the noise
|
||||
offset = np.random.uniform(0, duration - max_dur)
|
||||
|
||||
audio_segment = AudioSegment.from_file(
|
||||
audio_file=sample['audio_filepath'],
|
||||
offset=offset,
|
||||
duration=max_dur,
|
||||
target_sr=sample_rate,
|
||||
)
|
||||
|
||||
if sum(audio_segment.samples) > 0:
|
||||
# break if the segment is not empty
|
||||
break
|
||||
cnt += 1
|
||||
else:
|
||||
audio_segment = AudioSegment.from_file(
|
||||
audio_file=sample['audio_filepath'],
|
||||
offset=offset,
|
||||
duration=duration,
|
||||
target_sr=sample_rate,
|
||||
)
|
||||
|
||||
if sum(audio_segment.samples) == 0:
|
||||
logging.warning(
|
||||
f"Loaded noise audio is empty: {sample}, with sampled offset={offset}, duration={max_dur}. Adding white noise."
|
||||
)
|
||||
WhiteNoisePerturbation(min_level=min_white_noise_db, max_level=max_white_noise_db).perturb(audio_segment)
|
||||
|
||||
noise = torch.tensor(audio_segment.samples, dtype=torch.float)
|
||||
noise_len = torch.tensor(noise.size(0)).long()
|
||||
# pad to max_audio_len if necessary
|
||||
if max_audio_len is not None and pad_to_max:
|
||||
if noise.size(0) < max_audio_len:
|
||||
pad = (0, max_audio_len - noise.size(0))
|
||||
noise = torch.nn.functional.pad(noise, pad)
|
||||
else:
|
||||
noise = noise[:max_audio_len]
|
||||
noise_len = torch.tensor(max_audio_len).long()
|
||||
return noise, noise_len
|
||||
|
||||
|
||||
def sample_noise(noise_data: List[Dict], sample_rate: int, max_audio_len: int | None = None, max_trial: int = 20):
|
||||
"""
|
||||
Randomly sample noise audio from the noise manifest.
|
||||
Args:
|
||||
noise_data: the noise manifest data
|
||||
sample_rate: target sample rate to resample the noise audio
|
||||
max_audio_len: the maximum audio length to load
|
||||
max_trial: the maximum number of trials to load noise audio before giving up
|
||||
Returns:
|
||||
noise_audio: the sampled noise audio
|
||||
noise_len: the length of the sampled noise audio
|
||||
"""
|
||||
cnt = 0
|
||||
noise_audio = torch.zeros(max_audio_len).float()
|
||||
noise_len = torch.tensor(max_audio_len).long()
|
||||
while cnt < max_trial and len(noise_data) > 0:
|
||||
try:
|
||||
noise_sample = noise_data[np.random.randint(len(noise_data))]
|
||||
noise_audio, noise_len = load_noise_audio(noise_sample, sample_rate, max_audio_len)
|
||||
break
|
||||
except Exception as e:
|
||||
logging.warning(f"Error loading noise audio with config {noise_sample} and exception: {e}, retrying.")
|
||||
cnt += 1
|
||||
if cnt == max_trial:
|
||||
logging.warning(f"Failed to load noise audio after {max_trial} attempts, returning zero noise.")
|
||||
return torch.zeros(max_audio_len).float(), torch.tensor(max_audio_len).long()
|
||||
return noise_audio, noise_len
|
||||
|
||||
|
||||
def pad_audio(audio: Tensor, min_len: int, pad_audio_mode) -> Tensor:
|
||||
"""
|
||||
Pad audio to min_len with the specified mode
|
||||
Args:
|
||||
audio: the input audio tensor
|
||||
min_len: the minimum length to pad to
|
||||
pad_audio_mode: the padding mode, either 'repeat' or 'zero'
|
||||
Returns:
|
||||
audio: the padded audio tensor
|
||||
"""
|
||||
allowed_mode = ['repeat', 'zero']
|
||||
if audio.size(0) < min_len:
|
||||
if pad_audio_mode == 'repeat' and audio.size(0) > 0:
|
||||
num_repeats = int(np.ceil(min_len / audio.size(0)))
|
||||
audio = audio.repeat(num_repeats)[:min_len]
|
||||
elif pad_audio_mode == 'zero' or audio.size(0) == 0:
|
||||
audio = torch.nn.functional.pad(audio, (0, min_len - audio.size(0)))
|
||||
else:
|
||||
raise ValueError(f"Unsupported pad_audio_mode: {pad_audio_mode}, must be one of {allowed_mode}")
|
||||
return audio
|
||||
|
||||
|
||||
class AudioNoiseDataset(audio_to_text.AudioToCharDataset):
|
||||
@property
|
||||
def output_types(self):
|
||||
# disable type checking for since it doesn't support dataclass
|
||||
return None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
noise_manifest: str | None = None,
|
||||
batch_augmentor: Any | None = None,
|
||||
min_audio_len_secs: float = 1.0,
|
||||
pad_audio_mode: str = 'repeat',
|
||||
**kwargs,
|
||||
):
|
||||
# add bos_id=0 to avoid empty text token
|
||||
super().__init__(bos_id=0, manifest_parse_func=_parse_manifest_item, **kwargs)
|
||||
self.noise_manifest = noise_manifest
|
||||
self.batch_augmentor = batch_augmentor
|
||||
self.noise_data = load_noise_manifest(noise_manifest)
|
||||
self.min_audio_len_secs = min_audio_len_secs
|
||||
self.pad_audio_mode = pad_audio_mode
|
||||
|
||||
def __getitem__(self, index) -> AudioNoiseItem:
|
||||
sample = self.manifest_processor.collection[index]
|
||||
offset = sample.offset
|
||||
|
||||
if offset is None:
|
||||
offset = 0
|
||||
|
||||
audio = self.featurizer.process(
|
||||
sample.audio_file,
|
||||
offset=offset,
|
||||
duration=sample.duration,
|
||||
trim=self.trim,
|
||||
orig_sr=sample.orig_sr,
|
||||
channel_selector=self.channel_selector,
|
||||
)
|
||||
if audio.size(0) == 0:
|
||||
logging.warning(f"Loaded audio has zero length: {sample}.")
|
||||
|
||||
min_len = int(self.min_audio_len_secs * self.featurizer.sample_rate)
|
||||
audio = pad_audio(audio, min_len, self.pad_audio_mode)
|
||||
audio_len = torch.tensor(audio.shape[0]).long()
|
||||
noise, noise_len = sample_noise(self.noise_data, self.featurizer.sample_rate, audio_len.item())
|
||||
|
||||
item = AudioNoiseItem(
|
||||
sample_id=str(index),
|
||||
audio=audio,
|
||||
audio_len=audio_len,
|
||||
noise=noise,
|
||||
noise_len=noise_len,
|
||||
)
|
||||
return item
|
||||
|
||||
def _collate_fn(self, batch: List[AudioNoiseItem]) -> AudioNoiseBatch:
|
||||
return _audio_noise_collate_fn(batch, self.batch_augmentor)
|
||||
|
||||
|
||||
class TarredAudioNoiseDataset(audio_to_text.TarredAudioToCharDataset):
|
||||
@property
|
||||
def output_types(self):
|
||||
# disable type checking for since it doesn't support dataclass
|
||||
return None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
noise_manifest: str | None = None,
|
||||
batch_augmentor: Any | None = None,
|
||||
min_audio_len_secs: float = 1.0,
|
||||
pad_audio_mode: str = 'repeat',
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
noise_manifest: the noise manifest file
|
||||
batch_augmentor: the batch augmentor
|
||||
min_audio_len_secs: the minimum audio length in seconds, audios shorter than this will be padded
|
||||
pad_audio_mode: the padding mode for audios shorter than min_audio_len_secs, either 'repeat' or 'zero'
|
||||
**kwargs: other arguments for TarredAudioToCharDataset
|
||||
|
||||
"""
|
||||
super().__init__(bos_id=0, manifest_parse_func=_parse_manifest_item, **kwargs)
|
||||
self.noise_manifest = noise_manifest
|
||||
self.batch_augmentor = batch_augmentor
|
||||
self.noise_data = load_noise_manifest(noise_manifest)
|
||||
self.min_audio_len_secs = min_audio_len_secs
|
||||
self.pad_audio_mode = pad_audio_mode
|
||||
|
||||
def _build_sample(self, tup):
|
||||
"""Builds the training sample by combining the data from the WebDataset with the manifest info."""
|
||||
audio_bytes, audio_filename, offset_id = tup
|
||||
|
||||
# Grab manifest entry from self.manifest_preprocessor.collection
|
||||
file_id, _ = os.path.splitext(os.path.basename(audio_filename))
|
||||
manifest_idx = self.manifest_processor.collection.mapping[file_id][offset_id]
|
||||
manifest_entry = self.manifest_processor.collection[manifest_idx]
|
||||
|
||||
offset = manifest_entry.offset
|
||||
if offset is None:
|
||||
offset = 0
|
||||
|
||||
try:
|
||||
# Convert audio bytes to IO stream for processing (for SoundFile to read)
|
||||
audio_filestream = io.BytesIO(audio_bytes)
|
||||
audio = self.featurizer.process(
|
||||
audio_filestream,
|
||||
offset=offset,
|
||||
duration=manifest_entry.duration,
|
||||
trim=self.trim,
|
||||
orig_sr=manifest_entry.orig_sr,
|
||||
)
|
||||
audio_filestream.close()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error reading audio sample: {manifest_entry}, with exception: {e}.")
|
||||
|
||||
min_len = int(self.min_audio_len_secs * self.featurizer.sample_rate)
|
||||
audio = pad_audio(audio, min_len, self.pad_audio_mode)
|
||||
audio_len = torch.tensor(audio.shape[0]).long()
|
||||
noise, noise_len = sample_noise(self.noise_data, self.featurizer.sample_rate, audio_len.item())
|
||||
|
||||
item = AudioNoiseItem(
|
||||
sample_id=str(manifest_idx),
|
||||
audio=audio,
|
||||
audio_len=audio_len,
|
||||
noise=noise,
|
||||
noise_len=noise_len,
|
||||
)
|
||||
return item
|
||||
|
||||
def _pad_audio(self, audio: Tensor) -> Tensor:
|
||||
min_len = int(self.min_audio_len_secs * self.featurizer.sample_rate)
|
||||
if audio.size(0) < min_len:
|
||||
if self.pad_audio_mode == 'repeat':
|
||||
num_repeats = int(np.ceil(min_len / audio.size(0)))
|
||||
audio = audio.repeat(num_repeats)[:min_len]
|
||||
elif self.pad_audio_mode == 'zero':
|
||||
audio = torch.nn.functional.pad(audio, (0, min_len - audio.size(0)))
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported pad_audio_mode: {self.pad_audio_mode}, must be one of ['repeat', 'zero']"
|
||||
)
|
||||
return audio
|
||||
|
||||
def _collate_fn(self, batch: List[AudioNoiseItem]) -> AudioNoiseBatch:
|
||||
return _audio_noise_collate_fn(batch, self.batch_augmentor)
|
||||
|
||||
|
||||
class LhotseAudioNoiseDataset(torch.utils.data.Dataset):
|
||||
def __init__(self, noise_manifest: str | None = None, batch_augmentor_cfg: DictConfig = None):
|
||||
super().__init__()
|
||||
|
||||
if batch_augmentor_cfg:
|
||||
batch_augmentor = Serialization.from_config_dict(batch_augmentor_cfg)
|
||||
else:
|
||||
batch_augmentor = None
|
||||
|
||||
self.batch_augmentor = batch_augmentor
|
||||
self.noise_data = load_noise_manifest(noise_manifest)
|
||||
self.load_audio = AudioSamples(fault_tolerant=True)
|
||||
|
||||
def __getitem__(self, cuts):
|
||||
|
||||
audios, audio_lens, cuts = self.load_audio(cuts)
|
||||
if len(self.noise_data) > 0:
|
||||
sampled_noises = [sample_noise(self.noise_data, cut.sampling_rate, cut.num_samples) for cut in cuts]
|
||||
sampled_noises, sampled_noises_lens = zip(*sampled_noises)
|
||||
sampled_noises = torch.stack(sampled_noises).float()
|
||||
sampled_noises_lens = torch.tensor(sampled_noises_lens).long()
|
||||
else:
|
||||
sampled_noises = torch.zeros_like(audios)
|
||||
sampled_noises_lens = audio_lens
|
||||
|
||||
output = AudioNoiseBatch(
|
||||
audio=audios,
|
||||
audio_len=audio_lens,
|
||||
noise=sampled_noises,
|
||||
noise_len=sampled_noises_lens,
|
||||
)
|
||||
|
||||
if self.batch_augmentor is not None:
|
||||
output = self.batch_augmentor(output)
|
||||
else:
|
||||
output.noisy_audio = output.audio + output.noise
|
||||
output.noisy_audio_len = output.audio_len
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def get_audio_noise_dataset(
|
||||
config: Dict[str, Any], augmentor: Any = None, batch_augmentor: Any = None
|
||||
) -> AudioNoiseDataset:
|
||||
dataset = AudioNoiseDataset(
|
||||
noise_manifest=config.get('noise_manifest', None),
|
||||
batch_augmentor=batch_augmentor,
|
||||
manifest_filepath=config['manifest_filepath'],
|
||||
labels=config.get('labels', None),
|
||||
sample_rate=config['sample_rate'],
|
||||
int_values=config.get('int_values', False),
|
||||
augmentor=augmentor,
|
||||
max_duration=config.get('max_duration', None),
|
||||
min_duration=config.get('min_duration', None),
|
||||
trim=config.get('trim_silence', False),
|
||||
channel_selector=config.get('channel_selector', None),
|
||||
)
|
||||
return dataset
|
||||
|
||||
|
||||
def get_concat_audio_noise_dataset(
|
||||
config: Dict[str, Any], global_rank: int, world_size: int, augmentor: Any = None, batch_augmentor: Any = None
|
||||
) -> ConcatDataset:
|
||||
manifest_filepaths = config['manifest_filepath']
|
||||
datasets = []
|
||||
|
||||
# needed to support validation Concat Datasets that arrive here as
|
||||
# [[dataset1,dataset2]] otherwise ModelPT would interfere
|
||||
if len(manifest_filepaths) == 1 and not isinstance(manifest_filepaths[0], str):
|
||||
logging.info(f"removing an extra nesting level from {manifest_filepaths}")
|
||||
manifest_filepaths = config['manifest_filepath'][0]
|
||||
|
||||
for manifest_filepath in manifest_filepaths:
|
||||
conf = copy.deepcopy(config)
|
||||
conf['manifest_filepath'] = manifest_filepath
|
||||
|
||||
dataset = get_audio_noise_dataset(config=conf, augmentor=augmentor)
|
||||
datasets.append(dataset)
|
||||
|
||||
dataset = ConcatDataset(
|
||||
datasets,
|
||||
sampling_technique=config.get('concat_sampling_technique', 'temperature'),
|
||||
sampling_temperature=config.get('concat_sampling_temperature', 5),
|
||||
sampling_scale=config.get('concat_sampling_scale', 1),
|
||||
sampling_probabilities=config.get('concat_sampling_probabilities', None),
|
||||
shuffle=config.get('concat_shuffle', True),
|
||||
seed=config.get('concat_sampling_seed', None),
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
)
|
||||
return dataset
|
||||
|
||||
|
||||
def get_tarred_audio_noise_dataset(config, shuffle_n, global_rank, world_size, augmentor, batch_augmentor: Any = None):
|
||||
tarred_audio_filepaths = config['tarred_audio_filepaths']
|
||||
manifest_filepaths = config['manifest_filepath']
|
||||
datasets = []
|
||||
tarred_audio_filepaths = audio_to_text_dataset.convert_to_config_list(tarred_audio_filepaths)
|
||||
manifest_filepaths = audio_to_text_dataset.convert_to_config_list(manifest_filepaths)
|
||||
|
||||
bucketing_weights = config.get('bucketing_weights', None) # For upsampling buckets
|
||||
if bucketing_weights:
|
||||
for idx, weight in enumerate(bucketing_weights):
|
||||
if not isinstance(weight, int) or weight <= 0:
|
||||
raise ValueError("bucket weights must be positive integers")
|
||||
|
||||
if len(manifest_filepaths) != len(tarred_audio_filepaths):
|
||||
raise ValueError(
|
||||
f"manifest_filepaths (length={len(manifest_filepaths)}) and tarred_audio_filepaths (length={len(tarred_audio_filepaths)}) need to have the same number of buckets."
|
||||
)
|
||||
|
||||
for dataset_idx, (tarred_audio_filepath, manifest_filepath) in enumerate(
|
||||
zip(tarred_audio_filepaths, manifest_filepaths)
|
||||
):
|
||||
if len(tarred_audio_filepath) == 1:
|
||||
tarred_audio_filepath = tarred_audio_filepath[0]
|
||||
if len(manifest_filepath) == 1:
|
||||
manifest_filepath = manifest_filepath[0]
|
||||
|
||||
is_sharded_manifest = True if "_OP_" in manifest_filepath and "_CL_" in manifest_filepath else False
|
||||
logging.info(
|
||||
f"Loading TarredAudioNoiseDataset from {tarred_audio_filepath} and {manifest_filepath}, shard={is_sharded_manifest}"
|
||||
)
|
||||
dataset = TarredAudioNoiseDataset(
|
||||
noise_manifest=config.get('noise_manifest', None),
|
||||
batch_augmentor=batch_augmentor,
|
||||
audio_tar_filepaths=tarred_audio_filepath,
|
||||
manifest_filepath=manifest_filepath,
|
||||
labels=config.get('labels', None),
|
||||
sample_rate=config['sample_rate'],
|
||||
int_values=config.get('int_values', False),
|
||||
augmentor=augmentor,
|
||||
shuffle_n=shuffle_n,
|
||||
max_duration=config.get('max_duration', None),
|
||||
min_duration=config.get('min_duration', None),
|
||||
trim=config.get('trim_silence', False),
|
||||
shard_strategy=config.get('tarred_shard_strategy', 'scatter'),
|
||||
shard_manifests=is_sharded_manifest,
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
)
|
||||
if bucketing_weights:
|
||||
[datasets.append(dataset) for _ in range(bucketing_weights[dataset_idx])]
|
||||
else:
|
||||
datasets.append(dataset)
|
||||
|
||||
return audio_to_text_dataset.get_chain_dataset(datasets=datasets, ds_config=config, rank=global_rank)
|
||||
|
||||
|
||||
def get_concat_tarred_audio_noise_dataset(
|
||||
config, shuffle_n, global_rank, world_size, augmentor, batch_augmentor: Any = None
|
||||
):
|
||||
tarred_audio_filepaths = config['tarred_audio_filepaths']
|
||||
manifest_filepaths = config['manifest_filepath']
|
||||
datasets = []
|
||||
for dataset_idx, (tarred_audio_filepath, manifest_filepath) in enumerate(
|
||||
zip(tarred_audio_filepaths, manifest_filepaths)
|
||||
):
|
||||
conf = copy.deepcopy(config)
|
||||
conf['manifest_filepath'] = manifest_filepath
|
||||
conf['tarred_audio_filepaths'] = tarred_audio_filepath
|
||||
dataset = get_tarred_audio_noise_dataset(
|
||||
config=conf,
|
||||
shuffle_n=shuffle_n,
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
augmentor=augmentor,
|
||||
batch_augmentor=batch_augmentor,
|
||||
)
|
||||
datasets.append(dataset)
|
||||
|
||||
dataset = ConcatDataset(
|
||||
datasets,
|
||||
sampling_technique=config.get('concat_sampling_technique', 'temperature'),
|
||||
sampling_temperature=config.get('concat_sampling_temperature', 5),
|
||||
sampling_scale=config.get('concat_sampling_scale', 1),
|
||||
sampling_probabilities=config.get('concat_sampling_probabilities', None),
|
||||
shuffle=config.get('concat_shuffle', True),
|
||||
seed=config.get('concat_sampling_seed', None),
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
)
|
||||
return dataset
|
||||
|
||||
|
||||
def get_audio_noise_dataset_from_config(
|
||||
config,
|
||||
global_rank: int,
|
||||
world_size: int,
|
||||
):
|
||||
if 'augmentor' in config:
|
||||
augmentor = process_augmentations(config['augmentor'], global_rank=global_rank, world_size=world_size)
|
||||
else:
|
||||
augmentor = None
|
||||
|
||||
if 'batch_augmentor' in config:
|
||||
batch_augmentor = Serialization.from_config_dict(config['batch_augmentor'])
|
||||
else:
|
||||
batch_augmentor = None
|
||||
|
||||
is_concat = config.get('is_concat', False)
|
||||
if is_concat:
|
||||
if config.get('concat_sampling_technique', None) is None:
|
||||
logging.warning(
|
||||
f"Concat dataset requires `concat_sampling_technique` but it was not provided, using round-robin. Config: {config}"
|
||||
)
|
||||
config['concat_sampling_technique'] = 'round-robin'
|
||||
|
||||
if config['concat_sampling_technique'] == 'random':
|
||||
if not 'concat_sampling_probabilities' in config:
|
||||
logging.warning(
|
||||
f"Concat dataset requires `concat_sampling_probabilities` list, using uniform weights. Config: {config}"
|
||||
)
|
||||
with open_dict(config):
|
||||
config['concat_sampling_probabilities'] = [1 / len(config['manifest_filepath'])] * len(
|
||||
config['manifest_filepath']
|
||||
)
|
||||
elif not isclose(sum(config['concat_sampling_probabilities']), 1, abs_tol=1e-6):
|
||||
raise ValueError(
|
||||
f"`concat_sampling_probabilities` need to sum to 1 with 1e-6 tolerance. Config: {config}"
|
||||
)
|
||||
|
||||
shuffle = config['shuffle']
|
||||
if config.get('is_tarred', False):
|
||||
if ('tarred_audio_filepaths' in config and config['tarred_audio_filepaths'] is None) or (
|
||||
'manifest_filepath' in config and config['manifest_filepath'] is None
|
||||
):
|
||||
logging.warning(
|
||||
"Could not load dataset as `manifest_filepath` was None or "
|
||||
f"`tarred_audio_filepaths` is None. Provided config : {config}"
|
||||
)
|
||||
return None
|
||||
|
||||
shuffle_n = config.get('shuffle_n', 4 * config['batch_size']) if shuffle else 0
|
||||
if is_concat:
|
||||
dataset = get_concat_tarred_audio_noise_dataset(
|
||||
config=config,
|
||||
shuffle_n=shuffle_n,
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
augmentor=augmentor,
|
||||
batch_augmentor=batch_augmentor,
|
||||
)
|
||||
else:
|
||||
dataset = get_tarred_audio_noise_dataset(
|
||||
config=config,
|
||||
shuffle_n=shuffle_n,
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
augmentor=augmentor,
|
||||
batch_augmentor=batch_augmentor,
|
||||
)
|
||||
else:
|
||||
if 'manifest_filepath' in config and config['manifest_filepath'] is None:
|
||||
logging.warning(f"Could not load dataset as `manifest_filepath` was None. Provided config : {config}")
|
||||
return None
|
||||
if is_concat:
|
||||
dataset = get_concat_audio_noise_dataset(
|
||||
config=config,
|
||||
global_rank=global_rank,
|
||||
world_size=world_size,
|
||||
augmentor=augmentor,
|
||||
batch_augmentor=batch_augmentor,
|
||||
)
|
||||
else:
|
||||
dataset = get_audio_noise_dataset(config=config, augmentor=augmentor, batch_augmentor=batch_augmentor)
|
||||
return dataset
|
||||
@@ -0,0 +1,484 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION & AFFILIATES. 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import copy
|
||||
import gc
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Set, Union
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.utils.data
|
||||
from torch.nn.utils.rnn import pad_sequence
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
from nemo.collections.asr.data.audio_to_text import _speech_collate_fn
|
||||
from nemo.collections.common.tokenizers import TokenizerSpec
|
||||
from nemo.core.classes import Dataset, IterableDataset
|
||||
from nemo.utils import logging
|
||||
|
||||
try:
|
||||
from nemo_text_processing.text_normalization.normalize import Normalizer
|
||||
except Exception as e:
|
||||
pass # Normalizer imported only for annotation purposes, error can be ignored
|
||||
|
||||
AnyPath = Union[Path, str]
|
||||
|
||||
|
||||
class TextToTextItem(NamedTuple):
|
||||
tts_text: torch.Tensor # normalized and tokenized text for TTS
|
||||
transcript: torch.Tensor # tokenized text for ASR
|
||||
speaker: int # speaker id for multi-speaker TTS
|
||||
|
||||
|
||||
class TextToTextBatch(NamedTuple):
|
||||
tts_texts: torch.Tensor # tokenized texts for tts
|
||||
tts_text_lengths: torch.Tensor
|
||||
transcripts: torch.Tensor # tokenized texts for ASR
|
||||
transcript_lengths: torch.Tensor
|
||||
speakers: torch.Tensor # speaker ids for multi-speaker TTS
|
||||
|
||||
@staticmethod
|
||||
def collate_fn(batch: List[TextToTextItem], asr_pad_id: int, tts_text_pad_id: int) -> TextToTextBatch:
|
||||
return TextToTextBatch(
|
||||
tts_texts=pad_sequence([item.tts_text for item in batch], batch_first=True, padding_value=tts_text_pad_id),
|
||||
tts_text_lengths=torch.tensor([item.tts_text.shape[0] for item in batch]).long(),
|
||||
transcripts=pad_sequence([item.transcript for item in batch], batch_first=True, padding_value=asr_pad_id),
|
||||
transcript_lengths=torch.tensor([item.transcript.shape[0] for item in batch]).long(),
|
||||
speakers=torch.tensor([item.speaker for item in batch]).long(),
|
||||
)
|
||||
|
||||
|
||||
class TextOrAudioToTextBatch(NamedTuple):
|
||||
audio_signals: torch.Tensor
|
||||
audio_signal_lengths: torch.Tensor
|
||||
tts_texts: torch.Tensor
|
||||
tts_text_lengths: torch.Tensor
|
||||
speakers: torch.Tensor
|
||||
transcripts: torch.Tensor
|
||||
transcript_lengths: torch.Tensor
|
||||
|
||||
@staticmethod
|
||||
def collate_fn(
|
||||
batch: List[Union[TextToTextItem, tuple]], tts_text_pad_id: int, asr_pad_id: int
|
||||
) -> Union[TextToTextBatch, TextOrAudioToTextBatch, tuple]:
|
||||
"""
|
||||
Collate function for dataloader
|
||||
Can accept mixed batch of text-to-text items and audio-text items (typical for ASR)
|
||||
"""
|
||||
text_items: List[TextToTextItem] = [item for item in batch if isinstance(item, TextToTextItem)]
|
||||
if not text_items:
|
||||
# pure audio-text batch
|
||||
return _speech_collate_fn(batch=batch, pad_id=asr_pad_id)
|
||||
|
||||
asr_items = [item for item in batch if not isinstance(item, TextToTextItem)]
|
||||
|
||||
if not asr_items:
|
||||
# pure text-to-text batch
|
||||
return TextToTextBatch.collate_fn(batch=text_items, asr_pad_id=asr_pad_id, tts_text_pad_id=tts_text_pad_id)
|
||||
|
||||
# mixed batch
|
||||
|
||||
# each asr item is a tuple:
|
||||
# audio_signal (0), audio_length (1), transcript (2), transcript_length (3), sample_id (4, optional)
|
||||
audio_signals = pad_sequence([item[0] for item in asr_items], batch_first=True, padding_value=0.0)
|
||||
audio_signal_lengths = torch.tensor([item[1] for item in asr_items]).long()
|
||||
|
||||
tts_texts = pad_sequence(
|
||||
[item.tts_text for item in text_items], batch_first=True, padding_value=tts_text_pad_id
|
||||
)
|
||||
tts_text_lengths = torch.tensor([item.tts_text.shape[0] for item in text_items]).long()
|
||||
speakers = torch.tensor([item.speaker for item in text_items]).long()
|
||||
|
||||
transcripts = pad_sequence(
|
||||
[item.transcript for item in text_items] + [item[2] for item in asr_items],
|
||||
batch_first=True,
|
||||
padding_value=asr_pad_id,
|
||||
)
|
||||
transcript_lengths = torch.tensor(
|
||||
[item.transcript.shape[0] for item in text_items] + [item[3] for item in asr_items]
|
||||
).long()
|
||||
|
||||
return TextOrAudioToTextBatch(
|
||||
audio_signals=audio_signals,
|
||||
audio_signal_lengths=audio_signal_lengths,
|
||||
tts_texts=tts_texts,
|
||||
tts_text_lengths=tts_text_lengths,
|
||||
speakers=speakers,
|
||||
transcripts=transcripts,
|
||||
transcript_lengths=transcript_lengths,
|
||||
)
|
||||
|
||||
|
||||
def _asr_text_to_tokens(text: str) -> np.ndarray:
|
||||
"""
|
||||
Helper function for asr tokenization with multiprocessing pool only.
|
||||
Must be defined on the top level.
|
||||
Expects asr_tokenizer_global, asr_bos_id_global, asr_eos_id_global to exist in the current pool process
|
||||
"""
|
||||
ids = asr_tokenizer_global.text_to_ids(text)
|
||||
if asr_bos_id_global is not None:
|
||||
ids = [asr_bos_id_global] + ids
|
||||
if asr_eos_id_global is not None:
|
||||
ids.append(asr_eos_id_global)
|
||||
return np.asarray(ids)
|
||||
|
||||
|
||||
def _tts_text_to_tokens(text: str) -> np.ndarray:
|
||||
"""
|
||||
Helper function for asr tokenization with multiprocessing pool only.
|
||||
Must be defined on the top level.
|
||||
Expects tts_tokenizer_global to exist in the current pool process
|
||||
"""
|
||||
return np.asarray(tts_tokenizer_global(text))
|
||||
|
||||
|
||||
def _iterate_manifest(filepath: AnyPath) -> Iterable[Dict[str, Any]]:
|
||||
"""
|
||||
Helper function to iterate manifest
|
||||
"""
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
record = json.loads(line)
|
||||
yield record
|
||||
|
||||
|
||||
class TextToTextDatasetBase:
|
||||
"""
|
||||
Base class for loading text-to-text manifests
|
||||
Map-style and Iterable datasets should inherit this class
|
||||
"""
|
||||
|
||||
asr_pad_id: int
|
||||
tts_text_pad_id: int
|
||||
asr_bos_id: Optional[int] = None
|
||||
asr_eos_id: Optional[int] = None
|
||||
data: List[Dict[str, Any]]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manifest_filepath: Union[AnyPath, List[AnyPath]],
|
||||
speakers_filepath: Union[AnyPath, List[AnyPath]],
|
||||
asr_tokenizer: TokenizerSpec,
|
||||
asr_use_start_end_token: bool,
|
||||
tts_parser: Callable,
|
||||
tts_text_pad_id: int,
|
||||
tts_text_normalizer: "Normalizer",
|
||||
tts_text_normalizer_call_kwargs: Dict,
|
||||
min_words: int = 1,
|
||||
max_words: int = 1_000_000,
|
||||
tokenizer_workers: int = 1,
|
||||
num_parts: int = 1,
|
||||
current_part_index: int = 0,
|
||||
):
|
||||
super().__init__()
|
||||
# ASR tokenizer setup
|
||||
if asr_use_start_end_token and hasattr(asr_tokenizer, 'bos_token'):
|
||||
self.asr_bos_id = asr_tokenizer.bos_id
|
||||
|
||||
if asr_use_start_end_token and hasattr(asr_tokenizer, 'eos_token'):
|
||||
self.asr_eos_id = asr_tokenizer.eos_id
|
||||
|
||||
if hasattr(asr_tokenizer, 'pad_token'):
|
||||
self.asr_pad_id = asr_tokenizer.pad_id
|
||||
else:
|
||||
self.asr_pad_id = 0
|
||||
|
||||
self.asr_tokenizer = asr_tokenizer
|
||||
|
||||
# TTS tokenizer setup
|
||||
self.tts_parser = tts_parser
|
||||
self.tts_normalizer = tts_text_normalizer
|
||||
self.tts_normalizer_kwargs = tts_text_normalizer_call_kwargs
|
||||
self.tts_text_pad_id = tts_text_pad_id
|
||||
|
||||
# Load speakers
|
||||
if isinstance(speakers_filepath, str):
|
||||
speakers_filepath = speakers_filepath.split(",")
|
||||
elif isinstance(speakers_filepath, Path):
|
||||
speakers_filepath = [speakers_filepath]
|
||||
speakers: Set[int] = set()
|
||||
for filepath in speakers_filepath:
|
||||
with open(Path(filepath).expanduser(), "r") as f:
|
||||
speakers.update(map(int, f.read().split()))
|
||||
self.speakers = np.asarray(sorted(speakers))
|
||||
logging.info(f"Loaded {len(self.speakers)} speakers")
|
||||
|
||||
# Load manifest
|
||||
if isinstance(manifest_filepath, str):
|
||||
manifest_filepath = manifest_filepath.split(",")
|
||||
elif isinstance(manifest_filepath, Path):
|
||||
manifest_filepath = [manifest_filepath]
|
||||
self.manifest_paths = [Path(filepath) for filepath in manifest_filepath]
|
||||
|
||||
num_skipped_words = 0
|
||||
num_skipped_utterances = 0
|
||||
asr_texts = []
|
||||
tts_texts = []
|
||||
need_normalization = False
|
||||
|
||||
for manifest_path in self.manifest_paths:
|
||||
for tmp_item in tqdm(_iterate_manifest(manifest_path)):
|
||||
text = tmp_item["text"]
|
||||
num_words = len(text.split())
|
||||
# skip if number of works not in desired range
|
||||
# TODO: maybe it would be valuable to sample sub-utterances from long utterances
|
||||
if not (min_words <= num_words <= max_words):
|
||||
num_skipped_words += num_words
|
||||
num_skipped_utterances += 1
|
||||
continue
|
||||
asr_texts.append(tmp_item["text"])
|
||||
if "tts_text_normalized" in tmp_item:
|
||||
tts_texts.append(tmp_item["tts_text_normalized"])
|
||||
else:
|
||||
tts_texts.append(tmp_item["tts_text"])
|
||||
need_normalization = True
|
||||
|
||||
if need_normalization:
|
||||
logging.warning("TTS normalization is extremely slow! It is recommended to normalize TTS text")
|
||||
|
||||
if num_skipped_utterances:
|
||||
logging.warning(f"Skipped {num_skipped_utterances} utterances " f"with {num_skipped_words}")
|
||||
|
||||
num_utterances = len(asr_texts)
|
||||
# preprocessing is very costly, if we need only part - remove unnecessary utterances
|
||||
if num_parts > 1:
|
||||
# NB: floor division, full dataset can contain fewer utterances than original, like in tarred dataset
|
||||
num_utterances_part = num_utterances // num_parts
|
||||
start = num_utterances_part * current_part_index
|
||||
end = start + num_utterances_part
|
||||
logging.info(
|
||||
f"Taking part of the dataset: {current_part_index} index, total {num_parts} from {start} to {end}"
|
||||
)
|
||||
asr_texts = asr_texts[start:end]
|
||||
tts_texts = tts_texts[start:end]
|
||||
num_utterances = num_utterances_part
|
||||
|
||||
self.data = [dict() for _ in range(num_utterances)]
|
||||
|
||||
if len(asr_texts) == 0:
|
||||
# no data was loaded
|
||||
logging.warning("Text-to-text dataset is empty")
|
||||
return
|
||||
|
||||
if tokenizer_workers == 1:
|
||||
logging.warning(
|
||||
"Preprocessing large text with tokenizer_workers=1 may be slow with TTS tokenizer. "
|
||||
"Prefer tokenizer_workers=(num_cpu_cores/num_gpus_per_node)"
|
||||
)
|
||||
for i, tokenized_text in enumerate(
|
||||
tqdm((self._asr_text_to_tokens(text) for text in asr_texts), total=len(asr_texts))
|
||||
):
|
||||
self.data[i]["asr_text_tokens"] = tokenized_text
|
||||
else:
|
||||
# Multiprocessing hack: use global variables for every process (not really global in program context)
|
||||
def _init_asr_tokenize_process(tokenizer, bos_id, eos_id):
|
||||
global asr_tokenizer_global, asr_bos_id_global, asr_eos_id_global # process-global
|
||||
# deepcopy to avoid serialization of parent models
|
||||
asr_tokenizer_global = copy.deepcopy(tokenizer)
|
||||
asr_bos_id_global = copy.deepcopy(bos_id)
|
||||
asr_eos_id_global = copy.deepcopy(eos_id)
|
||||
|
||||
with concurrent.futures.ProcessPoolExecutor(
|
||||
initializer=_init_asr_tokenize_process,
|
||||
initargs=(asr_tokenizer, self.asr_bos_id, self.asr_eos_id),
|
||||
max_workers=tokenizer_workers,
|
||||
) as pool:
|
||||
# chunk size for pool map is empirically chosen as a trade-off between speed and responsiveness
|
||||
for i, tokenized_text in enumerate(
|
||||
tqdm(pool.map(_asr_text_to_tokens, asr_texts, chunksize=1000), total=len(asr_texts))
|
||||
):
|
||||
self.data[i]["asr_text_tokens"] = tokenized_text
|
||||
# force free memory
|
||||
del asr_texts
|
||||
gc.collect()
|
||||
|
||||
if tokenizer_workers == 1:
|
||||
logging.warning(
|
||||
"Preprocessing large text with tokenizer_workers=1 may be slow with TTS tokenizer. "
|
||||
"Prefer tokenizer_workers=(num_cpu_cores/num_gpus_per_node)"
|
||||
)
|
||||
for i, tokenized_text in enumerate(
|
||||
tqdm(
|
||||
(self._tts_text_to_tokens(text, normalize=need_normalization) for text in tts_texts),
|
||||
total=len(tts_texts),
|
||||
)
|
||||
):
|
||||
self.data[i]["tts_text_tokens"] = tokenized_text
|
||||
else:
|
||||
if need_normalization:
|
||||
# TODO: implement, if we really need normalization inplace
|
||||
raise NotImplementedError(
|
||||
"Normalization with tokenizer_workers > 1 is not implemented. "
|
||||
"It is not recommended to use normalization on the fly at all, since it's extremely slow"
|
||||
)
|
||||
|
||||
def _init_tts_tokenize_process(tokenizer):
|
||||
global tts_tokenizer_global # process-global
|
||||
tts_tokenizer_global = copy.deepcopy(tokenizer)
|
||||
|
||||
with concurrent.futures.ProcessPoolExecutor(
|
||||
initializer=_init_tts_tokenize_process,
|
||||
initargs=(tts_parser,),
|
||||
max_workers=tokenizer_workers,
|
||||
) as pool:
|
||||
# chunk size for pool map is empirically chosen as a trade-off between speed and responsiveness
|
||||
for i, tokenized_text in enumerate(
|
||||
tqdm(pool.map(_tts_text_to_tokens, tts_texts, chunksize=1000), total=len(tts_texts))
|
||||
):
|
||||
self.data[i]["tts_text_tokens"] = tokenized_text
|
||||
# force free memory
|
||||
del tts_texts
|
||||
gc.collect()
|
||||
|
||||
def _asr_text_to_tokens(self, text: str) -> np.ndarray:
|
||||
ids = self.asr_tokenizer.text_to_ids(text)
|
||||
if self.asr_bos_id is not None:
|
||||
ids = [self.asr_bos_id] + ids
|
||||
if self.asr_eos_id is not None:
|
||||
ids.append(self.asr_eos_id)
|
||||
return np.asarray(ids)
|
||||
|
||||
def _tts_text_to_tokens(self, text: str, normalize=True) -> np.ndarray:
|
||||
if normalize:
|
||||
text = self.tts_normalizer.normalize(text, **self.tts_normalizer_kwargs)
|
||||
tokens = self.tts_parser(text)
|
||||
return np.asarray(tokens)
|
||||
|
||||
def __getitem__(self, index):
|
||||
item = self.data[index]
|
||||
return TextToTextItem(
|
||||
transcript=torch.from_numpy(item["asr_text_tokens"]).long(),
|
||||
tts_text=torch.from_numpy(item["tts_text_tokens"]).long(),
|
||||
speaker=random.choice(self.speakers),
|
||||
)
|
||||
|
||||
def __len__(self):
|
||||
return len(self.data)
|
||||
|
||||
|
||||
class TextToTextDataset(TextToTextDatasetBase, Dataset):
|
||||
"""Text-to-Text Map-style Dataset."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manifest_filepath: Union[AnyPath, List[AnyPath]],
|
||||
speakers_filepath: Union[AnyPath, List[AnyPath]],
|
||||
asr_tokenizer: TokenizerSpec,
|
||||
asr_use_start_end_token: bool,
|
||||
tts_parser: Callable,
|
||||
tts_text_pad_id: int,
|
||||
tts_text_normalizer: "Normalizer",
|
||||
tts_text_normalizer_call_kwargs: Dict,
|
||||
min_words: int = 1,
|
||||
max_words: int = 1_000_000,
|
||||
tokenizer_workers: int = 1,
|
||||
):
|
||||
super().__init__(
|
||||
manifest_filepath=manifest_filepath,
|
||||
speakers_filepath=speakers_filepath,
|
||||
asr_tokenizer=asr_tokenizer,
|
||||
asr_use_start_end_token=asr_use_start_end_token,
|
||||
tts_parser=tts_parser,
|
||||
tts_text_pad_id=tts_text_pad_id,
|
||||
tts_text_normalizer=tts_text_normalizer,
|
||||
tts_text_normalizer_call_kwargs=tts_text_normalizer_call_kwargs,
|
||||
min_words=min_words,
|
||||
max_words=max_words,
|
||||
tokenizer_workers=tokenizer_workers,
|
||||
num_parts=1,
|
||||
)
|
||||
|
||||
def collate_fn(
|
||||
self, batch: List[Union[TextToTextItem, tuple]]
|
||||
) -> Union[TextToTextBatch, TextOrAudioToTextBatch, tuple]:
|
||||
"""
|
||||
Collate function for dataloader
|
||||
Can accept mixed batch of text-to-text items and audio-text items (typical for ASR)
|
||||
"""
|
||||
return TextOrAudioToTextBatch.collate_fn(
|
||||
batch=batch, asr_pad_id=self.asr_pad_id, tts_text_pad_id=self.tts_text_pad_id
|
||||
)
|
||||
|
||||
|
||||
class TextToTextIterableDataset(TextToTextDatasetBase, IterableDataset):
|
||||
"""
|
||||
Text-to-Text Iterable Dataset.
|
||||
Only part necessary for current process should be loaded and stored.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manifest_filepath: Union[AnyPath, List[AnyPath]],
|
||||
speakers_filepath: Union[AnyPath, List[AnyPath]],
|
||||
asr_tokenizer: TokenizerSpec,
|
||||
asr_use_start_end_token: bool,
|
||||
tts_parser: Callable,
|
||||
tts_text_pad_id: int,
|
||||
tts_text_normalizer: "Normalizer",
|
||||
tts_text_normalizer_call_kwargs: Dict,
|
||||
min_words: int = 1,
|
||||
max_words: int = 1_000_000,
|
||||
tokenizer_workers: int = 1,
|
||||
num_parts: int = 1,
|
||||
current_part_index: int = 0,
|
||||
):
|
||||
super().__init__(
|
||||
manifest_filepath=manifest_filepath,
|
||||
speakers_filepath=speakers_filepath,
|
||||
asr_tokenizer=asr_tokenizer,
|
||||
asr_use_start_end_token=asr_use_start_end_token,
|
||||
tts_parser=tts_parser,
|
||||
tts_text_pad_id=tts_text_pad_id,
|
||||
tts_text_normalizer=tts_text_normalizer,
|
||||
tts_text_normalizer_call_kwargs=tts_text_normalizer_call_kwargs,
|
||||
min_words=min_words,
|
||||
max_words=max_words,
|
||||
tokenizer_workers=tokenizer_workers,
|
||||
num_parts=num_parts,
|
||||
current_part_index=current_part_index,
|
||||
)
|
||||
|
||||
def __iter__(self):
|
||||
# Implementation based on docs: https://pytorch.org/docs/stable/data.html#torch.utils.data.IterableDataset
|
||||
worker_info = torch.utils.data.get_worker_info()
|
||||
if worker_info is None: # single-process data loading, return the full iterator
|
||||
start = 0
|
||||
end = len(self)
|
||||
else: # in a worker process
|
||||
# split workload
|
||||
per_worker = int(math.ceil(len(self) / float(worker_info.num_workers)))
|
||||
worker_id = worker_info.id
|
||||
start = worker_id * per_worker
|
||||
end = min(start + per_worker, len(self))
|
||||
indices = np.arange(start, end)
|
||||
np.random.shuffle(indices)
|
||||
return map(self.__getitem__, indices)
|
||||
|
||||
def collate_fn(
|
||||
self, batch: List[Union[TextToTextItem, tuple]]
|
||||
) -> Union[TextToTextBatch, TextOrAudioToTextBatch, tuple]:
|
||||
"""
|
||||
Collate function for dataloader
|
||||
Can accept mixed batch of text-to-text items and audio-text items (typical for ASR)
|
||||
"""
|
||||
return TextOrAudioToTextBatch.collate_fn(
|
||||
batch=batch, asr_pad_id=self.asr_pad_id, tts_text_pad_id=self.tts_text_pad_id
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,203 @@
|
||||
# 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from omegaconf import OmegaConf, open_dict
|
||||
from omegaconf.dictconfig import DictConfig
|
||||
|
||||
from nemo.collections.asr.inference.model_wrappers.cache_aware_ctc_inference_wrapper import (
|
||||
CacheAwareCTCInferenceWrapper,
|
||||
)
|
||||
from nemo.collections.asr.inference.model_wrappers.cache_aware_rnnt_inference_wrapper import (
|
||||
CacheAwareRNNTInferenceWrapper,
|
||||
)
|
||||
from nemo.collections.asr.inference.model_wrappers.ctc_inference_wrapper import CTCInferenceWrapper
|
||||
from nemo.collections.asr.inference.model_wrappers.rnnt_inference_wrapper import RNNTInferenceWrapper
|
||||
from nemo.collections.asr.inference.model_wrappers.salm_asr_inference_wrapper import SALMASRInferenceWrapper
|
||||
from nemo.collections.asr.inference.utils.enums import ASRDecodingType, PipelineType
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import CTCDecodingConfig
|
||||
from nemo.collections.asr.parts.submodules.rnnt_decoding import RNNTDecodingConfig
|
||||
from nemo.utils import logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nemo.collections.asr.inference.itn.inverse_normalizer import AlignmentPreservingInverseNormalizer
|
||||
from nemo.collections.asr.inference.nmt.llm_translator import LLMTranslator
|
||||
|
||||
|
||||
class BaseBuilder:
|
||||
"""
|
||||
Base Builder class.
|
||||
Builds the ASR/ITN components.
|
||||
Derived classes should implement the `build` method which should include the logic of creating concrete pipeline.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def _build_nmt(cls, cfg: DictConfig) -> LLMTranslator | None:
|
||||
"""
|
||||
Build the NMT model based on the config.
|
||||
Args:
|
||||
cfg: (DictConfig) Config
|
||||
Returns:
|
||||
(LLMTranslator | None) NMT model
|
||||
"""
|
||||
nmt_model = None
|
||||
if cfg.enable_nmt:
|
||||
from nemo.collections.asr.inference.nmt.llm_translator import LLMTranslator
|
||||
|
||||
nmt_model = LLMTranslator(
|
||||
model_name=cfg.nmt.model_name,
|
||||
source_language=cfg.nmt.source_language,
|
||||
target_language=cfg.nmt.target_language,
|
||||
waitk=cfg.nmt.waitk,
|
||||
device=cfg.nmt.device,
|
||||
device_id=cfg.nmt.device_id,
|
||||
batch_size=cfg.nmt.batch_size,
|
||||
llm_params=cfg.nmt.llm_params,
|
||||
sampling_params=cfg.nmt.sampling_params,
|
||||
)
|
||||
logging.info(f"NMT model `{cfg.nmt.model_name}` loaded")
|
||||
return nmt_model
|
||||
|
||||
@staticmethod
|
||||
def _apply_confidence_cfg(cfg: DictConfig, decoding_cfg: RNNTDecodingConfig) -> None:
|
||||
"""
|
||||
Wire the separately-stored `confidence` block into the RNNT decoding confidence config so the
|
||||
greedy or batched-beam decoder computes per-token confidence with the configured method. The streaming
|
||||
pipelines only support non-blank confidence (`confidence.exclude_blank=true`).
|
||||
Args:
|
||||
cfg: (DictConfig) Full pipeline config (provides the top-level `confidence` block).
|
||||
decoding_cfg: (RNNTDecodingConfig) Decoding config to update in place.
|
||||
"""
|
||||
preserve_frame_confidence = decoding_cfg.greedy.get(
|
||||
"preserve_frame_confidence", False
|
||||
) or decoding_cfg.beam.get("preserve_frame_confidence", False)
|
||||
if not preserve_frame_confidence:
|
||||
return
|
||||
confidence_cfg = cfg.get("confidence", None)
|
||||
if confidence_cfg is None:
|
||||
return
|
||||
if not confidence_cfg.get("exclude_blank", True):
|
||||
raise ValueError(
|
||||
"Streaming confidence supports only non-blank confidence (`confidence.exclude_blank=true`)."
|
||||
)
|
||||
decoding_cfg.confidence_cfg.preserve_frame_confidence = True
|
||||
decoding_cfg.confidence_cfg.preserve_token_confidence = True
|
||||
decoding_cfg.confidence_cfg.preserve_word_confidence = True
|
||||
decoding_cfg.confidence_cfg.exclude_blank = True
|
||||
decoding_cfg.confidence_cfg.aggregation = confidence_cfg.get("aggregation", "mean")
|
||||
decoding_cfg.confidence_cfg.method_cfg = OmegaConf.merge(
|
||||
decoding_cfg.confidence_cfg.method_cfg, confidence_cfg.method_cfg
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_asr(cls, cfg: DictConfig, decoding_cfg: CTCDecodingConfig | RNNTDecodingConfig | None) -> Any:
|
||||
"""
|
||||
Build the ASR model based on the config.
|
||||
Args:
|
||||
cfg: (DictConfig) Config
|
||||
decoding_cfg: (CTCDecodingConfig | RNNTDecodingConfig | None) Decoding config
|
||||
Returns:
|
||||
(Any) ASR inference model
|
||||
"""
|
||||
|
||||
asr_decoding_type = ASRDecodingType.from_str(cfg.asr_decoding_type)
|
||||
pipeline_type = PipelineType.from_str(cfg.pipeline_type)
|
||||
model_params = {
|
||||
"model_name": cfg.asr.model_name,
|
||||
"device": cfg.asr.device,
|
||||
"device_id": cfg.asr.device_id,
|
||||
"compute_dtype": cfg.asr.compute_dtype,
|
||||
"use_amp": cfg.asr.use_amp,
|
||||
"decoding_cfg": decoding_cfg,
|
||||
}
|
||||
match (asr_decoding_type, pipeline_type):
|
||||
case (ASRDecodingType.CTC, PipelineType.BUFFERED):
|
||||
asr_class = CTCInferenceWrapper
|
||||
case (ASRDecodingType.RNNT, PipelineType.BUFFERED):
|
||||
asr_class = RNNTInferenceWrapper
|
||||
case (ASRDecodingType.SALM, PipelineType.BUFFERED):
|
||||
asr_class = SALMASRInferenceWrapper
|
||||
# remove decoding_cfg, SALM AED does not use decoding_cfg yet
|
||||
model_params.pop("decoding_cfg")
|
||||
case (ASRDecodingType.CTC, PipelineType.CACHE_AWARE):
|
||||
asr_class = CacheAwareCTCInferenceWrapper
|
||||
case (ASRDecodingType.RNNT, PipelineType.CACHE_AWARE):
|
||||
asr_class = CacheAwareRNNTInferenceWrapper
|
||||
case _:
|
||||
raise ValueError(
|
||||
f"Wrong combination of ASR decoding type and pipeline type: {asr_decoding_type, pipeline_type}"
|
||||
)
|
||||
|
||||
asr_model = asr_class(**model_params)
|
||||
logging.info(f"ASR model `{cfg.asr.model_name}` loaded")
|
||||
return asr_model
|
||||
|
||||
@classmethod
|
||||
def _build_itn(cls, cfg: DictConfig, input_is_lower_cased: bool) -> AlignmentPreservingInverseNormalizer | None:
|
||||
"""
|
||||
Build the ITN model based on the config.
|
||||
Args:
|
||||
cfg: (DictConfig) Config
|
||||
input_is_lower_cased: (bool) Whether the input is lower cased
|
||||
Returns:
|
||||
(AlignmentPreservingInverseNormalizer | None) ITN model
|
||||
"""
|
||||
itn_model = None
|
||||
if cfg.enable_itn:
|
||||
# Do not remove this import. It is used to avoid nemo_text_processing import when verbatim transcripts is enabled.
|
||||
from nemo.collections.asr.inference.itn.inverse_normalizer import AlignmentPreservingInverseNormalizer
|
||||
|
||||
input_case = (
|
||||
AlignmentPreservingInverseNormalizer.LOWER_CASED
|
||||
if input_is_lower_cased
|
||||
else AlignmentPreservingInverseNormalizer.UPPER_CASED
|
||||
)
|
||||
|
||||
target_lang = getattr(cfg, "lang", getattr(cfg, "target_lang", None))
|
||||
if target_lang is None:
|
||||
raise ValueError("Language is not specified. Cannot load ITN model.")
|
||||
|
||||
itn_cfg = cfg.itn
|
||||
with open_dict(itn_cfg):
|
||||
itn_cfg.lang = target_lang
|
||||
itn_cfg.input_case = input_case
|
||||
itn_cfg.cache_dir = cfg.cache_dir
|
||||
|
||||
itn_model = AlignmentPreservingInverseNormalizer(
|
||||
lang=itn_cfg.lang,
|
||||
input_case=itn_cfg.input_case,
|
||||
whitelist=itn_cfg.whitelist,
|
||||
cache_dir=itn_cfg.cache_dir,
|
||||
overwrite_cache=itn_cfg.overwrite_cache,
|
||||
max_number_of_permutations_per_split=itn_cfg.max_number_of_permutations_per_split,
|
||||
)
|
||||
logging.info(f"Built inverse text normalizer with the input case: `{input_case}`.")
|
||||
|
||||
if itn_model is not None:
|
||||
logging.info("ITN model loaded")
|
||||
return itn_model
|
||||
|
||||
@classmethod
|
||||
def build(cls, cfg: DictConfig) -> Any:
|
||||
"""
|
||||
Build the pipeline based on the config.
|
||||
Args:
|
||||
cfg: (DictConfig) Config
|
||||
Returns:
|
||||
Returns object responsible for the inference
|
||||
"""
|
||||
raise NotImplementedError("This method should be implemented in subclasses.")
|
||||
@@ -0,0 +1,148 @@
|
||||
# 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.
|
||||
|
||||
from omegaconf import DictConfig, OmegaConf
|
||||
|
||||
from nemo.collections.asr.inference.factory.base_builder import BaseBuilder
|
||||
from nemo.collections.asr.inference.pipelines.buffered_ctc_pipeline import BufferedCTCPipeline
|
||||
from nemo.collections.asr.inference.pipelines.buffered_rnnt_pipeline import BufferedRNNTPipeline
|
||||
from nemo.collections.asr.inference.pipelines.buffered_salm_pipeline import BufferedSALMPipeline
|
||||
from nemo.collections.asr.inference.utils.enums import ASRDecodingType
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import CTCDecodingConfig
|
||||
from nemo.collections.asr.parts.submodules.rnnt_decoding import RNNTDecodingConfig
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
class BufferedPipelineBuilder(BaseBuilder):
|
||||
"""
|
||||
Buffered Pipeline Builder class.
|
||||
Builds:
|
||||
- buffered CTC/RNNT/TDT pipelines.
|
||||
- buffered SALM pipeline.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def build(cls, cfg: DictConfig) -> BufferedRNNTPipeline | BufferedCTCPipeline | BufferedSALMPipeline:
|
||||
"""
|
||||
Build the buffered streaming pipeline based on the config.
|
||||
Args:
|
||||
cfg: (DictConfig) Config
|
||||
Returns:
|
||||
Returns BufferedRNNTPipeline, BufferedCTCPipeline, or BufferedSALMPipeline object
|
||||
"""
|
||||
asr_decoding_type = ASRDecodingType.from_str(cfg.asr_decoding_type)
|
||||
|
||||
if asr_decoding_type is ASRDecodingType.RNNT:
|
||||
return cls.build_buffered_rnnt_pipeline(cfg)
|
||||
elif asr_decoding_type is ASRDecodingType.CTC:
|
||||
return cls.build_buffered_ctc_pipeline(cfg)
|
||||
elif asr_decoding_type is ASRDecodingType.SALM:
|
||||
return cls.build_buffered_salm_pipeline(cfg)
|
||||
|
||||
raise ValueError("Invalid asr decoding type for buffered streaming. Need to be one of ['CTC', 'RNNT', 'SALM']")
|
||||
|
||||
@classmethod
|
||||
def get_rnnt_decoding_cfg(cls, cfg: DictConfig) -> RNNTDecodingConfig:
|
||||
"""
|
||||
Get the decoding config for the RNNT pipeline.
|
||||
Returns:
|
||||
(RNNTDecodingConfig) Decoding config
|
||||
"""
|
||||
base_cfg_structured = OmegaConf.structured(RNNTDecodingConfig)
|
||||
base_cfg = OmegaConf.create(OmegaConf.to_container(base_cfg_structured))
|
||||
decoding_cfg = OmegaConf.merge(base_cfg, cfg.asr.decoding)
|
||||
cls._apply_confidence_cfg(cfg, decoding_cfg)
|
||||
return decoding_cfg
|
||||
|
||||
@classmethod
|
||||
def get_ctc_decoding_cfg(cls) -> CTCDecodingConfig:
|
||||
"""
|
||||
Get the decoding config for the CTC pipeline.
|
||||
Returns:
|
||||
(CTCDecodingConfig) Decoding config
|
||||
"""
|
||||
decoding_cfg = CTCDecodingConfig()
|
||||
decoding_cfg.strategy = "greedy"
|
||||
return decoding_cfg
|
||||
|
||||
@classmethod
|
||||
def build_buffered_rnnt_pipeline(cls, cfg: DictConfig) -> BufferedRNNTPipeline:
|
||||
"""
|
||||
Build the RNNT streaming pipeline based on the config.
|
||||
Args:
|
||||
cfg: (DictConfig) Config
|
||||
Returns:
|
||||
Returns BufferedRNNTPipeline object
|
||||
"""
|
||||
# building ASR model
|
||||
decoding_cfg = cls.get_rnnt_decoding_cfg(cfg)
|
||||
asr_model = cls._build_asr(cfg, decoding_cfg)
|
||||
|
||||
# building ITN model
|
||||
itn_model = cls._build_itn(cfg, input_is_lower_cased=True)
|
||||
|
||||
# building NMT model
|
||||
nmt_model = cls._build_nmt(cfg)
|
||||
|
||||
# building RNNT pipeline
|
||||
rnnt_pipeline = BufferedRNNTPipeline(cfg, asr_model, itn_model, nmt_model)
|
||||
logging.info(f"`{type(rnnt_pipeline).__name__}` pipeline loaded")
|
||||
return rnnt_pipeline
|
||||
|
||||
@classmethod
|
||||
def build_buffered_ctc_pipeline(cls, cfg: DictConfig) -> BufferedCTCPipeline:
|
||||
"""
|
||||
Build the CTC buffered streaming pipeline based on the config.
|
||||
Args:
|
||||
cfg: (DictConfig) Config
|
||||
Returns:
|
||||
Returns BufferedCTCPipeline object
|
||||
"""
|
||||
# building ASR model
|
||||
decoding_cfg = cls.get_ctc_decoding_cfg()
|
||||
asr_model = cls._build_asr(cfg, decoding_cfg)
|
||||
|
||||
# building ITN model
|
||||
itn_model = cls._build_itn(cfg, input_is_lower_cased=True)
|
||||
|
||||
# building NMT model
|
||||
nmt_model = cls._build_nmt(cfg)
|
||||
|
||||
# building CTC pipeline
|
||||
ctc_pipeline = BufferedCTCPipeline(cfg, asr_model, itn_model, nmt_model)
|
||||
logging.info(f"`{type(ctc_pipeline).__name__}` pipeline loaded")
|
||||
return ctc_pipeline
|
||||
|
||||
@classmethod
|
||||
def build_buffered_salm_pipeline(cls, cfg: DictConfig) -> BufferedSALMPipeline:
|
||||
"""
|
||||
Build the buffered SALM pipeline based on the config.
|
||||
Args:
|
||||
cfg: (DictConfig) Config
|
||||
Returns:
|
||||
Returns BufferedSALMPipeline object
|
||||
"""
|
||||
# building ASR model
|
||||
asr_model = cls._build_asr(cfg, decoding_cfg=None)
|
||||
|
||||
# building ITN model
|
||||
itn_model = cls._build_itn(cfg, input_is_lower_cased=True)
|
||||
|
||||
# building NMT model
|
||||
nmt_model = cls._build_nmt(cfg)
|
||||
|
||||
# building SALM pipeline
|
||||
salm_pipeline = BufferedSALMPipeline(cfg, asr_model, itn_model, nmt_model)
|
||||
logging.info(f"`{type(salm_pipeline).__name__}` pipeline loaded")
|
||||
return salm_pipeline
|
||||
@@ -0,0 +1,128 @@
|
||||
# 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.
|
||||
|
||||
from omegaconf import DictConfig, OmegaConf
|
||||
|
||||
from nemo.collections.asr.inference.factory.base_builder import BaseBuilder
|
||||
from nemo.collections.asr.inference.pipelines.cache_aware_ctc_pipeline import CacheAwareCTCPipeline
|
||||
from nemo.collections.asr.inference.pipelines.cache_aware_rnnt_pipeline import CacheAwareRNNTPipeline
|
||||
from nemo.collections.asr.inference.utils.enums import ASRDecodingType
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import CTCDecodingConfig
|
||||
from nemo.collections.asr.parts.submodules.rnnt_decoding import RNNTDecodingConfig
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
class CacheAwarePipelineBuilder(BaseBuilder):
|
||||
"""
|
||||
Cache Aware Pipeline Builder class.
|
||||
Builds the cache aware CTC/RNNT pipelines.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def build(cls, cfg: DictConfig) -> CacheAwareCTCPipeline | CacheAwareRNNTPipeline:
|
||||
"""
|
||||
Build the cache aware streaming pipeline based on the config.
|
||||
Args:
|
||||
cfg: (DictConfig) Config
|
||||
Returns:
|
||||
Returns CacheAwareCTCPipeline or CacheAwareRNNTPipeline object
|
||||
"""
|
||||
asr_decoding_type = ASRDecodingType.from_str(cfg.asr_decoding_type)
|
||||
|
||||
if asr_decoding_type is ASRDecodingType.RNNT:
|
||||
return cls.build_cache_aware_rnnt_pipeline(cfg)
|
||||
elif asr_decoding_type is ASRDecodingType.CTC:
|
||||
return cls.build_cache_aware_ctc_pipeline(cfg)
|
||||
|
||||
raise ValueError("Invalid asr decoding type for cache aware streaming. Need to be one of ['CTC', 'RNNT']")
|
||||
|
||||
@classmethod
|
||||
def get_rnnt_decoding_cfg(cls, cfg: DictConfig) -> RNNTDecodingConfig:
|
||||
"""
|
||||
Get the decoding config for the RNNT pipeline.
|
||||
Returns:
|
||||
(RNNTDecodingConfig) Decoding config
|
||||
"""
|
||||
base_cfg_structured = OmegaConf.structured(RNNTDecodingConfig)
|
||||
base_cfg = OmegaConf.create(OmegaConf.to_container(base_cfg_structured))
|
||||
decoding_cfg = OmegaConf.merge(base_cfg, cfg.asr.decoding)
|
||||
cls._apply_confidence_cfg(cfg, decoding_cfg)
|
||||
return decoding_cfg
|
||||
|
||||
@classmethod
|
||||
def get_ctc_decoding_cfg(cls) -> CTCDecodingConfig:
|
||||
"""
|
||||
Get the decoding config for the CTC pipeline.
|
||||
Returns:
|
||||
(CTCDecodingConfig) Decoding config
|
||||
"""
|
||||
decoding_cfg = CTCDecodingConfig()
|
||||
decoding_cfg.strategy = "greedy"
|
||||
decoding_cfg.preserve_alignments = False
|
||||
return decoding_cfg
|
||||
|
||||
@classmethod
|
||||
def build_cache_aware_rnnt_pipeline(cls, cfg: DictConfig) -> CacheAwareRNNTPipeline:
|
||||
"""
|
||||
Build the cache aware RNNT streaming pipeline based on the config.
|
||||
Args:
|
||||
cfg: (DictConfig) Config
|
||||
Returns:
|
||||
Returns CacheAwareRNNTPipeline object
|
||||
"""
|
||||
strategy = str(getattr(cfg.asr.decoding, "strategy", "greedy_batch"))
|
||||
if strategy not in {"greedy_batch", "malsd_batch"}:
|
||||
raise ValueError(
|
||||
"Cache-aware RNNT streaming supports `greedy_batch` and `malsd_batch` only; "
|
||||
f"configured decoding strategy is `{strategy}`."
|
||||
)
|
||||
|
||||
# building ASR model
|
||||
decoding_cfg = cls.get_rnnt_decoding_cfg(cfg)
|
||||
asr_model = cls._build_asr(cfg, decoding_cfg)
|
||||
|
||||
# building ITN model
|
||||
itn_model = cls._build_itn(cfg, input_is_lower_cased=True)
|
||||
|
||||
# building NMT model
|
||||
nmt_model = cls._build_nmt(cfg)
|
||||
|
||||
# building cache aware RNNT pipeline
|
||||
ca_rnnt_pipeline = CacheAwareRNNTPipeline(cfg, asr_model, itn_model, nmt_model)
|
||||
logging.info(f"`{type(ca_rnnt_pipeline).__name__}` pipeline loaded")
|
||||
return ca_rnnt_pipeline
|
||||
|
||||
@classmethod
|
||||
def build_cache_aware_ctc_pipeline(cls, cfg: DictConfig) -> CacheAwareCTCPipeline:
|
||||
"""
|
||||
Build the cache aware CTC streaming pipeline based on the config.
|
||||
Args:
|
||||
cfg: (DictConfig) Config
|
||||
Returns:
|
||||
Returns CacheAwareCTCPipeline object
|
||||
"""
|
||||
# building ASR model
|
||||
decoding_cfg = cls.get_ctc_decoding_cfg()
|
||||
asr_model = cls._build_asr(cfg, decoding_cfg)
|
||||
|
||||
# building ITN model
|
||||
itn_model = cls._build_itn(cfg, input_is_lower_cased=True)
|
||||
|
||||
# building NMT model
|
||||
nmt_model = cls._build_nmt(cfg)
|
||||
|
||||
# building cache aware CTC pipeline
|
||||
ca_ctc_pipeline = CacheAwareCTCPipeline(cfg, asr_model, itn_model, nmt_model)
|
||||
logging.info(f"`{type(ca_ctc_pipeline).__name__}` pipeline loaded")
|
||||
return ca_ctc_pipeline
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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 torch
|
||||
from omegaconf.dictconfig import DictConfig
|
||||
|
||||
from nemo.collections.asr.inference.factory.buffered_pipeline_builder import BufferedPipelineBuilder
|
||||
from nemo.collections.asr.inference.factory.cache_aware_pipeline_builder import CacheAwarePipelineBuilder
|
||||
from nemo.collections.asr.inference.pipelines.base_pipeline import BasePipeline
|
||||
from nemo.collections.asr.inference.utils.enums import PipelineType
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
class PipelineBuilder:
|
||||
"""Router for building the pipeline based on the pipeline type."""
|
||||
|
||||
@staticmethod
|
||||
def set_matmul_precision(matmul_precision: str) -> None:
|
||||
"""
|
||||
Set the matmul precision.
|
||||
Args:
|
||||
matmul_precision: (str) Matmul precision: highest, high, medium
|
||||
"""
|
||||
choices = ["highest", "high", "medium"]
|
||||
matmul_precision = matmul_precision.lower()
|
||||
if matmul_precision not in choices:
|
||||
raise ValueError(f"Invalid matmul precision: {matmul_precision}. Need to be one of {choices}")
|
||||
torch.set_float32_matmul_precision(matmul_precision)
|
||||
logging.info(f"Using matmul precision: {matmul_precision}")
|
||||
|
||||
@staticmethod
|
||||
def set_log_level(log_level: int) -> None:
|
||||
"""
|
||||
Set the logging level.
|
||||
Args:
|
||||
log_level: (int) Logging level: 0 (NOTSET), 10 (DEBUG), 20 (INFO), 30 (WARNING), 40 (ERROR), 50 (CRITICAL)
|
||||
"""
|
||||
choices = [0, 10, 20, 30, 40, 50]
|
||||
if log_level not in choices:
|
||||
raise ValueError(f"Invalid log level: {log_level}. Need to be one of {choices}")
|
||||
logging.setLevel(log_level)
|
||||
|
||||
@staticmethod
|
||||
def build_pipeline(cfg: DictConfig) -> BasePipeline:
|
||||
"""
|
||||
Build the pipeline based on the config.
|
||||
Args:
|
||||
cfg: (DictConfig) Config
|
||||
Returns:
|
||||
Returns Pipeline object
|
||||
"""
|
||||
PipelineBuilder.set_log_level(cfg.log_level)
|
||||
PipelineBuilder.set_matmul_precision(cfg.matmul_precision)
|
||||
pipeline_type = PipelineType.from_str(cfg.pipeline_type)
|
||||
if pipeline_type is PipelineType.BUFFERED:
|
||||
builder = BufferedPipelineBuilder
|
||||
elif pipeline_type is PipelineType.CACHE_AWARE:
|
||||
builder = CacheAwarePipelineBuilder
|
||||
else:
|
||||
raise ValueError(f"Invalid pipeline type: {cfg.pipeline_type}")
|
||||
|
||||
return builder.build(cfg)
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,185 @@
|
||||
# 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 itertools
|
||||
from typing import Callable
|
||||
|
||||
from joblib import Parallel, delayed
|
||||
|
||||
from nemo.collections.asr.inference.itn.inverse_normalizer import AlignmentPreservingInverseNormalizer
|
||||
from nemo.collections.asr.inference.utils.text_segment import Word
|
||||
|
||||
|
||||
def merge_punctuation_and_itn_tags(
|
||||
input_words: list[str],
|
||||
output_words: list[str],
|
||||
word_alignment: list[tuple],
|
||||
pnc_words: list[Word],
|
||||
punct_marks: set,
|
||||
sep: str,
|
||||
conf_aggregate_fn: Callable,
|
||||
) -> list[Word]:
|
||||
"""
|
||||
Merge the punctuation marks and ITN tags to the final text.
|
||||
It will also preserve first letter capitalization, start and end time of the span.
|
||||
Args:
|
||||
input_words: (list[str]) List of input words
|
||||
output_words: (list[str]) List of output words
|
||||
word_alignment: (list[tuple[list[int], list[int]]]) Word alignment between the input and output words
|
||||
pnc_words: (list[Word]) List of words with punctuation marks
|
||||
punct_marks: (set) Punctuation marks
|
||||
sep: (str) Separator
|
||||
conf_aggregate_fn: (Callable) Confidence aggregation function
|
||||
Returns:
|
||||
(list[Word]) Final words after merging the punctuation marks and ITN tags
|
||||
"""
|
||||
assert len(input_words) == len(pnc_words)
|
||||
spans = []
|
||||
for s_idx, t_idx, semiotic_class in word_alignment:
|
||||
if len(t_idx) == 1 and len(s_idx) == 1 and input_words[s_idx[0]] == output_words[t_idx[0]]:
|
||||
span = pnc_words[s_idx[0]]
|
||||
span.semiotic_class = semiotic_class
|
||||
else:
|
||||
span_text = sep.join([output_words[i] for i in t_idx])
|
||||
last_char = pnc_words[s_idx[-1]].text[-1]
|
||||
first_char = pnc_words[s_idx[0]].text[0]
|
||||
|
||||
# preserve the first char capitalization
|
||||
first_word = pnc_words[s_idx[0]].copy()
|
||||
first_char_is_upper = first_word.text[0].isupper()
|
||||
first_word.normalize_text_inplace(punct_marks, sep)
|
||||
if span_text.startswith(first_word.text):
|
||||
if first_char_is_upper:
|
||||
span_text = span_text[0].upper() + span_text[1:]
|
||||
|
||||
# preserve the last punctuation mark
|
||||
if last_char in punct_marks:
|
||||
span_text += last_char
|
||||
|
||||
# preserve the first punctuation mark
|
||||
if first_char in punct_marks:
|
||||
span_text = first_char + span_text
|
||||
|
||||
scores = [pnc_words[i].conf for i in s_idx]
|
||||
conf = conf_aggregate_fn(scores) if len(scores) > 0 else 0.0
|
||||
span = Word(
|
||||
text=span_text,
|
||||
start=pnc_words[s_idx[0]].start,
|
||||
end=pnc_words[s_idx[-1]].end,
|
||||
semiotic_class=semiotic_class,
|
||||
conf=conf,
|
||||
)
|
||||
spans.append(span)
|
||||
return spans
|
||||
|
||||
|
||||
class BatchAlignmentPreservingInverseNormalizer:
|
||||
"""
|
||||
Batch Alignment Preserving Inverse Text Normalizer. It is used to apply ITN to a batch of texts.
|
||||
joblib.Parallel is used to parallelize the processing.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
itn_model: AlignmentPreservingInverseNormalizer,
|
||||
sep: str,
|
||||
asr_supported_puncts: set[str],
|
||||
post_word_punctuation: set[str],
|
||||
conf_aggregate_fn: Callable,
|
||||
):
|
||||
"""
|
||||
Batch Alignment Preserving Inverse Text Normalizer. It is used to apply ITN to a batch of texts.
|
||||
Args:
|
||||
itn_model: (AlignmentPreservingInverseNormalizer) Alignment Preserving Inverse Text Normalizer
|
||||
sep: (str) Separator
|
||||
asr_supported_puncts: (Set[str]) Punctuation marks supported by ASR model
|
||||
post_word_punctuation: (Set[str]) Punctuation marks which usually appear after a word
|
||||
conf_aggregate_fn: (Callable) Confidence aggregation function
|
||||
"""
|
||||
self.itn_model = itn_model
|
||||
self.sep = sep
|
||||
self.asr_supported_puncts = asr_supported_puncts
|
||||
self.conf_aggregate_fn = conf_aggregate_fn
|
||||
self.punct_marks = self.asr_supported_puncts | post_word_punctuation
|
||||
|
||||
def apply_itn(
|
||||
self, asr_words: list[Word], pnc_words: list[Word], return_alignment: bool = False
|
||||
) -> list[Word] | tuple[list[Word], list]:
|
||||
"""
|
||||
Apply Alignment Preserving Inverse Text Normalization.
|
||||
Args:
|
||||
asr_words: (list[Word]) List of ASR words
|
||||
pnc_words: (list[Word]) List of words with punctuation/capitalization
|
||||
return_alignment: (bool) Flag to return the word alignment
|
||||
Returns:
|
||||
(list[Word]) List of words after applying ITN
|
||||
"""
|
||||
input_words = []
|
||||
for word in asr_words:
|
||||
word.normalize_text_inplace(self.asr_supported_puncts, self.sep)
|
||||
input_words.append(word.text)
|
||||
|
||||
input_words, output_words, word_alignment = self.itn_model.get_word_alignment(input_words, sep=self.sep)
|
||||
spans = merge_punctuation_and_itn_tags(
|
||||
input_words, output_words, word_alignment, pnc_words, self.punct_marks, self.sep, self.conf_aggregate_fn
|
||||
)
|
||||
|
||||
if return_alignment:
|
||||
# word alignment is needed for streaming inference
|
||||
return spans, word_alignment
|
||||
return spans
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
asr_words_list: list[list[Word]],
|
||||
pnc_words_list: list[list[Word]],
|
||||
itn_params: dict,
|
||||
return_alignment: bool = False,
|
||||
) -> list[list[Word]] | list[tuple]:
|
||||
"""
|
||||
Batch Alignment Preserving Inverse Text Normalization.
|
||||
Args:
|
||||
asr_words_list: (list[list[Word]]) List of ASR words
|
||||
pnc_words_list: (list[list[Word]]) List of words with punctuation/capitalization
|
||||
itn_params: (dict) Parameters for the ITN model
|
||||
return_alignment: (bool) Flag to return the word alignment
|
||||
Returns:
|
||||
(list[list[Word]]) List of words after applying ITN
|
||||
"""
|
||||
if len(asr_words_list) == 0:
|
||||
return []
|
||||
|
||||
batch_size = itn_params.get("batch_size", 1)
|
||||
n_texts = len(asr_words_list)
|
||||
batch_size = min(n_texts, batch_size)
|
||||
|
||||
def process_batch(batch_words, batch_words_with_pnc):
|
||||
return [
|
||||
self.apply_itn(words, words_with_pnc, return_alignment)
|
||||
for words, words_with_pnc in zip(batch_words, batch_words_with_pnc)
|
||||
]
|
||||
|
||||
if n_texts <= 3 * batch_size or n_texts == 1:
|
||||
# If the number of texts is less than 3 * batch_size, process the batch sequentially
|
||||
# For small batch size, it is faster to process the batch sequentially
|
||||
return process_batch(asr_words_list, pnc_words_list)
|
||||
|
||||
n_jobs = itn_params.get("n_jobs", 1)
|
||||
itn_words_list = Parallel(n_jobs=n_jobs)(
|
||||
delayed(process_batch)(asr_words_list[i : i + batch_size], pnc_words_list[i : i + batch_size])
|
||||
for i in range(0, n_texts, batch_size)
|
||||
)
|
||||
itn_words_list = list(itertools.chain(*itn_words_list))
|
||||
return itn_words_list
|
||||
@@ -0,0 +1,355 @@
|
||||
# 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 os
|
||||
import re
|
||||
from multiprocessing import Manager
|
||||
|
||||
from nemo.collections.asr.inference.utils.itn_utils import (
|
||||
DEFAULT_SEMIOTIC_CLASS,
|
||||
fallback_to_trivial_alignment,
|
||||
find_tokens,
|
||||
get_semiotic_class,
|
||||
split_text,
|
||||
)
|
||||
from nemo.utils import logging
|
||||
|
||||
IN_MEM_CACHE = Manager().dict(lock=False)
|
||||
|
||||
try:
|
||||
import pynini
|
||||
from nemo_text_processing.inverse_text_normalization.inverse_normalize import InverseNormalizer, Normalizer
|
||||
from nemo_text_processing.text_normalization.en.graph_utils import INPUT_CASED, INPUT_LOWER_CASED
|
||||
|
||||
HAVE_NEMO_TEXT_PROCESSING = True
|
||||
except ImportError:
|
||||
INPUT_CASED, INPUT_LOWER_CASED = "undefined", "undefined"
|
||||
HAVE_NEMO_TEXT_PROCESSING = False
|
||||
|
||||
try:
|
||||
import diskcache
|
||||
|
||||
CACHING_FROM_DISK = True
|
||||
except ImportError:
|
||||
logging.warning("diskcache package is not installed, caching from disk is disabled")
|
||||
CACHING_FROM_DISK = False
|
||||
|
||||
|
||||
class AlignmentPreservingInverseNormalizer:
|
||||
"""
|
||||
Inverse Text Normalizer that preserves the word alignment.
|
||||
It is used to convert the spoken text to written text and preserve the alignment between the input and output words.
|
||||
"""
|
||||
|
||||
LOWER_CASED = INPUT_LOWER_CASED
|
||||
UPPER_CASED = INPUT_CASED
|
||||
GRAMMAR = "itn"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_case: str = LOWER_CASED,
|
||||
lang: str = "en",
|
||||
whitelist: str = None,
|
||||
cache_dir: str = None,
|
||||
overwrite_cache: bool = False,
|
||||
max_number_of_permutations_per_split: int = 729,
|
||||
):
|
||||
"""
|
||||
Inverse normalizer that converts text from spoken to written form.
|
||||
Args:
|
||||
input_case: Input text capitalization, set to 'cased' if text contains capital letters.
|
||||
This flag affects normalization rules applied to the text. Note, `lower_cased` won't lower case input.
|
||||
lang: language specifying the ITN
|
||||
whitelist: path to a file with whitelist replacements. (each line of the file: written_form\tspoken_form\n),
|
||||
e.g. nemo_text_processing/inverse_text_normalization/en/data/whitelist.tsv
|
||||
cache_dir: path to a dir with .far grammar file. Set to None to avoid using cache.
|
||||
overwrite_cache: set to True to overwrite .far files
|
||||
max_number_of_permutations_per_split: a maximum number
|
||||
of permutations which can be generated from input sequence of tokens.
|
||||
"""
|
||||
if not HAVE_NEMO_TEXT_PROCESSING:
|
||||
raise RuntimeError(
|
||||
"In order to use AlignmentPreservingInverseNormalizer, "
|
||||
"please install the nemo_text_processing and pynini packages."
|
||||
)
|
||||
self.itn_model = InverseNormalizer(
|
||||
lang=lang,
|
||||
input_case=input_case,
|
||||
whitelist=whitelist,
|
||||
cache_dir=cache_dir,
|
||||
overwrite_cache=overwrite_cache,
|
||||
max_number_of_permutations_per_split=max_number_of_permutations_per_split,
|
||||
)
|
||||
if cache_dir and CACHING_FROM_DISK:
|
||||
self.DISK_TAG_CACHE = diskcache.Cache(os.path.join(cache_dir, "itn_tag_cache"))
|
||||
self.DISK_VERB_CACHE = diskcache.Cache(os.path.join(cache_dir, "itn_verb_cache"))
|
||||
self.caching_from_disk_enabled = True
|
||||
else:
|
||||
self.DISK_TAG_CACHE = None
|
||||
self.DISK_VERB_CACHE = None
|
||||
self.caching_from_disk_enabled = False
|
||||
|
||||
def inverse_normalize_list(self, texts: list[str], params: dict) -> list[str]:
|
||||
"""
|
||||
Applies Inverse Text Normalization to the list of texts.
|
||||
Args:
|
||||
texts: (list[str]) list of input strings.
|
||||
params: (dict) dictionary of runtime parameters.
|
||||
Returns:
|
||||
(list[str]) Returns converted list of input strings.
|
||||
"""
|
||||
normalized_texts = self.itn_model.normalize_list(
|
||||
texts,
|
||||
verbose=params.get('verbose', False),
|
||||
punct_pre_process=params.get("punct_pre_process", False),
|
||||
punct_post_process=params.get("punct_post_process", False),
|
||||
batch_size=params.get("batch_size", 1),
|
||||
n_jobs=params.get("n_jobs", 1),
|
||||
)
|
||||
return normalized_texts
|
||||
|
||||
def verbalize(self, tokens: list, sep: str) -> str | None:
|
||||
"""
|
||||
Appplies verbalization to the list of tokens.
|
||||
Args:
|
||||
tokens: (list) list of tokens
|
||||
sep: (str) word separator
|
||||
Returns:
|
||||
(str | None) Returns verbalized text. If verbalization fails, returns None.
|
||||
"""
|
||||
split_tokens = self.itn_model._split_tokens_to_reduce_number_of_permutations(tokens)
|
||||
output_str = ""
|
||||
for s in split_tokens:
|
||||
try:
|
||||
tags_reordered = self.itn_model.generate_permutations(s)
|
||||
verbalizer_lattice = None
|
||||
for tagged_text_r in tags_reordered:
|
||||
tagged_text_r = pynini.escape(tagged_text_r)
|
||||
|
||||
verbalizer_lattice = self.itn_model.find_verbalizer(tagged_text_r)
|
||||
if verbalizer_lattice.num_states() != 0:
|
||||
break
|
||||
|
||||
if verbalizer_lattice is None:
|
||||
return None
|
||||
|
||||
verbalized_text = Normalizer.select_verbalizer(verbalizer_lattice)
|
||||
output_str += sep + verbalized_text
|
||||
except Exception as e:
|
||||
logging.warning("Failed to verbalize tagged text: " + str(e))
|
||||
return None
|
||||
|
||||
output_str = output_str.strip(sep)
|
||||
return re.sub(r"({sep})+".format(sep=sep), sep, output_str)
|
||||
|
||||
def tag(self, text: str, no_cache: bool = False) -> str:
|
||||
"""
|
||||
Tags the input text.
|
||||
Args:
|
||||
text: (str) input text
|
||||
no_cache: (bool) whether to use cache
|
||||
Returns:
|
||||
(str) tagged text
|
||||
"""
|
||||
if not no_cache:
|
||||
# In-memory cache check
|
||||
if text in IN_MEM_CACHE:
|
||||
return IN_MEM_CACHE[text]
|
||||
|
||||
# Disk cache check
|
||||
if self.caching_from_disk_enabled and text in self.DISK_TAG_CACHE:
|
||||
x = self.DISK_TAG_CACHE[text]
|
||||
IN_MEM_CACHE[text] = x
|
||||
return x
|
||||
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return text
|
||||
|
||||
text = pynini.escape(text)
|
||||
tagged_lattice = self.itn_model.find_tags(text)
|
||||
tagged_text = Normalizer.select_tag(tagged_lattice)
|
||||
IN_MEM_CACHE[text] = tagged_text
|
||||
if self.caching_from_disk_enabled:
|
||||
self.DISK_TAG_CACHE[text] = tagged_text
|
||||
return tagged_text
|
||||
|
||||
def parse_and_verbalize(self, tagged_text: str, sep: str) -> tuple[str, str]:
|
||||
"""
|
||||
Tags and verbalizes the input text.
|
||||
Args:
|
||||
tagged_text: (str) tagged input text
|
||||
sep: (str) word separator
|
||||
Returns:
|
||||
(str, str) Returns the verbalized text, and the semiotic class.
|
||||
"""
|
||||
|
||||
# In-memory cache check
|
||||
if tagged_text in IN_MEM_CACHE:
|
||||
return IN_MEM_CACHE[tagged_text]
|
||||
|
||||
# Disk cache check
|
||||
if self.caching_from_disk_enabled and tagged_text in self.DISK_VERB_CACHE:
|
||||
x = self.DISK_VERB_CACHE[tagged_text]
|
||||
IN_MEM_CACHE[tagged_text] = x
|
||||
return x
|
||||
|
||||
self.itn_model.parser(tagged_text)
|
||||
tokens = self.itn_model.parser.parse()
|
||||
span_text = self.verbalize(tokens, sep)
|
||||
semiotic_class = DEFAULT_SEMIOTIC_CLASS if span_text is None else get_semiotic_class(tokens)
|
||||
|
||||
IN_MEM_CACHE[tagged_text] = (span_text, semiotic_class)
|
||||
if self.caching_from_disk_enabled:
|
||||
self.DISK_VERB_CACHE[tagged_text] = (span_text, semiotic_class)
|
||||
return span_text, semiotic_class
|
||||
|
||||
def find_token_words(
|
||||
self, token: str, start_idx: int, input_words: list[str], sep: str
|
||||
) -> tuple[list[int], bool, int]:
|
||||
"""
|
||||
Finds the words that make up the token.
|
||||
Args:
|
||||
token: (str) token
|
||||
start_idx: (int) start index
|
||||
input_words: (list[str]) list of input words
|
||||
sep: (str) word separator
|
||||
Returns:
|
||||
(tuple) Returns a tuple of indices, success, and the new start index
|
||||
"""
|
||||
indices, tmp_text, success = [], "", False
|
||||
length = len(input_words)
|
||||
for i in range(start_idx, length):
|
||||
tmp_text = tmp_text + sep + input_words[i] if tmp_text else input_words[i]
|
||||
tmp_tagged_text = self.tag(tmp_text)
|
||||
|
||||
if tmp_tagged_text == token:
|
||||
indices.append(i)
|
||||
|
||||
# Try to extend the token by one word
|
||||
if i + 1 < length:
|
||||
extended_tmp_text = tmp_text + sep + input_words[i + 1]
|
||||
extended_tmp_tagged_text = self.tag(extended_tmp_text)
|
||||
if extended_tmp_tagged_text == token:
|
||||
continue
|
||||
|
||||
success = True
|
||||
break
|
||||
else:
|
||||
indices.append(i)
|
||||
|
||||
return indices, success, i
|
||||
|
||||
def find_alignment(
|
||||
self,
|
||||
tokens: list[str],
|
||||
input_words: list[str],
|
||||
sep: str,
|
||||
iwords: list[str],
|
||||
owords: list[str],
|
||||
word_alignment: list[tuple],
|
||||
) -> bool:
|
||||
"""
|
||||
Finds the word alignment for the input text.
|
||||
Args:
|
||||
tokens: (list[str]) list of tokens
|
||||
input_words: (list[str]) list of input words
|
||||
sep: (str) word separator
|
||||
iwords: (list[str]) list of input words to be updated
|
||||
owords: (list[str]) list of output words to be updated
|
||||
word_alignment: (list[tuple]) list of word alignments to be updated
|
||||
Returns:
|
||||
(bool) True if the word alignment is found, False otherwise
|
||||
"""
|
||||
success = True
|
||||
token_start_idx = word_start_idx = 0
|
||||
iwords_len = owords_len = 0
|
||||
|
||||
while token_start_idx < len(tokens):
|
||||
token = tokens[token_start_idx]
|
||||
current_word = input_words[word_start_idx]
|
||||
if token == f"tokens {{ name: \"{current_word}\" }}":
|
||||
word_alignment.append(([iwords_len], [owords_len], DEFAULT_SEMIOTIC_CLASS))
|
||||
iwords.append(current_word)
|
||||
owords.append(current_word)
|
||||
iwords_len += 1
|
||||
owords_len += 1
|
||||
else:
|
||||
indices, success, word_start_idx = self.find_token_words(token, word_start_idx, input_words, sep)
|
||||
if success:
|
||||
span_text, semiotic_class = self.parse_and_verbalize(token, sep)
|
||||
if span_text is None:
|
||||
logging.warning(f"Failed to verbalize the token: {token}")
|
||||
return False
|
||||
|
||||
span_words, n_span_words = split_text(span_text, sep)
|
||||
word_alignment.append(
|
||||
(
|
||||
[iwords_len + i for i in range(len(indices))],
|
||||
[owords_len + i for i in range(n_span_words)],
|
||||
semiotic_class,
|
||||
)
|
||||
)
|
||||
owords.extend(span_words)
|
||||
iwords.extend([input_words[i] for i in indices])
|
||||
iwords_len += len(indices)
|
||||
owords_len += n_span_words
|
||||
else:
|
||||
success = False
|
||||
break
|
||||
|
||||
token_start_idx += 1
|
||||
word_start_idx += 1
|
||||
|
||||
return success
|
||||
|
||||
def get_word_alignment(self, input: str | list[str], sep: str) -> tuple[list[str], list[str], list[tuple]]:
|
||||
"""
|
||||
Returns a word alignment for the input text.
|
||||
Args:
|
||||
input: (str | list[str]) input text or list of input words
|
||||
sep: (str) word separator
|
||||
Returns:
|
||||
(tuple) Returns a tuple of input words, output words, and a word alignment between input and output words
|
||||
"""
|
||||
|
||||
if isinstance(input, str):
|
||||
input_text = input
|
||||
input_words, n_words = split_text(input_text, sep)
|
||||
else:
|
||||
input_words, n_words = input, len(input)
|
||||
input_text = sep.join(input_words)
|
||||
|
||||
# If input_text is empty, return empty lists
|
||||
if n_words == 0:
|
||||
return [], [], []
|
||||
|
||||
# Tag the input text
|
||||
tagged_text = self.tag(input_text, no_cache=False)
|
||||
|
||||
# Find the tokens in the tagged text
|
||||
tokens = find_tokens(tagged_text)
|
||||
|
||||
# Find the word alignment
|
||||
iwords, owords, word_alignment = [], [], []
|
||||
success = self.find_alignment(
|
||||
tokens, input_words, sep, iwords=iwords, owords=owords, word_alignment=word_alignment
|
||||
)
|
||||
|
||||
# If the word alignment is not found, fallback to the trivial alignment
|
||||
if not success:
|
||||
return fallback_to_trivial_alignment(input_words, i_shift=0, o_shift=0)
|
||||
|
||||
return iwords, owords, word_alignment
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,285 @@
|
||||
# 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 copy
|
||||
from functools import cached_property
|
||||
from typing import Callable
|
||||
|
||||
import torch
|
||||
from omegaconf import DictConfig, open_dict
|
||||
|
||||
from nemo.collections.asr.inference.utils.constants import SENTENCEPIECE_UNDERSCORE
|
||||
from nemo.collections.asr.inference.utils.device_utils import setup_device
|
||||
from nemo.collections.asr.inference.utils.pipeline_utils import make_preprocessor_deterministic
|
||||
from nemo.collections.asr.models import ASRModel, EncDecHybridRNNTCTCModel
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import CTCDecodingConfig
|
||||
from nemo.collections.asr.parts.submodules.rnnt_decoding import RNNTDecodingConfig
|
||||
from nemo.collections.asr.parts.utils.asr_confidence_utils import get_confidence_aggregation_bank
|
||||
|
||||
SUPPORTED_CONFIDENCE_AGGREGATORS = get_confidence_aggregation_bank()
|
||||
|
||||
|
||||
class ASRInferenceWrapper:
|
||||
"""
|
||||
Base class for ASR inference wrappers.
|
||||
It provides a common interface for ASR inference wrappers.
|
||||
Derived classes MUST implement the following methods:
|
||||
- __post_init__: Additional post initialization steps that must be implemented in the derived classes.
|
||||
- get_blank_id: Returns the blank id for the model.
|
||||
- get_vocabulary: Returns the vocabulary for the model.
|
||||
- get_subsampling_factor: Returns the subsampling factor for the model.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
decoding_cfg: CTCDecodingConfig | RNNTDecodingConfig,
|
||||
device: str = 'cuda',
|
||||
device_id: int = 0,
|
||||
compute_dtype: str = 'bfloat16',
|
||||
use_amp: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize the ASR inference wrapper.
|
||||
Args:
|
||||
model_name: (str) path to the model checkpoint or a model name from the NGC cloud.
|
||||
decoding_cfg: (CTCDecodingConfig | RNNTDecodingConfig) decoding configuration.
|
||||
device: (str) device to run the model on.
|
||||
device_id: (int) device ID to run the model on.
|
||||
compute_dtype: (str) compute dtype to run the model on.
|
||||
use_amp: (bool) Use Automatic Mixed Precision
|
||||
"""
|
||||
|
||||
self.decoding_cfg = decoding_cfg
|
||||
self.device_str, self.device_id, self.compute_dtype = setup_device(device.strip(), device_id, compute_dtype)
|
||||
self.device = torch.device(self.device_str)
|
||||
self.use_amp = use_amp
|
||||
self.asr_model = self.load_model(model_name, self.device)
|
||||
self.asr_model_cfg = self.asr_model._cfg
|
||||
self.set_dither_to_zero()
|
||||
self.tokenizer = self.asr_model.tokenizer
|
||||
|
||||
# post initialization steps that must be implemented in the derived classes
|
||||
self.__post_init__()
|
||||
|
||||
@staticmethod
|
||||
def load_model(model_name: str, map_location: torch.device) -> ASRModel:
|
||||
"""
|
||||
Load the ASR model.
|
||||
Args:
|
||||
model_name: (str) path to the model checkpoint or a model name from the NGC cloud.
|
||||
map_location: (torch.device) device to load the model on.
|
||||
Returns:
|
||||
(ASRModel) loaded ASR model.
|
||||
"""
|
||||
try:
|
||||
if model_name.endswith('.nemo'):
|
||||
asr_model = ASRModel.restore_from(model_name, map_location=map_location)
|
||||
else:
|
||||
asr_model = ASRModel.from_pretrained(model_name, map_location=map_location)
|
||||
asr_model.eval()
|
||||
return asr_model
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to load model {model_name}: {str(e)}") from e
|
||||
|
||||
@property
|
||||
def word_separator(self) -> str:
|
||||
"""
|
||||
Returns word separator.
|
||||
Returns:
|
||||
(str) word separator.
|
||||
"""
|
||||
return self.decoding_cfg.word_seperator
|
||||
|
||||
@property
|
||||
def confidence_aggregator(self) -> Callable:
|
||||
"""
|
||||
Returns confidence aggregator function.
|
||||
Returns:
|
||||
(Callable) confidence aggregator function.
|
||||
"""
|
||||
return SUPPORTED_CONFIDENCE_AGGREGATORS[self.decoding_cfg.confidence_cfg.aggregation]
|
||||
|
||||
def copy_asr_config(self) -> DictConfig:
|
||||
"""
|
||||
Copies the ASR model config.
|
||||
Returns:
|
||||
(DictConfig) copy of the ASR model configuration.
|
||||
"""
|
||||
return copy.deepcopy(self.asr_model_cfg)
|
||||
|
||||
def create_preprocessor(self) -> tuple[Callable, DictConfig]:
|
||||
"""
|
||||
Creates a deterministic preprocessor from the ASR model configuration.
|
||||
Disables normalization, dither and padding.
|
||||
Returns:
|
||||
(Callable, DictConfig) deterministic preprocessor and its configuration.
|
||||
"""
|
||||
new_asr_config = self.copy_asr_config()
|
||||
new_asr_config = make_preprocessor_deterministic(new_asr_config)
|
||||
preprocessor_config = copy.deepcopy(new_asr_config.preprocessor)
|
||||
preprocessor = ASRModel.from_config_dict(preprocessor_config)
|
||||
preprocessor.to(self.device)
|
||||
return preprocessor, preprocessor_config
|
||||
|
||||
def supports_capitalization(self) -> bool:
|
||||
"""
|
||||
Checks if the ASR model supports capitalization.
|
||||
Returns:
|
||||
(bool) True if the ASR model supports capitalization, False otherwise.
|
||||
"""
|
||||
return self.tokenizer.supports_capitalization
|
||||
|
||||
def supports_punctuation(self) -> bool:
|
||||
"""
|
||||
Checks if the ASR model supports punctuation.
|
||||
Returns:
|
||||
(bool) True if the ASR model supports punctuation, False otherwise.
|
||||
"""
|
||||
return self.supported_punctuation() != set()
|
||||
|
||||
def supported_punctuation(self) -> set:
|
||||
"""
|
||||
Returns supported punctuation symbol set without single quote.
|
||||
Returns:
|
||||
(set) Set of supported punctuation symbols.
|
||||
"""
|
||||
return self.tokenizer.supported_punctuation - set("'")
|
||||
|
||||
@cached_property
|
||||
def punctuation_ids(self) -> set:
|
||||
"""
|
||||
Returns ids of supported punctuation symbols.
|
||||
Returns:
|
||||
(set) Set of punctuation ids.
|
||||
"""
|
||||
punctuation_ids = set()
|
||||
if self.supports_punctuation():
|
||||
for punctuation in self.supported_punctuation():
|
||||
punctuation_ids.add(self.tokenizer.tokens_to_ids(punctuation)[0])
|
||||
return punctuation_ids
|
||||
|
||||
@cached_property
|
||||
def underscore_id(self) -> int:
|
||||
"""
|
||||
Returns id of the underscore token.
|
||||
Returns:
|
||||
(int) underscore id for the model.
|
||||
"""
|
||||
if getattr(self.asr_model.tokenizer, "spm_separator_id", None) is not None:
|
||||
return self.asr_model.tokenizer.spm_separator_id
|
||||
else:
|
||||
return self.asr_model.tokenizer.tokens_to_ids(SENTENCEPIECE_UNDERSCORE)
|
||||
|
||||
@cached_property
|
||||
def language_token_ids(self) -> set:
|
||||
"""
|
||||
This property is used for some Riva models that have language tokens included in the vocabulary.
|
||||
Returns:
|
||||
(set) Set of language token ids.
|
||||
"""
|
||||
vocab = self.get_vocabulary()
|
||||
language_token_ids = set()
|
||||
for token in vocab:
|
||||
if token.startswith("<") and token.endswith(">") and token != "<unk>":
|
||||
language_token_ids.add(self.asr_model.tokenizer.tokens_to_ids(token)[0])
|
||||
return language_token_ids
|
||||
|
||||
def reset_decoding_strategy(self, decoder_type: str) -> None:
|
||||
"""
|
||||
Reset the decoding strategy for the model.
|
||||
Args:
|
||||
decoder_type: (str) decoding type either 'ctc', 'rnnt'.
|
||||
"""
|
||||
if isinstance(self.asr_model, EncDecHybridRNNTCTCModel):
|
||||
self.asr_model.change_decoding_strategy(decoding_cfg=None, decoder_type=decoder_type)
|
||||
else:
|
||||
self.asr_model.change_decoding_strategy(None)
|
||||
|
||||
def set_decoding_strategy(self, decoder_type: str) -> None:
|
||||
"""
|
||||
Set the decoding strategy for the model.
|
||||
Args:
|
||||
decoder_type: (str) decoding type either 'ctc', 'rnnt'.
|
||||
"""
|
||||
if isinstance(self.asr_model, EncDecHybridRNNTCTCModel):
|
||||
self.asr_model.change_decoding_strategy(decoding_cfg=self.decoding_cfg, decoder_type=decoder_type)
|
||||
else:
|
||||
self.asr_model.change_decoding_strategy(self.decoding_cfg)
|
||||
|
||||
def set_dither_to_zero(self) -> None:
|
||||
"""
|
||||
To remove randomness from preprocessor set the dither value to zero.
|
||||
"""
|
||||
self.asr_model.preprocessor.featurizer.dither = 0.0
|
||||
with open_dict(self.asr_model_cfg):
|
||||
self.asr_model_cfg.preprocessor.dither = 0.0
|
||||
|
||||
def get_window_stride(self) -> float:
|
||||
"""
|
||||
Get the window stride for the model.
|
||||
Returns:
|
||||
(float) window stride for the model.
|
||||
"""
|
||||
return self.asr_model_cfg.preprocessor.window_stride
|
||||
|
||||
def get_model_stride(self, in_secs: bool = False, in_milliseconds: bool = False) -> float:
|
||||
"""
|
||||
Get the model stride in seconds for the model.
|
||||
Args:
|
||||
in_secs: (bool) Whether to return the model stride in seconds.
|
||||
in_milliseconds: (bool) Whether to return the model stride in milliseconds.
|
||||
Returns:
|
||||
(float) model stride in seconds or milliseconds.
|
||||
"""
|
||||
if in_secs and in_milliseconds:
|
||||
raise ValueError("Cannot return both seconds and milliseconds at the same time.")
|
||||
if in_secs:
|
||||
return self.get_window_stride() * self.get_subsampling_factor()
|
||||
if in_milliseconds:
|
||||
return self.get_window_stride() * self.get_subsampling_factor() * 1000
|
||||
|
||||
return self.get_window_stride() * self.get_subsampling_factor()
|
||||
|
||||
# Methods that must be implemented in the derived classes.
|
||||
def __post_init__(self):
|
||||
"""
|
||||
Additional post initialization steps that must be implemented in the derived classes.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_blank_id(self) -> int:
|
||||
"""
|
||||
Returns id of the blank token.
|
||||
Returns:
|
||||
(int) blank id for the model.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_vocabulary(self) -> list[str]:
|
||||
"""
|
||||
Returns the list of vocabulary tokens.
|
||||
Returns:
|
||||
(list[str]) list of vocabulary tokens.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_subsampling_factor(self) -> int:
|
||||
"""
|
||||
Returns the subsampling factor for the model.
|
||||
Returns:
|
||||
(int) subsampling factor for the model.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,136 @@
|
||||
# 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.
|
||||
|
||||
|
||||
from typing import Any
|
||||
|
||||
from torch import Tensor
|
||||
|
||||
from nemo.collections.asr.inference.model_wrappers.asr_inference_wrapper import ASRInferenceWrapper
|
||||
|
||||
|
||||
class CacheAwareASRInferenceWrapper(ASRInferenceWrapper):
|
||||
"""
|
||||
Base class for Cache-Aware inference wrappers.
|
||||
It provides a common interface for Cache-Aware models.
|
||||
Derived classes MUST implement the following methods:
|
||||
- stream_step: Executes a single streaming step.
|
||||
"""
|
||||
|
||||
def get_input_features(self) -> int:
|
||||
"""
|
||||
Returns the number of channels in the input features.
|
||||
Returns:
|
||||
(int) number of channels in the input features.
|
||||
"""
|
||||
return self.asr_model.encoder._feat_in
|
||||
|
||||
def get_sampling_frames(self) -> list[int] | int | None:
|
||||
"""
|
||||
It is used for checking to make sure the audio chunk has enough frames to produce at least one output after downsampling.
|
||||
Returns:
|
||||
(list[int] | int | None) sampling frames for the encoder.
|
||||
"""
|
||||
self.sampling_frames = None
|
||||
if hasattr(self.asr_model.encoder, "pre_encode") and hasattr(
|
||||
self.asr_model.encoder.pre_encode, "get_sampling_frames"
|
||||
):
|
||||
self.sampling_frames = self.asr_model.encoder.pre_encode.get_sampling_frames()
|
||||
return self.sampling_frames
|
||||
|
||||
def get_initial_cache_state(self, batch_size: int) -> tuple[Tensor, Tensor, Tensor]:
|
||||
"""
|
||||
Returns the initial cache state for the encoder.
|
||||
Returns:
|
||||
(tuple[Tensor, Tensor, Tensor]) the initial cache state of the encoder.
|
||||
"""
|
||||
return self.asr_model.encoder.get_initial_cache_state(batch_size=batch_size, dtype=self.cast_dtype)
|
||||
|
||||
def get_drop_extra_pre_encoded(self) -> int:
|
||||
"""
|
||||
Returns the number of extra pre-encoded frames to drop.
|
||||
Returns:
|
||||
(int) drop_extra_pre_encoded.
|
||||
"""
|
||||
return self.asr_model.encoder.streaming_cfg.drop_extra_pre_encoded
|
||||
|
||||
def get_chunk_size(self) -> list[int] | int:
|
||||
"""
|
||||
Returns the chunk size for the encoder.
|
||||
Returns:
|
||||
(list[int] | int) the chunk size.
|
||||
"""
|
||||
return self.asr_model.encoder.streaming_cfg.chunk_size
|
||||
|
||||
def get_shift_size(self) -> list[int] | int:
|
||||
"""
|
||||
Returns the shift size for the encoder.
|
||||
Returns:
|
||||
(list[int] | int) the shift size.
|
||||
"""
|
||||
return self.asr_model.encoder.streaming_cfg.shift_size
|
||||
|
||||
def get_pre_encode_cache_size(self) -> list[int] | int:
|
||||
"""
|
||||
Returns the pre-encode cache size for the encoder.
|
||||
Returns:
|
||||
(list[int] | int) the pre_encode cache size.
|
||||
"""
|
||||
return self.asr_model.encoder.streaming_cfg.pre_encode_cache_size
|
||||
|
||||
def get_subsampling_factor(self) -> int:
|
||||
"""
|
||||
Returns the subsampling factor for the ASR encoder.
|
||||
Returns:
|
||||
(int) subsampling factor for the ASR encoder model.
|
||||
"""
|
||||
return self.asr_model.encoder.subsampling_factor
|
||||
|
||||
def get_att_context_size(self) -> list:
|
||||
"""
|
||||
Returns the attention context size for the encoder.
|
||||
Returns:
|
||||
(list) copy of the attention context size.
|
||||
"""
|
||||
return self.asr_model.encoder.att_context_size.copy()
|
||||
|
||||
def set_default_att_context_size(self, att_context_size: list) -> None:
|
||||
"""
|
||||
Set the default attention context size for the encoder.
|
||||
The list of the supported look-ahead: [[70, 13], [70, 6], [70, 1], [70, 0]]
|
||||
Args:
|
||||
att_context_size: (list) the attention context size.
|
||||
"""
|
||||
if hasattr(self.asr_model.encoder, "set_default_att_context_size"):
|
||||
self.asr_model.encoder.set_default_att_context_size(att_context_size=att_context_size)
|
||||
else:
|
||||
raise ValueError("Model does not support multiple lookaheads.")
|
||||
|
||||
def setup_streaming_params(self, chunk_size: int, shift_size: int) -> None:
|
||||
"""
|
||||
Setup the streaming parameters (chunk_size, shift_size) for the encoder.
|
||||
Args:
|
||||
chunk_size: (int) the chunk size.
|
||||
shift_size: (int) the shift size.
|
||||
"""
|
||||
self.asr_model.encoder.setup_streaming_params(chunk_size=chunk_size, shift_size=shift_size)
|
||||
|
||||
def stream_step(self, *args, **kwargs) -> Any:
|
||||
"""
|
||||
Executes a single streaming step.
|
||||
Each derived class must implement this method, with arguments and return types specific to that class.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"`stream_step` method is not implemented. It is required for cache-aware transcribers."
|
||||
)
|
||||
@@ -0,0 +1,208 @@
|
||||
# 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 torch
|
||||
from torch import Tensor
|
||||
|
||||
from nemo.collections.asr.inference.model_wrappers.cache_aware_asr_inference_wrapper import (
|
||||
CacheAwareASRInferenceWrapper,
|
||||
)
|
||||
from nemo.collections.asr.inference.utils.context_manager import CacheAwareContext
|
||||
from nemo.collections.asr.models import EncDecCTCModel, EncDecHybridRNNTCTCModel
|
||||
from nemo.collections.asr.parts.mixins.streaming import StreamingEncoder
|
||||
|
||||
|
||||
class CacheAwareCTCInferenceWrapper(CacheAwareASRInferenceWrapper):
|
||||
"""
|
||||
Provides a unified interface to work with Cache-Aware CTC models.
|
||||
"""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""
|
||||
Additional post initialization step
|
||||
Checks if the model is a ctc model and sets the decoding strategy to ctc.
|
||||
"""
|
||||
|
||||
if not isinstance(self.asr_model, (EncDecCTCModel, EncDecHybridRNNTCTCModel)):
|
||||
raise ValueError(
|
||||
"Provided model is not a CTC type. You are trying to use a CTC Inference with a non-CTC model."
|
||||
)
|
||||
|
||||
if not isinstance(self.asr_model.encoder, StreamingEncoder):
|
||||
raise NotImplementedError("Encoder of this model does not support streaming!")
|
||||
|
||||
decoder_type = 'ctc'
|
||||
if isinstance(self.asr_model, EncDecHybridRNNTCTCModel):
|
||||
self.asr_model.cur_decoder = decoder_type
|
||||
|
||||
# reset the decoding strategy
|
||||
self.reset_decoding_strategy(decoder_type)
|
||||
self.set_decoding_strategy(decoder_type)
|
||||
|
||||
# setup streaming parameters
|
||||
if self.asr_model.encoder.streaming_cfg is None:
|
||||
self.asr_model.encoder.setup_streaming_params()
|
||||
|
||||
self.drop_extra_pre_encoded = self.get_drop_extra_pre_encoded()
|
||||
|
||||
self.cast_dtype = torch.float32 if self.use_amp else self.compute_dtype
|
||||
self.asr_model.to(self.cast_dtype)
|
||||
|
||||
def get_blank_id(self) -> int:
|
||||
"""
|
||||
Returns id of the blank token.
|
||||
Returns:
|
||||
(int) blank id for the model.
|
||||
"""
|
||||
if isinstance(self.asr_model, EncDecCTCModel):
|
||||
blank_id = len(self.asr_model.decoder.vocabulary)
|
||||
else:
|
||||
blank_id = len(self.asr_model.ctc_decoder.vocabulary)
|
||||
return blank_id
|
||||
|
||||
def get_vocabulary(self) -> list[str]:
|
||||
"""
|
||||
Returns the list of vocabulary tokens.
|
||||
Returns:
|
||||
(list[str]) list of vocabulary tokens.
|
||||
"""
|
||||
if isinstance(self.asr_model, EncDecCTCModel):
|
||||
return self.asr_model.decoder.vocabulary
|
||||
else:
|
||||
return self.asr_model.ctc_decoder.vocabulary
|
||||
|
||||
def execute_step(
|
||||
self,
|
||||
processed_signal: Tensor,
|
||||
processed_signal_length: Tensor,
|
||||
context: CacheAwareContext,
|
||||
drop_extra_pre_encoded: int | None,
|
||||
keep_all_outputs: bool,
|
||||
drop_left_context: int | None = None,
|
||||
valid_out_len: int | None = None,
|
||||
return_tail_result: bool = False,
|
||||
) -> tuple[Tensor, Tensor | None, CacheAwareContext]:
|
||||
"""
|
||||
Executes a single streaming step.
|
||||
Args:
|
||||
processed_signal: (Tensor) input signal tensor.
|
||||
processed_signal_length: (Tensor) input signal length tensor.
|
||||
context: (CacheAwareContext) context object.
|
||||
drop_extra_pre_encoded: (int | None) number of extra pre-encoded frames to drop.
|
||||
keep_all_outputs: (bool) whether to keep all outputs or not.
|
||||
drop_left_context: (int | None) number of left context frames to drop.
|
||||
valid_out_len: (int | None) number of valid output frames.
|
||||
return_tail_result: (bool) whether to return tail result or not.
|
||||
Returns:
|
||||
(tuple[Tensor, Tensor | None, CacheAwareContext]) log probabilities, tail log probabilities and new context.
|
||||
"""
|
||||
|
||||
(
|
||||
encoded,
|
||||
encoded_len,
|
||||
cache_last_channel,
|
||||
cache_last_time,
|
||||
cache_last_channel_len,
|
||||
) = self.asr_model.encoder.cache_aware_stream_step(
|
||||
processed_signal=processed_signal,
|
||||
processed_signal_length=processed_signal_length,
|
||||
cache_last_channel=context.cache_last_channel,
|
||||
cache_last_time=context.cache_last_time,
|
||||
cache_last_channel_len=context.cache_last_channel_len,
|
||||
keep_all_outputs=keep_all_outputs,
|
||||
drop_extra_pre_encoded=drop_extra_pre_encoded,
|
||||
)
|
||||
|
||||
if drop_left_context:
|
||||
# drop left context
|
||||
encoded = encoded[:, :, drop_left_context:]
|
||||
|
||||
if isinstance(self.asr_model, EncDecHybridRNNTCTCModel):
|
||||
all_log_probs = self.asr_model.ctc_decoder(encoder_output=encoded)
|
||||
else:
|
||||
all_log_probs = self.asr_model.decoder(encoder_output=encoded)
|
||||
|
||||
tail_log_probs = None
|
||||
if valid_out_len and not keep_all_outputs:
|
||||
# drop right context if any
|
||||
log_probs = all_log_probs[:, :valid_out_len, :]
|
||||
if return_tail_result:
|
||||
tail_log_probs = all_log_probs[:, valid_out_len:, :]
|
||||
else:
|
||||
log_probs = all_log_probs
|
||||
|
||||
# create a new context
|
||||
new_context = CacheAwareContext(
|
||||
cache_last_channel=cache_last_channel,
|
||||
cache_last_time=cache_last_time,
|
||||
cache_last_channel_len=cache_last_channel_len,
|
||||
)
|
||||
return log_probs, tail_log_probs, new_context
|
||||
|
||||
def stream_step(
|
||||
self,
|
||||
processed_signal: Tensor,
|
||||
processed_signal_length: Tensor,
|
||||
context: CacheAwareContext = None,
|
||||
drop_extra_pre_encoded: int | None = None,
|
||||
keep_all_outputs: bool = False,
|
||||
drop_left_context: int | None = None,
|
||||
valid_out_len: int | None = None,
|
||||
return_tail_result: bool = False,
|
||||
) -> tuple[Tensor, Tensor | None, CacheAwareContext]:
|
||||
"""
|
||||
Executes a single streaming step.
|
||||
Args:
|
||||
processed_signal: (Tensor) input signal tensor.
|
||||
processed_signal_length: (Tensor) input signal length tensor.
|
||||
context: (CacheAwareContext) context object.
|
||||
drop_extra_pre_encoded: (int | None) number of extra pre-encoded frames to drop.
|
||||
keep_all_outputs: (bool) whether to keep all outputs or not.
|
||||
drop_left_context: (int | None) number of left context frames to drop.
|
||||
valid_out_len: (int | None) number of valid output frames.
|
||||
return_tail_result: (bool) whether to return tail result or not.
|
||||
Returns:
|
||||
(tuple[Tensor, Tensor | None, CacheAwareContext]) log probabilities, tail log probabilities and new context.
|
||||
"""
|
||||
|
||||
if processed_signal.device != self.device:
|
||||
processed_signal = processed_signal.to(self.device)
|
||||
|
||||
if processed_signal_length.device != self.device:
|
||||
processed_signal_length = processed_signal_length.to(self.device)
|
||||
|
||||
processed_signal = processed_signal.to(self.cast_dtype)
|
||||
|
||||
if context is None:
|
||||
# create a dummy context
|
||||
context = CacheAwareContext()
|
||||
|
||||
with (
|
||||
torch.amp.autocast(device_type=self.device_str, dtype=self.compute_dtype, enabled=self.use_amp),
|
||||
torch.inference_mode(),
|
||||
torch.no_grad(),
|
||||
):
|
||||
|
||||
log_probs, tail_log_probs, new_context = self.execute_step(
|
||||
processed_signal,
|
||||
processed_signal_length,
|
||||
context,
|
||||
drop_extra_pre_encoded,
|
||||
keep_all_outputs,
|
||||
drop_left_context,
|
||||
valid_out_len,
|
||||
return_tail_result,
|
||||
)
|
||||
return log_probs, tail_log_probs, new_context
|
||||
@@ -0,0 +1,314 @@
|
||||
# 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 torch
|
||||
from torch import Tensor
|
||||
|
||||
from nemo.collections.asr.inference.model_wrappers.cache_aware_asr_inference_wrapper import (
|
||||
CacheAwareASRInferenceWrapper,
|
||||
)
|
||||
from nemo.collections.asr.inference.streaming.state.cache_aware_rnnt_state import CacheAwareRNNTBeamStreamingState
|
||||
from nemo.collections.asr.inference.utils.context_manager import CacheAwareContext
|
||||
from nemo.collections.asr.inference.utils.per_stream_biasing import multi_biasing_ids_tensor_from_states
|
||||
from nemo.collections.asr.models import EncDecHybridRNNTCTCModel, EncDecRNNTModel
|
||||
from nemo.collections.asr.parts.mixins.streaming import StreamingEncoder
|
||||
from nemo.collections.asr.parts.submodules.rnnt_malsd_batched_computer import ModifiedALSDBatchedRNNTComputer
|
||||
from nemo.collections.asr.parts.utils.batched_beam_decoding_utils import export_batched_beam_hyps_to_cpu_lists
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
|
||||
|
||||
|
||||
class CacheAwareRNNTInferenceWrapper(CacheAwareASRInferenceWrapper):
|
||||
"""
|
||||
Provides a unified interface to work with Cache-Aware RNNT models.
|
||||
"""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""
|
||||
Additional post initialization step
|
||||
Checks if the model is a rnnt model and sets the decoding strategy to rnnt.
|
||||
"""
|
||||
if not isinstance(self.asr_model, (EncDecRNNTModel, EncDecHybridRNNTCTCModel)):
|
||||
raise ValueError(
|
||||
"Provided model is not a RNNT type. You are trying to use a RNNT Inference with a non-RNNT model."
|
||||
)
|
||||
|
||||
if not isinstance(self.asr_model.encoder, StreamingEncoder):
|
||||
raise NotImplementedError("Encoder of this model does not support streaming!")
|
||||
|
||||
decoder_type = 'rnnt'
|
||||
if isinstance(self.asr_model, EncDecHybridRNNTCTCModel):
|
||||
self.asr_model.cur_decoder = decoder_type
|
||||
|
||||
# reset the decoding strategy
|
||||
self.reset_decoding_strategy(decoder_type)
|
||||
self.set_decoding_strategy(decoder_type)
|
||||
|
||||
# setup streaming parameters
|
||||
if self.asr_model.encoder.streaming_cfg is None:
|
||||
self.asr_model.encoder.setup_streaming_params()
|
||||
|
||||
self.drop_extra_pre_encoded = self.get_drop_extra_pre_encoded()
|
||||
|
||||
self.cast_dtype = torch.float32 if self.use_amp else self.compute_dtype
|
||||
self.asr_model.to(self.cast_dtype)
|
||||
|
||||
def get_blank_id(self) -> int:
|
||||
"""
|
||||
Returns id of the blank token.
|
||||
Returns:
|
||||
(int) blank id for the model.
|
||||
"""
|
||||
blank_id = len(self.asr_model.joint.vocabulary)
|
||||
return blank_id
|
||||
|
||||
def get_vocabulary(self) -> list[str]:
|
||||
"""
|
||||
Returns the list of vocabulary tokens.
|
||||
Returns:
|
||||
(list[str]) list of vocabulary tokens.
|
||||
"""
|
||||
return self.asr_model.joint.vocabulary
|
||||
|
||||
def encoder_step(
|
||||
self,
|
||||
processed_signal: Tensor,
|
||||
processed_signal_length: Tensor,
|
||||
context: CacheAwareContext,
|
||||
drop_extra_pre_encoded: int | None,
|
||||
keep_all_outputs: bool,
|
||||
drop_left_context: int | None = None,
|
||||
valid_out_len: int | None = None,
|
||||
) -> tuple[Tensor, Tensor, CacheAwareContext]:
|
||||
"""
|
||||
Run the cache-aware encoder for one streaming chunk. Decoder is NOT invoked.
|
||||
Args:
|
||||
processed_signal: (Tensor) input signal tensor.
|
||||
processed_signal_length: (Tensor) input signal length tensor.
|
||||
context: (CacheAwareContext) context object.
|
||||
drop_extra_pre_encoded: (int | None) number of extra pre-encoded frames to drop.
|
||||
keep_all_outputs: (bool) whether to keep all outputs or not.
|
||||
drop_left_context: (int | None) number of left context frames to drop.
|
||||
valid_out_len: (int | None) number of valid output frames.
|
||||
Returns:
|
||||
(tuple[Tensor, Tensor, CacheAwareContext]) encoder output, encoder output lengths, and new context.
|
||||
"""
|
||||
(
|
||||
encoded,
|
||||
encoded_len,
|
||||
cache_last_channel,
|
||||
cache_last_time,
|
||||
cache_last_channel_len,
|
||||
) = self.asr_model.encoder.cache_aware_stream_step(
|
||||
processed_signal=processed_signal,
|
||||
processed_signal_length=processed_signal_length,
|
||||
cache_last_channel=context.cache_last_channel,
|
||||
cache_last_time=context.cache_last_time,
|
||||
cache_last_channel_len=context.cache_last_channel_len,
|
||||
keep_all_outputs=keep_all_outputs,
|
||||
drop_extra_pre_encoded=drop_extra_pre_encoded,
|
||||
)
|
||||
new_context = CacheAwareContext(
|
||||
cache_last_channel=cache_last_channel,
|
||||
cache_last_time=cache_last_time,
|
||||
cache_last_channel_len=cache_last_channel_len,
|
||||
)
|
||||
|
||||
if drop_left_context:
|
||||
# drop left context
|
||||
encoded = encoded[:, :, drop_left_context:]
|
||||
encoded_len = encoded_len - drop_left_context
|
||||
|
||||
if valid_out_len and not keep_all_outputs:
|
||||
# drop right context if any
|
||||
encoded = encoded[:, :, :valid_out_len]
|
||||
encoded_len = torch.ones_like(encoded_len) * valid_out_len
|
||||
|
||||
return encoded, encoded_len, new_context
|
||||
|
||||
def execute_step(
|
||||
self,
|
||||
processed_signal: Tensor,
|
||||
processed_signal_length: Tensor,
|
||||
context: CacheAwareContext,
|
||||
previous_hypotheses: list[Hypothesis] | None,
|
||||
drop_extra_pre_encoded: int | None,
|
||||
keep_all_outputs: bool,
|
||||
drop_left_context: int | None = None,
|
||||
valid_out_len: int | None = None,
|
||||
prompt_vectors: Tensor | None = None,
|
||||
) -> tuple[list[Hypothesis], CacheAwareContext]:
|
||||
"""
|
||||
Executes a single streaming step.
|
||||
Args:
|
||||
processed_signal: (Tensor) input signal tensor.
|
||||
processed_signal_length: (Tensor) input signal length tensor.
|
||||
context: (CacheAwareContext) context object.
|
||||
previous_hypotheses: (list[Hypothesis] | None) list of previous hypotheses for RNNT decoding.
|
||||
drop_extra_pre_encoded: (int | None) number of extra pre-encoded frames to drop.
|
||||
keep_all_outputs: (bool) whether to keep all outputs or not.
|
||||
drop_left_context: (int | None) number of left context frames to drop.
|
||||
valid_out_len: (int | None) number of valid output frames.
|
||||
prompt_vectors: (Tensor | None) Optional prompt vectors of shape [B, num_prompts].
|
||||
Returns:
|
||||
(tuple[list[Hypothesis], CacheAwareContext]) best hypothesis and new context.
|
||||
"""
|
||||
encoded, encoded_len, new_context = self.encoder_step(
|
||||
processed_signal=processed_signal,
|
||||
processed_signal_length=processed_signal_length,
|
||||
context=context,
|
||||
drop_extra_pre_encoded=drop_extra_pre_encoded,
|
||||
keep_all_outputs=keep_all_outputs,
|
||||
drop_left_context=drop_left_context,
|
||||
valid_out_len=valid_out_len,
|
||||
)
|
||||
|
||||
best_hyp = self.asr_model.decoding.rnnt_decoder_predictions_tensor(
|
||||
encoded, encoded_len, return_hypotheses=True, partial_hypotheses=previous_hypotheses
|
||||
)
|
||||
return best_hyp, new_context
|
||||
|
||||
def malsd_stream_step(
|
||||
self,
|
||||
malsd_computer: ModifiedALSDBatchedRNNTComputer,
|
||||
states: list[CacheAwareRNNTBeamStreamingState],
|
||||
processed_signal: Tensor,
|
||||
processed_signal_length: Tensor,
|
||||
context: CacheAwareContext,
|
||||
drop_extra_pre_encoded: int | None,
|
||||
keep_all_outputs: bool,
|
||||
drop_left_context: int | None = None,
|
||||
valid_out_len: int | None = None,
|
||||
) -> tuple[list[Hypothesis], CacheAwareContext]:
|
||||
"""Cache-aware MALSD encode/decode step for one chunk."""
|
||||
if processed_signal.device != self.device:
|
||||
processed_signal = processed_signal.to(self.device)
|
||||
|
||||
if processed_signal_length.device != self.device:
|
||||
processed_signal_length = processed_signal_length.to(self.device)
|
||||
|
||||
carries = [state.hyp_decoding_state for state in states]
|
||||
if all(c is None for c in carries):
|
||||
batched_state = None
|
||||
else:
|
||||
batched_state = malsd_computer.merge_to_batched_state(carries)
|
||||
|
||||
multi_biasing_ids = multi_biasing_ids_tensor_from_states(
|
||||
states,
|
||||
self.device,
|
||||
per_stream_biasing_enabled=malsd_computer.per_stream_biasing_enabled,
|
||||
)
|
||||
|
||||
with (
|
||||
torch.amp.autocast(device_type=self.device_str, dtype=self.compute_dtype, enabled=self.use_amp),
|
||||
torch.inference_mode(),
|
||||
torch.no_grad(),
|
||||
):
|
||||
processed_signal = processed_signal.to(self.cast_dtype)
|
||||
encoded, encoded_len, new_context = self.encoder_step(
|
||||
processed_signal=processed_signal,
|
||||
processed_signal_length=processed_signal_length,
|
||||
context=context,
|
||||
drop_extra_pre_encoded=drop_extra_pre_encoded,
|
||||
keep_all_outputs=keep_all_outputs,
|
||||
drop_left_context=drop_left_context,
|
||||
valid_out_len=valid_out_len,
|
||||
)
|
||||
encs_dim_last = encoded.transpose(1, 2).contiguous()
|
||||
|
||||
best_batched_hyps, batched_state = malsd_computer(
|
||||
encs_dim_last, encoded_len, batched_state, multi_biasing_ids=multi_biasing_ids
|
||||
)
|
||||
|
||||
chunk_tokens, chunk_timestamps, chunk_confidences, root_ptrs = export_batched_beam_hyps_to_cpu_lists(
|
||||
best_batched_hyps
|
||||
)
|
||||
beam_indices = best_batched_hyps.scores.argmax(dim=-1).detach().cpu().tolist()
|
||||
scores_cpu = best_batched_hyps.scores.detach().cpu()
|
||||
|
||||
carry_items = malsd_computer.split_batched_state(batched_state)
|
||||
for b, (state, ct, cts, rp, best_hyp_idx, carry) in enumerate(
|
||||
zip(states, chunk_tokens, chunk_timestamps, root_ptrs, beam_indices, carry_items)
|
||||
):
|
||||
state.append_chunk_beam_(
|
||||
ct,
|
||||
cts,
|
||||
rp,
|
||||
best_batched_hyps.beam_size,
|
||||
best_hyp_idx,
|
||||
chunk_confidences=(chunk_confidences[b] if chunk_confidences is not None else None),
|
||||
)
|
||||
state.hyp_decoding_state = carry
|
||||
|
||||
hyps = [state.get_hypothesis(float(scores_cpu[b, beam_indices[b]].item())) for b, state in enumerate(states)]
|
||||
return hyps, new_context
|
||||
|
||||
def stream_step(
|
||||
self,
|
||||
processed_signal: Tensor,
|
||||
processed_signal_length: Tensor,
|
||||
context: CacheAwareContext = None,
|
||||
previous_hypotheses: list[Hypothesis] | None = None,
|
||||
drop_extra_pre_encoded: int | None = None,
|
||||
keep_all_outputs: bool = False,
|
||||
drop_left_context: int | None = None,
|
||||
valid_out_len: int | None = None,
|
||||
prompt_vectors: Tensor | None = None,
|
||||
) -> tuple[list[Hypothesis], CacheAwareContext]:
|
||||
"""
|
||||
Executes a single streaming step.
|
||||
Args:
|
||||
processed_signal: (Tensor) input signal tensor.
|
||||
processed_signal_length: (Tensor) input signal length tensor.
|
||||
context: (CacheAwareContext) context object.
|
||||
previous_hypotheses: (list[Hypothesis] | None) list of previous hypotheses for RNNT decoding.
|
||||
drop_extra_pre_encoded: (int | None) number of extra pre-encoded frames to drop.
|
||||
keep_all_outputs: (bool) whether to keep all outputs or not.
|
||||
drop_left_context: (int | None) number of left context frames to drop.
|
||||
valid_out_len: (int | None) number of valid output frames.
|
||||
prompt_vectors: (Tensor | None) Optional prompt vectors of shape [B, num_prompts].
|
||||
Returns:
|
||||
(tuple[list[Hypothesis], CacheAwareContext]) best hypothesis and new context.
|
||||
"""
|
||||
|
||||
if processed_signal.device != self.device:
|
||||
processed_signal = processed_signal.to(self.device)
|
||||
|
||||
if processed_signal_length.device != self.device:
|
||||
processed_signal_length = processed_signal_length.to(self.device)
|
||||
|
||||
processed_signal = processed_signal.to(self.cast_dtype)
|
||||
|
||||
if context is None:
|
||||
# create a dummy context
|
||||
context = CacheAwareContext()
|
||||
|
||||
with (
|
||||
torch.amp.autocast(device_type=self.device_str, dtype=self.compute_dtype, enabled=self.use_amp),
|
||||
torch.inference_mode(),
|
||||
torch.no_grad(),
|
||||
):
|
||||
|
||||
best_hyp, new_context = self.execute_step(
|
||||
processed_signal,
|
||||
processed_signal_length,
|
||||
context,
|
||||
previous_hypotheses,
|
||||
drop_extra_pre_encoded,
|
||||
keep_all_outputs,
|
||||
drop_left_context,
|
||||
valid_out_len,
|
||||
prompt_vectors,
|
||||
)
|
||||
|
||||
return best_hyp, new_context
|
||||
@@ -0,0 +1,109 @@
|
||||
# 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 torch
|
||||
from torch import Tensor
|
||||
|
||||
from nemo.collections.asr.inference.model_wrappers.asr_inference_wrapper import ASRInferenceWrapper
|
||||
from nemo.collections.asr.models import EncDecCTCModel, EncDecHybridRNNTCTCModel
|
||||
|
||||
|
||||
class CTCInferenceWrapper(ASRInferenceWrapper):
|
||||
"""
|
||||
Provides a unified interface to work with CTC/Hybrid-CTC models.
|
||||
"""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""
|
||||
Additional post initialization step
|
||||
Checks if the model is a ctc model and sets the decoding strategy to ctc.
|
||||
"""
|
||||
if not isinstance(self.asr_model, (EncDecCTCModel, EncDecHybridRNNTCTCModel)):
|
||||
raise ValueError(
|
||||
"Provided model is not a CTC type. You are trying to use a CTC transcriber with a non-CTC model."
|
||||
)
|
||||
|
||||
decoder_type = 'ctc'
|
||||
if isinstance(self.asr_model, EncDecHybridRNNTCTCModel):
|
||||
self.asr_model.cur_decoder = decoder_type
|
||||
|
||||
# reset the decoding strategy
|
||||
self.reset_decoding_strategy(decoder_type)
|
||||
self.set_decoding_strategy(decoder_type)
|
||||
|
||||
self.cast_dtype = torch.float32 if self.use_amp else self.compute_dtype
|
||||
self.asr_model.to(self.cast_dtype)
|
||||
|
||||
def get_blank_id(self) -> int:
|
||||
"""
|
||||
Returns id of the blank token.
|
||||
Returns:
|
||||
(int) blank id for the model.
|
||||
"""
|
||||
if isinstance(self.asr_model, EncDecCTCModel):
|
||||
blank_id = len(self.asr_model.decoder.vocabulary)
|
||||
else:
|
||||
blank_id = len(self.asr_model.ctc_decoder.vocabulary)
|
||||
return blank_id
|
||||
|
||||
def get_vocabulary(self) -> list[str]:
|
||||
"""
|
||||
Returns the list of vocabulary tokens.
|
||||
Returns:
|
||||
(list[str]) list of vocabulary tokens.
|
||||
"""
|
||||
if isinstance(self.asr_model, EncDecCTCModel):
|
||||
return self.asr_model.decoder.vocabulary
|
||||
else:
|
||||
return self.asr_model.ctc_decoder.vocabulary
|
||||
|
||||
def get_subsampling_factor(self) -> int:
|
||||
"""
|
||||
Returns the subsampling factor for the ASR encoder.
|
||||
Returns:
|
||||
(int) subsampling factor for the ASR encoder model.
|
||||
"""
|
||||
return self.asr_model.encoder.subsampling_factor
|
||||
|
||||
def get_logprobs(self, processed_signal: Tensor, processed_signal_length: Tensor) -> Tensor:
|
||||
"""
|
||||
Get log probabilities from the model. It is used for streaming inference.
|
||||
Args:
|
||||
processed_signal: (Tensor) processed signal. Shape is torch.Size([B, C, T]).
|
||||
processed_signal_length: (Tensor) processed signal length. Shape is torch.Size([B]).
|
||||
Returns:
|
||||
(Tensor) log probabilities. Shape is torch.Size([B, T, V+1]).
|
||||
"""
|
||||
if processed_signal.device != self.device:
|
||||
processed_signal = processed_signal.to(self.device)
|
||||
|
||||
if processed_signal_length.device != self.device:
|
||||
processed_signal_length = processed_signal_length.to(self.device)
|
||||
|
||||
with (
|
||||
torch.amp.autocast(device_type=self.device_str, dtype=self.compute_dtype, enabled=self.use_amp),
|
||||
torch.inference_mode(),
|
||||
torch.no_grad(),
|
||||
):
|
||||
|
||||
forward_outs = self.asr_model(
|
||||
processed_signal=processed_signal.to(self.cast_dtype), processed_signal_length=processed_signal_length
|
||||
)
|
||||
|
||||
if isinstance(self.asr_model, EncDecHybridRNNTCTCModel):
|
||||
encoded, encoded_len = forward_outs
|
||||
log_probs = self.asr_model.ctc_decoder(encoder_output=encoded.clone())
|
||||
else:
|
||||
log_probs, encoded_len, predictions = forward_outs
|
||||
return log_probs
|
||||
@@ -0,0 +1,147 @@
|
||||
# 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 torch
|
||||
from torch import Tensor
|
||||
|
||||
from nemo.collections.asr.inference.model_wrappers.asr_inference_wrapper import ASRInferenceWrapper
|
||||
from nemo.collections.asr.models import EncDecHybridRNNTCTCModel, EncDecRNNTModel
|
||||
|
||||
|
||||
class RNNTInferenceWrapper(ASRInferenceWrapper):
|
||||
"""
|
||||
Provides a unified interface to work with RNNT/TDT/Hybrid models.
|
||||
"""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""
|
||||
Additional post initialization step
|
||||
Checks if the model is a rnnt model and sets the decoding strategy to rnnt.
|
||||
"""
|
||||
if not isinstance(self.asr_model, (EncDecRNNTModel, EncDecHybridRNNTCTCModel)):
|
||||
raise ValueError(
|
||||
"Provided model is not a RNNT type. You are trying to use a RNNT transcriber with a non-RNNT model."
|
||||
)
|
||||
|
||||
decoder_type = 'rnnt'
|
||||
if isinstance(self.asr_model, EncDecHybridRNNTCTCModel):
|
||||
self.asr_model.cur_decoder = decoder_type
|
||||
|
||||
# reset the decoding strategy
|
||||
self.reset_decoding_strategy(decoder_type)
|
||||
self.set_decoding_strategy(decoder_type)
|
||||
|
||||
self.cast_dtype = torch.float32 if self.use_amp else self.compute_dtype
|
||||
self.asr_model.to(self.cast_dtype)
|
||||
|
||||
def get_blank_id(self) -> int:
|
||||
"""
|
||||
Returns id of the blank token.
|
||||
Returns:
|
||||
(int) blank id for the model.
|
||||
"""
|
||||
blank_id = len(self.asr_model.joint.vocabulary)
|
||||
return blank_id
|
||||
|
||||
def get_vocabulary(self) -> list[str]:
|
||||
"""
|
||||
Returns the list of vocabulary tokens.
|
||||
Returns:
|
||||
(list[str]) list of vocabulary tokens.
|
||||
"""
|
||||
return self.asr_model.joint.vocabulary
|
||||
|
||||
def get_subsampling_factor(self) -> int:
|
||||
"""
|
||||
Returns the subsampling factor for the ASR encoder.
|
||||
Returns:
|
||||
(int) subsampling factor for the ASR encoder model.
|
||||
"""
|
||||
return self.asr_model.encoder.subsampling_factor
|
||||
|
||||
def encode(
|
||||
self, processed_signal: Tensor, processed_signal_length: Tensor, prompt_vectors: Tensor | None = None
|
||||
) -> tuple[Tensor, Tensor]:
|
||||
"""
|
||||
Get encoder output from the model. It is used for streaming inference.
|
||||
Args:
|
||||
processed_signal: (Tensor) processed signal. Shape is torch.Size([B, C, T]).
|
||||
processed_signal_length: (Tensor) processed signal length. Shape is torch.Size([B]).
|
||||
prompt_vectors: (Tensor | None) Optional prompt vectors for multilingual models.
|
||||
Shape can be torch.Size([B, num_prompts]) or torch.Size([B, T_enc, num_prompts]) if already expanded.
|
||||
Returns:
|
||||
(tuple[Tensor, Tensor]) encoder output and encoder output length of shape torch.Size([B, T, D]), torch.Size([B]).
|
||||
"""
|
||||
if processed_signal.device != self.device:
|
||||
processed_signal = processed_signal.to(self.device)
|
||||
|
||||
if processed_signal_length.device != self.device:
|
||||
processed_signal_length = processed_signal_length.to(self.device)
|
||||
|
||||
with (
|
||||
torch.amp.autocast(device_type=self.device_str, dtype=self.compute_dtype, enabled=self.use_amp),
|
||||
torch.inference_mode(),
|
||||
torch.no_grad(),
|
||||
):
|
||||
|
||||
# Prepare model arguments
|
||||
model_args = {
|
||||
'processed_signal': processed_signal.to(self.cast_dtype),
|
||||
'processed_signal_length': processed_signal_length,
|
||||
}
|
||||
if prompt_vectors is not None:
|
||||
model_args['prompt'] = prompt_vectors
|
||||
|
||||
forward_outs = self.asr_model(**model_args)
|
||||
|
||||
encoded, encoded_len = forward_outs
|
||||
return encoded, encoded_len
|
||||
|
||||
def decode(self, encoded: Tensor, encoded_len: Tensor, partial_hypotheses: list) -> list:
|
||||
"""
|
||||
Decode the encoder output using the RNNT decoder.
|
||||
Args:
|
||||
encoded: (Tensor) encoder output.
|
||||
encoded_len: (Tensor) encoder output length.
|
||||
partial_hypotheses: (list) list of partial hypotheses for stateful decoding.
|
||||
Returns:
|
||||
(list) list of best hypotheses.
|
||||
"""
|
||||
best_hyp = self.asr_model.decoding.rnnt_decoder_predictions_tensor(
|
||||
encoded.to(self.cast_dtype), encoded_len, return_hypotheses=True, partial_hypotheses=partial_hypotheses
|
||||
)
|
||||
return best_hyp
|
||||
|
||||
def encode_with_prompts(
|
||||
self, processed_signal: Tensor, processed_signal_length: Tensor, prompt_vectors: Tensor
|
||||
) -> tuple[Tensor, Tensor]:
|
||||
"""
|
||||
Convenience wrapper for prompt-enabled encoding.
|
||||
Expands prompt vectors across the time dimension before calling encode.
|
||||
Args:
|
||||
processed_signal: (Tensor) processed signal. Shape is torch.Size([B, C, T]).
|
||||
processed_signal_length: (Tensor) processed signal length. Shape is torch.Size([B]).
|
||||
prompt_vectors: (Tensor) prompt vectors. Shape is torch.Size([B, num_prompts]).
|
||||
Returns:
|
||||
(tuple[Tensor, Tensor]) encoder output and encoder output length.
|
||||
"""
|
||||
encoder_time_steps = processed_signal.shape[2] // self.get_subsampling_factor()
|
||||
# Expand prompts: [B, num_prompts] -> [B, T_enc, num_prompts]
|
||||
prompt_vectors = prompt_vectors.unsqueeze(1).expand(-1, encoder_time_steps, -1)
|
||||
return self.encode(
|
||||
processed_signal=processed_signal,
|
||||
processed_signal_length=processed_signal_length,
|
||||
prompt_vectors=prompt_vectors,
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
# 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.inference.utils.device_utils import setup_device
|
||||
from nemo.collections.common.prompts import PromptFormatter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nemo.collections.speechlm2.models import SALM
|
||||
|
||||
|
||||
class SALMASRInferenceWrapper:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
device: str = 'cuda',
|
||||
device_id: int = 0,
|
||||
compute_dtype: str = 'bfloat16',
|
||||
use_amp: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize the SALM ASR inference wrapper.
|
||||
Args:
|
||||
model_name: (str) model name at Hugging Face or NGC cloud.
|
||||
device: (str) device to run the model on.
|
||||
device_id: (int) device ID to run the model on.
|
||||
compute_dtype: (str) compute dtype to run the model on.
|
||||
use_amp: (bool) Use Automatic Mixed Precision
|
||||
"""
|
||||
|
||||
self.device_str, self.device_id, self.compute_dtype = setup_device(device.strip(), device_id, compute_dtype)
|
||||
self.use_amp = use_amp
|
||||
self.device = torch.device(self.device_str)
|
||||
self.salm_model = self.load_model(model_name, self.device)
|
||||
self.audio_locator_tag = self.salm_model.audio_locator_tag
|
||||
self.tokenizer = self.salm_model.tokenizer
|
||||
self.set_dither_to_zero()
|
||||
|
||||
@property
|
||||
def eos_token_ids(self) -> list[int]:
|
||||
"""Returns the end of sentence token ids."""
|
||||
return [self.salm_model.text_eos_id]
|
||||
|
||||
@property
|
||||
def word_separator(self) -> str:
|
||||
"""Returns word separator."""
|
||||
return ' '
|
||||
|
||||
@property
|
||||
def word_separator_ids(self) -> list[int]:
|
||||
"""Returns the word separator token ids."""
|
||||
return self.tokenizer.text_to_ids(self.word_separator)
|
||||
|
||||
@staticmethod
|
||||
def load_model(model_name: str, device: torch.device) -> SALM:
|
||||
"""
|
||||
Load the SALM model.
|
||||
Args:
|
||||
model_name: (str) model name at Hugging Face or NGC cloud.
|
||||
device: (torch.device) device to load the model on.
|
||||
Returns:
|
||||
(SALM) loaded SALM model.
|
||||
"""
|
||||
try:
|
||||
from nemo.collections.speechlm2.models import SALM
|
||||
|
||||
model = SALM.from_pretrained(model_name).eval()
|
||||
model.to(device)
|
||||
return model
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to load model {model_name}: {str(e)}") from e
|
||||
|
||||
def get_window_stride(self) -> float:
|
||||
"""Returns the window stride of the model."""
|
||||
return self.salm_model.cfg.perception.preprocessor.window_stride
|
||||
|
||||
def get_subsampling_factor(self) -> int:
|
||||
"""Returns the subsampling factor of the model."""
|
||||
return self.salm_model.cfg.perception.encoder.subsampling_factor
|
||||
|
||||
def get_model_stride(self, in_secs: bool = False, in_milliseconds: bool = False) -> float:
|
||||
"""
|
||||
Returns the model stride in seconds or milliseconds.
|
||||
Args:
|
||||
in_secs: (bool) Whether to return the model stride in seconds.
|
||||
in_milliseconds: (bool) Whether to return the model stride in milliseconds.
|
||||
Returns:
|
||||
(float) model stride in seconds or milliseconds.
|
||||
"""
|
||||
if in_secs and in_milliseconds:
|
||||
raise ValueError("Cannot return both seconds and milliseconds at the same time.")
|
||||
token_duration = self.salm_model.token_equivalent_duration
|
||||
if in_secs:
|
||||
return token_duration
|
||||
if in_milliseconds:
|
||||
return token_duration * 1000
|
||||
return token_duration
|
||||
|
||||
def set_dither_to_zero(self) -> None:
|
||||
"""Sets the dither to zero."""
|
||||
self.salm_model.cfg.perception.preprocessor.dither = 0.0
|
||||
self.salm_model.perception.preprocessor.featurizer.dither = 0.0
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompts: list[list[dict[str]]] | torch.Tensor,
|
||||
audios: torch.Tensor,
|
||||
audio_lens: torch.Tensor,
|
||||
max_new_tokens: int = 128,
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Generate the model output.
|
||||
Args:
|
||||
prompts: (list[list[dict[str]]] | torch.Tensor) List of prompts or token ids.
|
||||
audios: (torch.Tensor) Audio tensor of shape (batch_size, num_samples).
|
||||
audio_lens: (torch.Tensor) Audio length tensor of shape (batch_size).
|
||||
max_new_tokens: (int) Maximum number of new tokens to generate.
|
||||
Returns:
|
||||
(torch.Tensor) Model output.
|
||||
"""
|
||||
with (
|
||||
torch.amp.autocast(device_type=self.device_str, dtype=self.compute_dtype, enabled=self.use_amp),
|
||||
torch.inference_mode(),
|
||||
torch.no_grad(),
|
||||
):
|
||||
answer_ids = self.salm_model.generate(
|
||||
prompts=prompts,
|
||||
audios=audios,
|
||||
audio_lens=audio_lens,
|
||||
max_new_tokens=max_new_tokens,
|
||||
)
|
||||
return answer_ids
|
||||
|
||||
def preprocess_prompts(self, prompts: list[list[dict[str]]]) -> torch.Tensor:
|
||||
"""
|
||||
Convert the prompts to token ids.
|
||||
Args:
|
||||
prompts: (list[list[dict[str]]]) List of prompts.
|
||||
Returns:
|
||||
(torch.Tensor) Token ids of size (batch_size, max_prompt_length).
|
||||
"""
|
||||
from nemo.collections.speechlm2.data.salm_dataset import left_collate_vectors
|
||||
|
||||
formatter = PromptFormatter.resolve(self.salm_model.cfg.prompt_format)(self.tokenizer)
|
||||
tokens = left_collate_vectors(
|
||||
[formatter.encode_dialog(turns=prompt)["input_ids"] for prompt in prompts],
|
||||
padding_value=self.salm_model.text_pad_id,
|
||||
).to(self.device)
|
||||
return tokens
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,296 @@
|
||||
# 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 os
|
||||
import string
|
||||
|
||||
import torch
|
||||
from omegaconf import DictConfig, OmegaConf
|
||||
|
||||
from nemo.collections.asr.inference.nmt.prompts import EuroLLMTranslatorPromptTemplate, PromptTemplate
|
||||
|
||||
try:
|
||||
from vllm import LLM, SamplingParams
|
||||
except ImportError as e:
|
||||
raise ImportError("Failed to import vLLM.") from e
|
||||
|
||||
from nemo.utils import logging
|
||||
|
||||
EURO_LLM_INSTRUCT_SMALL = "utter-project/EuroLLM-1.7B-Instruct"
|
||||
EURO_LLM_INSTRUCT_LARGE = "utter-project/EuroLLM-9B-Instruct"
|
||||
SUPPORTED_TRANSLATION_MODELS = [EURO_LLM_INSTRUCT_SMALL, EURO_LLM_INSTRUCT_LARGE]
|
||||
|
||||
|
||||
class LLMTranslator:
|
||||
"""
|
||||
A vLLM-based LLM translator for ASR transcripts.
|
||||
It takes ASR transcripts and prefixes to start translation from, and returns corresponding continuations of translations.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
source_language: str,
|
||||
target_language: str,
|
||||
waitk: int = -1,
|
||||
device: str = "cuda",
|
||||
device_id: int = 0,
|
||||
batch_size: int = -1,
|
||||
llm_params: dict | DictConfig | None = None,
|
||||
sampling_params: dict | DictConfig | None = None,
|
||||
):
|
||||
"""
|
||||
A model for translating ASR transcripts with LLM.
|
||||
Args:
|
||||
model_name: (str) path to the model name on HuggingFace.
|
||||
source_language: (str) source language
|
||||
target_language: (str) target language
|
||||
waitk: (int) sets the maximum number of words the translation is allowed to lag behind the ASR transcript.
|
||||
If the translation falls more than waitk words behind, it automatically extends the prefix
|
||||
using the current translation. -1 disables this rule and relies on the longest common prefix (LCP)
|
||||
between current and previous translations. Larger values of waitk lead to more coherent translations,
|
||||
but the cost of generating the translation increases, because the model needs to generate more tokens.
|
||||
device: (str) device to run the model on
|
||||
device_id: (int) device ID to run the model on
|
||||
batch_size: (int) batch size for the LLM model, in case of -1, the batch size is set to the number of ASR transcripts
|
||||
llm_params: (dict | DictConfig | None) parameters for the LLM model
|
||||
sampling_params: (dict | DictConfig | None) parameters for the sampling
|
||||
"""
|
||||
self.model_name = model_name
|
||||
if model_name not in SUPPORTED_TRANSLATION_MODELS:
|
||||
raise ValueError(
|
||||
f"Model {model_name} is not supported for translation. Supported models are: {SUPPORTED_TRANSLATION_MODELS}"
|
||||
)
|
||||
|
||||
llm_params = self.convert_to_dict(llm_params)
|
||||
sampling_params = self.convert_to_dict(sampling_params)
|
||||
|
||||
self.device_str, self.device_id = self.setup_device(device, device_id)
|
||||
|
||||
self.batch_size = batch_size
|
||||
self.split_batch = self.batch_size > 0
|
||||
|
||||
self.nmt_model = self.load_model(llm_params)
|
||||
self.sampling_params = SamplingParams(**sampling_params)
|
||||
|
||||
self.source_language = source_language
|
||||
self.target_language = target_language
|
||||
self.prompt_template = self.get_prompt_template(model_name)
|
||||
self.waitk = waitk
|
||||
|
||||
@staticmethod
|
||||
def convert_to_dict(params: dict | DictConfig | None) -> dict:
|
||||
"""
|
||||
Convert DictConfig to dict.
|
||||
Args:
|
||||
params: (dict | DictConfig | None) parameters to convert
|
||||
Returns:
|
||||
dict: converted parameters
|
||||
"""
|
||||
if params is None:
|
||||
return dict()
|
||||
if isinstance(params, DictConfig):
|
||||
return OmegaConf.to_container(params)
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def setup_device(device: str, device_id: int) -> tuple[str, int]:
|
||||
"""
|
||||
Setup device for the LLM model.
|
||||
Args:
|
||||
device: (str) device to run the model on
|
||||
device_id: (int) device ID to run the model on
|
||||
Returns:
|
||||
device_str: (str) device string, e.g. "cuda:1"
|
||||
device_id: (int) device ID, e.g. 1
|
||||
Raises:
|
||||
ValueError: if device is not supported, or CUDA is not available
|
||||
"""
|
||||
if device == "cpu":
|
||||
raise ValueError("Currently, CPU is not supported for vLLM.")
|
||||
|
||||
if device == "cuda":
|
||||
if not torch.cuda.is_available():
|
||||
raise ValueError("CUDA is not available.")
|
||||
|
||||
if device_id >= torch.cuda.device_count():
|
||||
logging.warning(f"Device ID {device_id} is not available. Using GPU 0 instead.")
|
||||
device_id = 0
|
||||
|
||||
device_str = f"cuda:{device_id}"
|
||||
return device_str, device_id
|
||||
|
||||
raise ValueError(f"Unsupported device: {device}")
|
||||
|
||||
@staticmethod
|
||||
def get_prompt_template(model_name: str) -> PromptTemplate:
|
||||
"""
|
||||
Returns prompt template for the LLM model.
|
||||
Args:
|
||||
model_name: (str) name of the model to get prompt template for
|
||||
Returns:
|
||||
PromptTemplate: prompt template for the LLM model
|
||||
Raises:
|
||||
ValueError: if model is not supported for translation
|
||||
"""
|
||||
if model_name in [EURO_LLM_INSTRUCT_SMALL, EURO_LLM_INSTRUCT_LARGE]:
|
||||
return EuroLLMTranslatorPromptTemplate
|
||||
|
||||
raise ValueError(
|
||||
f"Model {model_name} is not supported for translation. Supported models are: {SUPPORTED_TRANSLATION_MODELS}"
|
||||
)
|
||||
|
||||
def load_model(self, llm_params: dict) -> LLM:
|
||||
"""
|
||||
Load NMT model in vLLM format.
|
||||
Args:
|
||||
llm_params: (dict) parameters for the LLM model
|
||||
Returns:
|
||||
Loaded LLM instance.
|
||||
Raises:
|
||||
RuntimeError: If model loading fails.
|
||||
"""
|
||||
try:
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = str(self.device_id)
|
||||
model = LLM(model=self.model_name, **llm_params)
|
||||
return model
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Model loading failed: {str(e)}") from e
|
||||
|
||||
def translate_batch(
|
||||
self,
|
||||
asr_transcripts: list[str],
|
||||
prefixes: list[str],
|
||||
src_langs: list[str],
|
||||
tgt_langs: list[str],
|
||||
src_contexts: list[str],
|
||||
tgt_contexts: list[str],
|
||||
) -> list[str]:
|
||||
"""
|
||||
Translate ASR transcripts starting from pre-defined prefixes in target language.
|
||||
Args:
|
||||
asr_transcripts: (list[str]) batch of ASR transcripts to be translated
|
||||
prefixes: (list[str]) batch of prefixes to start translation from
|
||||
src_langs: (list[str]) batch of source languages
|
||||
tgt_langs: (list[str]) batch of target languages
|
||||
src_contexts: (list[str]) batch of source contexts
|
||||
tgt_contexts: (list[str]) batch of target contexts
|
||||
Returns:
|
||||
list[str] translations of ASR transcripts
|
||||
"""
|
||||
input_texts = []
|
||||
for src_lang, tgt_lang, src_prefix, tgt_prefix, src_context, tgt_context in zip(
|
||||
src_langs, tgt_langs, asr_transcripts, prefixes, src_contexts, tgt_contexts
|
||||
):
|
||||
text = self.prompt_template.format(src_lang, tgt_lang, src_prefix, tgt_prefix, src_context, tgt_context)
|
||||
input_texts.append(text)
|
||||
|
||||
outputs = self.nmt_model.generate(input_texts, self.sampling_params, use_tqdm=False)
|
||||
translations = []
|
||||
for tgt_prefix, output in zip(prefixes, outputs):
|
||||
output_text = output.outputs[0].text
|
||||
output_text = self.prompt_template.extract(output_text)
|
||||
translations.append(f"{tgt_prefix}{output_text}")
|
||||
return translations
|
||||
|
||||
def translate(
|
||||
self,
|
||||
asr_transcripts: list[str],
|
||||
prefixes: list[str],
|
||||
src_langs: list[str],
|
||||
tgt_langs: list[str],
|
||||
src_contexts: list[str],
|
||||
tgt_contexts: list[str],
|
||||
) -> list[str]:
|
||||
"""
|
||||
Translate ASR transcript starting from pre-defined prefix in target language.
|
||||
Args:
|
||||
asr_transcripts: (list[str]) ASR transcripts to be translated
|
||||
prefixes: (list[str]) prefixes to start translation from
|
||||
src_langs: (list[str]) source languages
|
||||
tgt_langs: (list[str]) target languages
|
||||
src_contexts: (list[str]) source contexts
|
||||
tgt_contexts: (list[str]) target contexts
|
||||
Returns:
|
||||
list[str] translations of ASR transcripts
|
||||
"""
|
||||
all_translations = []
|
||||
n_requests = len(asr_transcripts)
|
||||
bs = self.batch_size if self.split_batch else n_requests
|
||||
for i in range(0, n_requests, bs):
|
||||
all_translations.extend(
|
||||
self.translate_batch(
|
||||
asr_transcripts=asr_transcripts[i : i + bs],
|
||||
prefixes=prefixes[i : i + bs],
|
||||
src_langs=src_langs[i : i + bs],
|
||||
tgt_langs=tgt_langs[i : i + bs],
|
||||
src_contexts=src_contexts[i : i + bs],
|
||||
tgt_contexts=tgt_contexts[i : i + bs],
|
||||
)
|
||||
)
|
||||
return all_translations
|
||||
|
||||
def get_prefixes(
|
||||
self,
|
||||
asr_transcripts: list[str],
|
||||
translations: list[str],
|
||||
prev_translations: list[str],
|
||||
) -> list[str]:
|
||||
"""
|
||||
Generates new prefixes in target language for the next translation step.
|
||||
Args:
|
||||
asr_transcripts: (list[str]) current ASR transcripts to be translated
|
||||
translations: (list[str]) translations obtained with LLM on current step
|
||||
prev_translations: (list[str]) translations obtained with LLM on previous step
|
||||
Returns:
|
||||
list[str] new prefixes for LLM translation
|
||||
"""
|
||||
|
||||
new_prefixes = []
|
||||
for asr, trans, prev_trans in zip(asr_transcripts, translations, prev_translations):
|
||||
|
||||
# Longest common prefix of translations on current and previous steps
|
||||
lcp = os.path.commonprefix([prev_trans, trans])
|
||||
had_leading_space = lcp.startswith(" ")
|
||||
|
||||
# If lcp happens mid-word, remove generated ending up to the first full word
|
||||
if (len(lcp) > 0) and (lcp[-1] not in f"{string.punctuation} "):
|
||||
lcp = " ".join(lcp.split()[:-1])
|
||||
|
||||
# Remove trailing whitespaces
|
||||
lcp = lcp.strip()
|
||||
|
||||
# Remove hallucinations if ASR transcript is empty string
|
||||
if len(asr) == 0:
|
||||
lcp = ""
|
||||
|
||||
# If the LLM-generated translations disagree too much between steps,
|
||||
# and the translation falls more than waitk words behind the ASR transcript,
|
||||
# the algorithm forcibly advances the prefix based on the current translation.
|
||||
n_asr_words = len(asr.split())
|
||||
n_lcp_words = len(lcp.split())
|
||||
if (self.waitk > 0) and (n_asr_words - n_lcp_words > self.waitk):
|
||||
num_words_to_pick = n_asr_words - self.waitk
|
||||
new_prefix = " ".join(trans.split()[:num_words_to_pick])
|
||||
else:
|
||||
new_prefix = lcp
|
||||
|
||||
# Preserve leading space if it was present in the previous translation
|
||||
if len(new_prefix) > 0 and had_leading_space and not new_prefix.startswith(" "):
|
||||
new_prefix = " " + new_prefix
|
||||
|
||||
new_prefixes.append(new_prefix)
|
||||
|
||||
return new_prefixes
|
||||
@@ -0,0 +1,96 @@
|
||||
# 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
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class PromptTemplate(ABC):
|
||||
"""
|
||||
Base class for prompt templates.
|
||||
Derived classes should implement the format and extract methods.
|
||||
- format: format the prompt template with the given arguments
|
||||
- extract: extract the answer from the response
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def format(cls, **kwargs) -> str:
|
||||
"""
|
||||
Format the prompt template with the given arguments.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def extract(cls, response: str) -> str:
|
||||
"""
|
||||
Extract the answer from the response.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class EuroLLMTranslatorPromptTemplate(PromptTemplate):
|
||||
"""
|
||||
Provides a prompt template for the EuroLLM model to perform translation.
|
||||
"""
|
||||
|
||||
PROMPT_TEMPLATE = (
|
||||
"<|im_start|>system\n<|im_end|>\n"
|
||||
"<|im_start|>user\n"
|
||||
"Translate the following {src_lang} source text to {tgt_lang}. Always output text in the {tgt_lang} language:\n"
|
||||
"{src_lang}: {src_text}\n"
|
||||
"{tgt_lang}: <|im_end|>\n"
|
||||
"<|im_start|>assistant\n"
|
||||
"{tgt_text}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def format(
|
||||
cls,
|
||||
src_lang: str,
|
||||
tgt_lang: str,
|
||||
src_prefix: str,
|
||||
tgt_prefix: str,
|
||||
src_context: str = "",
|
||||
tgt_context: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Generate a translation prompt for the EuroLLM model.
|
||||
Args:
|
||||
src_lang (str): Source language name.
|
||||
tgt_lang (str): Target language name.
|
||||
src_prefix (str): Source text to translate.
|
||||
tgt_prefix (str): Optional target prefix or placeholder for completion.
|
||||
src_context (str): Optional source context to start translation from.
|
||||
tgt_context (str): Optional target context to start translation from.
|
||||
Returns:
|
||||
str: Formatted translation prompt.
|
||||
"""
|
||||
src_text = f"{src_context} {src_prefix}"
|
||||
tgt_text = f"{tgt_context} {tgt_prefix}"
|
||||
src_text = re.sub(r'\s+', ' ', src_text).strip()
|
||||
tgt_text = re.sub(r'\s+', ' ', tgt_text).strip()
|
||||
return cls.PROMPT_TEMPLATE.format(src_lang=src_lang, tgt_lang=tgt_lang, src_text=src_text, tgt_text=tgt_text)
|
||||
|
||||
@classmethod
|
||||
def extract(cls, response: str) -> str:
|
||||
"""
|
||||
Extract the first line of text from a model response.
|
||||
Args:
|
||||
response (str): The full response from the model.
|
||||
Returns:
|
||||
str: The text before the first newline.
|
||||
"""
|
||||
return response.split('\n')[0]
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,647 @@
|
||||
# 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Iterable
|
||||
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
from torch import Tensor
|
||||
|
||||
from nemo.collections.asr.inference.model_wrappers.asr_inference_wrapper import ASRInferenceWrapper
|
||||
from nemo.collections.asr.inference.pipelines.pipeline_interface import PipelineInterface
|
||||
from nemo.collections.asr.inference.streaming.buffering.audio_bufferer import BatchedAudioBufferer
|
||||
from nemo.collections.asr.inference.streaming.buffering.cache_feature_bufferer import BatchedCacheFeatureBufferer
|
||||
from nemo.collections.asr.inference.streaming.buffering.feature_bufferer import BatchedFeatureBufferer
|
||||
from nemo.collections.asr.inference.streaming.framing.multi_stream import ContinuousBatchedRequestStreamer
|
||||
from nemo.collections.asr.inference.streaming.framing.request import FeatureBuffer, Frame, Request
|
||||
from nemo.collections.asr.inference.streaming.framing.request_options import ASRRequestOptions
|
||||
from nemo.collections.asr.inference.streaming.state.state import StreamingState
|
||||
from nemo.collections.asr.inference.streaming.text.text_processing import StreamingTextProcessor
|
||||
from nemo.collections.asr.inference.utils.bpe_decoder import BPEDecoder
|
||||
from nemo.collections.asr.inference.utils.context_manager import CacheAwareContextManager
|
||||
from nemo.collections.asr.inference.utils.enums import RequestType
|
||||
from nemo.collections.asr.inference.utils.pipeline_utils import (
|
||||
check_existance_of_required_attributes,
|
||||
get_leading_punctuation_regex_pattern,
|
||||
ids_to_text_without_stripping,
|
||||
)
|
||||
from nemo.collections.asr.inference.utils.progressbar import ProgressBar
|
||||
from nemo.collections.asr.inference.utils.text_segment import TextSegment
|
||||
from nemo.collections.common.tokenizers.tokenizer_spec import TokenizerSpec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nemo.collections.asr.inference.itn.inverse_normalizer import AlignmentPreservingInverseNormalizer
|
||||
from nemo.collections.asr.inference.nmt.llm_translator import LLMTranslator
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscribeStepOutput:
|
||||
"""
|
||||
Stores the output of a single transcribe step.
|
||||
"""
|
||||
|
||||
stream_id: int
|
||||
# Final transcript is the transcript generated started from the previous EoU to the current EoU
|
||||
# It is finalized transcript, optionally punctuated and ITN-normalized. It's not subject to further modifications.
|
||||
# Final segments contains metadata for each word/segment in the final transcript.
|
||||
final_transcript: str = ""
|
||||
final_segments: list[TextSegment] | None = None
|
||||
final_translation: str = ""
|
||||
# Partial transcript is the transcript generated started from the previous EoU up to the current frame
|
||||
# It is not finalized transcript, it may be subject to further modifications.
|
||||
# It can also contain transcript from future frames.
|
||||
partial_transcript: str = ""
|
||||
partial_translation: str = ""
|
||||
# Current step transcript/translation is the transcript/translation generated from the current frame
|
||||
current_step_transcript: str = ""
|
||||
current_step_translation: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_state(cls, state: StreamingState, request: Request, sep: str = ' ') -> 'TranscribeStepOutput':
|
||||
"""
|
||||
Create a TranscribeStepOutput from a StreamingState
|
||||
Args:
|
||||
state (StreamingState): The state to create the output from.
|
||||
request (Request): The request to create the output from.
|
||||
sep (str): The separator for the text postprocessor.
|
||||
Returns:
|
||||
TranscribeStepOutput: The output for the step.
|
||||
"""
|
||||
final_transcript = state.final_transcript.strip()
|
||||
final_segments = [seg.copy() for seg in state.final_segments]
|
||||
if len(final_segments) > 0:
|
||||
final_segments[0].text = final_segments[0].text.lstrip(sep)
|
||||
final_segments[-1].text = final_segments[-1].text.rstrip(sep)
|
||||
|
||||
if final_transcript:
|
||||
separator = ''
|
||||
if not request.is_first and state.concat_with_space:
|
||||
separator = sep
|
||||
final_transcript = separator + final_transcript
|
||||
if len(final_segments) > 0:
|
||||
final_segments[0].text = separator + final_segments[0].text
|
||||
return cls(
|
||||
stream_id=request.stream_id,
|
||||
final_transcript=final_transcript,
|
||||
final_segments=final_segments,
|
||||
partial_transcript=state.partial_transcript,
|
||||
current_step_transcript=state.current_step_transcript,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Return a string representation of the TranscribeStepOutput
|
||||
"""
|
||||
info = {
|
||||
"final_transcript": self.final_transcript,
|
||||
"final_translation": self.final_translation,
|
||||
"partial_transcript": self.partial_transcript,
|
||||
"partial_translation": self.partial_translation,
|
||||
"current_step_transcript": self.current_step_transcript,
|
||||
}
|
||||
return json.dumps(info, indent=4, ensure_ascii=False)
|
||||
|
||||
|
||||
class BasePipeline(PipelineInterface):
|
||||
"""
|
||||
Base class for all pipelines.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize state pool to store the state for each stream"""
|
||||
self._state_pool: dict[int, StreamingState] = {}
|
||||
|
||||
def get_state(self, stream_id: int) -> StreamingState:
|
||||
"""Retrieve state for a given stream ID."""
|
||||
return self._state_pool.get(stream_id, None)
|
||||
|
||||
def get_states(self, stream_ids: Iterable[int]) -> list[StreamingState]:
|
||||
"""Retrieve states for a list of stream IDs."""
|
||||
return [self.get_state(stream_id) for stream_id in stream_ids]
|
||||
|
||||
def delete_state(self, stream_id: int) -> None:
|
||||
"""Delete the state from the state pool."""
|
||||
if stream_id in self._state_pool:
|
||||
del self._state_pool[stream_id]
|
||||
|
||||
def delete_states(self, stream_ids: Iterable[int]) -> None:
|
||||
"""Delete states for a list of stream IDs."""
|
||||
for stream_id in stream_ids:
|
||||
self.delete_state(stream_id)
|
||||
|
||||
def init_state(self, stream_id: int, options: ASRRequestOptions) -> StreamingState:
|
||||
"""Initialize the state of the stream"""
|
||||
if stream_id not in self._state_pool:
|
||||
state = self.create_state(options)
|
||||
self._state_pool[stream_id] = state
|
||||
return self._state_pool[stream_id]
|
||||
|
||||
def reset_session(self) -> None:
|
||||
"""Reset the frame buffer and internal state pool"""
|
||||
self._state_pool.clear()
|
||||
|
||||
def open_session(self) -> None:
|
||||
"""Start a new session by resetting the internal state pool"""
|
||||
self.reset_session()
|
||||
|
||||
def close_session(self) -> None:
|
||||
"""Close the session by resetting the internal state pool"""
|
||||
self.reset_session()
|
||||
|
||||
@abstractmethod
|
||||
def transcribe_step_for_frames(self, frames: list[Frame]) -> None:
|
||||
"""Transcribe a step for frames"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def transcribe_step_for_feature_buffers(self, fbuffers: list[FeatureBuffer]) -> None:
|
||||
"""Transcribe a step for feature buffers"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_request_generator(self) -> ContinuousBatchedRequestStreamer:
|
||||
"""Return the request generator."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_sep(self) -> str:
|
||||
"""Return the separator for the text postprocessor."""
|
||||
pass
|
||||
|
||||
def translate_step(self, states: list[StreamingState], step_outputs: list[TranscribeStepOutput]) -> None:
|
||||
"""
|
||||
Translate step
|
||||
Args:
|
||||
states (list[StreamingState]): List of StreamingState objects.
|
||||
step_outputs (list[TranscribeStepOutput]): List of TranscribeStepOutput objects.
|
||||
"""
|
||||
src_langs, tgt_langs = [], []
|
||||
asr_transcripts, current_prefixes, previous_translations = [], [], []
|
||||
final_transcript_mask = []
|
||||
states_to_translate = []
|
||||
|
||||
src_contexts, tgt_contexts = [], []
|
||||
for state, step_output in zip(states, step_outputs):
|
||||
if not state.options.enable_nmt:
|
||||
continue
|
||||
|
||||
src_lang = state.options.source_language
|
||||
tgt_lang = state.options.target_language
|
||||
if not src_lang or not tgt_lang:
|
||||
raise ValueError("Source and target languages must be set when NMT is enabled")
|
||||
|
||||
final = step_output.final_transcript
|
||||
partial = step_output.partial_transcript
|
||||
if not (final.strip() or partial.strip()):
|
||||
continue
|
||||
|
||||
transcript = final or partial
|
||||
is_final = bool(final)
|
||||
prev_translation, prefix = state.previous_translation_info
|
||||
|
||||
states_to_translate.append((state, step_output))
|
||||
src_langs.append(src_lang)
|
||||
tgt_langs.append(tgt_lang)
|
||||
asr_transcripts.append(transcript)
|
||||
current_prefixes.append(prefix)
|
||||
previous_translations.append(prev_translation)
|
||||
final_transcript_mask.append(is_final)
|
||||
|
||||
src_context, tgt_context = state.previous_context
|
||||
src_contexts.append(src_context)
|
||||
tgt_contexts.append(tgt_context)
|
||||
|
||||
if len(states_to_translate) == 0:
|
||||
return
|
||||
|
||||
translations = self.nmt_model.translate(
|
||||
asr_transcripts, current_prefixes, src_langs, tgt_langs, src_contexts, tgt_contexts
|
||||
)
|
||||
new_prefixes = self.nmt_model.get_prefixes(asr_transcripts, translations, previous_translations)
|
||||
|
||||
for (state, step_output), translation, new_prefix, prev_prefix, is_final in zip(
|
||||
states_to_translate, translations, new_prefixes, current_prefixes, final_transcript_mask
|
||||
):
|
||||
if is_final:
|
||||
step_output.final_translation = translation
|
||||
step_output.partial_translation = ""
|
||||
state.cleanup_translation_info_after_eou()
|
||||
state.set_translation_context(step_output.final_transcript, translation)
|
||||
new_prefix = translation
|
||||
else:
|
||||
step_output.partial_translation = translation
|
||||
step_output.final_translation = ""
|
||||
state.set_translation_info(translation, new_prefix)
|
||||
|
||||
lcp = os.path.commonprefix([prev_prefix, new_prefix])
|
||||
step_output.current_step_translation = new_prefix[len(lcp) :]
|
||||
|
||||
def transcribe_step(self, requests: list[Request]) -> list[TranscribeStepOutput]:
|
||||
"""
|
||||
Transcribe step
|
||||
Args:
|
||||
requests (list[Request]): List of Request objects.
|
||||
Returns:
|
||||
list[TranscribeStepOutput]: List of TranscribeStepOutput objects.
|
||||
"""
|
||||
|
||||
# Initialize the state if it is the first request for the stream
|
||||
states = []
|
||||
for request in requests:
|
||||
if request.is_first:
|
||||
self.init_state(request.stream_id, request.options)
|
||||
states.append(self.get_state(request.stream_id))
|
||||
|
||||
# Perform the transcribe step for the frames or feature buffers
|
||||
if isinstance(requests[0], Frame):
|
||||
self.transcribe_step_for_frames(frames=requests)
|
||||
elif isinstance(requests[0], FeatureBuffer):
|
||||
self.transcribe_step_for_feature_buffers(fbuffers=requests)
|
||||
else:
|
||||
raise ValueError(f"Invalid request type: {type(requests[0])}")
|
||||
|
||||
# Create current step output for each request
|
||||
outputs = []
|
||||
sep = self.get_sep()
|
||||
for request, state in zip(requests, states):
|
||||
step_output = TranscribeStepOutput.from_state(state=state, request=request, sep=sep)
|
||||
outputs.append(step_output)
|
||||
|
||||
# Perform the translation step
|
||||
if self.nmt_enabled:
|
||||
self.translate_step(states=states, step_outputs=outputs)
|
||||
|
||||
# Cleanup the states after the response is sent
|
||||
# If last request, delete state from the state pool to free memory
|
||||
for state, request in zip(states, requests):
|
||||
state.cleanup_after_response()
|
||||
if request.is_last:
|
||||
self.delete_state(request.stream_id)
|
||||
return outputs
|
||||
|
||||
def copy_asr_model_attributes(self, asr_model: ASRInferenceWrapper) -> None:
|
||||
"""
|
||||
Copy the attributes from the ASR model
|
||||
Args:
|
||||
asr_model (ASRInferenceWrapper): ASR model to copy the attributes from.
|
||||
"""
|
||||
self.asr_model = asr_model
|
||||
self.tokenizer = asr_model.tokenizer
|
||||
self.device = asr_model.device
|
||||
self.supports_punctuation = asr_model.supports_punctuation()
|
||||
self.asr_supported_puncts = asr_model.supported_punctuation()
|
||||
self.leading_regex_pattern = get_leading_punctuation_regex_pattern(self.asr_supported_puncts)
|
||||
self.blank_id = asr_model.get_blank_id()
|
||||
self.vocabulary = asr_model.get_vocabulary()
|
||||
self.sep = asr_model.word_separator
|
||||
self.underscore_id = asr_model.underscore_id
|
||||
self.punctuation_ids = asr_model.punctuation_ids
|
||||
self.language_token_ids = asr_model.language_token_ids
|
||||
self.preprocessor, self.preprocessor_config = asr_model.create_preprocessor()
|
||||
self.subsampling_factor = asr_model.get_subsampling_factor()
|
||||
self.window_stride = asr_model.get_window_stride()
|
||||
self.model_stride_in_secs = asr_model.get_model_stride(in_secs=True)
|
||||
self.model_stride_in_milliseconds = asr_model.get_model_stride(in_milliseconds=True)
|
||||
|
||||
def update_partial_transcript(
|
||||
self, requests: list[Request], tokenizer: TokenizerSpec, leading_regex_pattern: str
|
||||
) -> None:
|
||||
"""
|
||||
Update partial and current step transcripts from the state.
|
||||
Args:
|
||||
requests (list[Request]): List of Request objects.
|
||||
tokenizer (TokenizerSpec): Used to convert tokens into text
|
||||
leading_regex_pattern (str): Regex pattern for the punctuation marks.
|
||||
"""
|
||||
word_separator = self.get_sep()
|
||||
for request in requests:
|
||||
state = self.get_state(request.stream_id)
|
||||
# state tokens represent all tokens accumulated since the EOU
|
||||
# incomplete segment tokens are the remaining tokens on the right side of the buffer after EOU
|
||||
all_tokens = state.tokens + state.incomplete_segment_tokens
|
||||
if len(all_tokens) > 0:
|
||||
pt_string = ids_to_text_without_stripping(all_tokens, tokenizer, word_separator)
|
||||
if leading_regex_pattern:
|
||||
pt_string = re.sub(leading_regex_pattern, r'\1', pt_string)
|
||||
state.partial_transcript = pt_string
|
||||
else:
|
||||
state.partial_transcript = ""
|
||||
|
||||
current_step_tokens = state.current_step_tokens
|
||||
if len(current_step_tokens) > 0:
|
||||
step_transcript = ids_to_text_without_stripping(current_step_tokens, tokenizer, word_separator)
|
||||
state.current_step_transcript = step_transcript
|
||||
else:
|
||||
state.current_step_transcript = ""
|
||||
|
||||
def init_bpe_decoder(self) -> None:
|
||||
"""Initialize the BPE decoder"""
|
||||
check_existance_of_required_attributes(
|
||||
self,
|
||||
[
|
||||
'vocabulary',
|
||||
'tokenizer',
|
||||
'confidence_aggregator',
|
||||
'asr_supported_puncts',
|
||||
'word_boundary_tolerance',
|
||||
'model_stride_in_secs',
|
||||
],
|
||||
)
|
||||
|
||||
self.bpe_decoder = BPEDecoder(
|
||||
vocabulary=self.vocabulary,
|
||||
tokenizer=self.tokenizer,
|
||||
confidence_aggregator=self.confidence_aggregator,
|
||||
asr_supported_puncts=self.asr_supported_puncts,
|
||||
word_boundary_tolerance=self.word_boundary_tolerance,
|
||||
token_duration_in_secs=self.model_stride_in_secs,
|
||||
)
|
||||
|
||||
def init_text_processor(
|
||||
self,
|
||||
cfg: DictConfig,
|
||||
itn_model: AlignmentPreservingInverseNormalizer | None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the text processor.
|
||||
Args:
|
||||
cfg: (DictConfig) Configuration parameters.
|
||||
itn_model: (AlignmentPreservingInverseNormalizer | None) Inverse Text Normalization model.
|
||||
"""
|
||||
check_existance_of_required_attributes(
|
||||
self,
|
||||
[
|
||||
'asr_supported_puncts',
|
||||
'supports_punctuation',
|
||||
'confidence_aggregator',
|
||||
'sep',
|
||||
],
|
||||
)
|
||||
|
||||
self.text_processor = StreamingTextProcessor(
|
||||
itn_cfg=cfg.itn,
|
||||
itn_model=itn_model,
|
||||
asr_supported_puncts=self.asr_supported_puncts,
|
||||
asr_supports_punctuation=self.supports_punctuation,
|
||||
confidence_aggregator=self.confidence_aggregator,
|
||||
sep=self.sep,
|
||||
enable_itn=cfg.enable_itn,
|
||||
)
|
||||
|
||||
def init_nmt_model(self, nmt_model: LLMTranslator | None) -> None:
|
||||
"""
|
||||
Initialize the Translation model.
|
||||
Args:
|
||||
nmt_model: (LLMTranslator | None) LLM based translation model.
|
||||
"""
|
||||
self.nmt_model = nmt_model
|
||||
self.nmt_enabled = nmt_model is not None
|
||||
|
||||
def init_bufferer_for_buffered_streaming(self) -> None:
|
||||
"""Initialize the bufferer."""
|
||||
check_existance_of_required_attributes(
|
||||
self,
|
||||
[
|
||||
'request_type',
|
||||
'sample_rate',
|
||||
'buffer_size_in_secs',
|
||||
'preprocessor_config',
|
||||
'device',
|
||||
],
|
||||
)
|
||||
|
||||
if self.request_type is RequestType.FEATURE_BUFFER:
|
||||
# Feature buffering: It will be used when the input is feature buffers
|
||||
self.bufferer = BatchedFeatureBufferer(
|
||||
sample_rate=self.sample_rate,
|
||||
buffer_size_in_secs=self.buffer_size_in_secs,
|
||||
preprocessor_cfg=self.preprocessor_config,
|
||||
device=self.device,
|
||||
)
|
||||
elif self.request_type is RequestType.FRAME:
|
||||
# Audio buffering: It will be used when the input is audio frames
|
||||
self.bufferer = BatchedAudioBufferer(
|
||||
sample_rate=self.sample_rate, buffer_size_in_secs=self.buffer_size_in_secs
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown request type: {self.request_type}")
|
||||
|
||||
def init_bufferer_for_cache_aware_streaming(self) -> None:
|
||||
"""Initialize the bufferer for cache-aware streaming."""
|
||||
check_existance_of_required_attributes(
|
||||
self,
|
||||
[
|
||||
'num_slots',
|
||||
'use_feat_cache',
|
||||
'chunk_size_in_secs',
|
||||
'buffer_size_in_secs',
|
||||
'sample_rate',
|
||||
'preprocessor_config',
|
||||
'device',
|
||||
],
|
||||
)
|
||||
|
||||
if self.use_feat_cache:
|
||||
# Only calculate mel-spec features for last chunk
|
||||
chunk_size_for_feature_buffer = self.chunk_size_in_secs
|
||||
else:
|
||||
# Calculate mel-spec features for the whole buffer
|
||||
chunk_size_for_feature_buffer = self.buffer_size_in_secs
|
||||
|
||||
self.bufferer = BatchedCacheFeatureBufferer(
|
||||
num_slots=self.num_slots,
|
||||
sample_rate=self.sample_rate,
|
||||
buffer_size_in_secs=self.buffer_size_in_secs,
|
||||
chunk_size_in_secs=chunk_size_for_feature_buffer,
|
||||
preprocessor_cfg=self.preprocessor_config,
|
||||
device=self.device,
|
||||
)
|
||||
|
||||
def init_context_manager(self) -> None:
|
||||
"""Initialize the context manager."""
|
||||
check_existance_of_required_attributes(self, ['asr_model', 'num_slots', 'use_cache'])
|
||||
self.context_manager = CacheAwareContextManager(
|
||||
cache_aware_model=self.asr_model, num_slots=self.num_slots, use_cache=self.use_cache
|
||||
)
|
||||
|
||||
def init_prompt_support(self) -> None:
|
||||
"""Initialize prompt support for multilingual models."""
|
||||
self.prompt_enabled = hasattr(self.asr_model.asr_model, 'concat') and self.asr_model.asr_model.concat
|
||||
|
||||
if self.prompt_enabled:
|
||||
self._prompt_config = self._load_prompt_config()
|
||||
|
||||
def _load_prompt_config(self) -> dict:
|
||||
"""
|
||||
Load prompt configuration from model.
|
||||
Returns:
|
||||
(dict) Prompt configuration containing num_prompts, prompt_dict, and compute_dtype.
|
||||
"""
|
||||
cfg = self.asr_model.asr_model.cfg
|
||||
if cfg and hasattr(cfg, 'model_defaults'):
|
||||
model_defaults = cfg.model_defaults
|
||||
num_prompts = model_defaults.get('num_prompts', None)
|
||||
prompt_dict = model_defaults.get('prompt_dictionary', None)
|
||||
|
||||
# Validate and convert types once
|
||||
num_prompts_int = int(num_prompts) if num_prompts is not None else 0
|
||||
|
||||
is_dict_like = isinstance(prompt_dict, dict) or (
|
||||
hasattr(prompt_dict, 'get') and hasattr(prompt_dict, '__contains__')
|
||||
)
|
||||
|
||||
if num_prompts_int > 0 and is_dict_like:
|
||||
return {
|
||||
'num_prompts': num_prompts_int,
|
||||
'prompt_dict': prompt_dict,
|
||||
'compute_dtype': getattr(self.asr_model.asr_model, 'dtype', torch.float32),
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
def _resolve_prompt_index(self, language_code: str) -> int:
|
||||
"""
|
||||
Resolve language_code to a strict prompt index; raise if invalid.
|
||||
Args:
|
||||
language_code: (str) Language code to resolve (e.g., "en-US", "es-ES").
|
||||
Returns:
|
||||
(int) Prompt index corresponding to the language code.
|
||||
Raises:
|
||||
RuntimeError: If prompt configuration is missing.
|
||||
ValueError: If language_code is not found in prompt dictionary.
|
||||
"""
|
||||
if not hasattr(self, '_prompt_config') or not self._prompt_config:
|
||||
raise RuntimeError("Prompt configuration is missing for a prompt-enabled model.")
|
||||
prompt_dict = self._prompt_config['prompt_dict']
|
||||
lang_index = prompt_dict.get(language_code, None)
|
||||
if lang_index is None:
|
||||
raise ValueError(
|
||||
f"Language code '{language_code}' not found in prompt dictionary. "
|
||||
f"Available languages: {list(prompt_dict.keys())}"
|
||||
)
|
||||
return lang_index
|
||||
|
||||
def _create_one_hot_prompts(self, indices: Tensor) -> Tensor:
|
||||
"""
|
||||
Create one-hot prompt vectors from indices.
|
||||
Args:
|
||||
indices: (Tensor) Prompt indices of shape [B].
|
||||
Returns:
|
||||
(Tensor) One-hot prompt vectors of shape [B, num_prompts].
|
||||
"""
|
||||
num_prompts = self._prompt_config['num_prompts']
|
||||
return torch.nn.functional.one_hot(indices, num_classes=num_prompts).to(self._prompt_config['compute_dtype'])
|
||||
|
||||
def _build_prompt_vectors(self, states: list) -> Tensor:
|
||||
"""
|
||||
Build prompt vectors for a batch of states using one-hot encoding.
|
||||
Args:
|
||||
states: (list) List of streaming states.
|
||||
Returns:
|
||||
(Tensor) Prompt vectors of shape [B, num_prompts].
|
||||
Raises:
|
||||
ValueError: If any prompt index is out of range.
|
||||
"""
|
||||
indices = torch.tensor([getattr(s, 'prompt_idx', 0) for s in states], device=self.device, dtype=torch.long)
|
||||
num_prompts = self._prompt_config['num_prompts']
|
||||
if torch.any((indices < 0) | (indices >= num_prompts)):
|
||||
raise ValueError("Found out-of-range prompt index in batch.")
|
||||
return self._create_one_hot_prompts(indices)
|
||||
|
||||
def run(
|
||||
self,
|
||||
audio_filepaths: list[str],
|
||||
options: list[ASRRequestOptions] | None = None,
|
||||
progress_bar: ProgressBar | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Orchestrates reading from audio_filepaths in a streaming manner,
|
||||
transcribes them, and packs the results into a PipelineOutput.
|
||||
Args:
|
||||
audio_filepaths (list[str]): List of audio filepaths to transcribe.
|
||||
options (list[ASRRequestOptions] | None): List of RequestOptions for each stream.
|
||||
progress_bar (ProgressBar | None): Progress bar to show the progress. Default is None.
|
||||
Returns:
|
||||
dict: A dictionary containing transcriptions and segments for each stream.
|
||||
"""
|
||||
if progress_bar is not None and not isinstance(progress_bar, ProgressBar):
|
||||
raise ValueError("progress_bar must be an instance of ProgressBar.")
|
||||
|
||||
if options is None:
|
||||
# Use default options if not provided
|
||||
options = [ASRRequestOptions() for _ in audio_filepaths]
|
||||
|
||||
if len(options) != len(audio_filepaths):
|
||||
raise ValueError("options must be the same length as audio_filepaths")
|
||||
|
||||
request_generator = self.get_request_generator()
|
||||
request_generator.set_audio_filepaths(audio_filepaths, options)
|
||||
request_generator.set_progress_bar(progress_bar)
|
||||
|
||||
pipeline_output = {}
|
||||
sep = self.get_sep()
|
||||
self.open_session()
|
||||
for requests in request_generator:
|
||||
step_outputs = self.transcribe_step(requests)
|
||||
for step_output in step_outputs:
|
||||
stream_id = step_output.stream_id
|
||||
if stream_id not in pipeline_output:
|
||||
pipeline_output[stream_id] = {
|
||||
"text": "",
|
||||
"translation": "",
|
||||
"segments": [],
|
||||
"audio_filepath": request_generator.get_audio_filepath(stream_id),
|
||||
"translation_segments": [],
|
||||
"asr_segments": [],
|
||||
}
|
||||
|
||||
accumulated_text = pipeline_output[stream_id]["text"]
|
||||
accumulated_translation = pipeline_output[stream_id]["translation"]
|
||||
final_transcript = step_output.final_transcript
|
||||
final_translation = step_output.final_translation
|
||||
final_segments = step_output.final_segments
|
||||
if not accumulated_text:
|
||||
final_transcript = final_transcript.lstrip(sep)
|
||||
if len(final_segments) > 0:
|
||||
first_segment = final_segments[0]
|
||||
first_segment.text = first_segment.text.lstrip(sep)
|
||||
|
||||
if not accumulated_translation:
|
||||
final_translation = final_translation.lstrip(sep)
|
||||
|
||||
accumulated_text += final_transcript
|
||||
accumulated_translation += final_translation
|
||||
pipeline_output[stream_id]["text"] = accumulated_text
|
||||
pipeline_output[stream_id]["translation"] = accumulated_translation
|
||||
pipeline_output[stream_id]["segments"].extend(final_segments)
|
||||
|
||||
# Record the text finalized in this step with the audio elapsed at finalization.
|
||||
if final_transcript:
|
||||
delay = request_generator.get_elapsed_duration(stream_id)
|
||||
pipeline_output[stream_id]["asr_segments"].append((final_transcript, delay))
|
||||
|
||||
if self.nmt_enabled:
|
||||
step_translation = step_output.current_step_translation
|
||||
delay = request_generator.get_elapsed_duration(stream_id)
|
||||
pipeline_output[stream_id]["translation_segments"].append((step_translation, delay))
|
||||
|
||||
self.close_session()
|
||||
return pipeline_output
|
||||
@@ -0,0 +1,443 @@
|
||||
# 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
from torch import Tensor
|
||||
|
||||
from nemo.collections.asr.inference.model_wrappers.ctc_inference_wrapper import CTCInferenceWrapper
|
||||
from nemo.collections.asr.inference.pipelines.base_pipeline import BasePipeline
|
||||
from nemo.collections.asr.inference.streaming.decoders.greedy.greedy_ctc_decoder import ClippedCTCGreedyDecoder
|
||||
from nemo.collections.asr.inference.streaming.endpointing.greedy.greedy_ctc_endpointing import CTCGreedyEndpointing
|
||||
from nemo.collections.asr.inference.streaming.framing.multi_stream import ContinuousBatchedRequestStreamer
|
||||
from nemo.collections.asr.inference.streaming.framing.request import FeatureBuffer, Frame, Request
|
||||
from nemo.collections.asr.inference.streaming.framing.request_options import ASRRequestOptions
|
||||
from nemo.collections.asr.inference.streaming.state.ctc_state import CTCStreamingState
|
||||
from nemo.collections.asr.inference.utils.enums import FeatureBufferPaddingMode, RequestType
|
||||
from nemo.collections.asr.inference.utils.pipeline_utils import (
|
||||
check_existance_of_required_attributes,
|
||||
drop_trailing_features,
|
||||
get_confidence_utils,
|
||||
normalize_features,
|
||||
normalize_log_probs,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nemo.collections.asr.inference.itn.inverse_normalizer import AlignmentPreservingInverseNormalizer
|
||||
from nemo.collections.asr.inference.nmt.llm_translator import LLMTranslator
|
||||
|
||||
|
||||
class BufferedCTCPipeline(BasePipeline):
|
||||
"""Buffered CTC pipeline."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cfg: DictConfig,
|
||||
asr_model: CTCInferenceWrapper,
|
||||
itn_model: AlignmentPreservingInverseNormalizer | None = None,
|
||||
nmt_model: LLMTranslator | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the BufferedCTCPipeline.
|
||||
Args:
|
||||
cfg: (DictConfig) Configuration parameters.
|
||||
asr_model: (CTCInferenceWrapper) ASR model.
|
||||
itn_model: (AlignmentPreservingInverseNormalizer | None) Inverse Text Normalization model.
|
||||
nmt_model: (LLMTranslator | None) LLM based translation model.
|
||||
"""
|
||||
self.copy_asr_model_attributes(asr_model)
|
||||
self.init_parameters(cfg)
|
||||
self.init_bufferer_for_buffered_streaming()
|
||||
self.conf_func, self.confidence_aggregator = get_confidence_utils(cfg.confidence)
|
||||
self.init_endpointer()
|
||||
self.init_bpe_decoder()
|
||||
self.init_greedy_ctc_decoder()
|
||||
self.init_text_processor(cfg, itn_model)
|
||||
self.init_nmt_model(nmt_model)
|
||||
super().__init__()
|
||||
|
||||
def init_parameters(self, cfg: DictConfig) -> None:
|
||||
"""
|
||||
Initialize the configuration parameters.
|
||||
Args:
|
||||
cfg: (DictConfig) Configuration parameters.
|
||||
"""
|
||||
self.sample_rate = cfg.streaming.sample_rate
|
||||
self.asr_output_granularity = cfg.asr_output_granularity
|
||||
self.batch_size = cfg.streaming.batch_size
|
||||
|
||||
self.chunk_size = cfg.streaming.chunk_size
|
||||
self.left_padding_size = cfg.streaming.left_padding_size
|
||||
self.right_padding_size = cfg.streaming.right_padding_size
|
||||
self.buffer_size_in_secs = self.chunk_size + self.left_padding_size + self.right_padding_size
|
||||
self.expected_feature_buffer_len = int(self.buffer_size_in_secs / self.window_stride)
|
||||
self.tokens_per_frame_float = self.chunk_size / self.model_stride_in_secs
|
||||
self.tokens_per_frame = math.ceil(self.tokens_per_frame_float)
|
||||
self.initial_delay = (self.left_padding_size + self.right_padding_size) / self.model_stride_in_secs
|
||||
self.mid_delay = math.ceil((self.chunk_size + self.right_padding_size) / self.model_stride_in_secs)
|
||||
|
||||
self.stop_history_eou_in_milliseconds = cfg.endpointing.stop_history_eou
|
||||
self.residue_tokens_at_end = cfg.endpointing.residue_tokens_at_end
|
||||
self.request_type = RequestType.from_str(cfg.streaming.request_type)
|
||||
self.word_boundary_tolerance = cfg.streaming.word_boundary_tolerance
|
||||
self.padding_mode = FeatureBufferPaddingMode.from_str(cfg.streaming.padding_mode)
|
||||
self.right_padding = self.padding_mode is FeatureBufferPaddingMode.RIGHT
|
||||
self.return_tail_result = cfg.return_tail_result
|
||||
|
||||
self.zero_log_probs = self.init_zero_log_probs() if self.right_padding else None
|
||||
|
||||
def init_endpointer(self) -> None:
|
||||
"""Initialize the endpointing."""
|
||||
check_existance_of_required_attributes(
|
||||
self,
|
||||
[
|
||||
'vocabulary',
|
||||
'model_stride_in_milliseconds',
|
||||
'stop_history_eou_in_milliseconds',
|
||||
'residue_tokens_at_end',
|
||||
],
|
||||
)
|
||||
|
||||
self.endpointer = CTCGreedyEndpointing(
|
||||
vocabulary=self.vocabulary,
|
||||
ms_per_timestep=self.model_stride_in_milliseconds,
|
||||
stop_history_eou=self.stop_history_eou_in_milliseconds,
|
||||
residue_tokens_at_end=self.residue_tokens_at_end,
|
||||
)
|
||||
|
||||
def init_greedy_ctc_decoder(self) -> None:
|
||||
"""Initialize the CTC decoder."""
|
||||
check_existance_of_required_attributes(self, ['vocabulary', 'conf_func', 'endpointer', 'tokens_per_frame'])
|
||||
self.greedy_ctc_decoder = ClippedCTCGreedyDecoder(
|
||||
vocabulary=self.vocabulary,
|
||||
conf_func=self.conf_func,
|
||||
endpointer=self.endpointer,
|
||||
tokens_per_frame=self.tokens_per_frame,
|
||||
)
|
||||
|
||||
def init_zero_log_probs(self) -> Tensor:
|
||||
"""
|
||||
Initialize the log probabilities for the zero buffer.
|
||||
Returns:
|
||||
(Tensor) Log probabilities for the zero buffer.
|
||||
"""
|
||||
check_existance_of_required_attributes(
|
||||
self, ['asr_model', 'buffer_size_in_secs', 'sample_rate', 'device', 'expected_feature_buffer_len']
|
||||
)
|
||||
buffer_size_in_samples = int(self.buffer_size_in_secs * self.sample_rate)
|
||||
zero_buffer = torch.zeros(1, buffer_size_in_samples, device=self.device)
|
||||
zero_features, zero_features_len = self.preprocess(
|
||||
buffers=zero_buffer,
|
||||
buffer_lens=torch.tensor([zero_buffer.shape[1]], device=self.device),
|
||||
expected_feature_buffer_len=self.expected_feature_buffer_len,
|
||||
)
|
||||
return self.asr_model.get_logprobs(processed_signal=zero_features, processed_signal_length=zero_features_len)[
|
||||
0
|
||||
]
|
||||
|
||||
def create_state(self, options: ASRRequestOptions) -> CTCStreamingState:
|
||||
"""
|
||||
Create new empty state.
|
||||
Args:
|
||||
options: (ASRRequestOptions) Request options for particular stream.
|
||||
Returns:
|
||||
(CTCStreamingState) New empty state.
|
||||
"""
|
||||
state = CTCStreamingState()
|
||||
state.set_global_offset(-self.initial_delay)
|
||||
new_options = options.fill_defaults(
|
||||
default_enable_itn=self.text_processor.itn_enabled,
|
||||
default_enable_nmt=self.nmt_enabled,
|
||||
default_source_language=self.nmt_model.source_language if self.nmt_enabled else None,
|
||||
default_target_language=self.nmt_model.target_language if self.nmt_enabled else None,
|
||||
default_stop_history_eou=self.stop_history_eou_in_milliseconds,
|
||||
default_asr_output_granularity=self.asr_output_granularity,
|
||||
)
|
||||
state.set_options(new_options)
|
||||
return state
|
||||
|
||||
def get_sep(self) -> str:
|
||||
"""Return the separator for the text processor."""
|
||||
return self.sep
|
||||
|
||||
def get_cut_off_range(self, T: int, is_last: bool) -> tuple[int, int]:
|
||||
"""
|
||||
Compute the start and end indices to clip the log probs.
|
||||
Args:
|
||||
T: (int) Time dimension of the log probabilities.
|
||||
is_last: (bool) Whether the last frame is reached.
|
||||
Returns:
|
||||
(tuple[int, int]) Start and end indices to clip the log probs.
|
||||
"""
|
||||
start = max(T - 1 - self.mid_delay, 0)
|
||||
end = T if is_last else min(start + self.tokens_per_frame, T)
|
||||
return start, end
|
||||
|
||||
def preprocess(
|
||||
self, buffers: Tensor, buffer_lens: Tensor, expected_feature_buffer_len: int
|
||||
) -> tuple[Tensor, Tensor]:
|
||||
"""
|
||||
Preprocess the buffered frames and extract features.
|
||||
Args:
|
||||
buffers: (Tensor) Audio buffers.
|
||||
buffer_lens: (Tensor) Lengths of the audio buffers.
|
||||
expected_feature_buffer_len: (int) Expected length of the feature buffers.
|
||||
Returns:
|
||||
(tuple[Tensor, Tensor]) Processed feature buffers and their lengths.
|
||||
"""
|
||||
feature_buffers, feature_buffer_lens = self.preprocessor(input_signal=buffers, length=buffer_lens)
|
||||
feature_buffers = drop_trailing_features(feature_buffers, expected_feature_buffer_len)
|
||||
feature_buffers = normalize_features(feature_buffers, feature_buffer_lens)
|
||||
feature_buffer_lens = feature_buffer_lens.clamp(max=feature_buffers.shape[2])
|
||||
return feature_buffers, feature_buffer_lens
|
||||
|
||||
def get_logprobs_given_raw_signals(
|
||||
self, frames: list[Frame], raw_signals: list[Tensor], left_paddings: list[int]
|
||||
) -> Tensor:
|
||||
"""
|
||||
Get log probs from the CTC model.
|
||||
Args:
|
||||
frames: (list[Frame]) Frames to transcribe.
|
||||
raw_signals: (list[Tensor]) Audio buffers.
|
||||
left_paddings: (list[int]) Left paddings for audio buffers.
|
||||
Returns:
|
||||
(Tensor) Log probabilities.
|
||||
"""
|
||||
|
||||
if self.right_padding:
|
||||
left_paddings = torch.tensor(left_paddings, dtype=torch.int64, device=self.device)
|
||||
|
||||
buffers = []
|
||||
for i in range(len(raw_signals)):
|
||||
buffer = raw_signals[i]
|
||||
# Roll the buffered frames to the left by the left padding
|
||||
# This is done to avoid the padding at the beginning of the buffered frames
|
||||
# which can cause the performance degradation
|
||||
if self.right_padding:
|
||||
lpad = left_paddings[i].item()
|
||||
if lpad > 0:
|
||||
buffer = buffer.roll(shifts=-lpad)
|
||||
buffers.append(buffer.unsqueeze_(0))
|
||||
|
||||
# Only final frames have right padding
|
||||
# Calculate right paddings
|
||||
right_paddings = torch.tensor([frame.size - frame.valid_size for frame in frames], device=self.device).clamp(
|
||||
min=0
|
||||
)
|
||||
|
||||
# Create and adjust the buffer lens
|
||||
buffer_lens = torch.tensor([buffers[0].size(1)] * len(buffers), device=self.device)
|
||||
buffer_lens = buffer_lens - right_paddings
|
||||
if self.right_padding:
|
||||
buffer_lens = buffer_lens - left_paddings
|
||||
|
||||
# Preprocess the buffers with corresponding buffer lens
|
||||
feature_buffers, feature_buffer_lens = self.preprocess(
|
||||
buffers=torch.cat(buffers).to(self.device),
|
||||
buffer_lens=buffer_lens,
|
||||
expected_feature_buffer_len=self.expected_feature_buffer_len,
|
||||
)
|
||||
|
||||
# Get the log probabilities from the ASR model
|
||||
log_probs = self.asr_model.get_logprobs(
|
||||
processed_signal=feature_buffers, processed_signal_length=feature_buffer_lens
|
||||
).clone()
|
||||
|
||||
# Roll back the log probabilities to the right
|
||||
if self.right_padding:
|
||||
for i in range(len(log_probs)):
|
||||
lpad = left_paddings[i]
|
||||
if lpad > 0:
|
||||
lpad = int(lpad / self.sample_rate / self.model_stride_in_secs)
|
||||
log_probs[i] = log_probs[i].roll(lpad, dims=0)
|
||||
log_probs[i][:lpad, :] = self.zero_log_probs[:lpad, :]
|
||||
return log_probs
|
||||
|
||||
def get_logprobs_given_processed_signals(
|
||||
self, fbuffers: list[FeatureBuffer], processed_signals: list[Tensor]
|
||||
) -> Tensor:
|
||||
"""
|
||||
Get log probs from the ASR model.
|
||||
Args:
|
||||
fbuffers: (list[FeatureBuffer]) Feature buffers.
|
||||
processed_signals: (list[Tensor]) Processed buffers.
|
||||
Returns:
|
||||
(Tensor) Log probabilities.
|
||||
"""
|
||||
processed_signals = torch.cat([sig.unsqueeze_(0) for sig in processed_signals]).to(self.device)
|
||||
processed_signals = drop_trailing_features(processed_signals, self.expected_feature_buffer_len)
|
||||
processed_signal_lengths = torch.tensor([f.valid_size for f in fbuffers], device=self.device)
|
||||
processed_signals = normalize_features(processed_signals, processed_signal_lengths)
|
||||
processed_signal_lengths = processed_signal_lengths.clamp(max=processed_signals.shape[2])
|
||||
|
||||
log_probs = self.asr_model.get_logprobs(
|
||||
processed_signal=processed_signals, processed_signal_length=processed_signal_lengths
|
||||
).clone()
|
||||
|
||||
if self.right_padding:
|
||||
for i in range(len(log_probs)):
|
||||
lpad = int(fbuffers[i].roll_size / self.subsampling_factor)
|
||||
if lpad > 0:
|
||||
log_probs[i] = log_probs[i].roll(lpad, dims=0)
|
||||
log_probs[i][:lpad, :] = self.zero_log_probs[:lpad, :]
|
||||
return log_probs
|
||||
|
||||
def compute_logprobs_from_frames(self, frames: list[Frame]) -> Tensor:
|
||||
"""
|
||||
Buffer the frames and get the log probabilities.
|
||||
Args:
|
||||
frames: (list[Frame]) List of frames to transcribe.
|
||||
Returns:
|
||||
(Tensor) Log probabilities.
|
||||
"""
|
||||
raw_signals, left_paddings = self.bufferer.update(frames)
|
||||
log_probs = None
|
||||
if len(raw_signals) > 0:
|
||||
log_probs = self.get_logprobs_given_raw_signals(frames, raw_signals, left_paddings)
|
||||
return log_probs
|
||||
|
||||
def compute_logprobs_from_feature_buffers(self, fbuffers: list[FeatureBuffer]) -> Tensor:
|
||||
"""
|
||||
Buffer the feature buffers and get the log probabilities.
|
||||
Args:
|
||||
fbuffers: (list[FeatureBuffer]) List of feature buffers to transcribe.
|
||||
Returns:
|
||||
(Tensor) Log probabilities.
|
||||
"""
|
||||
processed_signals = self.bufferer.update(fbuffers)
|
||||
log_probs = None
|
||||
if len(processed_signals) > 0:
|
||||
log_probs = self.get_logprobs_given_processed_signals(fbuffers, processed_signals)
|
||||
return log_probs
|
||||
|
||||
def run_greedy_decoder(
|
||||
self, state: CTCStreamingState, request: Request, buffer_log_probs: Tensor, start: int, end: int
|
||||
) -> bool:
|
||||
"""
|
||||
Run Greedy decoder, update state and trigger EOU detection.
|
||||
Args:
|
||||
state: (CTCStreamingState) Current state for the particular stream.
|
||||
request: (Request) Current request for the particular stream.
|
||||
buffer_log_probs: (Tensor) Log probabilities.
|
||||
start: (int) Start index of the log probabilities.
|
||||
end: (int) End index of the log probabilities.
|
||||
Returns:
|
||||
(bool) Whether EOU is detected.
|
||||
"""
|
||||
clipped_output, tail_output, eou_detected, start_idx, end_idx = self.greedy_ctc_decoder(
|
||||
buffer_log_probs,
|
||||
start,
|
||||
end,
|
||||
request.is_last,
|
||||
is_start=request.is_first,
|
||||
return_partial_result=self.return_tail_result,
|
||||
state_start_idx=state.decoder_start_idx,
|
||||
state_end_idx=state.decoder_end_idx,
|
||||
stop_history_eou=state.options.stop_history_eou,
|
||||
compute_confidence=True,
|
||||
)
|
||||
|
||||
state.update_state(clipped_output, eou_detected)
|
||||
state.set_last_token(clipped_output["last_token"], clipped_output["last_token_idx"])
|
||||
state.update_from_decoder_results(start_idx, end_idx)
|
||||
state.increment_global_offset(self.tokens_per_frame_float)
|
||||
state.set_incomplete_segment_tokens(tail_output["tokens"])
|
||||
return eou_detected
|
||||
|
||||
def shared_transcribe_step(self, requests: list[Request], log_probs: Tensor) -> None:
|
||||
"""
|
||||
Shared transcribe step for frames and feature buffers.
|
||||
Args:
|
||||
requests: (list[Request]) List of frames or feature buffers to transcribe.
|
||||
log_probs: (Tensor) Log probabilities.
|
||||
"""
|
||||
postponed_requests = [(ridx, request.stream_id) for ridx, request in enumerate(requests)]
|
||||
next_postponed_requests = []
|
||||
|
||||
while len(postponed_requests) > 0:
|
||||
|
||||
ready_state_ids = set()
|
||||
for ridx, stream_id in postponed_requests:
|
||||
|
||||
if stream_id in ready_state_ids:
|
||||
# Skip if the state is already ready
|
||||
next_postponed_requests.append((ridx, stream_id))
|
||||
continue
|
||||
|
||||
request = requests[ridx]
|
||||
state = self.get_state(stream_id)
|
||||
lp = log_probs[ridx].cpu()
|
||||
start, end = self.get_cut_off_range(lp.shape[0], request.is_last)
|
||||
eou_detected = self.run_greedy_decoder(state, request, lp, start, end)
|
||||
|
||||
if eou_detected:
|
||||
self.bpe_decoder.decode_bpe_tokens(state)
|
||||
state.cleanup_after_eou()
|
||||
ready_state_ids.add(stream_id)
|
||||
|
||||
if len(ready_state_ids) > 0:
|
||||
self.text_processor.process([self.get_state(stream_id) for stream_id in ready_state_ids])
|
||||
ready_state_ids.clear()
|
||||
|
||||
postponed_requests = next_postponed_requests.copy()
|
||||
next_postponed_requests.clear()
|
||||
|
||||
self.update_partial_transcript(requests, self.tokenizer, self.leading_regex_pattern)
|
||||
|
||||
def transcribe_step_for_feature_buffers(self, fbuffers: list[FeatureBuffer]) -> None:
|
||||
"""
|
||||
Transcribe a step for feature buffers.
|
||||
Args:
|
||||
fbuffers: (list[FeatureBuffer]) List of feature buffers to transcribe.
|
||||
"""
|
||||
log_probs = self.compute_logprobs_from_feature_buffers(fbuffers)
|
||||
if log_probs is not None:
|
||||
log_probs = normalize_log_probs(log_probs)
|
||||
self.shared_transcribe_step(requests=fbuffers, log_probs=log_probs)
|
||||
|
||||
def transcribe_step_for_frames(self, frames: list[Frame]) -> None:
|
||||
"""
|
||||
Transcribe step for frames.
|
||||
Args:
|
||||
frames: (list[Frame]) List of frames to transcribe.
|
||||
"""
|
||||
log_probs = self.compute_logprobs_from_frames(frames)
|
||||
if log_probs is not None:
|
||||
log_probs = normalize_log_probs(log_probs)
|
||||
self.shared_transcribe_step(requests=frames, log_probs=log_probs)
|
||||
|
||||
def get_request_generator(self) -> ContinuousBatchedRequestStreamer:
|
||||
"""
|
||||
Initialize the request generator.
|
||||
Returns:
|
||||
(ContinuousBatchedRequestStreamer) Request generator.
|
||||
"""
|
||||
request_generator = ContinuousBatchedRequestStreamer(
|
||||
n_frames_per_stream=1,
|
||||
frame_size_in_secs=self.chunk_size,
|
||||
sample_rate=self.sample_rate,
|
||||
batch_size=self.batch_size,
|
||||
request_type=self.request_type,
|
||||
preprocessor=self.preprocessor,
|
||||
buffer_size_in_secs=self.buffer_size_in_secs,
|
||||
device=self.device,
|
||||
pad_last_frame=True,
|
||||
right_pad_features=self.right_padding,
|
||||
)
|
||||
return request_generator
|
||||
@@ -0,0 +1,818 @@
|
||||
# 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
from torch import Tensor
|
||||
|
||||
from nemo.collections.asr.inference.model_wrappers.rnnt_inference_wrapper import RNNTInferenceWrapper
|
||||
from nemo.collections.asr.inference.pipelines.base_pipeline import BasePipeline
|
||||
from nemo.collections.asr.inference.streaming.decoders.greedy.greedy_rnnt_decoder import ClippedRNNTGreedyDecoder
|
||||
from nemo.collections.asr.inference.streaming.endpointing.greedy.greedy_rnnt_endpointing import RNNTGreedyEndpointing
|
||||
from nemo.collections.asr.inference.streaming.framing.multi_stream import ContinuousBatchedRequestStreamer
|
||||
from nemo.collections.asr.inference.streaming.framing.request import FeatureBuffer, Frame, Request
|
||||
from nemo.collections.asr.inference.streaming.framing.request_options import ASRRequestOptions
|
||||
from nemo.collections.asr.inference.streaming.state.rnnt_state import RNNTStreamingState
|
||||
from nemo.collections.asr.inference.utils.enums import FeatureBufferPaddingMode, RequestType
|
||||
from nemo.collections.asr.inference.utils.pipeline_utils import (
|
||||
adjust_vad_segments,
|
||||
check_existance_of_required_attributes,
|
||||
drop_trailing_features,
|
||||
get_confidence_utils,
|
||||
normalize_features,
|
||||
update_punctuation_and_language_tokens_timestamps,
|
||||
)
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis as NemoHypothesis
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import batched_hyps_to_hypotheses
|
||||
from nemo.utils import logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nemo.collections.asr.inference.itn.inverse_normalizer import AlignmentPreservingInverseNormalizer
|
||||
from nemo.collections.asr.inference.nmt.llm_translator import LLMTranslator
|
||||
|
||||
|
||||
class BufferedRNNTPipeline(BasePipeline):
|
||||
"""Buffered RNN-T/TDT pipeline."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cfg: DictConfig,
|
||||
asr_model: RNNTInferenceWrapper,
|
||||
itn_model: AlignmentPreservingInverseNormalizer | None = None,
|
||||
nmt_model: LLMTranslator | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the BufferedRNNTPipeline.
|
||||
Args:
|
||||
cfg: (DictConfig) Configuration parameters.
|
||||
asr_model: (RNNTInferenceWrapper) ASR model.
|
||||
itn_model: (AlignmentPreservingInverseNormalizer | None) Inverse Text Normalization model.
|
||||
nmt_model: (LLMTranslator | None) LLM based translation model.
|
||||
"""
|
||||
|
||||
self.copy_asr_model_attributes(asr_model)
|
||||
self.init_prompt_support()
|
||||
self.init_parameters(cfg)
|
||||
self.init_bufferer_for_buffered_streaming()
|
||||
self.conf_func, self.confidence_aggregator = get_confidence_utils(cfg.confidence)
|
||||
self.init_endpointer()
|
||||
self.init_greedy_rnnt_decoder()
|
||||
self.init_bpe_decoder()
|
||||
self.init_decoding_computer()
|
||||
self.init_text_processor(cfg, itn_model)
|
||||
self.init_nmt_model(nmt_model)
|
||||
super().__init__()
|
||||
|
||||
def init_parameters(self, cfg: DictConfig) -> None:
|
||||
"""
|
||||
Initialize the configuration parameters.
|
||||
Args:
|
||||
cfg: (DictConfig) Configuration parameters.
|
||||
"""
|
||||
self.asr_output_granularity = cfg.asr_output_granularity
|
||||
self.sample_rate = cfg.streaming.sample_rate
|
||||
self.stateful = cfg.streaming.stateful
|
||||
self.stateless = not self.stateful
|
||||
self.batch_size = cfg.streaming.batch_size
|
||||
|
||||
self.chunk_size = cfg.streaming.chunk_size
|
||||
self.left_padding_size = cfg.streaming.left_padding_size
|
||||
self.right_padding_size = cfg.streaming.right_padding_size
|
||||
self.buffer_size_in_secs = self.chunk_size + self.left_padding_size + self.right_padding_size
|
||||
self.expected_feature_buffer_len = int(self.buffer_size_in_secs / self.window_stride)
|
||||
|
||||
self.mid_delay = math.ceil((self.chunk_size + self.right_padding_size) / self.model_stride_in_secs)
|
||||
self.tokens_per_frame_float = self.chunk_size / self.model_stride_in_secs
|
||||
self.tokens_per_left_padding_float = self.left_padding_size / self.model_stride_in_secs
|
||||
self.tokens_per_right_padding_float = self.right_padding_size / self.model_stride_in_secs
|
||||
self.tokens_per_frame = math.ceil(self.tokens_per_frame_float)
|
||||
self.tokens_per_left_padding = math.ceil(self.tokens_per_left_padding_float)
|
||||
self.tokens_per_right_padding = math.ceil(self.tokens_per_right_padding_float)
|
||||
|
||||
if self.stateful:
|
||||
self.initial_delay = self.right_padding_size / self.model_stride_in_secs
|
||||
else:
|
||||
self.initial_delay = (self.left_padding_size + self.right_padding_size) / self.model_stride_in_secs
|
||||
|
||||
if self.stateful and (
|
||||
abs(self.tokens_per_frame_float - self.tokens_per_frame) > 1e-5
|
||||
or abs(self.tokens_per_left_padding_float - self.tokens_per_left_padding) > 1e-5
|
||||
or abs(self.tokens_per_right_padding_float - self.tokens_per_right_padding) > 1e-5
|
||||
):
|
||||
self.tokens_per_frame_float = self.tokens_per_frame
|
||||
self.tokens_per_left_padding_float = self.tokens_per_left_padding
|
||||
self.left_padding_size = self.tokens_per_left_padding * self.model_stride_in_secs
|
||||
self.chunk_size = self.tokens_per_frame * self.model_stride_in_secs
|
||||
self.right_padding_size = self.tokens_per_right_padding * self.model_stride_in_secs
|
||||
self.buffer_size_in_secs = self.chunk_size + self.left_padding_size + self.right_padding_size
|
||||
|
||||
self.request_type = RequestType.from_str(cfg.streaming.request_type)
|
||||
self.padding_mode = FeatureBufferPaddingMode.from_str(cfg.streaming.padding_mode)
|
||||
self.right_padding = self.padding_mode is FeatureBufferPaddingMode.RIGHT
|
||||
self.stop_history_eou_in_milliseconds = cfg.endpointing.stop_history_eou
|
||||
self.residue_tokens_at_end = cfg.endpointing.residue_tokens_at_end
|
||||
self.word_boundary_tolerance = cfg.streaming.word_boundary_tolerance
|
||||
self.return_tail_result = cfg.return_tail_result
|
||||
self.tokens_to_move = self.punctuation_ids.union(self.language_token_ids)
|
||||
|
||||
self.zero_encoded = self.init_zero_enc() if self.right_padding else None
|
||||
|
||||
def init_endpointer(self) -> None:
|
||||
"""Initialize the endpointer."""
|
||||
check_existance_of_required_attributes(
|
||||
self,
|
||||
[
|
||||
'stateful',
|
||||
'chunk_size',
|
||||
'right_padding_size',
|
||||
'buffer_size_in_secs',
|
||||
'vocabulary',
|
||||
'model_stride_in_milliseconds',
|
||||
'stop_history_eou_in_milliseconds',
|
||||
'residue_tokens_at_end',
|
||||
],
|
||||
)
|
||||
|
||||
if self.stateful:
|
||||
effective_buffer_size_in_secs = self.chunk_size + self.right_padding_size
|
||||
else:
|
||||
effective_buffer_size_in_secs = self.buffer_size_in_secs
|
||||
|
||||
self.endpointer = RNNTGreedyEndpointing(
|
||||
vocabulary=self.vocabulary,
|
||||
ms_per_timestep=self.model_stride_in_milliseconds,
|
||||
effective_buffer_size_in_secs=effective_buffer_size_in_secs,
|
||||
stop_history_eou=self.stop_history_eou_in_milliseconds,
|
||||
residue_tokens_at_end=self.residue_tokens_at_end,
|
||||
)
|
||||
|
||||
def init_greedy_rnnt_decoder(self) -> None:
|
||||
"""Initialize the greedy RNNT decoder."""
|
||||
check_existance_of_required_attributes(self, ['vocabulary', 'conf_func', 'endpointer', 'tokens_per_frame'])
|
||||
self.greedy_rnnt_decoder = ClippedRNNTGreedyDecoder(
|
||||
vocabulary=self.vocabulary,
|
||||
conf_func=self.conf_func,
|
||||
endpointer=self.endpointer,
|
||||
tokens_per_frame=self.tokens_per_frame,
|
||||
)
|
||||
|
||||
def init_decoding_computer(self) -> None:
|
||||
"""Initialize the decoding computer."""
|
||||
check_existance_of_required_attributes(self, ['stateful', 'asr_model'])
|
||||
self.decoding_computer = None
|
||||
if self.stateful:
|
||||
self.decoding_computer = self.asr_model.asr_model.decoding.decoding.decoding_computer
|
||||
|
||||
def init_zero_enc(self) -> Tensor:
|
||||
"""
|
||||
Initialize the encoder output for the zero buffer.
|
||||
Returns:
|
||||
(Tensor) Encoder output for the zero buffer.
|
||||
"""
|
||||
check_existance_of_required_attributes(
|
||||
self, ['buffer_size_in_secs', 'sample_rate', 'device', 'expected_feature_buffer_len']
|
||||
)
|
||||
buffer_size_in_samples = int(self.buffer_size_in_secs * self.sample_rate)
|
||||
zero_buffer = torch.zeros(1, buffer_size_in_samples, device=self.device)
|
||||
zero_features, zero_features_len = self.preprocess(
|
||||
buffers=zero_buffer,
|
||||
buffer_lens=torch.tensor([zero_buffer.shape[1]], device=self.device),
|
||||
expected_feature_buffer_len=self.expected_feature_buffer_len,
|
||||
)
|
||||
|
||||
if self.prompt_enabled:
|
||||
# Use "en-US" as the default prompt for zero encoding
|
||||
# This region is sliced out before decoding, so language choice doesn't matter
|
||||
default_prompt_idx = self._resolve_prompt_index("en-US")
|
||||
prompt_indices = torch.tensor([default_prompt_idx], device=self.device, dtype=torch.long)
|
||||
prompt_vector = self._create_one_hot_prompts(prompt_indices) # [1, num_prompts]
|
||||
|
||||
zero_encoded, _ = self.asr_model.encode_with_prompts(
|
||||
processed_signal=zero_features,
|
||||
processed_signal_length=zero_features_len,
|
||||
prompt_vectors=prompt_vector,
|
||||
)
|
||||
else:
|
||||
zero_encoded, _ = self.asr_model.encode(
|
||||
processed_signal=zero_features, processed_signal_length=zero_features_len
|
||||
)
|
||||
|
||||
return zero_encoded[0]
|
||||
|
||||
def create_state(self, options: ASRRequestOptions) -> RNNTStreamingState:
|
||||
"""
|
||||
Create new empty state.
|
||||
Args:
|
||||
options: (ASRRequestOptions) Request options for particular stream.
|
||||
Returns:
|
||||
(RNNTStreamingState) New empty state.
|
||||
"""
|
||||
state = RNNTStreamingState()
|
||||
state.set_global_offset(-self.initial_delay)
|
||||
new_options = options.fill_defaults(
|
||||
default_enable_itn=self.text_processor.itn_enabled,
|
||||
default_enable_nmt=self.nmt_enabled,
|
||||
default_source_language=self.nmt_model.source_language if self.nmt_enabled else None,
|
||||
default_target_language=self.nmt_model.target_language if self.nmt_enabled else None,
|
||||
default_stop_history_eou=self.stop_history_eou_in_milliseconds,
|
||||
default_asr_output_granularity=self.asr_output_granularity,
|
||||
default_language_code="en-US" if self.prompt_enabled else None,
|
||||
)
|
||||
state.set_options(new_options)
|
||||
|
||||
# Create per-stream prompt index for prompt-enabled models
|
||||
if self.prompt_enabled:
|
||||
lang_code = getattr(new_options, "language_code", None)
|
||||
if not isinstance(lang_code, str) or len(lang_code) == 0:
|
||||
raise ValueError("Prompt-enabled model requires a valid language_code in request options.")
|
||||
prompt_idx = self._resolve_prompt_index(lang_code)
|
||||
state.set_prompt_index(prompt_idx)
|
||||
|
||||
return state
|
||||
|
||||
def get_sep(self) -> str:
|
||||
"""Return the separator for the text processor."""
|
||||
return self.sep
|
||||
|
||||
def preprocess(
|
||||
self, buffers: Tensor, buffer_lens: Tensor, expected_feature_buffer_len: int
|
||||
) -> tuple[Tensor, Tensor]:
|
||||
"""
|
||||
Preprocess the buffered frames and extract features.
|
||||
Args:
|
||||
buffers: (Tensor) Audio buffers.
|
||||
buffer_lens: (Tensor) Lengths of the audio buffers.
|
||||
expected_feature_buffer_len: (int) Expected length of the feature buffers.
|
||||
Returns:
|
||||
(tuple[Tensor, Tensor]) Processed feature buffers and their lengths.
|
||||
"""
|
||||
feature_buffers, feature_buffer_lens = self.preprocessor(input_signal=buffers, length=buffer_lens)
|
||||
feature_buffers = drop_trailing_features(feature_buffers, expected_feature_buffer_len)
|
||||
feature_buffers = normalize_features(feature_buffers, feature_buffer_lens)
|
||||
feature_buffer_lens = feature_buffer_lens.clamp(max=feature_buffers.shape[2])
|
||||
return feature_buffers, feature_buffer_lens
|
||||
|
||||
def get_cut_off_range(self, T: int, is_last: bool) -> tuple[int, int]:
|
||||
"""
|
||||
Compute the start and end indices to clip.
|
||||
Args:
|
||||
T: (int) Time dimension of the alignment.
|
||||
is_last: (bool) Whether the last frame is reached.
|
||||
Returns:
|
||||
(tuple[int, int]) Start and end indices to clip.
|
||||
"""
|
||||
start = max(T - 1 - self.mid_delay, 0)
|
||||
end = T if is_last else min(start + self.tokens_per_frame, T)
|
||||
return start, end
|
||||
|
||||
def encode_raw_signals(
|
||||
self, frames: list[Frame], raw_signals: list[Tensor], left_paddings: list[int]
|
||||
) -> tuple[Tensor, Tensor]:
|
||||
"""
|
||||
Run Encoder part on the audio buffers.
|
||||
Args:
|
||||
frames: (list[Frame]) Frames to transcribe.
|
||||
raw_signals: (list[Tensor]) Audio buffers.
|
||||
left_paddings: (list[int]) Left paddings for audio buffers.
|
||||
Returns:
|
||||
(tuple[Tensor, Tensor]) Encoded signals and their lengths.
|
||||
"""
|
||||
|
||||
if self.right_padding:
|
||||
left_paddings = torch.tensor(left_paddings, dtype=torch.int64, device=self.device)
|
||||
|
||||
buffers = []
|
||||
for i in range(len(raw_signals)):
|
||||
buffer = raw_signals[i]
|
||||
if self.right_padding:
|
||||
# Roll the buffered frames to the left by the left padding
|
||||
# This is done to avoid the padding at the beginning of the buffered frames
|
||||
# which can cause the performance degradation
|
||||
lpad = left_paddings[i].item()
|
||||
if lpad > 0:
|
||||
buffer = buffer.roll(shifts=-lpad)
|
||||
buffers.append(buffer.unsqueeze_(0))
|
||||
|
||||
# Only final frames have right padding
|
||||
# Calculate right paddings
|
||||
right_paddings = torch.tensor([frame.size - frame.valid_size for frame in frames], device=self.device).clamp(
|
||||
min=0
|
||||
)
|
||||
|
||||
# Create and adjust the buffer lens
|
||||
buffer_lens = torch.tensor([buffers[0].size(1)] * len(buffers), device=self.device)
|
||||
buffer_lens = buffer_lens - right_paddings
|
||||
if self.right_padding:
|
||||
buffer_lens = buffer_lens - left_paddings
|
||||
|
||||
feature_buffers, feature_buffer_lens = self.preprocess(
|
||||
buffers=torch.cat(buffers).to(self.device),
|
||||
buffer_lens=buffer_lens,
|
||||
expected_feature_buffer_len=self.expected_feature_buffer_len,
|
||||
)
|
||||
|
||||
# Build prompt vectors if prompts are enabled
|
||||
if self.prompt_enabled:
|
||||
requests_states = [self.get_state(f.stream_id) for f in frames]
|
||||
prompt_vectors = self._build_prompt_vectors(requests_states)
|
||||
|
||||
# Use encode_with_prompts which handles dimension expansion
|
||||
encoded, encoded_len = self.asr_model.encode_with_prompts(
|
||||
processed_signal=feature_buffers,
|
||||
processed_signal_length=feature_buffer_lens,
|
||||
prompt_vectors=prompt_vectors,
|
||||
)
|
||||
else:
|
||||
encoded, encoded_len = self.asr_model.encode(
|
||||
processed_signal=feature_buffers, processed_signal_length=feature_buffer_lens
|
||||
)
|
||||
encoded = encoded.clone()
|
||||
encoded_len = encoded_len.clone()
|
||||
|
||||
# Roll back the encoded signals to the right
|
||||
if self.right_padding:
|
||||
for i in range(encoded.shape[0]):
|
||||
lpad = left_paddings[i]
|
||||
if lpad > 0:
|
||||
lpad = int(lpad / self.sample_rate / self.model_stride_in_secs)
|
||||
encoded[i] = encoded[i].roll(lpad, dims=1)
|
||||
encoded[i][:, :lpad] = self.zero_encoded[:, :lpad]
|
||||
encoded_len[i] = encoded_len[i] + lpad
|
||||
|
||||
return encoded, encoded_len
|
||||
|
||||
def encode_processed_signals(
|
||||
self, fbuffers: list[FeatureBuffer], processed_signals: list[Tensor]
|
||||
) -> tuple[Tensor, Tensor]:
|
||||
"""
|
||||
Run Encoder part on the feature buffers.
|
||||
Args:
|
||||
fbuffers: (list[FeatureBuffer]) Feature buffers.
|
||||
processed_signals: (list[Tensor]) Processed buffers.
|
||||
Returns:
|
||||
(tuple[Tensor, Tensor]) Encoder output and their lengths.
|
||||
"""
|
||||
|
||||
processed_signals = torch.cat([sig.unsqueeze_(0) for sig in processed_signals]).to(self.device)
|
||||
processed_signals = drop_trailing_features(processed_signals, self.expected_feature_buffer_len)
|
||||
processed_signal_lengths = torch.tensor([f.valid_size for f in fbuffers], device=self.device)
|
||||
processed_signals = normalize_features(processed_signals, processed_signal_lengths)
|
||||
processed_signal_lengths = processed_signal_lengths.clamp(max=processed_signals.shape[2])
|
||||
|
||||
# Build prompt vectors if prompts are enabled
|
||||
if self.prompt_enabled:
|
||||
requests_states = [self.get_state(f.stream_id) for f in fbuffers]
|
||||
prompt_vectors = self._build_prompt_vectors(requests_states)
|
||||
|
||||
# Use encode_with_prompts which handles dimension expansion
|
||||
encoded, encoded_len = self.asr_model.encode_with_prompts(
|
||||
processed_signal=processed_signals,
|
||||
processed_signal_length=processed_signal_lengths,
|
||||
prompt_vectors=prompt_vectors,
|
||||
)
|
||||
else:
|
||||
encoded, encoded_len = self.asr_model.encode(
|
||||
processed_signal=processed_signals, processed_signal_length=processed_signal_lengths
|
||||
)
|
||||
encoded = encoded.clone()
|
||||
encoded_len = encoded_len.clone()
|
||||
|
||||
if self.right_padding:
|
||||
for i in range(encoded.shape[0]):
|
||||
lpad = int(fbuffers[i].roll_size / self.subsampling_factor)
|
||||
if lpad > 0:
|
||||
encoded[i] = encoded[i].roll(lpad, dims=1)
|
||||
encoded[i][:, :lpad] = self.zero_encoded[:, :lpad]
|
||||
encoded_len[i] = encoded_len[i] + lpad
|
||||
return encoded, encoded_len
|
||||
|
||||
def encode_frames(self, frames: list[Frame]) -> tuple[Tensor, Tensor]:
|
||||
"""
|
||||
Encode the frames using the Encoder part of the ASR model.
|
||||
Args:
|
||||
frames: (list[Frame]) Frames to transcribe.
|
||||
Returns:
|
||||
(tuple[Tensor, Tensor]) Encoder output and their lengths.
|
||||
"""
|
||||
raw_signals, left_paddings = self.bufferer.update(frames)
|
||||
encs, enc_lens = None, None
|
||||
if len(raw_signals) > 0:
|
||||
encs, enc_lens = self.encode_raw_signals(frames, raw_signals, left_paddings)
|
||||
return encs, enc_lens
|
||||
|
||||
def encode_feature_buffers(self, fbuffers: list[FeatureBuffer]) -> tuple[Tensor, Tensor]:
|
||||
"""
|
||||
Encode the feature buffers using the Encoder part of the ASR model.
|
||||
Args:
|
||||
fbuffers: (list[FeatureBuffer]) Feature buffers to transcribe.
|
||||
Returns:
|
||||
(tuple[Tensor, Tensor]) Encoder output and their lengths.
|
||||
"""
|
||||
processed_signals = self.bufferer.update(fbuffers)
|
||||
encs, enc_lens = None, None
|
||||
if len(processed_signals) > 0:
|
||||
encs, enc_lens = self.encode_processed_signals(fbuffers, processed_signals)
|
||||
return encs, enc_lens
|
||||
|
||||
def run_greedy_decoder(
|
||||
self,
|
||||
state: RNNTStreamingState,
|
||||
request: Request,
|
||||
timesteps: torch.Tensor,
|
||||
tokens: torch.Tensor,
|
||||
start: int,
|
||||
end: int,
|
||||
alignment_length: int,
|
||||
timestamp_offset: int = 0,
|
||||
vad_segments: torch.Tensor = None,
|
||||
confidences: torch.Tensor | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Greedy RNN-T decoder.
|
||||
Args:
|
||||
state: (RNNTStreamingState) Current state for the particular stream.
|
||||
request: (Request) Current request for the particular stream.
|
||||
timesteps: (Tensor) Timesteps.
|
||||
tokens: (Tensor) Tokens.
|
||||
start: (int) Start index.
|
||||
end: (int) End index.
|
||||
alignment_length: (int) Length of the alignment.
|
||||
timestamp_offset: (int) Timestamp offset.
|
||||
vad_segments: (Tensor) VAD segments.
|
||||
confidences: (Tensor | None) Per-token (non-blank) confidence scores aligned with `tokens`.
|
||||
Returns:
|
||||
(bool) Whether EOU is detected.
|
||||
"""
|
||||
if self.stateful and vad_segments is not None:
|
||||
vad_segments = adjust_vad_segments(vad_segments, self.left_padding_size)
|
||||
|
||||
clipped_output, tail_output, eou_detected, start_idx, end_idx = self.greedy_rnnt_decoder(
|
||||
global_timesteps=timesteps,
|
||||
tokens=tokens,
|
||||
alignment_length=alignment_length,
|
||||
clip_start=start,
|
||||
clip_end=end,
|
||||
is_last=request.is_last,
|
||||
is_start=request.is_first,
|
||||
return_tail_result=self.return_tail_result,
|
||||
state_start_idx=state.decoder_start_idx,
|
||||
state_end_idx=state.decoder_end_idx,
|
||||
timestamp_offset=timestamp_offset,
|
||||
vad_segments=vad_segments,
|
||||
stop_history_eou=state.options.stop_history_eou,
|
||||
confidences=confidences,
|
||||
)
|
||||
state.update_state(clipped_output, eou_detected)
|
||||
state.update_from_decoder_results(start_idx, end_idx)
|
||||
if self.stateless:
|
||||
# For stateless mode, we need to set the last token, it will be used for filtering duplicate token
|
||||
state.set_last_token(clipped_output["last_token"], clipped_output["last_token_idx"])
|
||||
# For stateless mode, we need to increment the global offset
|
||||
state.increment_global_offset(self.tokens_per_frame_float)
|
||||
state.set_incomplete_segment_tokens(tail_output["tokens"])
|
||||
return eou_detected
|
||||
|
||||
def stateless_transcribe_step(
|
||||
self, requests: list[Request], encs: Tensor, enc_lens: Tensor, ready_state_ids: set
|
||||
) -> None:
|
||||
"""
|
||||
Stateless transcribe step.
|
||||
Stateless assumes that we don't keep track of partial hypotheses (partial_hypotheses=None).
|
||||
Args:
|
||||
requests: (list[Request]) List of requests to transcribe.
|
||||
encs: (Tensor) Encoder output.
|
||||
enc_lens: (Tensor) Encoder output lengths.
|
||||
ready_state_ids: (set) Set of ready state IDs.
|
||||
"""
|
||||
states = [self.get_state(request.stream_id) for request in requests]
|
||||
best_hyp = self.asr_model.decode(encs, enc_lens, partial_hypotheses=None)
|
||||
# For stateless mode, use zero timestamp offsets since we don't track timestamps
|
||||
ready_states = self.decode_step(best_hyp, requests, states)
|
||||
ready_state_ids.update(ready_states)
|
||||
|
||||
def stateful_transcribe_step(
|
||||
self, requests: list[Request], encs: Tensor, enc_lens_chunk: Tensor, enc_lens: Tensor, ready_state_ids: set
|
||||
) -> None:
|
||||
"""
|
||||
Stateful transcribe step.
|
||||
Stateful assumes that we keep track of partial hypotheses.
|
||||
Args:
|
||||
requests: (list[Request]) List of requests to transcribe.
|
||||
encs: (Tensor) Encoder output.
|
||||
enc_lens_chunk: (Tensor) Encoder output lengths for the chunk.
|
||||
enc_lens: (Tensor) Encoder output lengths.
|
||||
ready_state_ids: (set) Set of ready state IDs.
|
||||
"""
|
||||
states = [self.get_state(request.stream_id) for request in requests]
|
||||
partial_hypotheses, rnnt_states = [], []
|
||||
all_rnnt_states_are_none = True
|
||||
all_multi_biasing_models_empty = True
|
||||
multi_biasing_ids = np.full([len(states)], fill_value=-1)
|
||||
for i, state in enumerate(states):
|
||||
hyp_state = state.hyp_decoding_state
|
||||
rnnt_states.append(hyp_state)
|
||||
if hyp_state is not None:
|
||||
all_rnnt_states_are_none = False
|
||||
if state.has_biasing_request():
|
||||
if state.options.biasing_cfg.multi_model_id is not None:
|
||||
all_multi_biasing_models_empty = False
|
||||
multi_biasing_ids[i] = state.options.biasing_cfg.multi_model_id
|
||||
elif state.options.biasing_cfg.auto_manage_multi_model:
|
||||
state.options.biasing_cfg.add_to_multi_model(
|
||||
tokenizer=self.asr_model.tokenizer,
|
||||
biasing_multi_model=self.decoding_computer.biasing_multi_model,
|
||||
)
|
||||
multi_biasing_ids[i] = state.options.biasing_cfg.multi_model_id
|
||||
all_multi_biasing_models_empty = False
|
||||
else:
|
||||
logging.warning("Biasing request is not empty, not auto managed and not compiled. Skipping")
|
||||
if hyp_state is not None or state.has_biasing_request():
|
||||
partial_hypotheses.append(
|
||||
NemoHypothesis(
|
||||
score=0.0,
|
||||
y_sequence=torch.zeros([0], dtype=torch.long),
|
||||
dec_state=hyp_state,
|
||||
biasing_cfg=state.options.biasing_cfg,
|
||||
)
|
||||
)
|
||||
else:
|
||||
partial_hypotheses.append(None)
|
||||
|
||||
batched_rnnt_states = None
|
||||
if not all_rnnt_states_are_none:
|
||||
batched_rnnt_states = self.decoding_computer.merge_to_batched_state(rnnt_states)
|
||||
|
||||
if all_multi_biasing_models_empty:
|
||||
multi_biasing_ids = None
|
||||
else:
|
||||
multi_biasing_ids = torch.from_numpy(multi_biasing_ids).to(device=enc_lens_chunk.device)
|
||||
|
||||
encs_dim_last = encs.transpose(1, 2)
|
||||
# decode chunk
|
||||
with torch.inference_mode(), torch.no_grad():
|
||||
best_batched_hyps_chunk, batched_state = self.decoding_computer(
|
||||
encs_dim_last,
|
||||
enc_lens_chunk,
|
||||
batched_rnnt_states,
|
||||
multi_biasing_ids=multi_biasing_ids,
|
||||
)
|
||||
best_hyps = batched_hyps_to_hypotheses(best_batched_hyps_chunk, batch_size=enc_lens.shape[0])
|
||||
|
||||
# save state (after chunk)
|
||||
for state, rnnt_state in zip(states, self.decoding_computer.split_batched_state(batched_state)):
|
||||
state.hyp_decoding_state = rnnt_state
|
||||
|
||||
if self.tokens_per_right_padding > 0:
|
||||
# decode right context
|
||||
_, max_time, feat_dim = encs_dim_last.shape
|
||||
device = encs.device
|
||||
# we are indexing `encs_dim_last` with `shift_indices` to get a tensor where right context is at the start
|
||||
# everything after right context is padded with `0` index (first encoder vector)
|
||||
# padding will be ignored by decoder_computer since we pass the lengths
|
||||
shift_indices = torch.arange(max_time, device=device, dtype=torch.long)[None, :] + enc_lens_chunk[:, None]
|
||||
# pad with zeros everything beyond needed context
|
||||
shift_indices = torch.where(shift_indices < max_time, shift_indices, torch.zeros_like(shift_indices))
|
||||
with torch.inference_mode(), torch.no_grad():
|
||||
best_batched_hyps_rc, _ = self.decoding_computer(
|
||||
torch.gather(encs_dim_last, dim=1, index=shift_indices[:, :, None].expand(-1, -1, feat_dim)),
|
||||
enc_lens - enc_lens_chunk,
|
||||
batched_state,
|
||||
multi_biasing_ids=multi_biasing_ids,
|
||||
)
|
||||
best_hyps_rc = batched_hyps_to_hypotheses(best_batched_hyps_rc, batch_size=enc_lens.shape[0])
|
||||
# merge right context to chunk hypothesis
|
||||
for hyp, hyp_rc in zip(best_hyps, best_hyps_rc):
|
||||
hyp.merge_(hyp_rc)
|
||||
|
||||
ready_states = self.decode_step(best_hyps, requests, states)
|
||||
for curr_state in states:
|
||||
curr_state.timestamp_offset += self.tokens_per_frame_float
|
||||
ready_state_ids.update(ready_states)
|
||||
|
||||
for request, state in zip(requests, states):
|
||||
# only the first request contains biasing options; biasing options for the stream are stored in state
|
||||
if request.is_last and state.has_biasing_request():
|
||||
if state.options.biasing_cfg.auto_manage_multi_model:
|
||||
state.options.biasing_cfg.remove_from_multi_model(
|
||||
biasing_multi_model=self.decoding_computer.biasing_multi_model
|
||||
)
|
||||
|
||||
def decode_step(self, best_hyp: list, requests: list[Request], states: list[RNNTStreamingState]) -> set:
|
||||
"""
|
||||
Perform greedy RNNT decoding to get the best hypothesis and update the state.
|
||||
If EOU is detected, push the words to the state and cleanup the state.
|
||||
Args:
|
||||
best_hyp: (list) Best hypothesis.
|
||||
requests: (list[Request]) List of requests to transcribe.
|
||||
states: (list[RNNTStreamingState]) List of states.
|
||||
Returns:
|
||||
(set) Set of ready state IDs.
|
||||
"""
|
||||
ready_state_ids = set()
|
||||
for idx, hyp in enumerate(best_hyp):
|
||||
state = states[idx]
|
||||
request = requests[idx]
|
||||
# Perform timestamp based decoding for the hypothesis
|
||||
if self.stateful:
|
||||
alignment_length = self.tokens_per_right_padding + self.tokens_per_frame
|
||||
else:
|
||||
if self.request_type is RequestType.FEATURE_BUFFER:
|
||||
alignment_length = math.ceil(request.size / self.subsampling_factor)
|
||||
else: # RequestType.FRAME
|
||||
alignment_length = math.ceil(self.expected_feature_buffer_len / self.subsampling_factor)
|
||||
|
||||
if self.stateful:
|
||||
start, end = 0, self.tokens_per_frame
|
||||
else:
|
||||
# For stateless mode
|
||||
if request.is_first and request.is_last:
|
||||
start, end = 0, alignment_length
|
||||
else:
|
||||
start, end = self.get_cut_off_range(alignment_length, request.is_last)
|
||||
|
||||
timestamp = hyp.timestamp
|
||||
tokens = hyp.y_sequence
|
||||
timestamp = torch.tensor(timestamp) if isinstance(timestamp, list) else timestamp
|
||||
tokens = torch.tensor(tokens) if isinstance(tokens, list) else tokens
|
||||
timestamp = update_punctuation_and_language_tokens_timestamps(
|
||||
tokens, timestamp, self.tokens_to_move, self.underscore_id
|
||||
)
|
||||
# Per-token non-blank confidence precomputed during RNN-T decoding (aligned with `tokens`).
|
||||
# Populated when greedy or batched-beam preserve_frame_confidence is enabled; otherwise None.
|
||||
confidences = hyp.non_blank_step_confidence_precomputed
|
||||
if confidences is not None:
|
||||
confidences = torch.tensor(confidences, dtype=torch.float32, device=tokens.device)
|
||||
vad_segments = request.vad_segments
|
||||
eou_detected = self.run_greedy_decoder(
|
||||
state=state,
|
||||
request=request,
|
||||
timesteps=timestamp,
|
||||
tokens=tokens,
|
||||
start=start,
|
||||
end=end,
|
||||
alignment_length=alignment_length,
|
||||
timestamp_offset=state.timestamp_offset,
|
||||
vad_segments=vad_segments,
|
||||
confidences=confidences,
|
||||
)
|
||||
|
||||
if eou_detected:
|
||||
self.bpe_decoder.decode_bpe_tokens(state)
|
||||
state.cleanup_after_eou()
|
||||
ready_state_ids.add(request.stream_id)
|
||||
return ready_state_ids
|
||||
|
||||
def shared_transcribe_step_stateful(self, requests: list[Request], encs: Tensor, enc_lens: Tensor) -> None:
|
||||
"""
|
||||
Stateful transcribe step.
|
||||
After detecting EOU, it updates the state and run text processor.
|
||||
If there are multiple streams, it waits until all states are ready to run text processor.
|
||||
Args:
|
||||
requests: (list[Request]) List of requests to transcribe.
|
||||
encs: (Tensor) Encoder output.
|
||||
enc_lens: (Tensor) Encoder output lengths.
|
||||
"""
|
||||
tokens_per_left_padding_tensor = torch.tensor(self.tokens_per_left_padding, device=self.device)
|
||||
tokens_per_frame_tensor = torch.tensor(self.tokens_per_frame, device=self.device)
|
||||
postponed_requests = [(ridx, request.stream_id) for ridx, request in enumerate(requests)]
|
||||
next_postponed_requests = []
|
||||
ready_state_ids = set()
|
||||
while len(postponed_requests) > 0:
|
||||
request_ids_to_process = []
|
||||
for ridx, stream_id in postponed_requests:
|
||||
if stream_id in ready_state_ids:
|
||||
next_postponed_requests.append((ridx, stream_id))
|
||||
continue
|
||||
request_ids_to_process.append(ridx)
|
||||
if len(request_ids_to_process) > 0:
|
||||
requests_to_process = [requests[jdx] for jdx in request_ids_to_process]
|
||||
request_is_last = torch.tensor(
|
||||
[request.is_last for request in requests_to_process], dtype=torch.bool, device=self.device
|
||||
)
|
||||
enc_lens_dec = enc_lens - tokens_per_left_padding_tensor
|
||||
enc_lens_dec_trimmed = torch.where(
|
||||
request_is_last,
|
||||
enc_lens_dec,
|
||||
torch.minimum(enc_lens_dec, tokens_per_frame_tensor.expand_as(enc_lens_dec)),
|
||||
)
|
||||
self.stateful_transcribe_step(
|
||||
requests_to_process,
|
||||
encs[request_ids_to_process][:, :, self.tokens_per_left_padding :],
|
||||
enc_lens_dec_trimmed,
|
||||
enc_lens_dec,
|
||||
ready_state_ids,
|
||||
)
|
||||
if len(ready_state_ids) > 0:
|
||||
self.text_processor.process([self.get_state(stream_id) for stream_id in ready_state_ids])
|
||||
ready_state_ids.clear()
|
||||
postponed_requests = next_postponed_requests.copy()
|
||||
next_postponed_requests.clear()
|
||||
|
||||
self.update_partial_transcript(requests, self.tokenizer, self.leading_regex_pattern)
|
||||
|
||||
def shared_transcribe_step(self, requests: list[Request], encs: Tensor, enc_lens: Tensor) -> None:
|
||||
"""
|
||||
Stateless transcribe step.
|
||||
After detecting EOU, it updates the state and run text processor.
|
||||
If there are multiple streams, it waits until all stated are ready to run text processor.
|
||||
Args:
|
||||
requests: (list[Request]) List of requests to transcribe.
|
||||
encs: (Tensor) Encoder output.
|
||||
enc_lens: (Tensor) Encoder output lengths.
|
||||
"""
|
||||
postponed_requests = [(ridx, request.stream_id) for ridx, request in enumerate(requests)]
|
||||
next_postponed_requests = []
|
||||
ready_state_ids = set()
|
||||
|
||||
while len(postponed_requests) > 0:
|
||||
|
||||
request_ids_to_process = []
|
||||
for ridx, stream_id in postponed_requests:
|
||||
|
||||
if stream_id in ready_state_ids:
|
||||
# Skip if the state is already ready
|
||||
next_postponed_requests.append((ridx, stream_id))
|
||||
continue
|
||||
|
||||
request_ids_to_process.append(ridx)
|
||||
|
||||
if len(request_ids_to_process) > 0:
|
||||
requests_to_process = [requests[jdx] for jdx in request_ids_to_process]
|
||||
self.stateless_transcribe_step(
|
||||
requests_to_process,
|
||||
encs=encs[request_ids_to_process],
|
||||
enc_lens=enc_lens[request_ids_to_process],
|
||||
ready_state_ids=ready_state_ids,
|
||||
)
|
||||
|
||||
if len(ready_state_ids) > 0:
|
||||
self.text_processor.process([self.get_state(stream_id) for stream_id in ready_state_ids])
|
||||
ready_state_ids.clear()
|
||||
|
||||
postponed_requests = next_postponed_requests.copy()
|
||||
next_postponed_requests.clear()
|
||||
|
||||
self.update_partial_transcript(requests, self.tokenizer, self.leading_regex_pattern)
|
||||
|
||||
def transcribe_step_for_feature_buffers(self, fbuffers: list[FeatureBuffer]) -> None:
|
||||
"""
|
||||
Transcribe a step for feature buffers.
|
||||
Args:
|
||||
fbuffers: (list[FeatureBuffer]) List of feature buffers to transcribe.
|
||||
"""
|
||||
encs, enc_lens = self.encode_feature_buffers(fbuffers)
|
||||
if encs is not None:
|
||||
if self.stateful:
|
||||
self.shared_transcribe_step_stateful(requests=fbuffers, encs=encs, enc_lens=enc_lens)
|
||||
else:
|
||||
self.shared_transcribe_step(requests=fbuffers, encs=encs, enc_lens=enc_lens)
|
||||
|
||||
def transcribe_step_for_frames(self, frames: list[Frame]) -> None:
|
||||
"""
|
||||
Transcribe a step for frames.
|
||||
Args:
|
||||
frames: (list[Frame]) List of frames to transcribe.
|
||||
"""
|
||||
encs, enc_lens = self.encode_frames(frames)
|
||||
if encs is not None:
|
||||
if self.stateful:
|
||||
self.shared_transcribe_step_stateful(requests=frames, encs=encs, enc_lens=enc_lens)
|
||||
else:
|
||||
self.shared_transcribe_step(requests=frames, encs=encs, enc_lens=enc_lens)
|
||||
|
||||
def get_request_generator(self) -> ContinuousBatchedRequestStreamer:
|
||||
"""
|
||||
Initialize the request generator.
|
||||
Returns:
|
||||
(ContinuousBatchedRequestStreamer) Request generator.
|
||||
"""
|
||||
request_generator = ContinuousBatchedRequestStreamer(
|
||||
n_frames_per_stream=1,
|
||||
frame_size_in_secs=self.chunk_size,
|
||||
sample_rate=self.sample_rate,
|
||||
batch_size=self.batch_size,
|
||||
request_type=self.request_type,
|
||||
preprocessor=self.preprocessor,
|
||||
buffer_size_in_secs=self.buffer_size_in_secs,
|
||||
device=self.device,
|
||||
pad_last_frame=True,
|
||||
right_pad_features=self.right_padding,
|
||||
)
|
||||
return request_generator
|
||||
@@ -0,0 +1,248 @@
|
||||
# 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.
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.inference.model_wrappers.salm_asr_inference_wrapper import SALMASRInferenceWrapper
|
||||
from nemo.collections.asr.inference.pipelines.base_pipeline import BasePipeline
|
||||
from nemo.collections.asr.inference.streaming.buffering.incremental_audio_bufferer import (
|
||||
BatchedIncrementalAudioBufferer,
|
||||
)
|
||||
from nemo.collections.asr.inference.streaming.framing.multi_stream import ContinuousBatchedRequestStreamer
|
||||
from nemo.collections.asr.inference.streaming.framing.request import FeatureBuffer, Frame
|
||||
from nemo.collections.asr.inference.streaming.framing.request_options import ASRRequestOptions
|
||||
from nemo.collections.asr.inference.streaming.state.salm_state import SALMStreamingState
|
||||
from nemo.collections.asr.inference.utils.enums import ASROutputGranularity, MergingStrategy, RequestType
|
||||
from nemo.collections.asr.inference.utils.lcs_merge import lcs_merge
|
||||
from nemo.utils.decorators import experimental
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nemo.collections.asr.inference.itn.inverse_normalizer import AlignmentPreservingInverseNormalizer
|
||||
from nemo.collections.asr.inference.nmt.llm_translator import LLMTranslator
|
||||
|
||||
|
||||
def parse_hyp(answer: torch.Tensor, eos_tokens: list[int]):
|
||||
"""
|
||||
Parse the hypothesis. Extract the tokens before the EOS tokens.
|
||||
Args:
|
||||
answer: (torch.Tensor) Answer tensor.
|
||||
eos_tokens: (list[int]) EOS tokens.
|
||||
Returns:
|
||||
(torch.Tensor) Parsed hypothesis.
|
||||
"""
|
||||
end = torch.isin(answer, torch.tensor(eos_tokens)).nonzero(as_tuple=True)[0]
|
||||
if end.numel() == 0:
|
||||
return answer
|
||||
end = end[0]
|
||||
return answer[:end]
|
||||
|
||||
|
||||
@experimental
|
||||
class BufferedSALMPipeline(BasePipeline):
|
||||
"""Buffered SALM pipeline."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cfg: DictConfig,
|
||||
asr_model: SALMASRInferenceWrapper,
|
||||
itn_model: AlignmentPreservingInverseNormalizer | None = None,
|
||||
nmt_model: LLMTranslator | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the BufferedSALMPipeline.
|
||||
Args:
|
||||
cfg: (DictConfig) Configuration parameters.
|
||||
asr_model: (SALMASRInferenceWrapper) ASR model.
|
||||
itn_model: (AlignmentPreservingInverseNormalizer | None) Inverse Text Normalization model.
|
||||
nmt_model: (LLMTranslator | None) LLM based translation model.
|
||||
"""
|
||||
self.asr_model = asr_model
|
||||
self.init_parameters(cfg)
|
||||
self.init_nmt_model(nmt_model)
|
||||
super().__init__()
|
||||
|
||||
def init_parameters(self, cfg: DictConfig) -> None:
|
||||
"""
|
||||
Initialize the parameters.
|
||||
Args:
|
||||
cfg: (DictConfig) Configuration parameters.
|
||||
"""
|
||||
self.sample_rate = cfg.streaming.sample_rate
|
||||
self.asr_output_granularity = ASROutputGranularity.from_str(cfg.asr_output_granularity)
|
||||
if self.asr_output_granularity is ASROutputGranularity.WORD:
|
||||
raise ValueError("Word level output granularity is not supported for SALM AED pipeline")
|
||||
|
||||
self.batch_size = cfg.streaming.batch_size
|
||||
self.max_new_tokens = cfg.streaming.max_new_tokens
|
||||
self.device = self.asr_model.device
|
||||
self.stop_history_eou_in_milliseconds = cfg.endpointing.stop_history_eou
|
||||
|
||||
self.chunk_size_in_secs = cfg.streaming.chunk_size
|
||||
self.buffer_size_in_secs = cfg.streaming.buffer_size
|
||||
self.overlap_size_in_secs = cfg.streaming.overlap_size
|
||||
self.overlap_ratio = self.overlap_size_in_secs / self.buffer_size_in_secs
|
||||
self.extra_overlap_tokens = 2 # extra tokens for better overlap detection
|
||||
self.merging_strategy = MergingStrategy.from_str(cfg.streaming.merging_strategy)
|
||||
|
||||
self.audio_bufferer = BatchedIncrementalAudioBufferer(
|
||||
self.sample_rate,
|
||||
self.buffer_size_in_secs,
|
||||
self.chunk_size_in_secs,
|
||||
self.overlap_size_in_secs,
|
||||
)
|
||||
|
||||
self.request_type = RequestType.from_str(cfg.streaming.request_type)
|
||||
if self.request_type is RequestType.FEATURE_BUFFER:
|
||||
raise ValueError("Feature buffer request type is not supported for SALM pipeline")
|
||||
|
||||
self.prompts = [[{"role": "user", "content": f"Transcribe the following: {self.asr_model.audio_locator_tag}"}]]
|
||||
self.tokens = self.asr_model.preprocess_prompts(self.prompts)
|
||||
|
||||
def create_state(self, options: ASRRequestOptions) -> SALMStreamingState:
|
||||
"""
|
||||
Create new empty state.
|
||||
Args:
|
||||
options: (ASRRequestOptions) Request options for particular stream.
|
||||
Returns:
|
||||
(SALMStreamingState) New empty state.
|
||||
"""
|
||||
state = SALMStreamingState()
|
||||
state.set_global_offset(0)
|
||||
new_options = options.fill_defaults(
|
||||
default_enable_itn=False,
|
||||
default_enable_nmt=False,
|
||||
default_source_language=None,
|
||||
default_target_language=None,
|
||||
default_stop_history_eou=self.stop_history_eou_in_milliseconds,
|
||||
default_asr_output_granularity=self.asr_output_granularity,
|
||||
default_language_code=None,
|
||||
)
|
||||
state.set_options(new_options)
|
||||
return state
|
||||
|
||||
def get_sep(self) -> str:
|
||||
"""Return the separator for the text processor."""
|
||||
return self.asr_model.word_separator
|
||||
|
||||
def lcs_merge(self, state: SALMStreamingState, data: list[int]) -> None:
|
||||
"""
|
||||
Merge the buffer and data using the LCS algorithm.
|
||||
Args:
|
||||
state: (SALMStreamingState) The state of the streaming pipeline.
|
||||
data: (list[int]) The new tokens to merge with the buffer.
|
||||
"""
|
||||
if len(state.tokens) == 0:
|
||||
state.tokens.extend(data)
|
||||
return
|
||||
|
||||
# extra overlap tokens for better overlap detection
|
||||
delay = int(self.overlap_ratio * len(data)) + self.extra_overlap_tokens
|
||||
state.tokens = lcs_merge(
|
||||
buffer=state.tokens,
|
||||
data=data[:delay],
|
||||
search_size=delay,
|
||||
sep_id=self.asr_model.word_separator_ids,
|
||||
min_lcs_length=1,
|
||||
merging_strategy=self.merging_strategy,
|
||||
)
|
||||
state.tokens.extend(data[delay:])
|
||||
|
||||
def transcribe_step_for_frames(self, frames: list[Frame]) -> None:
|
||||
"""
|
||||
Perform the transcribe step for frames.
|
||||
Args:
|
||||
frames: (list[Frame]) List of frames to transcribe.
|
||||
"""
|
||||
buffers, paddings = self.audio_bufferer.update(frames)
|
||||
paddings = torch.tensor(paddings, dtype=torch.int64, device=self.device)
|
||||
|
||||
# Right paddings for the final frames
|
||||
# Only for last frames frame.size is greater than frame.valid_size
|
||||
right_paddings = torch.tensor(
|
||||
[int(frame.size - frame.valid_size) for frame in frames], dtype=torch.int64, device=self.device
|
||||
).clamp(min=0)
|
||||
|
||||
# stack buffers
|
||||
audios = torch.cat([buffer.unsqueeze_(0) for buffer in buffers]).to(self.device)
|
||||
audio_lens = torch.tensor([audios.size(1)] * audios.size(0), dtype=torch.int64, device=self.device)
|
||||
audio_lens = audio_lens - paddings - right_paddings
|
||||
|
||||
answer_ids = self.asr_model.generate(
|
||||
prompts=self.tokens.expand(len(audios), -1),
|
||||
audios=audios,
|
||||
audio_lens=audio_lens,
|
||||
max_new_tokens=self.max_new_tokens,
|
||||
).cpu()
|
||||
|
||||
for i, frame in enumerate(frames):
|
||||
state = self.get_state(frame.stream_id)
|
||||
new_tokens = parse_hyp(answer_ids[i], self.asr_model.eos_token_ids).tolist()
|
||||
state.incomplete_segment_tokens.clear()
|
||||
if self.audio_bufferer.is_full(frame.stream_id) or frame.is_last:
|
||||
self.lcs_merge(state, new_tokens)
|
||||
else:
|
||||
state.incomplete_segment_tokens.extend(new_tokens)
|
||||
|
||||
if frame.is_last:
|
||||
state.final_transcript = self.asr_model.tokenizer.ids_to_text(state.tokens)
|
||||
state.partial_transcript = ""
|
||||
else:
|
||||
all_tokens = state.tokens.copy()
|
||||
if len(state.incomplete_segment_tokens) > 0:
|
||||
# extra overlap tokens for better overlap detection
|
||||
delay = int(self.overlap_ratio * len(state.incomplete_segment_tokens)) + self.extra_overlap_tokens
|
||||
all_tokens = lcs_merge(
|
||||
buffer=all_tokens,
|
||||
data=state.incomplete_segment_tokens[:delay],
|
||||
search_size=delay,
|
||||
sep_id=self.asr_model.word_separator_ids,
|
||||
min_lcs_length=1,
|
||||
merging_strategy=self.merging_strategy,
|
||||
)
|
||||
all_tokens.extend(state.incomplete_segment_tokens[delay:])
|
||||
|
||||
if len(all_tokens) > 0:
|
||||
state.partial_transcript = self.asr_model.tokenizer.ids_to_text(all_tokens)
|
||||
else:
|
||||
state.partial_transcript = ""
|
||||
|
||||
def transcribe_step_for_feature_buffers(self, fbuffers: list[FeatureBuffer]) -> None:
|
||||
"""
|
||||
Transcribe a step for feature buffers.
|
||||
Args:
|
||||
fbuffers: (list[FeatureBuffer]) List of feature buffers to transcribe.
|
||||
"""
|
||||
raise NotImplementedError("Feature buffer request type is not supported for SALM pipeline")
|
||||
|
||||
def get_request_generator(self) -> ContinuousBatchedRequestStreamer:
|
||||
"""
|
||||
Initialize the request generator.
|
||||
Returns:
|
||||
(ContinuousBatchedRequestStreamer) Request generator.
|
||||
"""
|
||||
request_generator = ContinuousBatchedRequestStreamer(
|
||||
n_frames_per_stream=1,
|
||||
frame_size_in_secs=self.chunk_size_in_secs,
|
||||
sample_rate=self.sample_rate,
|
||||
batch_size=self.batch_size,
|
||||
request_type=self.request_type,
|
||||
pad_last_frame=True,
|
||||
)
|
||||
return request_generator
|
||||
@@ -0,0 +1,431 @@
|
||||
# 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
from torch import Tensor
|
||||
|
||||
from nemo.collections.asr.inference.model_wrappers.cache_aware_ctc_inference_wrapper import (
|
||||
CacheAwareCTCInferenceWrapper,
|
||||
)
|
||||
from nemo.collections.asr.inference.pipelines.base_pipeline import BasePipeline
|
||||
from nemo.collections.asr.inference.streaming.decoders.greedy.greedy_ctc_decoder import CTCGreedyDecoder
|
||||
from nemo.collections.asr.inference.streaming.endpointing.greedy.greedy_ctc_endpointing import CTCGreedyEndpointing
|
||||
from nemo.collections.asr.inference.streaming.framing.multi_stream import ContinuousBatchedRequestStreamer
|
||||
from nemo.collections.asr.inference.streaming.framing.request import FeatureBuffer, Frame, Request
|
||||
from nemo.collections.asr.inference.streaming.framing.request_options import ASRRequestOptions
|
||||
from nemo.collections.asr.inference.streaming.state.cache_aware_ctc_state import CacheAwareCTCStreamingState
|
||||
from nemo.collections.asr.inference.utils.endpointing_utils import millisecond_to_frames
|
||||
from nemo.collections.asr.inference.utils.enums import RequestType
|
||||
from nemo.collections.asr.inference.utils.pipeline_utils import (
|
||||
check_existance_of_required_attributes,
|
||||
drop_trailing_features,
|
||||
get_confidence_utils,
|
||||
normalize_log_probs,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nemo.collections.asr.inference.itn.inverse_normalizer import AlignmentPreservingInverseNormalizer
|
||||
from nemo.collections.asr.inference.nmt.llm_translator import LLMTranslator
|
||||
|
||||
|
||||
class CacheAwareCTCPipeline(BasePipeline):
|
||||
"""Cache Aware CTC pipeline."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cfg: DictConfig,
|
||||
asr_model: CacheAwareCTCInferenceWrapper,
|
||||
itn_model: AlignmentPreservingInverseNormalizer | None = None,
|
||||
nmt_model: LLMTranslator | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the CacheAwareCTCPipeline.
|
||||
Args:
|
||||
cfg: (DictConfig) Configuration parameters.
|
||||
asr_model: (CacheAwareCTCInferenceWrapper) ASR model.
|
||||
itn_model: (AlignmentPreservingInverseNormalizer | None) Inverse Text Normalization model.
|
||||
"""
|
||||
self.copy_asr_model_attributes(asr_model)
|
||||
self.init_parameters(cfg)
|
||||
self.init_context_manager()
|
||||
self.init_bufferer_for_cache_aware_streaming()
|
||||
self.conf_func, self.confidence_aggregator = get_confidence_utils(cfg.confidence)
|
||||
self.init_bpe_decoder()
|
||||
self.init_greedy_ctc_decoder()
|
||||
self.init_endpointer()
|
||||
self.init_text_processor(cfg, itn_model)
|
||||
self.init_nmt_model(nmt_model)
|
||||
super().__init__()
|
||||
|
||||
def init_parameters(self, cfg: DictConfig) -> None:
|
||||
"""
|
||||
Initialize the configuration parameters.
|
||||
Args:
|
||||
cfg: (DictConfig) Configuration parameters.
|
||||
"""
|
||||
if cfg.streaming.att_context_size is not None:
|
||||
self.asr_model.set_default_att_context_size(att_context_size=cfg.streaming.att_context_size)
|
||||
self.sample_rate = cfg.streaming.sample_rate
|
||||
self.asr_output_granularity = cfg.asr_output_granularity
|
||||
|
||||
self.use_cache = cfg.streaming.use_cache
|
||||
self.use_feat_cache = cfg.streaming.use_feat_cache
|
||||
self.batch_size = cfg.streaming.batch_size
|
||||
self.num_slots = cfg.streaming.num_slots
|
||||
if self.num_slots < self.batch_size:
|
||||
raise ValueError(
|
||||
f"Number of slots in the context manager must be >= batch_size: {self.num_slots} < {self.batch_size}"
|
||||
)
|
||||
self.request_type = RequestType.from_str(cfg.streaming.request_type)
|
||||
self.word_boundary_tolerance = cfg.streaming.word_boundary_tolerance
|
||||
self.stop_history_eou_in_milliseconds = cfg.endpointing.stop_history_eou
|
||||
self.residue_tokens_at_end = cfg.endpointing.residue_tokens_at_end
|
||||
self.return_tail_result = cfg.return_tail_result
|
||||
|
||||
self.pre_encode_cache_size = self.asr_model.get_pre_encode_cache_size()
|
||||
self.model_chunk_size = self.asr_model.get_chunk_size()
|
||||
if isinstance(self.model_chunk_size, list):
|
||||
self.model_chunk_size = self.model_chunk_size[1]
|
||||
|
||||
if cfg.streaming.get("chunk_size_in_secs", None) is not None:
|
||||
self.chunk_size_in_secs = cfg.streaming.chunk_size_in_secs
|
||||
self.tokens_per_frame = math.ceil(
|
||||
np.trunc(self.chunk_size_in_secs / self.window_stride) / self.subsampling_factor
|
||||
)
|
||||
# overwrite the encoder streaming params with proper shift size for cache aware streaming
|
||||
self.asr_model.setup_streaming_params(
|
||||
chunk_size=self.model_chunk_size // self.subsampling_factor, shift_size=self.tokens_per_frame
|
||||
)
|
||||
else:
|
||||
self.chunk_size_in_secs = self.model_chunk_size * self.window_stride
|
||||
self.tokens_per_frame = math.ceil(self.model_chunk_size / self.subsampling_factor)
|
||||
|
||||
if isinstance(self.pre_encode_cache_size, list):
|
||||
self.pre_encode_cache_size = self.pre_encode_cache_size[1]
|
||||
self.pre_encode_cache_size_in_secs = self.pre_encode_cache_size * self.window_stride
|
||||
|
||||
model_chunk_size_in_secs = self.model_chunk_size * self.window_stride
|
||||
|
||||
if self.use_cache:
|
||||
# if using cache, we need to pad some samples for pre_encode
|
||||
self.buffer_size_in_secs = self.pre_encode_cache_size_in_secs + model_chunk_size_in_secs
|
||||
self.drop_left_context = None
|
||||
self.valid_out_len = None
|
||||
else:
|
||||
# if not using cache, we need to keep left context in buffer, but no extra padding in pre_encode
|
||||
left_context_size = self.asr_model.get_att_context_size()[0]
|
||||
if left_context_size < 0:
|
||||
raise ValueError(f"Left context size should not be a negative value: {left_context_size}")
|
||||
self.buffer_size_in_secs = (
|
||||
model_chunk_size_in_secs + left_context_size * self.subsampling_factor * self.window_stride
|
||||
)
|
||||
self.drop_left_context = left_context_size
|
||||
self.valid_out_len = self.tokens_per_frame
|
||||
|
||||
# Expected feature buffer length for trimming (safeguard for feature buffer inputs)
|
||||
self.expected_feature_buffer_len = int(self.buffer_size_in_secs / self.window_stride)
|
||||
|
||||
def init_greedy_ctc_decoder(self) -> None:
|
||||
"""Initialize the CTC decoder."""
|
||||
check_existance_of_required_attributes(self, ['vocabulary', 'conf_func'])
|
||||
self.greedy_ctc_decoder = CTCGreedyDecoder(vocabulary=self.vocabulary, conf_func=self.conf_func)
|
||||
|
||||
def init_endpointer(self) -> None:
|
||||
"""Initialize the endpointer."""
|
||||
check_existance_of_required_attributes(
|
||||
self,
|
||||
[
|
||||
'vocabulary',
|
||||
'model_stride_in_milliseconds',
|
||||
'stop_history_eou_in_milliseconds',
|
||||
'residue_tokens_at_end',
|
||||
],
|
||||
)
|
||||
|
||||
self.endpointer = CTCGreedyEndpointing(
|
||||
vocabulary=self.vocabulary,
|
||||
ms_per_timestep=self.model_stride_in_milliseconds,
|
||||
stop_history_eou=self.stop_history_eou_in_milliseconds,
|
||||
residue_tokens_at_end=self.residue_tokens_at_end,
|
||||
)
|
||||
|
||||
def create_state(self, options: ASRRequestOptions) -> CacheAwareCTCStreamingState:
|
||||
"""
|
||||
Create new empty state.
|
||||
Args:
|
||||
options: (ASRRequestOptions) Request options for particular stream.
|
||||
Returns:
|
||||
(CacheAwareCTCStreamingState) New empty state.
|
||||
"""
|
||||
state = CacheAwareCTCStreamingState()
|
||||
state.set_global_offset(0)
|
||||
new_options = options.fill_defaults(
|
||||
default_enable_itn=self.text_processor.itn_enabled,
|
||||
default_enable_nmt=self.nmt_enabled,
|
||||
default_source_language=self.nmt_model.source_language if self.nmt_enabled else None,
|
||||
default_target_language=self.nmt_model.target_language if self.nmt_enabled else None,
|
||||
default_stop_history_eou=self.stop_history_eou_in_milliseconds,
|
||||
default_asr_output_granularity=self.asr_output_granularity,
|
||||
)
|
||||
|
||||
eou_label_buffer_size = 0
|
||||
if new_options.stop_history_eou > 0:
|
||||
eou_label_buffer_size = millisecond_to_frames(
|
||||
new_options.stop_history_eou, math.ceil(self.model_stride_in_milliseconds)
|
||||
)
|
||||
eou_label_buffer_size += self.residue_tokens_at_end
|
||||
state.setup_label_buffer(eou_label_buffer_size, self.blank_id)
|
||||
state.set_options(new_options)
|
||||
return state
|
||||
|
||||
def get_sep(self) -> str:
|
||||
"""Return the separator for the text processor."""
|
||||
return self.sep
|
||||
|
||||
def preprocess(self, buffers: list[Tensor], right_paddings: list[int] | None = None) -> tuple[Tensor, Tensor]:
|
||||
"""
|
||||
Preprocess the feature buffers by stacking them and computing the lengths
|
||||
Args:
|
||||
buffers: (list[Tensor]) List of feature buffers.
|
||||
right_paddings: (list[int] | None) List of right paddings.
|
||||
Returns:
|
||||
(tuple[Tensor, Tensor]) Processed feature buffers and their lengths.
|
||||
"""
|
||||
feature_buffers = [f_buffer.unsqueeze_(0) for f_buffer in buffers]
|
||||
# Trim to expected feature buffer length (safeguard for external feature buffer inputs)
|
||||
feature_buffers = [
|
||||
drop_trailing_features(f_buffer, self.expected_feature_buffer_len) for f_buffer in feature_buffers
|
||||
]
|
||||
feature_buffer_lens = torch.tensor([f_buffer.shape[2] for f_buffer in feature_buffers], device=self.device)
|
||||
if right_paddings is not None:
|
||||
right_paddings = torch.tensor(right_paddings, device=feature_buffer_lens.device)
|
||||
feature_buffer_lens = feature_buffer_lens - right_paddings
|
||||
feature_buffers = torch.cat(feature_buffers).to(self.device)
|
||||
return feature_buffers, feature_buffer_lens
|
||||
|
||||
def run_greedy_decoder(self, state: CacheAwareCTCStreamingState, request: Request, log_probs: Tensor):
|
||||
"""
|
||||
Run the greedy CTC decoder on the log_probs and update the state
|
||||
Args:
|
||||
state: (CacheAwareCTCStreamingState) The state of the stream
|
||||
request: (Request) The current request (frame or feature buffer)
|
||||
log_probs: (Tensor) The log probabilities of the current request
|
||||
Returns:
|
||||
(bool) Whether EOU is detected.
|
||||
"""
|
||||
eou_detected = request.is_last
|
||||
last_token = state.label_buffer[-1] if len(state.label_buffer) > 0 else self.blank_id
|
||||
cur_output = self.greedy_ctc_decoder(log_probs, compute_confidence=True, previous=last_token)
|
||||
state.update_label_buffer(cur_output["labels"])
|
||||
|
||||
if not eou_detected:
|
||||
emissions = state.get_label_buffer()
|
||||
pivot_point = len(emissions) - 1
|
||||
eou_detected, _ = self.endpointer.detect_eou_near_pivot(
|
||||
emissions, pivot_point, stop_history_eou=state.options.stop_history_eou
|
||||
)
|
||||
|
||||
state.update_state(cur_output, eou_detected=eou_detected)
|
||||
state.increment_global_offset(self.tokens_per_frame)
|
||||
return eou_detected
|
||||
|
||||
def decode_log_probs(
|
||||
self,
|
||||
requests: list[Request],
|
||||
log_probs: Tensor,
|
||||
tail_log_probs: Tensor | None,
|
||||
ready_state_ids: set,
|
||||
) -> None:
|
||||
"""
|
||||
Decode the log probabilities and update the state
|
||||
Args:
|
||||
requests: (list[Request]) List of requests (frames or feature buffers) to transcribe.
|
||||
log_probs: (Tensor) Log probabilities.
|
||||
tail_log_probs: (Tensor | None) Tail log probabilities.
|
||||
ready_state_ids: (set) Set of ready state IDs.
|
||||
"""
|
||||
|
||||
for idx, request in enumerate(requests):
|
||||
state = self.get_state(request.stream_id)
|
||||
eou_detected = self.run_greedy_decoder(state, request, log_probs[idx])
|
||||
|
||||
if eou_detected:
|
||||
self.bpe_decoder.decode_bpe_tokens(state)
|
||||
state.cleanup_after_eou()
|
||||
ready_state_ids.add(request.stream_id)
|
||||
|
||||
if tail_log_probs is not None:
|
||||
last_token = state.label_buffer[-1] if len(state.label_buffer) > 0 else self.blank_id
|
||||
tail_output = self.greedy_ctc_decoder(
|
||||
tail_log_probs[idx], compute_confidence=False, previous=last_token
|
||||
)
|
||||
state.set_incomplete_segment_tokens(tail_output["tokens"])
|
||||
|
||||
def cache_aware_transcribe_step(
|
||||
self,
|
||||
requests: list[Request],
|
||||
buffered_features: list[Tensor],
|
||||
right_paddings: list[int] | None,
|
||||
ready_state_ids: set,
|
||||
keep_all_outputs: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Cache Aware Transcribe Step
|
||||
It receives a list of requests (Frame or FeatureBuffer) and features and do the following:
|
||||
|
||||
1. Preprocess the features by stacking them and computing the lengths
|
||||
2. Get the context and mapping from the context manager for cache aware streaming
|
||||
3. Perform a streaming step with the ASR model
|
||||
4. Update the cache and reset the cache slots for the streams that has ended
|
||||
5. Decode the log probabilities and update the state
|
||||
|
||||
Args:
|
||||
requests: (list[Request]) List of requests (frames or feature buffers) to transcribe.
|
||||
buffered_features: (list[Tensor]) List of buffered features.
|
||||
right_paddings: (list[int] | None) List of right paddings.
|
||||
ready_state_ids: (set) Set of ready state IDs.
|
||||
keep_all_outputs: (bool) Whether to keep all outputs or not.
|
||||
"""
|
||||
feature_buffers, feature_buffer_lens = self.preprocess(buffered_features, right_paddings)
|
||||
|
||||
stream_ids = [request.stream_id for request in requests]
|
||||
eos_flags = [request.is_last for request in requests]
|
||||
context, mapping = self.context_manager.get_context(stream_ids)
|
||||
|
||||
drop_extra_pre_encoded = 0 if not self.use_cache else self.asr_model.drop_extra_pre_encoded
|
||||
log_probs, tail_log_probs, new_context = self.asr_model.stream_step(
|
||||
processed_signal=feature_buffers,
|
||||
processed_signal_length=feature_buffer_lens,
|
||||
context=context,
|
||||
drop_extra_pre_encoded=drop_extra_pre_encoded,
|
||||
keep_all_outputs=keep_all_outputs,
|
||||
drop_left_context=self.drop_left_context,
|
||||
valid_out_len=self.valid_out_len,
|
||||
return_tail_result=self.return_tail_result,
|
||||
)
|
||||
|
||||
if log_probs is not None:
|
||||
log_probs = normalize_log_probs(log_probs)
|
||||
self.context_manager.update_cache(stream_ids, new_context, mapping)
|
||||
self.context_manager.reset_slots(stream_ids, eos_flags)
|
||||
self.decode_log_probs(requests, log_probs, tail_log_probs, ready_state_ids)
|
||||
|
||||
def transcribe_step_for_frames(self, frames: list[Frame]) -> None:
|
||||
"""
|
||||
Transcribes the frames in a streaming manner.
|
||||
After detecting EOU, it updates the state and run text processor.
|
||||
If there are multiple streams, it waits until all states are ready to run text processor.
|
||||
Args:
|
||||
frames: (list[Frame]) List of frames to transcribe.
|
||||
"""
|
||||
all_fbuffers, right_paddings = self.bufferer.update(frames)
|
||||
|
||||
ready_state_ids = set()
|
||||
if len(all_fbuffers) > 0:
|
||||
nonfinal_frames, nonfinal_fbuffers = [], []
|
||||
final_frames, final_fbuffers = [], []
|
||||
final_right_paddings = []
|
||||
for jdx, bfeature in enumerate(all_fbuffers):
|
||||
frame = frames[jdx]
|
||||
if frame.is_last:
|
||||
final_frames.append(frame)
|
||||
final_fbuffers.append(bfeature)
|
||||
final_right_paddings.append(right_paddings[jdx])
|
||||
else:
|
||||
nonfinal_frames.append(frame)
|
||||
nonfinal_fbuffers.append(bfeature)
|
||||
|
||||
if len(nonfinal_frames) > 0:
|
||||
self.cache_aware_transcribe_step(
|
||||
nonfinal_frames, nonfinal_fbuffers, None, ready_state_ids, keep_all_outputs=False
|
||||
)
|
||||
if len(final_frames) > 0:
|
||||
self.cache_aware_transcribe_step(
|
||||
final_frames, final_fbuffers, final_right_paddings, ready_state_ids, keep_all_outputs=True
|
||||
)
|
||||
|
||||
# Postprocess the ready states
|
||||
if len(ready_state_ids) > 0:
|
||||
self.text_processor.process([self.get_state(stream_id) for stream_id in ready_state_ids])
|
||||
ready_state_ids.clear()
|
||||
|
||||
self.update_partial_transcript(frames, self.tokenizer, self.leading_regex_pattern)
|
||||
|
||||
def transcribe_step_for_feature_buffers(self, fbuffers: list[FeatureBuffer]) -> None:
|
||||
"""
|
||||
Transcribes the feature buffers in a streaming manner.
|
||||
After detecting EOU, it updates the state and run text processor.
|
||||
If there are multiple streams, it waits until all states are ready to run text processor.
|
||||
Args:
|
||||
fbuffers: (list[FeatureBuffer]) List of feature buffers to transcribe.
|
||||
"""
|
||||
ready_state_ids = set()
|
||||
|
||||
final_fbuffers, final_features = [], []
|
||||
nonfinal_fbuffers, nonfinal_features = [], []
|
||||
final_right_paddings = []
|
||||
|
||||
for fbuffer in fbuffers:
|
||||
feature = fbuffer.features
|
||||
right_padding = max(0, self.expected_feature_buffer_len - fbuffer.valid_size)
|
||||
|
||||
if fbuffer.is_last:
|
||||
final_fbuffers.append(fbuffer)
|
||||
final_features.append(feature)
|
||||
final_right_paddings.append(right_padding)
|
||||
else:
|
||||
nonfinal_fbuffers.append(fbuffer)
|
||||
nonfinal_features.append(feature)
|
||||
|
||||
if len(nonfinal_fbuffers) > 0:
|
||||
self.cache_aware_transcribe_step(
|
||||
nonfinal_fbuffers, nonfinal_features, None, ready_state_ids, keep_all_outputs=False
|
||||
)
|
||||
|
||||
if len(final_fbuffers) > 0:
|
||||
self.cache_aware_transcribe_step(
|
||||
final_fbuffers, final_features, final_right_paddings, ready_state_ids, keep_all_outputs=True
|
||||
)
|
||||
|
||||
if len(ready_state_ids) > 0:
|
||||
self.text_processor.process([self.get_state(stream_id) for stream_id in ready_state_ids])
|
||||
ready_state_ids.clear()
|
||||
|
||||
self.update_partial_transcript(fbuffers, self.tokenizer, self.leading_regex_pattern)
|
||||
|
||||
def get_request_generator(self) -> ContinuousBatchedRequestStreamer:
|
||||
"""
|
||||
Initialize the request generator.
|
||||
Returns:
|
||||
(ContinuousBatchedRequestStreamer) Request generator.
|
||||
"""
|
||||
request_generator = ContinuousBatchedRequestStreamer(
|
||||
n_frames_per_stream=1,
|
||||
frame_size_in_secs=self.chunk_size_in_secs,
|
||||
sample_rate=self.sample_rate,
|
||||
batch_size=self.batch_size,
|
||||
request_type=self.request_type,
|
||||
preprocessor=self.preprocessor,
|
||||
buffer_size_in_secs=self.buffer_size_in_secs,
|
||||
device=self.device,
|
||||
pad_last_frame=True,
|
||||
)
|
||||
return request_generator
|
||||
@@ -0,0 +1,591 @@
|
||||
# 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
from torch import Tensor
|
||||
|
||||
from nemo.collections.asr.inference.model_wrappers.cache_aware_rnnt_inference_wrapper import (
|
||||
CacheAwareRNNTInferenceWrapper,
|
||||
)
|
||||
from nemo.collections.asr.inference.pipelines.base_pipeline import BasePipeline
|
||||
from nemo.collections.asr.inference.streaming.decoders.greedy.greedy_rnnt_decoder import RNNTGreedyDecoder
|
||||
from nemo.collections.asr.inference.streaming.endpointing.greedy.greedy_rnnt_endpointing import RNNTGreedyEndpointing
|
||||
from nemo.collections.asr.inference.streaming.framing.multi_stream import ContinuousBatchedRequestStreamer
|
||||
from nemo.collections.asr.inference.streaming.framing.request import FeatureBuffer, Frame, Request
|
||||
from nemo.collections.asr.inference.streaming.framing.request_options import ASRRequestOptions
|
||||
from nemo.collections.asr.inference.streaming.state.cache_aware_rnnt_state import (
|
||||
CacheAwareRNNTBeamStreamingState,
|
||||
CacheAwareRNNTStreamingState,
|
||||
)
|
||||
from nemo.collections.asr.inference.utils.endpointing_utils import millisecond_to_frames
|
||||
from nemo.collections.asr.inference.utils.enums import RequestType
|
||||
from nemo.collections.asr.inference.utils.per_stream_biasing import (
|
||||
build_multi_biasing_ids_np,
|
||||
release_all_biasing_models,
|
||||
release_auto_managed_stream_biasing,
|
||||
)
|
||||
from nemo.collections.asr.inference.utils.pipeline_utils import (
|
||||
check_existance_of_required_attributes,
|
||||
drop_trailing_features,
|
||||
get_confidence_utils,
|
||||
)
|
||||
from nemo.collections.asr.parts.submodules.rnnt_malsd_batched_computer import ModifiedALSDBatchedRNNTComputer
|
||||
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
|
||||
from nemo.utils import logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nemo.collections.asr.inference.itn.inverse_normalizer import AlignmentPreservingInverseNormalizer
|
||||
from nemo.collections.asr.inference.nmt.llm_translator import LLMTranslator
|
||||
|
||||
|
||||
class CacheAwareRNNTPipeline(BasePipeline):
|
||||
"""Cache Aware RNNT pipeline."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cfg: DictConfig,
|
||||
asr_model: CacheAwareRNNTInferenceWrapper,
|
||||
itn_model: AlignmentPreservingInverseNormalizer | None = None,
|
||||
nmt_model: LLMTranslator | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the CacheAwareRNNTPipeline.
|
||||
Args:
|
||||
cfg: (DictConfig) Configuration parameters.
|
||||
asr_model: (CacheAwareRNNTInferenceWrapper) ASR model.
|
||||
itn_model: (AlignmentPreservingInverseNormalizer | None) Inverse Text Normalization model.
|
||||
nmt_model: (LLMTranslator | None) LLM based translation model.
|
||||
"""
|
||||
self.copy_asr_model_attributes(asr_model)
|
||||
self.init_prompt_support()
|
||||
self.init_parameters(cfg)
|
||||
self.init_context_manager()
|
||||
self.init_bufferer_for_cache_aware_streaming()
|
||||
self.conf_func, self.confidence_aggregator = get_confidence_utils(cfg.confidence)
|
||||
self.init_bpe_decoder()
|
||||
self.init_greedy_rnnt_decoder()
|
||||
self.init_endpointer()
|
||||
self.init_text_processor(cfg, itn_model)
|
||||
self.init_nmt_model(nmt_model)
|
||||
self.init_decoding_computer()
|
||||
if self.beam_decoder_computer is not None and self.prompt_enabled:
|
||||
raise ValueError("Cache-aware RNNT MALSD beam search does not yet support prompt vectors.")
|
||||
super().__init__()
|
||||
|
||||
def init_decoding_computer(self) -> None:
|
||||
"""Initialize ``decoding_computer``."""
|
||||
self.decoding_computer = None
|
||||
asr_model = getattr(self.asr_model, "asr_model", None)
|
||||
if asr_model is None:
|
||||
return
|
||||
decoding = getattr(getattr(asr_model, "decoding", None), "decoding", None)
|
||||
if decoding is not None:
|
||||
self.decoding_computer = getattr(decoding, "decoding_computer", None)
|
||||
|
||||
@property
|
||||
def beam_decoder_computer(self) -> ModifiedALSDBatchedRNNTComputer | None:
|
||||
"""Return ``decoding_computer`` when beam-search decoding is active."""
|
||||
if isinstance(self.decoding_computer, ModifiedALSDBatchedRNNTComputer):
|
||||
return self.decoding_computer
|
||||
return None
|
||||
|
||||
def init_parameters(self, cfg: DictConfig) -> None:
|
||||
"""
|
||||
Initialize the parameters.
|
||||
Args:
|
||||
cfg: (DictConfig) Configuration parameters.
|
||||
"""
|
||||
if cfg.streaming.att_context_size is not None:
|
||||
self.asr_model.set_default_att_context_size(att_context_size=cfg.streaming.att_context_size)
|
||||
|
||||
self.sample_rate = cfg.streaming.sample_rate
|
||||
self.asr_output_granularity = cfg.asr_output_granularity
|
||||
self.pre_encode_cache_size = self.asr_model.get_pre_encode_cache_size()
|
||||
self.model_chunk_size = self.asr_model.get_chunk_size()
|
||||
if isinstance(self.model_chunk_size, list):
|
||||
self.model_chunk_size = self.model_chunk_size[1]
|
||||
|
||||
self.use_cache = cfg.streaming.use_cache
|
||||
self.use_feat_cache = cfg.streaming.use_feat_cache
|
||||
|
||||
if cfg.streaming.get("chunk_size_in_secs", None) is not None:
|
||||
self.chunk_size_in_secs = cfg.streaming.chunk_size_in_secs
|
||||
self.tokens_per_frame = math.ceil(
|
||||
np.trunc(self.chunk_size_in_secs / self.window_stride) / self.subsampling_factor
|
||||
)
|
||||
# overwrite the encoder streaming params with proper shift size for cache aware streaming
|
||||
self.asr_model.setup_streaming_params(
|
||||
chunk_size=self.model_chunk_size // self.subsampling_factor, shift_size=self.tokens_per_frame
|
||||
)
|
||||
else:
|
||||
self.chunk_size_in_secs = self.model_chunk_size * self.window_stride
|
||||
self.tokens_per_frame = math.ceil(self.model_chunk_size / self.subsampling_factor)
|
||||
|
||||
if isinstance(self.pre_encode_cache_size, list):
|
||||
self.pre_encode_cache_size = self.pre_encode_cache_size[1]
|
||||
self.pre_encode_cache_size_in_secs = self.pre_encode_cache_size * self.window_stride
|
||||
|
||||
# Context Manager
|
||||
self.batch_size = cfg.streaming.batch_size
|
||||
self.num_slots = cfg.streaming.num_slots
|
||||
if self.num_slots < self.batch_size:
|
||||
raise ValueError(
|
||||
f"Number of slots in the context manager must be >= batch_size: {self.num_slots} < {self.batch_size}"
|
||||
)
|
||||
model_chunk_size_in_secs = self.model_chunk_size * self.window_stride
|
||||
|
||||
if self.use_cache:
|
||||
# if using cache, we need to pad some samples for pre_encode
|
||||
self.buffer_size_in_secs = self.pre_encode_cache_size_in_secs + model_chunk_size_in_secs
|
||||
self.drop_left_context = None
|
||||
self.valid_out_len = None
|
||||
else:
|
||||
# if not using cache, we need to keep left context in buffer, but no extra padding in pre_encode
|
||||
left_context_size = self.asr_model.get_att_context_size()[0]
|
||||
if left_context_size < 0:
|
||||
raise ValueError(f"Left context size should not be a negative value: {left_context_size}")
|
||||
self.buffer_size_in_secs = (
|
||||
model_chunk_size_in_secs + left_context_size * self.subsampling_factor * self.window_stride
|
||||
)
|
||||
self.drop_left_context = left_context_size
|
||||
self.valid_out_len = self.tokens_per_frame
|
||||
|
||||
# Expected feature buffer length for trimming (safeguard for feature buffer inputs)
|
||||
self.expected_feature_buffer_len = int(self.buffer_size_in_secs / self.window_stride)
|
||||
|
||||
self.stop_history_eou_in_milliseconds = cfg.endpointing.stop_history_eou
|
||||
self.residue_tokens_at_end = cfg.endpointing.residue_tokens_at_end
|
||||
self.word_boundary_tolerance = cfg.streaming.word_boundary_tolerance
|
||||
self.return_tail_result = cfg.return_tail_result
|
||||
|
||||
self.request_type = RequestType.from_str(cfg.streaming.request_type)
|
||||
|
||||
def init_greedy_rnnt_decoder(self) -> None:
|
||||
"""Initialize the RNNT decoder."""
|
||||
check_existance_of_required_attributes(self, ['vocabulary', 'conf_func'])
|
||||
self.greedy_rnnt_decoder = RNNTGreedyDecoder(vocabulary=self.vocabulary, conf_func=self.conf_func)
|
||||
|
||||
def init_endpointer(self) -> None:
|
||||
"""Initialize the endpointer."""
|
||||
check_existance_of_required_attributes(
|
||||
self,
|
||||
[
|
||||
'vocabulary',
|
||||
'model_stride_in_milliseconds',
|
||||
'stop_history_eou_in_milliseconds',
|
||||
'residue_tokens_at_end',
|
||||
],
|
||||
)
|
||||
|
||||
self.endpointer = RNNTGreedyEndpointing(
|
||||
vocabulary=self.vocabulary,
|
||||
ms_per_timestep=self.model_stride_in_milliseconds,
|
||||
stop_history_eou=self.stop_history_eou_in_milliseconds,
|
||||
residue_tokens_at_end=self.residue_tokens_at_end,
|
||||
)
|
||||
|
||||
def create_state(self, options: ASRRequestOptions) -> CacheAwareRNNTStreamingState:
|
||||
"""
|
||||
Create new empty state.
|
||||
Args:
|
||||
options: (ASRRequestOptions) Request options for particular stream.
|
||||
Returns:
|
||||
(CacheAwareRNNTStreamingState) New empty state.
|
||||
"""
|
||||
state = (
|
||||
CacheAwareRNNTBeamStreamingState()
|
||||
if self.beam_decoder_computer is not None
|
||||
else CacheAwareRNNTStreamingState()
|
||||
)
|
||||
state.set_global_offset(0)
|
||||
new_options = options.fill_defaults(
|
||||
default_enable_itn=self.text_processor.itn_enabled,
|
||||
default_enable_nmt=self.nmt_enabled,
|
||||
default_source_language=self.nmt_model.source_language if self.nmt_enabled else None,
|
||||
default_target_language=self.nmt_model.target_language if self.nmt_enabled else None,
|
||||
default_stop_history_eou=self.stop_history_eou_in_milliseconds,
|
||||
default_asr_output_granularity=self.asr_output_granularity,
|
||||
default_language_code="en-US" if self.prompt_enabled else None,
|
||||
)
|
||||
|
||||
eou_label_buffer_size = 0
|
||||
if new_options.stop_history_eou > 0:
|
||||
eou_label_buffer_size = millisecond_to_frames(
|
||||
new_options.stop_history_eou, math.ceil(self.model_stride_in_milliseconds)
|
||||
)
|
||||
eou_label_buffer_size += self.residue_tokens_at_end
|
||||
state.setup_label_buffer(eou_label_buffer_size, self.blank_id)
|
||||
state.set_previous_hypothesis(None)
|
||||
state.set_options(new_options)
|
||||
|
||||
# Create per-stream prompt index for prompt-enabled models
|
||||
if self.prompt_enabled:
|
||||
lang_code = getattr(new_options, "language_code", None)
|
||||
if not isinstance(lang_code, str) or len(lang_code) == 0:
|
||||
raise ValueError("Prompt-enabled model requires a valid language_code in request options.")
|
||||
prompt_idx = self._resolve_prompt_index(lang_code)
|
||||
state.set_prompt_index(prompt_idx)
|
||||
|
||||
return state
|
||||
|
||||
def close_session(self) -> None:
|
||||
"""Close the session and release per-stream biasing models held in the decoder."""
|
||||
if self.decoding_computer is not None and self.decoding_computer.per_stream_biasing_enabled:
|
||||
release_all_biasing_models(self.decoding_computer.biasing_multi_model, self._state_pool.values())
|
||||
super().close_session()
|
||||
|
||||
def get_sep(self) -> str:
|
||||
"""Return the separator for the text processor."""
|
||||
return self.sep
|
||||
|
||||
def preprocess(self, buffers: list[Tensor], right_paddings: list[int] | None = None) -> tuple[Tensor, Tensor]:
|
||||
"""
|
||||
Preprocess the feature buffers by stacking them and computing the lengths
|
||||
Args:
|
||||
buffers: (list[Tensor]) List of feature buffers.
|
||||
right_paddings: (list[int] | None) List of right paddings.
|
||||
Returns:
|
||||
(tuple[Tensor, Tensor]) Processed feature buffers and their lengths.
|
||||
"""
|
||||
feature_buffers = [f_buffer.unsqueeze_(0) for f_buffer in buffers]
|
||||
# Trim to expected feature buffer length (safeguard for external feature buffer inputs)
|
||||
feature_buffers = [
|
||||
drop_trailing_features(f_buffer, self.expected_feature_buffer_len) for f_buffer in feature_buffers
|
||||
]
|
||||
feature_buffer_lens = torch.tensor([f_buffer.shape[2] for f_buffer in feature_buffers], device=self.device)
|
||||
if right_paddings is not None:
|
||||
right_paddings = torch.tensor(right_paddings, device=feature_buffer_lens.device)
|
||||
feature_buffer_lens = feature_buffer_lens - right_paddings
|
||||
feature_buffers = torch.cat(feature_buffers).to(self.device)
|
||||
return feature_buffers, feature_buffer_lens
|
||||
|
||||
def _streaming_step(
|
||||
self,
|
||||
states: list[CacheAwareRNNTStreamingState],
|
||||
feature_buffers: Tensor,
|
||||
feature_buffer_lens: Tensor,
|
||||
context,
|
||||
previous_hypotheses: list[Hypothesis | None],
|
||||
drop_extra_pre_encoded: int,
|
||||
keep_all_outputs: bool,
|
||||
prompt_vectors: Tensor | None,
|
||||
) -> tuple[list[Hypothesis], object]:
|
||||
"""
|
||||
Run one cache-aware encode/decode step for the current chunk.
|
||||
Returns per-stream hypotheses and the updated encoder cache context.
|
||||
"""
|
||||
if self.beam_decoder_computer is None:
|
||||
return self.asr_model.stream_step(
|
||||
processed_signal=feature_buffers,
|
||||
processed_signal_length=feature_buffer_lens,
|
||||
context=context,
|
||||
previous_hypotheses=previous_hypotheses,
|
||||
drop_extra_pre_encoded=drop_extra_pre_encoded,
|
||||
keep_all_outputs=keep_all_outputs,
|
||||
drop_left_context=self.drop_left_context,
|
||||
valid_out_len=self.valid_out_len,
|
||||
prompt_vectors=prompt_vectors,
|
||||
)
|
||||
return self.asr_model.malsd_stream_step(
|
||||
malsd_computer=self.beam_decoder_computer,
|
||||
states=states,
|
||||
processed_signal=feature_buffers,
|
||||
processed_signal_length=feature_buffer_lens,
|
||||
context=context,
|
||||
drop_extra_pre_encoded=drop_extra_pre_encoded,
|
||||
keep_all_outputs=keep_all_outputs,
|
||||
drop_left_context=self.drop_left_context,
|
||||
valid_out_len=self.valid_out_len,
|
||||
)
|
||||
|
||||
def _prepare_per_stream_biasing(
|
||||
self,
|
||||
states: list[CacheAwareRNNTStreamingState],
|
||||
previous_hypotheses: list[Hypothesis | None],
|
||||
) -> list[Hypothesis | None]:
|
||||
if self.decoding_computer is None or not self.decoding_computer.per_stream_biasing_enabled:
|
||||
if any(state.has_biasing_request() for state in states):
|
||||
logging.warning(
|
||||
"Biasing request is not empty, but decoder does not support per-stream biasing. Skipping"
|
||||
)
|
||||
return previous_hypotheses
|
||||
|
||||
multi_biasing_ids_np = build_multi_biasing_ids_np(
|
||||
states,
|
||||
self.decoding_computer.biasing_multi_model,
|
||||
self.asr_model.tokenizer,
|
||||
)
|
||||
|
||||
if self.beam_decoder_computer is not None:
|
||||
return previous_hypotheses
|
||||
|
||||
for i, (state, previous_hyp) in enumerate(zip(states, previous_hypotheses)):
|
||||
if multi_biasing_ids_np[i] < 0:
|
||||
continue
|
||||
biasing_cfg = state.options.biasing_cfg
|
||||
if previous_hyp is None:
|
||||
previous_hypotheses[i] = Hypothesis.empty_with_biasing_cfg(biasing_cfg)
|
||||
else:
|
||||
previous_hyp.biasing_cfg = biasing_cfg
|
||||
return previous_hypotheses
|
||||
|
||||
def _apply_beam_update_(self, state: CacheAwareRNNTBeamStreamingState, eou_detected: bool) -> None:
|
||||
"""After endpointing: refresh beam publish tokens and fold cumulative prefix on EOU."""
|
||||
if eou_detected and state.hyp_decoding_state is not None:
|
||||
beam_idx = state.select_best_beam_idx_(score_norm=True)
|
||||
self.beam_decoder_computer.select_beam_in_state_item_(state.hyp_decoding_state, beam_idx)
|
||||
state.update_(eou_detected)
|
||||
|
||||
def run_greedy_decoder(self, state: CacheAwareRNNTStreamingState, request: Request, hyp: Hypothesis) -> bool:
|
||||
"""
|
||||
Run the greedy RNNT decoder on the hypothesis and update the state
|
||||
Args:
|
||||
state: (CacheAwareRNNTStreamingState) The state of the stream
|
||||
request: (Request) The current request (frame or feature buffer)
|
||||
hyp: (Hypothesis) The hypothesis of the current request
|
||||
Returns:
|
||||
(bool) Whether EOU is detected.
|
||||
"""
|
||||
eou_detected = request.is_last
|
||||
# Per-token non-blank confidence precomputed during RNN-T decoding (aligned with `hyp.y_sequence`).
|
||||
# Populated when greedy or batched-beam preserve_frame_confidence is enabled; otherwise None.
|
||||
cur_output, cur_labels, new_offset = self.greedy_rnnt_decoder(
|
||||
global_timestamps=hyp.timestamp,
|
||||
tokens=hyp.y_sequence,
|
||||
length=self.tokens_per_frame,
|
||||
offset=state.offset,
|
||||
confidences=hyp.non_blank_step_confidence_precomputed,
|
||||
)
|
||||
state.set_offset(new_offset)
|
||||
|
||||
# cur labels contains blank tokens as well, it is needed for EOU detection
|
||||
state.update_label_buffer(cur_labels)
|
||||
|
||||
if not eou_detected:
|
||||
emissions = state.get_label_buffer()
|
||||
pivot_point = len(emissions) - 1
|
||||
eou_detected, _ = self.endpointer.detect_eou_near_pivot(
|
||||
emissions, pivot_point, stop_history_eou=state.options.stop_history_eou
|
||||
)
|
||||
|
||||
state.update_state(cur_output, eou_detected=eou_detected)
|
||||
return eou_detected
|
||||
|
||||
def cache_aware_transcribe_step(
|
||||
self,
|
||||
requests: list[Request],
|
||||
features: list[Tensor],
|
||||
right_paddings: list[int],
|
||||
ready_state_ids: set,
|
||||
keep_all_outputs: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Cache Aware Transcribe Step
|
||||
It receives a list of requests (Frame or FeatureBuffer) and features and do the following:
|
||||
|
||||
1. Preprocess the features by stacking them and computing the lengths
|
||||
2. Collecting previous hypotheses for stateful decoding
|
||||
3. Get the context and mapping from the context manager for cache aware streaming
|
||||
4. Perform a streaming step with the ASR model
|
||||
5. Update the cache and reset the cache slots for the streams that has ended
|
||||
6. Update the previous hypothesis and reset the previous hypothesis for the streams that has ended
|
||||
7. Perform greedy RNNT decoding to get the best hypothesis and update the states
|
||||
8. Update the ready states to indicate that the state is ready for text post-processing
|
||||
Args:
|
||||
requests: (list[Request]) List of requests (frames or feature buffers) to transcribe.
|
||||
features: (list[Tensor]) List of feature buffers.
|
||||
right_paddings: (list[int] | None) List of right paddings.
|
||||
ready_state_ids: (set) Set of ready state IDs.
|
||||
keep_all_outputs: (bool) Whether to keep all outputs or not.
|
||||
"""
|
||||
|
||||
feature_buffers, feature_buffer_lens = self.preprocess(features, right_paddings)
|
||||
states, stream_ids, eos_flags = [], [], []
|
||||
for request in requests:
|
||||
states.append(self.get_state(request.stream_id))
|
||||
stream_ids.append(request.stream_id)
|
||||
eos_flags.append(request.is_last)
|
||||
|
||||
previous_hypotheses = [state.get_previous_hypothesis() for state in states]
|
||||
|
||||
previous_hypotheses = self._prepare_per_stream_biasing(
|
||||
states=states,
|
||||
previous_hypotheses=previous_hypotheses,
|
||||
)
|
||||
|
||||
context, mapping = self.context_manager.get_context(stream_ids)
|
||||
|
||||
prompt_vectors = None
|
||||
if self.prompt_enabled:
|
||||
prompt_vectors = self._build_prompt_vectors(states)
|
||||
|
||||
drop_extra_pre_encoded = 0 if not self.use_cache else self.asr_model.drop_extra_pre_encoded
|
||||
best_hyp, new_context = self._streaming_step(
|
||||
states=states,
|
||||
feature_buffers=feature_buffers,
|
||||
feature_buffer_lens=feature_buffer_lens,
|
||||
context=context,
|
||||
previous_hypotheses=previous_hypotheses,
|
||||
drop_extra_pre_encoded=drop_extra_pre_encoded,
|
||||
keep_all_outputs=keep_all_outputs,
|
||||
prompt_vectors=prompt_vectors,
|
||||
)
|
||||
|
||||
# update the cache and reset the cache slots for the streams that has ended
|
||||
self.context_manager.update_cache(stream_ids, new_context, mapping)
|
||||
self.context_manager.reset_slots(stream_ids, eos_flags)
|
||||
|
||||
# update the previous hypothesis and reset the previous hypothesis for the streams that has ended
|
||||
for state, hyp, eos in zip(states, best_hyp, eos_flags):
|
||||
if eos:
|
||||
state.reset_previous_hypothesis()
|
||||
else:
|
||||
state.set_previous_hypothesis(hyp)
|
||||
|
||||
# run greedy decoder for each request-state-hypothesis tuple
|
||||
for request, state, hyp in zip(requests, states, best_hyp):
|
||||
eou_detected = self.run_greedy_decoder(state, request, hyp)
|
||||
if self.beam_decoder_computer is not None:
|
||||
self._apply_beam_update_(state, eou_detected)
|
||||
if eou_detected:
|
||||
self.bpe_decoder.decode_bpe_tokens(state)
|
||||
state.cleanup_after_eou()
|
||||
ready_state_ids.add(request.stream_id)
|
||||
|
||||
# Cleanup per-stream biasing models when stream ends
|
||||
if self.decoding_computer is not None and self.decoding_computer.per_stream_biasing_enabled:
|
||||
for request, state in zip(requests, states):
|
||||
# only the first request contains biasing options; biasing options for the stream are stored in state
|
||||
if request.is_last and state.has_biasing_request():
|
||||
release_auto_managed_stream_biasing(state, self.decoding_computer.biasing_multi_model)
|
||||
|
||||
if self.beam_decoder_computer is not None:
|
||||
for state, eos in zip(states, eos_flags):
|
||||
if eos:
|
||||
state.reset_beam_decoding_state_()
|
||||
|
||||
def transcribe_step_for_feature_buffers(self, fbuffers: list[FeatureBuffer]) -> None:
|
||||
"""
|
||||
Transcribes the feature buffers in a streaming manner.
|
||||
After detecting EOU, it updates the state and run text processor.
|
||||
If there are multiple streams, it waits until all states are ready to run text processor.
|
||||
Args:
|
||||
fbuffers: (list[FeatureBuffer]) List of feature buffers to transcribe.
|
||||
"""
|
||||
ready_state_ids = set()
|
||||
|
||||
final_fbuffers, final_features = [], []
|
||||
nonfinal_fbuffers, nonfinal_features = [], []
|
||||
final_right_paddings = []
|
||||
|
||||
for fbuffer in fbuffers:
|
||||
feature = fbuffer.features
|
||||
right_padding = max(0, self.expected_feature_buffer_len - fbuffer.valid_size)
|
||||
|
||||
if fbuffer.is_last:
|
||||
final_fbuffers.append(fbuffer)
|
||||
final_features.append(feature)
|
||||
final_right_paddings.append(right_padding)
|
||||
else:
|
||||
nonfinal_fbuffers.append(fbuffer)
|
||||
nonfinal_features.append(feature)
|
||||
|
||||
if len(nonfinal_fbuffers) > 0:
|
||||
self.cache_aware_transcribe_step(
|
||||
nonfinal_fbuffers, nonfinal_features, None, ready_state_ids, keep_all_outputs=False
|
||||
)
|
||||
|
||||
if len(final_fbuffers) > 0:
|
||||
self.cache_aware_transcribe_step(
|
||||
final_fbuffers, final_features, final_right_paddings, ready_state_ids, keep_all_outputs=True
|
||||
)
|
||||
|
||||
if len(ready_state_ids) > 0:
|
||||
self.text_processor.process([self.get_state(stream_id) for stream_id in ready_state_ids])
|
||||
ready_state_ids.clear()
|
||||
|
||||
self.update_partial_transcript(fbuffers, self.tokenizer, self.leading_regex_pattern)
|
||||
|
||||
def transcribe_step_for_frames(self, frames: list[Frame]) -> None:
|
||||
"""
|
||||
Transcribes the frames in a streaming manner.
|
||||
After detecting EOU, it updates the state and run text processor.
|
||||
If there are multiple streams, it waits until all states are ready to run text processor.
|
||||
Args:
|
||||
frames: (list[Frame]) List of frames to transcribe.
|
||||
"""
|
||||
|
||||
all_fbuffers, right_paddings = self.bufferer.update(frames)
|
||||
ready_state_ids = set()
|
||||
|
||||
# streams that contains multiple frames
|
||||
if len(all_fbuffers) > 0:
|
||||
final_frames, final_fbuffers = [], []
|
||||
nonfinal_frames, nonfinal_fbuffers = [], []
|
||||
final_right_paddings = []
|
||||
|
||||
for jdx, bfeature in enumerate(all_fbuffers):
|
||||
bframe = frames[jdx]
|
||||
|
||||
if bframe.is_last:
|
||||
final_frames.append(bframe)
|
||||
final_fbuffers.append(bfeature)
|
||||
final_right_paddings.append(right_paddings[jdx])
|
||||
else:
|
||||
nonfinal_frames.append(bframe)
|
||||
nonfinal_fbuffers.append(bfeature)
|
||||
|
||||
if len(nonfinal_frames) > 0:
|
||||
self.cache_aware_transcribe_step(
|
||||
nonfinal_frames, nonfinal_fbuffers, None, ready_state_ids, keep_all_outputs=False
|
||||
)
|
||||
|
||||
if len(final_frames) > 0:
|
||||
self.cache_aware_transcribe_step(
|
||||
final_frames, final_fbuffers, final_right_paddings, ready_state_ids, keep_all_outputs=True
|
||||
)
|
||||
|
||||
# post-process the ready states
|
||||
if len(ready_state_ids) > 0:
|
||||
self.text_processor.process([self.get_state(stream_id) for stream_id in ready_state_ids])
|
||||
ready_state_ids.clear()
|
||||
|
||||
self.update_partial_transcript(frames, self.tokenizer, self.leading_regex_pattern)
|
||||
|
||||
def get_request_generator(self) -> ContinuousBatchedRequestStreamer:
|
||||
"""
|
||||
Initialize the request generator.
|
||||
Returns:
|
||||
(ContinuousBatchedRequestStreamer) Request generator.
|
||||
"""
|
||||
# for cache aware streaming we need to process one frame at a time -> n_frames_per_stream=1
|
||||
request_generator = ContinuousBatchedRequestStreamer(
|
||||
n_frames_per_stream=1,
|
||||
frame_size_in_secs=self.chunk_size_in_secs,
|
||||
sample_rate=self.sample_rate,
|
||||
batch_size=self.batch_size,
|
||||
request_type=self.request_type,
|
||||
preprocessor=self.preprocessor,
|
||||
buffer_size_in_secs=self.buffer_size_in_secs,
|
||||
device=self.device,
|
||||
pad_last_frame=True,
|
||||
)
|
||||
return request_generator
|
||||
@@ -0,0 +1,79 @@
|
||||
# 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.
|
||||
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from nemo.collections.asr.inference.streaming.framing.request import Request
|
||||
from nemo.collections.asr.inference.streaming.framing.request_options import ASRRequestOptions
|
||||
|
||||
|
||||
class PipelineInterface(ABC):
|
||||
"""
|
||||
The base interface for streaming speech pipelines
|
||||
Base usage for all pipelines:
|
||||
pipeline.start_session()
|
||||
for requests in request_generator:
|
||||
pipeline.transcribe_step(requests)
|
||||
pipeline.close_session()
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def open_session(self):
|
||||
"""
|
||||
Open a new session
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def close_session(self):
|
||||
"""
|
||||
End the current session
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_state(self, stream_id: int):
|
||||
"""
|
||||
Get the state of the stream
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def delete_state(self, stream_id: int):
|
||||
"""
|
||||
Delete the state of the stream
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def create_state(self, options: ASRRequestOptions):
|
||||
"""
|
||||
Create a new empty state
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def init_state(self, stream_id: int, options: ASRRequestOptions):
|
||||
"""
|
||||
Initialize the state of the stream
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def transcribe_step(self, requests: list[Request]):
|
||||
"""
|
||||
Transcribe a step
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,131 @@
|
||||
# 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 torch
|
||||
from torch import Tensor
|
||||
from nemo.collections.asr.inference.streaming.framing.request import Frame
|
||||
|
||||
|
||||
class AudioBufferer:
|
||||
"""
|
||||
Audio bufferer class
|
||||
It buffers the audio chunks and maintains the buffer.
|
||||
"""
|
||||
|
||||
def __init__(self, sample_rate: int, buffer_size_in_secs: float):
|
||||
"""
|
||||
Args:
|
||||
sample_rate (int): sample rate
|
||||
buffer_size_in_secs (float): buffer size in seconds
|
||||
"""
|
||||
self.buffer_size = int(buffer_size_in_secs * sample_rate)
|
||||
self.sample_buffer = torch.zeros(self.buffer_size, dtype=torch.float32)
|
||||
self.left_padding = self.buffer_size
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
Reset the buffer to zero
|
||||
"""
|
||||
self.sample_buffer.zero_()
|
||||
self.left_padding = self.buffer_size
|
||||
|
||||
def update(self, frame: Frame) -> None:
|
||||
"""
|
||||
Update the buffer with the new frame
|
||||
Args:
|
||||
frame (Frame): frame to update the buffer with
|
||||
"""
|
||||
if frame.size > self.buffer_size:
|
||||
raise RuntimeError(f"Frame size ({frame.size}) exceeds buffer size ({self.buffer_size})")
|
||||
|
||||
shift = frame.size
|
||||
self.sample_buffer = torch.roll(self.sample_buffer, -shift)
|
||||
self.sample_buffer[-shift:].copy_(frame.samples)
|
||||
self.left_padding = max(0, self.left_padding - shift)
|
||||
|
||||
def get_buffer(self) -> Tensor:
|
||||
"""
|
||||
Get the current buffer
|
||||
Returns:
|
||||
Tensor: current state of the buffer
|
||||
"""
|
||||
return self.sample_buffer.clone()
|
||||
|
||||
def get_left_padding(self) -> int:
|
||||
"""
|
||||
Get the left padding
|
||||
Returns:
|
||||
int: left padding
|
||||
"""
|
||||
return self.left_padding
|
||||
|
||||
|
||||
class BatchedAudioBufferer:
|
||||
"""
|
||||
Batched audio bufferer class
|
||||
It buffers the audio chunks from multiple streams and returns the buffers.
|
||||
"""
|
||||
|
||||
def __init__(self, sample_rate: int, buffer_size_in_secs: float):
|
||||
"""
|
||||
Args:
|
||||
sample_rate (int): sample rate
|
||||
buffer_size_in_secs (float): buffer size in seconds
|
||||
"""
|
||||
self.sample_rate = sample_rate
|
||||
self.buffer_size_in_secs = buffer_size_in_secs
|
||||
self.bufferers = {}
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
Reset bufferers
|
||||
"""
|
||||
self.bufferers = {}
|
||||
|
||||
def rm_bufferer(self, stream_id: int) -> None:
|
||||
"""
|
||||
Remove bufferer for the given stream id
|
||||
Args:
|
||||
stream_id (int): stream id
|
||||
"""
|
||||
self.bufferers.pop(stream_id, None)
|
||||
|
||||
def update(self, frames: list[Frame]) -> tuple[list[Tensor], list[int]]:
|
||||
"""
|
||||
Update the bufferers with the new frames.
|
||||
Frames can come from different streams (audios), so we need to maintain a bufferer for each stream
|
||||
Args:
|
||||
frames (list[Frame]): list of frames
|
||||
Returns:
|
||||
tuple[list[Tensor], list[int]]:
|
||||
buffers: list of buffered audio tensors, one per input frame
|
||||
left_paddings: list of left paddings, one per input frame
|
||||
"""
|
||||
buffers, left_paddings = [], []
|
||||
for frame in frames:
|
||||
bufferer = self.bufferers.get(frame.stream_id, None)
|
||||
|
||||
if bufferer is None:
|
||||
bufferer = AudioBufferer(self.sample_rate, self.buffer_size_in_secs)
|
||||
self.bufferers[frame.stream_id] = bufferer
|
||||
|
||||
bufferer.update(frame)
|
||||
buffers.append(bufferer.get_buffer())
|
||||
left_paddings.append(bufferer.get_left_padding())
|
||||
|
||||
if frame.is_last:
|
||||
self.rm_bufferer(frame.stream_id)
|
||||
|
||||
return buffers, left_paddings
|
||||
@@ -0,0 +1,225 @@
|
||||
# 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 math
|
||||
from queue import Queue
|
||||
|
||||
import torch
|
||||
from omegaconf import DictConfig
|
||||
from torch import Tensor
|
||||
|
||||
from nemo.collections.asr.inference.streaming.buffering.audio_bufferer import AudioBufferer
|
||||
from nemo.collections.asr.inference.streaming.framing.request import Frame
|
||||
from nemo.collections.asr.inference.utils.constants import LOG_MEL_ZERO
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
|
||||
|
||||
class BatchedCacheFeatureBufferer:
|
||||
"""
|
||||
Batched cache feature bufferer class
|
||||
Buffers feature chunks from multiple audio streams and manages their storage.
|
||||
Maintains a tensor of shape (num_slots, n_feat, feature_buffer_len), where each slot
|
||||
corresponds to a single audio stream. The number of slots equals the number of
|
||||
active (or open) audio streams.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
num_slots: int,
|
||||
sample_rate: int,
|
||||
buffer_size_in_secs: float,
|
||||
chunk_size_in_secs: float,
|
||||
preprocessor_cfg: DictConfig,
|
||||
device: torch.device,
|
||||
fill_value: float = LOG_MEL_ZERO,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
num_slots (int): number of slots, where each slot contains feature buffer for a single audio stream
|
||||
sample_rate (int): sample rate
|
||||
buffer_size_in_secs (float): buffer size in seconds
|
||||
chunk_size_in_secs (float): chunk size in seconds
|
||||
preprocessor_cfg (DictConfig): preprocessor configuration
|
||||
device (torch.device): device
|
||||
fill_value (float): fill value for the feature buffer
|
||||
"""
|
||||
if buffer_size_in_secs < chunk_size_in_secs:
|
||||
raise ValueError(
|
||||
f"Buffer size ({buffer_size_in_secs}s) should be no less than chunk size ({chunk_size_in_secs}s)"
|
||||
)
|
||||
|
||||
self.num_slots = num_slots
|
||||
self.sample_rate = sample_rate
|
||||
self.buffer_size_in_secs = buffer_size_in_secs
|
||||
self.chunk_size_in_secs = chunk_size_in_secs
|
||||
self.preprocessor_cfg = preprocessor_cfg
|
||||
self.device = device
|
||||
|
||||
self.is_buffer_size_equal_to_chunk_size = math.isclose(self.buffer_size_in_secs, self.chunk_size_in_secs)
|
||||
self.plus_one = 0 if self.is_buffer_size_equal_to_chunk_size else 1
|
||||
|
||||
if hasattr(preprocessor_cfg, 'log') and preprocessor_cfg.log:
|
||||
self.ZERO_LEVEL_SPEC_DB_VAL = LOG_MEL_ZERO # Log-Mel spectrogram value for zero signals
|
||||
else:
|
||||
self.ZERO_LEVEL_SPEC_DB_VAL = fill_value # Custom fill value for the feature buffer
|
||||
|
||||
self.n_feat = preprocessor_cfg.features
|
||||
self.timestep_duration = preprocessor_cfg.window_stride
|
||||
self.n_chunk_look_back = int(self.timestep_duration * self.sample_rate)
|
||||
self.chunk_size = int(self.chunk_size_in_secs * self.sample_rate)
|
||||
self.extended_chunk_size = self.n_chunk_look_back + self.chunk_size
|
||||
self.audio_bufferers = [
|
||||
AudioBufferer(self.sample_rate, self.buffer_size_in_secs) for _ in range(self.num_slots)
|
||||
]
|
||||
|
||||
self.feature_buffer_len = int(buffer_size_in_secs / self.timestep_duration)
|
||||
self.feature_chunk_len = int(chunk_size_in_secs / self.timestep_duration)
|
||||
self.feature_buffer = torch.full(
|
||||
[self.num_slots, self.n_feat, self.feature_buffer_len],
|
||||
self.ZERO_LEVEL_SPEC_DB_VAL,
|
||||
dtype=torch.float32,
|
||||
device=self.device,
|
||||
)
|
||||
|
||||
self.preprocessor = ASRModel.from_config_dict(preprocessor_cfg)
|
||||
self.preprocessor.to(self.device)
|
||||
|
||||
self.streamidx2slotidx, self.slotidx2streamidx = {}, {}
|
||||
self.available_slots = Queue(self.num_slots)
|
||||
for i in range(self.num_slots):
|
||||
self.available_slots.put(i)
|
||||
|
||||
def free_slots(self, slot_ids: list[int]) -> None:
|
||||
"""
|
||||
Free the slots for the given slot_ids
|
||||
Args:
|
||||
slot_ids (list[int]): list of slot ids
|
||||
"""
|
||||
for slot_id in slot_ids:
|
||||
if slot_id not in self.slotidx2streamidx:
|
||||
continue
|
||||
self.available_slots.put(slot_id)
|
||||
stream_id = self.slotidx2streamidx[slot_id]
|
||||
del self.slotidx2streamidx[slot_id], self.streamidx2slotidx[stream_id]
|
||||
|
||||
def reset_slots(self, slot_ids: list[int]) -> None:
|
||||
"""
|
||||
Reset the slots for the given slot_ids
|
||||
Args:
|
||||
slot_ids (list[int]): list of slot ids
|
||||
"""
|
||||
slot_ids_tensor = torch.tensor(slot_ids, device=self.device, dtype=torch.long)
|
||||
self.feature_buffer.index_fill_(0, slot_ids_tensor, self.ZERO_LEVEL_SPEC_DB_VAL)
|
||||
for slot_id in slot_ids:
|
||||
self.audio_bufferers[slot_id].reset()
|
||||
|
||||
def preprocess(
|
||||
self, audio_buffers: list[Tensor], right_paddings: Tensor, expected_feat_len: int
|
||||
) -> tuple[Tensor, Tensor]:
|
||||
"""
|
||||
Preprocess the audio buffers with the given right paddings and expected feature length
|
||||
Args:
|
||||
audio_buffers (list[Tensor]): list of audio buffers
|
||||
right_paddings (Tensor): right paddings: right paddings are not zero for last frames
|
||||
expected_feat_len (int): expected feature length
|
||||
Returns:
|
||||
tuple[Tensor, Tensor]: features and right paddings
|
||||
"""
|
||||
signals = torch.vstack(audio_buffers).to(self.device) # B x T
|
||||
signals_len = torch.tensor([signals.shape[1]] * signals.shape[0], device=self.device, dtype=torch.long) # B
|
||||
signals_len = signals_len - right_paddings.long()
|
||||
features, feature_lens = self.preprocessor(input_signal=signals, length=signals_len)
|
||||
if features.shape[2] > expected_feat_len:
|
||||
features = features[:, :, :expected_feat_len] # B x F x T
|
||||
feature_lens = feature_lens.clamp(max=expected_feat_len)
|
||||
right_padding = (features.shape[2] - feature_lens).clamp(min=0).to(torch.long)
|
||||
return features, right_padding
|
||||
|
||||
def _update_feature_buffer(self, slot_ids: list[int], feat_chunk: Tensor) -> None:
|
||||
"""
|
||||
Add an extracted feature to `feature_buffer`
|
||||
Args:
|
||||
slot_ids (list[int]): list of slot ids
|
||||
feat_chunk (Tensor): feature chunk of shape (B, F, T)
|
||||
"""
|
||||
for i, slot_id in enumerate(slot_ids):
|
||||
chunk_len = feat_chunk[i].shape[-1]
|
||||
if chunk_len > self.feature_buffer_len:
|
||||
raise ValueError(f"feat_chunk ({chunk_len}) longer than buffer ({self.feature_buffer_len})")
|
||||
|
||||
shifted = self.feature_buffer[slot_id, :, chunk_len:].clone()
|
||||
self.feature_buffer[slot_id, :, :-chunk_len].copy_(shifted)
|
||||
self.feature_buffer[slot_id, :, -chunk_len:].copy_(feat_chunk[i])
|
||||
|
||||
def update(self, frames: list[Frame]) -> tuple[list[Tensor], list[int]]:
|
||||
"""
|
||||
Update the feature bufferers with the new frames.
|
||||
Args:
|
||||
frames (list[Frame]): list of frames with length equal to batch size
|
||||
Returns:
|
||||
tuple[list[Tensor], list[int]]: feature buffers and right paddings
|
||||
"""
|
||||
# if there are no frames, return empty lists
|
||||
if len(frames) == 0:
|
||||
return [], []
|
||||
|
||||
# if the stream_id is new, we need to assign a slot to it
|
||||
slot_ids, slots_to_reset, slots_to_free = [], [], []
|
||||
for frame in frames:
|
||||
stream_id = frame.stream_id
|
||||
slot_idx = self.streamidx2slotidx.get(stream_id, None)
|
||||
if stream_id not in self.streamidx2slotidx:
|
||||
if self.available_slots.empty():
|
||||
raise RuntimeError("No free slots available")
|
||||
slot_idx = self.available_slots.get()
|
||||
self.streamidx2slotidx[stream_id] = slot_idx
|
||||
self.slotidx2streamidx[slot_idx] = stream_id
|
||||
slots_to_reset.append(slot_idx)
|
||||
|
||||
slot_ids.append(slot_idx)
|
||||
if frame.is_last:
|
||||
slots_to_free.append(slot_idx)
|
||||
|
||||
# reset the slots for the new stream_ids
|
||||
if len(slots_to_reset) > 0:
|
||||
self.reset_slots(slots_to_reset)
|
||||
|
||||
right_paddings = torch.zeros(len(frames), dtype=torch.long, device=self.device)
|
||||
audio_buffers = []
|
||||
for i, frame in enumerate(frames):
|
||||
slot_id = slot_ids[i]
|
||||
right_paddings[i] = frame.size - frame.valid_size
|
||||
self.audio_bufferers[slot_id].update(frame)
|
||||
|
||||
buffer = self.audio_bufferers[slot_id].sample_buffer
|
||||
if not self.is_buffer_size_equal_to_chunk_size:
|
||||
# Add look_back to have context for the first feature
|
||||
audio_buffers.append(buffer[-(self.n_chunk_look_back + self.chunk_size) :])
|
||||
else:
|
||||
# If the buffer size is equal to the chunk size, just take the whole buffer
|
||||
audio_buffers.append(buffer)
|
||||
|
||||
features, right_paddings = self.preprocess(
|
||||
audio_buffers=audio_buffers,
|
||||
right_paddings=right_paddings,
|
||||
expected_feat_len=self.feature_chunk_len + self.plus_one,
|
||||
)
|
||||
self._update_feature_buffer(slot_ids=slot_ids, feat_chunk=features[:, :, -self.feature_chunk_len :])
|
||||
fbuffers = list(self.feature_buffer[slot_ids].unbind(0))
|
||||
|
||||
if len(slots_to_free) > 0:
|
||||
self.free_slots(slots_to_free)
|
||||
|
||||
return fbuffers, right_paddings.tolist()
|
||||
@@ -0,0 +1,160 @@
|
||||
# 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 torch
|
||||
from omegaconf import DictConfig
|
||||
|
||||
from nemo.collections.asr.inference.streaming.framing.request import FeatureBuffer
|
||||
from nemo.collections.asr.inference.utils.constants import LOG_MEL_ZERO
|
||||
|
||||
|
||||
class FeatureBufferer:
|
||||
"""
|
||||
Feature bufferer class
|
||||
It buffers the feature chunks and maintains the buffer.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sample_rate: int,
|
||||
buffer_size_in_secs: float,
|
||||
preprocessor_cfg: DictConfig,
|
||||
device: torch.device,
|
||||
fill_value: float = LOG_MEL_ZERO,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
sample_rate (int): sample rate
|
||||
buffer_size_in_secs (float): buffer size in seconds
|
||||
preprocessor_cfg (DictConfig): preprocessor config
|
||||
device (torch.device): device
|
||||
fill_value (float): value to fill the feature buffer with
|
||||
"""
|
||||
self.sample_rate = sample_rate
|
||||
self.buffer_size_in_secs = buffer_size_in_secs
|
||||
self.device = device
|
||||
|
||||
if hasattr(preprocessor_cfg, 'log') and preprocessor_cfg.log:
|
||||
self.ZERO_LEVEL_SPEC_DB_VAL = LOG_MEL_ZERO
|
||||
else:
|
||||
self.ZERO_LEVEL_SPEC_DB_VAL = fill_value
|
||||
|
||||
self.n_feat = preprocessor_cfg.features
|
||||
self.feature_buffer_len = int(buffer_size_in_secs / preprocessor_cfg.window_stride)
|
||||
self.feature_buffer = torch.full(
|
||||
[self.n_feat, self.feature_buffer_len],
|
||||
self.ZERO_LEVEL_SPEC_DB_VAL,
|
||||
dtype=torch.float32,
|
||||
device=self.device,
|
||||
)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
Reset the buffer to zero
|
||||
"""
|
||||
self.feature_buffer.fill_(self.ZERO_LEVEL_SPEC_DB_VAL)
|
||||
|
||||
def update(self, fbuffer: FeatureBuffer) -> None:
|
||||
"""
|
||||
Replace feature buffer with new data
|
||||
Args:
|
||||
fbuffer (FeatureBuffer): feature buffer to update
|
||||
"""
|
||||
# Resize if needed (optional)
|
||||
if fbuffer.size != self.feature_buffer.shape[1]:
|
||||
self.feature_buffer = torch.full(
|
||||
[self.n_feat, fbuffer.size],
|
||||
self.ZERO_LEVEL_SPEC_DB_VAL,
|
||||
dtype=torch.float32,
|
||||
device=self.device,
|
||||
)
|
||||
|
||||
self.feature_buffer.copy_(fbuffer.features)
|
||||
|
||||
def get_feature_buffer(self) -> torch.Tensor:
|
||||
"""
|
||||
Get the current feature buffer
|
||||
Returns:
|
||||
torch.Tensor: current state of the feature buffer
|
||||
"""
|
||||
return self.feature_buffer.clone()
|
||||
|
||||
|
||||
class BatchedFeatureBufferer:
|
||||
"""
|
||||
Batched feature bufferer class
|
||||
It buffers the feature chunks from multiple streams and maintains the buffers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sample_rate: int,
|
||||
buffer_size_in_secs: float,
|
||||
preprocessor_cfg: DictConfig,
|
||||
device: torch.device,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
sample_rate (int): sample rate
|
||||
buffer_size_in_secs (float): buffer size in seconds
|
||||
preprocessor_cfg (DictConfig): preprocessor config
|
||||
device (torch.device): device
|
||||
"""
|
||||
self.sample_rate = sample_rate
|
||||
self.buffer_size_in_secs = buffer_size_in_secs
|
||||
self.preprocessor_cfg = preprocessor_cfg
|
||||
self.device = device
|
||||
self.bufferers = {}
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset bufferers"""
|
||||
self.bufferers = {}
|
||||
|
||||
def rm_bufferer(self, stream_id: int) -> None:
|
||||
"""
|
||||
Remove bufferer for the given stream id
|
||||
Args:
|
||||
stream_id (int): stream id
|
||||
"""
|
||||
self.bufferers.pop(stream_id, None)
|
||||
|
||||
def update(self, fbuffers: list[FeatureBuffer]) -> list[torch.Tensor]:
|
||||
"""
|
||||
Update the feature bufferers with the new feature buffers.
|
||||
Feature buffers can come from different streams (audios), so we need to maintain a bufferer for each stream.
|
||||
Args:
|
||||
fbuffers (list[FeatureBuffer]): list of feature buffers
|
||||
Returns:
|
||||
list[torch.Tensor]: list of feature buffers, one per input frame
|
||||
"""
|
||||
result_buffers = []
|
||||
for fbuffer in fbuffers:
|
||||
bufferer = self.bufferers.get(fbuffer.stream_id, None)
|
||||
|
||||
if bufferer is None:
|
||||
bufferer = FeatureBufferer(
|
||||
self.sample_rate,
|
||||
self.buffer_size_in_secs,
|
||||
self.preprocessor_cfg,
|
||||
self.device,
|
||||
)
|
||||
self.bufferers[fbuffer.stream_id] = bufferer
|
||||
|
||||
bufferer.update(fbuffer)
|
||||
result_buffers.append(bufferer.get_feature_buffer())
|
||||
|
||||
if fbuffer.is_last:
|
||||
self.rm_bufferer(fbuffer.stream_id)
|
||||
|
||||
return result_buffers
|
||||
@@ -0,0 +1,173 @@
|
||||
# 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 torch
|
||||
from torch import Tensor
|
||||
from nemo.collections.asr.inference.streaming.framing.request import Frame
|
||||
|
||||
|
||||
class IncrementalAudioBufferer:
|
||||
"""
|
||||
Incremental Audio bufferer class
|
||||
It buffers the audio chunks and maintains the buffer.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sample_rate: int,
|
||||
buffer_size_in_secs: float,
|
||||
chunk_size_in_secs: float,
|
||||
overlap_size_in_secs: float,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
sample_rate (int): sample rate
|
||||
buffer_size_in_secs (float): buffer size in seconds
|
||||
chunk_size_in_secs (float): chunk size in seconds
|
||||
overlap_size_in_secs (float): overlap size in seconds
|
||||
"""
|
||||
self.sample_rate = sample_rate
|
||||
self.buffer_size = int(buffer_size_in_secs * sample_rate)
|
||||
self.chunk_size = int(chunk_size_in_secs * sample_rate)
|
||||
self.overlap_size = int(overlap_size_in_secs * sample_rate)
|
||||
|
||||
# Ensure overlap is within buffer bounds to keep drop_size non-negative and meaningful.
|
||||
if not (0 <= self.overlap_size <= self.buffer_size):
|
||||
raise ValueError(
|
||||
f"Overlap size in samples ({self.overlap_size}) must satisfy "
|
||||
f"0 <= overlap_size <= buffer_size ({self.buffer_size})."
|
||||
)
|
||||
|
||||
if self.buffer_size % self.chunk_size != 0:
|
||||
raise ValueError(f"Buffer size ({self.buffer_size}) must be divisible by chunk size ({self.chunk_size})")
|
||||
|
||||
if self.overlap_size % self.chunk_size != 0:
|
||||
raise ValueError(f"Overlap size ({self.overlap_size}) must be divisible by chunk size ({self.chunk_size})")
|
||||
|
||||
self.drop_size = self.buffer_size - self.overlap_size
|
||||
self.sample_buffer = torch.zeros(self.buffer_size, dtype=torch.float32)
|
||||
self.remaining_capacity = self.buffer_size
|
||||
self.head = 0
|
||||
|
||||
def is_full(self) -> bool:
|
||||
"""
|
||||
Check if the buffer is full
|
||||
Returns:
|
||||
bool: True if the buffer is full, False otherwise
|
||||
"""
|
||||
return self.remaining_capacity == 0
|
||||
|
||||
def update(self, frame: Frame) -> None:
|
||||
"""
|
||||
Update the buffer with the new frame
|
||||
Args:
|
||||
frame (Frame): frame to update the buffer with
|
||||
"""
|
||||
if frame.size > self.buffer_size:
|
||||
raise RuntimeError(f"Frame size ({frame.size}) exceeds buffer size ({self.buffer_size})")
|
||||
|
||||
if self.is_full():
|
||||
# Drop the oldest chunk to make space for the new chunk
|
||||
self.sample_buffer[0 : self.drop_size].zero_()
|
||||
self.sample_buffer = torch.roll(self.sample_buffer, -self.drop_size)
|
||||
self.head -= self.drop_size
|
||||
self.remaining_capacity += self.drop_size
|
||||
|
||||
self.sample_buffer[self.head : self.head + frame.size].copy_(frame.samples)
|
||||
self.head += frame.size
|
||||
self.remaining_capacity = max(0, self.remaining_capacity - frame.size)
|
||||
|
||||
|
||||
class BatchedIncrementalAudioBufferer:
|
||||
"""
|
||||
Batched incremental audio bufferer class
|
||||
It buffers the audio chunks from multiple streams and returns the buffers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sample_rate: int,
|
||||
buffer_size_in_secs: float,
|
||||
chunk_size_in_secs: float,
|
||||
overlap_size_in_secs: float,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
sample_rate (int): sample rate
|
||||
buffer_size_in_secs (float): buffer size in seconds
|
||||
chunk_size_in_secs (float): chunk size in seconds
|
||||
overlap_size_in_secs (float): overlap size in seconds
|
||||
"""
|
||||
self.sample_rate = sample_rate
|
||||
self.buffer_size_in_secs = buffer_size_in_secs
|
||||
self.chunk_size_in_secs = chunk_size_in_secs
|
||||
self.overlap_size_in_secs = overlap_size_in_secs
|
||||
self.bufferers = {}
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
Reset bufferers
|
||||
"""
|
||||
self.bufferers = {}
|
||||
|
||||
def rm_bufferer(self, stream_id: int) -> None:
|
||||
"""
|
||||
Remove bufferer for the given stream id
|
||||
Args:
|
||||
stream_id (int): stream id
|
||||
"""
|
||||
self.bufferers.pop(stream_id, None)
|
||||
|
||||
def is_full(self, stream_id: int) -> bool | None:
|
||||
"""
|
||||
Check if the buffer is full for the given stream id
|
||||
Returns:
|
||||
bool | None: True if the buffer is full, False otherwise
|
||||
"""
|
||||
if stream_id not in self.bufferers:
|
||||
return None
|
||||
return self.bufferers[stream_id].is_full()
|
||||
|
||||
def update(self, frames: list[Frame]) -> tuple[list[Tensor], list[int]]:
|
||||
"""
|
||||
Update the bufferers with the new frames.
|
||||
Frames can come from different streams (audios), so we need to maintain a bufferer for each stream
|
||||
Args:
|
||||
frames (list[Frame]): list of frames
|
||||
Returns:
|
||||
tuple[list[Tensor], list[int]]:
|
||||
buffers: list of buffered audio tensors, one per input frame
|
||||
paddings: list of paddings, one per input frame
|
||||
"""
|
||||
buffers, paddings = [], []
|
||||
for frame in frames:
|
||||
bufferer = self.bufferers.get(frame.stream_id, None)
|
||||
|
||||
if bufferer is None:
|
||||
bufferer = IncrementalAudioBufferer(
|
||||
self.sample_rate,
|
||||
self.buffer_size_in_secs,
|
||||
self.chunk_size_in_secs,
|
||||
self.overlap_size_in_secs,
|
||||
)
|
||||
self.bufferers[frame.stream_id] = bufferer
|
||||
|
||||
bufferer.update(frame)
|
||||
buffers.append(bufferer.sample_buffer.clone())
|
||||
paddings.append(bufferer.remaining_capacity)
|
||||
|
||||
if frame.is_last:
|
||||
self.rm_bufferer(frame.stream_id)
|
||||
|
||||
return buffers, paddings
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,199 @@
|
||||
# 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.
|
||||
|
||||
|
||||
from typing import Callable
|
||||
import torch
|
||||
|
||||
from nemo.collections.asr.inference.streaming.decoders.greedy.greedy_decoder import GreedyDecoder
|
||||
|
||||
|
||||
class CTCGreedyDecoder(GreedyDecoder):
|
||||
"""CTC Greedy decoder class"""
|
||||
|
||||
def __init__(self, vocabulary: list[str], conf_func: Callable = None):
|
||||
"""
|
||||
Initialize the CTCGreedyDecoder
|
||||
Args:
|
||||
vocabulary (list[str]): list of vocabulary tokens
|
||||
conf_func (Callable): function to compute confidence
|
||||
"""
|
||||
|
||||
super().__init__(vocabulary, conf_func)
|
||||
|
||||
@staticmethod
|
||||
def get_labels(log_probs: torch.Tensor) -> list[int]:
|
||||
"""
|
||||
Perform greedy decoding on the log probabilities
|
||||
Args:
|
||||
log_probs (torch.Tensor): log probabilities
|
||||
Returns:
|
||||
list[int]: list of tokens
|
||||
"""
|
||||
if log_probs.dim() != 2:
|
||||
raise ValueError("log_probs must be 2D tensor")
|
||||
|
||||
labels = log_probs.argmax(dim=-1).cpu() # T
|
||||
return labels.tolist()
|
||||
|
||||
def __call__(self, log_probs: torch.Tensor, compute_confidence: bool = True, previous: int = None) -> dict:
|
||||
"""
|
||||
Greedy decode the log probabilities
|
||||
Args:
|
||||
log_probs (torch.Tensor): log probabilities
|
||||
compute_confidence (bool): compute confidence or not
|
||||
Returns:
|
||||
dict: output dictionary containing tokens, timesteps, and confidences
|
||||
"""
|
||||
|
||||
compute_confidence = compute_confidence and self.conf_func is not None
|
||||
|
||||
if log_probs.dim() != 2:
|
||||
raise ValueError("log_probs must be 2D tensor")
|
||||
|
||||
if compute_confidence:
|
||||
# Add batch dimension
|
||||
log_probs = log_probs.unsqueeze(0) # 1 x T x N
|
||||
# Compute confidences
|
||||
confidences = torch.zeros(log_probs.shape[0], log_probs.shape[1]) # 1 x T
|
||||
confidences[0] = self.conf_func(log_probs[0], v=log_probs.shape[2]) # 1 x T
|
||||
# Remove batch dimension and convert to list
|
||||
confidences = confidences.squeeze(0).tolist() # T
|
||||
# Remove batch dimension
|
||||
log_probs = log_probs.squeeze(0) # T x N
|
||||
|
||||
labels = self.get_labels(log_probs) # T
|
||||
output = {"tokens": [], "timesteps": [], "confidences": []}
|
||||
previous = self.blank_id if previous is None else previous
|
||||
for i, p in enumerate(labels):
|
||||
if p != previous and p != self.blank_id:
|
||||
output["tokens"].append(p)
|
||||
output["timesteps"].append(i)
|
||||
if compute_confidence:
|
||||
output["confidences"].append(confidences[i])
|
||||
previous = p
|
||||
|
||||
output["labels"] = labels
|
||||
return output
|
||||
|
||||
|
||||
class ClippedCTCGreedyDecoder:
|
||||
"""
|
||||
Clipped CTC Greedy decoder class
|
||||
Decodes the tokens within a given clip range and returns the clipped tokens and timestamps.
|
||||
"""
|
||||
|
||||
def __init__(self, vocabulary: list[str], tokens_per_frame: int, conf_func: Callable = None, endpointer=None):
|
||||
"""
|
||||
Initialize the ClippedCTCGreedyDecoder
|
||||
Args:
|
||||
vocabulary (list[str]): list of vocabulary tokens
|
||||
tokens_per_frame (int): number of tokens per frame
|
||||
conf_func (Callable): function to compute confidence
|
||||
endpointer (Any): endpointer to detect EOU
|
||||
"""
|
||||
self.greedy_decoder = CTCGreedyDecoder(vocabulary, conf_func)
|
||||
self.endpointer = endpointer
|
||||
self.tokens_per_frame = tokens_per_frame
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
log_probs: torch.Tensor,
|
||||
clip_start: int,
|
||||
clip_end: int,
|
||||
is_last: bool = False,
|
||||
is_start: bool = True,
|
||||
return_partial_result: bool = True,
|
||||
state_start_idx: int = 0,
|
||||
state_end_idx: int = 0,
|
||||
stop_history_eou: int = None,
|
||||
compute_confidence: bool = True,
|
||||
) -> tuple[dict, dict, bool, int, int]:
|
||||
"""
|
||||
Decode the log probabilities within the clip range (clip_start, clip_end)
|
||||
Args:
|
||||
log_probs (torch.Tensor): log probabilities
|
||||
clip_start (int): start index of the clip
|
||||
clip_end (int): end index of the clip
|
||||
is_last (bool): is the last frame or not
|
||||
is_start (bool): is the first frame for this stream or not
|
||||
return_partial_result (bool): return partial result left after clip_end in the buffer
|
||||
state_start_idx (int): start index from stream state
|
||||
state_end_idx (int): end index from stream state
|
||||
stop_history_eou (int): stop history of EOU, if None then use the default stop history
|
||||
compute_confidence (bool): compute confidence or not
|
||||
Returns:
|
||||
tuple[dict, dict, bool, int, int]:
|
||||
clipped output, tail output, is_eou, updated start_idx, updated end_idx
|
||||
"""
|
||||
|
||||
is_eou = is_last
|
||||
eou_detected_at = len(log_probs)
|
||||
# Initialize state tracking variables from input parameters
|
||||
start_idx, end_idx = state_start_idx, state_end_idx
|
||||
# Update indices for next processing step
|
||||
if end_idx > clip_start:
|
||||
end_idx -= self.tokens_per_frame
|
||||
start_idx = end_idx
|
||||
|
||||
if is_start or end_idx <= clip_start:
|
||||
start_idx, end_idx = clip_start, clip_end
|
||||
|
||||
all_output = self.greedy_decoder(log_probs, compute_confidence=compute_confidence)
|
||||
|
||||
clipped_output = {"tokens": [], "timesteps": [], "confidences": [], "last_token": None, "last_token_idx": None}
|
||||
tail_output = {"tokens": []}
|
||||
|
||||
# check if EOU is detected or is the last frame
|
||||
if not is_eou and self.endpointer is not None:
|
||||
is_eou, eou_detected_at = self.endpointer.detect_eou(
|
||||
log_probs, pivot_point=start_idx, search_start_point=clip_start, stop_history_eou=stop_history_eou
|
||||
)
|
||||
|
||||
# if EOU is detected, and it is after the clip end, update the end index to the EOU
|
||||
if is_eou and eou_detected_at > end_idx:
|
||||
end_idx = eou_detected_at
|
||||
|
||||
# if the end index is within the clip range, update the end index to the clip end
|
||||
if clip_start <= end_idx < clip_end:
|
||||
end_idx = clip_end
|
||||
is_eou = False
|
||||
|
||||
# clip the output within the clip range [clip_start, clip_end)
|
||||
timesteps = all_output["timesteps"]
|
||||
i = 0
|
||||
while i < len(timesteps):
|
||||
if start_idx <= timesteps[i] < end_idx:
|
||||
clipped_output["tokens"].append(all_output["tokens"][i])
|
||||
clipped_output["timesteps"].append(timesteps[i])
|
||||
if compute_confidence:
|
||||
clipped_output["confidences"].append(all_output["confidences"][i])
|
||||
elif timesteps[i] >= end_idx:
|
||||
break
|
||||
i += 1
|
||||
|
||||
if end_idx - 1 < len(all_output["labels"]):
|
||||
clipped_output["last_token"] = all_output["labels"][end_idx - 1]
|
||||
clipped_output["last_token_idx"] = end_idx - 1
|
||||
|
||||
# return the partial result left after clip_end in the buffer
|
||||
if return_partial_result:
|
||||
while i < len(timesteps):
|
||||
if timesteps[i] >= end_idx:
|
||||
tail_output["tokens"] = all_output["tokens"][i:]
|
||||
break
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return clipped_output, tail_output, is_eou, start_idx, end_idx
|
||||
@@ -0,0 +1,102 @@
|
||||
# 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.
|
||||
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from nemo.collections.asr.inference.utils.constants import SENTENCEPIECE_UNDERSCORE
|
||||
|
||||
|
||||
class GreedyDecoder:
|
||||
"""Base class for the greedy decoder"""
|
||||
|
||||
def __init__(self, vocabulary: list[str], conf_func: Callable = None):
|
||||
"""
|
||||
Initialize the GreedyDecoder
|
||||
Args:
|
||||
vocabulary (list[str]): list of vocabulary tokens
|
||||
conf_func (Callable): function to compute confidence
|
||||
"""
|
||||
|
||||
self.vocabulary = vocabulary
|
||||
self.blank_id = len(vocabulary)
|
||||
self.conf_func = conf_func
|
||||
self.is_start_tokens = [token.startswith(SENTENCEPIECE_UNDERSCORE) for token in vocabulary]
|
||||
|
||||
def count_silent_tokens(self, tokens: list[int], start: int, end: int) -> int:
|
||||
"""
|
||||
Count how many silent tokens appear in [start, end).
|
||||
Args:
|
||||
tokens (list[int]): list of tokens
|
||||
start (int): start index
|
||||
end (int): end index
|
||||
Returns:
|
||||
int: number of silent tokens
|
||||
"""
|
||||
if end <= start or start >= len(tokens):
|
||||
return 0
|
||||
return sum(self.is_token_silent(tokens[i]) for i in range(start, min(end, len(tokens))))
|
||||
|
||||
def is_token_start_of_word(self, token_id: int) -> bool:
|
||||
"""
|
||||
Check if the token is the start of a word
|
||||
Args:
|
||||
token_id (int): token id
|
||||
Returns:
|
||||
bool: True if the token is the start of a word, False otherwise
|
||||
"""
|
||||
return self.is_start_tokens[token_id]
|
||||
|
||||
def is_token_silent(self, token_id: int) -> bool:
|
||||
"""
|
||||
Check if the token is silent
|
||||
Args:
|
||||
token_id (int): token id
|
||||
Returns:
|
||||
bool: True if the token is silent, False otherwise
|
||||
"""
|
||||
return token_id == self.blank_id
|
||||
|
||||
def first_non_silent_token(self, tokens: list[int], start: int, end: int) -> int:
|
||||
"""
|
||||
Return the index of the first non-silent token in [start, end).
|
||||
If none found, return -1.
|
||||
Args:
|
||||
tokens (list[int]): list of tokens
|
||||
start (int): start index
|
||||
end (int): end index
|
||||
Returns:
|
||||
int: index of the first non-silent token
|
||||
"""
|
||||
for i in range(start, min(end, len(tokens))):
|
||||
if not self.is_token_silent(tokens[i]):
|
||||
return i
|
||||
return -1
|
||||
|
||||
def count_non_silent_tokens(self, tokens: list[int], start: int, end: int) -> int:
|
||||
"""
|
||||
Count how many non-silent tokens appear in [start, end).
|
||||
Args:
|
||||
tokens (list[int]): list of tokens
|
||||
start (int): start index
|
||||
end (int): end index
|
||||
Returns:
|
||||
int: number of non-silent tokens
|
||||
"""
|
||||
if end <= start or start >= len(tokens):
|
||||
return 0
|
||||
return sum(not self.is_token_silent(tokens[i]) for i in range(start, min(end, len(tokens))))
|
||||
|
||||
def __call__(self, *args, **kwds):
|
||||
raise NotImplementedError("Subclass of GreedyDecoder should implement `__call__` method!")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user