nvidia-nemo--speech
ba4be087d5
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
CICD NeMo / cicd-test-container-build (push) Has been cancelled
CICD NeMo / cicd-import-tests (push) Has been cancelled
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Has been cancelled
CICD NeMo / cicd-main-unit-tests (push) Has been cancelled
CICD NeMo / cicd-main-speech (push) Has been cancelled
CICD NeMo / Nemo_CICD_Test (push) Has been cancelled
CICD NeMo / Coverage (e2e) (push) Has been cancelled
CICD NeMo / Coverage (unit-test) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CICD NeMo / cicd-wait-in-queue (push) Has been cancelled
382 行
14 KiB
Python
382 行
14 KiB
Python
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
|
|
import re
|
|
from typing import Callable
|
|
|
|
from nemo.collections.asr.inference.streaming.framing.request import RequestOptions
|
|
from nemo.collections.asr.inference.utils.constants import POST_WORD_PUNCTUATION
|
|
from nemo.collections.asr.inference.utils.state_management_utils import (
|
|
detect_overlap,
|
|
merge_segment_tail,
|
|
merge_timesteps,
|
|
merge_word_tail,
|
|
)
|
|
from nemo.collections.asr.inference.utils.text_segment import TextSegment, Word
|
|
|
|
CLOSE_IN_TIME_TH = 2.0
|
|
OVERLAP_SEARCH_TH = 3
|
|
|
|
|
|
class StreamingState:
|
|
"""
|
|
Generic state for the streaming ASR pipeline
|
|
"""
|
|
|
|
options: RequestOptions | None
|
|
|
|
def __init__(self):
|
|
"""
|
|
Initialize the StreamingState
|
|
"""
|
|
self._reset_streaming_state()
|
|
|
|
def reset(self) -> None:
|
|
"""
|
|
Reset the state to its initial values
|
|
"""
|
|
self._reset_streaming_state()
|
|
|
|
def _reset_streaming_state(self) -> None:
|
|
"""
|
|
Initialize the state with default values
|
|
"""
|
|
|
|
# Global offset is used to keep track of the timestamps
|
|
self.global_offset = 0
|
|
|
|
# All tokens, timestamps and conf scores that have been processed since the last EOU
|
|
self.tokens = []
|
|
self.timesteps = []
|
|
self.confidences = []
|
|
|
|
# Predicted tokens for the current step
|
|
self.current_step_tokens = []
|
|
|
|
# Last token and its index are used to detect overlap between the current and the previous output
|
|
self.last_token = None
|
|
self.last_token_idx = None
|
|
|
|
# Tokens left in the right padding segment of the buffer
|
|
self.incomplete_segment_tokens = []
|
|
|
|
# final_transcript, partial_transcript, current_step_transcript and final_segments will be sent to the client
|
|
self.final_transcript = ""
|
|
self.partial_transcript = ""
|
|
self.current_step_transcript = ""
|
|
self.concat_with_space = True
|
|
self.final_segments = []
|
|
|
|
# Translation attributes
|
|
self.previous_translation_info = ("", "")
|
|
self.previous_context = ("", "")
|
|
|
|
# Word-level ASR output attributes (cleared after cleanup_after_response):
|
|
# - words: Raw word-level ASR output
|
|
# - pnc_words: Words with punctuation and capitalization
|
|
# - itn_words: Words after applying ITN
|
|
# - word_alignment: ITN word alignment
|
|
# Segment-level ASR output attributes (cleared after cleanup_after_response):
|
|
# - segments: Raw segment-level ASR output
|
|
# - processed_segment_mask: Mask indicating which segments have been processed
|
|
# - final_segments: Final segment-level ASR output
|
|
self.words = []
|
|
self.pnc_words = []
|
|
self.itn_words = []
|
|
self.word_alignment = []
|
|
self.segments = []
|
|
self.processed_segment_mask = []
|
|
|
|
# Flag to indicate if EOU was detected before, used in merging logic
|
|
self.eou_detected_before = False
|
|
|
|
# Used in EoU detection logic
|
|
self.decoder_start_idx = 0
|
|
self.decoder_end_idx = 0
|
|
|
|
# Request options
|
|
self.options = None
|
|
|
|
# Prompt-related index (set by pipelines that use prompts)
|
|
self.prompt_idx = None
|
|
|
|
def set_options(self, options: RequestOptions) -> None:
|
|
"""
|
|
Set the options
|
|
Args:
|
|
options: (RequestOptions) The request options to store in the state
|
|
"""
|
|
self.options = options
|
|
|
|
def set_prompt_index(self, prompt_idx: int) -> None:
|
|
"""
|
|
Store the resolved prompt index for prompt-enabled models.
|
|
Args:
|
|
prompt_idx: (int) The prompt index to store in the state
|
|
"""
|
|
self.prompt_idx = prompt_idx
|
|
|
|
def set_incomplete_segment_tokens(self, incomplete_segment_tokens: list) -> None:
|
|
"""
|
|
Set the partial tokens
|
|
Args:
|
|
incomplete_segment_tokens: (list) The partial tokens to store in the state
|
|
"""
|
|
self.incomplete_segment_tokens = incomplete_segment_tokens
|
|
|
|
def set_global_offset(self, start_offset: float) -> None:
|
|
"""
|
|
Set the global offset
|
|
Args:
|
|
start_offset: (float) The global offset to store in the state
|
|
"""
|
|
self.global_offset = start_offset
|
|
|
|
def set_last_token(self, token: int | None, idx: int | None) -> None:
|
|
"""
|
|
Set the last token
|
|
Args:
|
|
token: (int | None) The last token to store in the state
|
|
idx: (int | None) The index of the last token to store in the state
|
|
"""
|
|
if None not in [token, idx]:
|
|
self.last_token_idx = idx + self.global_offset
|
|
self.last_token = token
|
|
else:
|
|
self.last_token_idx = None
|
|
self.last_token = None
|
|
|
|
def increment_global_offset(self, shift: float) -> None:
|
|
"""
|
|
Increment the global offset by the given shift
|
|
Args:
|
|
shift: (float) The shift to increment the global offset by
|
|
"""
|
|
self.global_offset += shift
|
|
|
|
def _update_state(self, output: dict, skip: int) -> None:
|
|
"""
|
|
Extend the tokens, timesteps and confidences, optionally skipping the first few tokens
|
|
Args:
|
|
output: (dict) The output to update the state with
|
|
skip: (int) The number of tokens to skip
|
|
"""
|
|
current_tokens = output["tokens"]
|
|
current_timesteps = output["timesteps"]
|
|
current_confidences = output["confidences"]
|
|
if skip > 0:
|
|
current_tokens = current_tokens[skip:]
|
|
current_timesteps = current_timesteps[skip:]
|
|
current_confidences = current_confidences[skip:]
|
|
|
|
self.current_step_tokens.extend(current_tokens)
|
|
self.tokens.extend(current_tokens)
|
|
self.confidences.extend(current_confidences)
|
|
self.timesteps = merge_timesteps(self.timesteps, current_timesteps)
|
|
|
|
def update_state(self, completed_output: dict, eou_detected: bool) -> None:
|
|
"""
|
|
Update the state with the completed output
|
|
Args:
|
|
completed_output: (dict) The completed output to update the state with
|
|
eou_detected: (bool) Whether EOU was detected
|
|
"""
|
|
|
|
if len(completed_output) == 0 or len(completed_output["tokens"]) == 0:
|
|
self.last_token = None
|
|
self.last_token_idx = None
|
|
return
|
|
|
|
timesteps = completed_output["timesteps"]
|
|
for i, t in enumerate(timesteps):
|
|
timesteps[i] = t + self.global_offset
|
|
|
|
overlap = 0
|
|
if not self.eou_detected_before:
|
|
overlap = detect_overlap(
|
|
state_tokens=self.tokens,
|
|
state_timesteps=self.timesteps,
|
|
new_tokens=completed_output["tokens"],
|
|
new_timesteps=timesteps,
|
|
overlap_search_th=OVERLAP_SEARCH_TH,
|
|
close_in_time_th=CLOSE_IN_TIME_TH,
|
|
)
|
|
|
|
# In case when the tokens are empty after EoU,
|
|
# we need to check if the last token is the same as the first token of the completed output
|
|
if (
|
|
self.eou_detected_before
|
|
and self.last_token == completed_output["tokens"][0]
|
|
and self.last_token_idx is not None
|
|
and abs(self.last_token_idx - timesteps[0]) <= CLOSE_IN_TIME_TH
|
|
):
|
|
overlap = max(overlap, 1)
|
|
|
|
self._update_state(completed_output, overlap)
|
|
self.eou_detected_before = eou_detected
|
|
|
|
def update_from_decoder_results(self, start_idx: int, end_idx: int) -> None:
|
|
"""
|
|
Update state based on decoder results
|
|
This is used to dynamically understand current token start and end indices
|
|
Args:
|
|
start_idx: (int) The start index of the decoder results
|
|
end_idx: (int) The end index of the decoder results
|
|
"""
|
|
self.decoder_start_idx = start_idx
|
|
self.decoder_end_idx = end_idx
|
|
|
|
def cleanup_translation_info_after_eou(self) -> None:
|
|
"""
|
|
Cleanup the translation info after an EOU is detected
|
|
"""
|
|
self.previous_translation_info = ("", "")
|
|
|
|
def set_translation_info(self, translation: str, prefix: str) -> None:
|
|
"""
|
|
Set the translation info
|
|
Args:
|
|
translation: (str) The translation to store in the state
|
|
prefix: (str) The prefix to store in the state
|
|
"""
|
|
self.previous_translation_info = (translation, prefix)
|
|
|
|
def set_translation_context(self, src_context: str, tgt_context: str) -> None:
|
|
"""
|
|
Set the translation context
|
|
Args:
|
|
src_context: (str) The source context to store in the state
|
|
tgt_context: (str) The target context to store in the state
|
|
"""
|
|
src_context = re.sub(r'\s+', ' ', src_context).strip()
|
|
tgt_context = re.sub(r'\s+', ' ', tgt_context).strip()
|
|
if not (src_context and tgt_context):
|
|
src_context = tgt_context = ""
|
|
|
|
self.previous_context = (src_context, tgt_context)
|
|
|
|
def cleanup_after_eou(self) -> None:
|
|
"""
|
|
Cleanup the state after an EOU is detected
|
|
"""
|
|
self.tokens.clear()
|
|
self.timesteps.clear()
|
|
self.confidences.clear()
|
|
|
|
def cleanup_after_response(self) -> None:
|
|
"""
|
|
Cleanup the state after a response is sent
|
|
Specifically used to clean the state after final transcript is sent
|
|
"""
|
|
|
|
if self.options.is_word_level_output():
|
|
self.words.clear()
|
|
self.pnc_words.clear()
|
|
self.itn_words.clear()
|
|
self.word_alignment.clear()
|
|
else:
|
|
self.segments.clear()
|
|
self.processed_segment_mask.clear()
|
|
|
|
self.final_transcript = ""
|
|
self.final_segments.clear()
|
|
self.current_step_transcript = ""
|
|
self.current_step_tokens.clear()
|
|
self.concat_with_space = True
|
|
|
|
def push_back_segment(
|
|
self,
|
|
segment: TextSegment,
|
|
need_merge: bool,
|
|
conf_aggregator: Callable = None,
|
|
) -> None:
|
|
"""
|
|
Push back the decoded segment to the state
|
|
Args:
|
|
segment: (TextSegment) The decoded segment to push back to the state
|
|
need_merge: (bool) Whether to merge the segment with the last segment in the state
|
|
conf_aggregator: (Callable) The function to aggregate the confidence
|
|
"""
|
|
|
|
# concat_with_space is used to determine if the final transcript should be concatenated with a space
|
|
if len(self.final_segments) == 0 and need_merge:
|
|
self.concat_with_space = False
|
|
else:
|
|
self.concat_with_space = True
|
|
|
|
if need_merge and len(self.segments) > 0:
|
|
head = merge_segment_tail(
|
|
segment_head=self.segments[-1],
|
|
segment_tail=segment,
|
|
conf_aggregator=conf_aggregator,
|
|
)
|
|
self.segments[-1] = head
|
|
self.processed_segment_mask[-1] = False
|
|
else:
|
|
self.segments.append(segment)
|
|
self.processed_segment_mask.append(False)
|
|
|
|
def push_back_words(
|
|
self,
|
|
decoded_words: list[Word],
|
|
merge_first_word: bool = False,
|
|
merge_first_word_punctuation: bool = True,
|
|
conf_aggregator: Callable = None,
|
|
) -> None:
|
|
"""
|
|
Push back the decoded words to the state
|
|
Args:
|
|
decoded_words: (list[Word]) The decoded words to push back to the state
|
|
merge_first_word: (bool) Whether to merge the first word with the last word in the state
|
|
merge_first_word_punctuation: (bool) Whether to merge the first word punctuation with the last word in the state
|
|
conf_aggregator: (Callable) The function to aggregate the confidence
|
|
"""
|
|
if not decoded_words:
|
|
return
|
|
|
|
# concat_with_space is used to determine if the final transcript should be concatenated with a space
|
|
if len(self.final_segments) == 0 and merge_first_word:
|
|
self.concat_with_space = False
|
|
else:
|
|
self.concat_with_space = True
|
|
|
|
if (
|
|
(fst_word_txt := decoded_words[0].text)
|
|
and fst_word_txt in POST_WORD_PUNCTUATION
|
|
and merge_first_word_punctuation
|
|
):
|
|
# if the first word is a punctuation mark, merge it with the last word stored in the state
|
|
if len(self.words) > 0:
|
|
self.words[-1].text += fst_word_txt
|
|
decoded_words = decoded_words[1:]
|
|
|
|
elif merge_first_word and len(self.words) > 0:
|
|
head, pnc_head = merge_word_tail(
|
|
word_head=self.words[-1],
|
|
word_tail=decoded_words[0],
|
|
pnc_word_head=self.pnc_words[-1] if len(self.pnc_words) > 0 else None,
|
|
conf_aggregator=conf_aggregator,
|
|
)
|
|
self.words[-1] = head
|
|
if pnc_head is not None:
|
|
self.pnc_words[-1] = pnc_head
|
|
decoded_words = decoded_words[1:]
|
|
|
|
self.words.extend(decoded_words)
|
|
|
|
def has_biasing_request(self) -> bool:
|
|
"""Return True if options contains non-empty biasing request"""
|
|
return self.options is not None and self.options.has_biasing_request()
|