From 15b6a00c7c2465f6a54e15e06678fab6f2c676d5 Mon Sep 17 00:00:00 2001 From: azziko Date: Wed, 29 Apr 2026 07:30:21 +0000 Subject: [PATCH 01/13] Add canary streamatt --- config/canary_streamaat.yaml | 13 ++ .../speech_processors/canary_streamatt.py | 167 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100755 config/canary_streamaat.yaml create mode 100755 simulstream/server/speech_processors/canary_streamatt.py diff --git a/config/canary_streamaat.yaml b/config/canary_streamaat.yaml new file mode 100755 index 0000000..861a4f7 --- /dev/null +++ b/config/canary_streamaat.yaml @@ -0,0 +1,13 @@ +type: "simulstream.server.speech_processors.canary_streamatt.CanaryStreamAtt" +model_name: "nvidia/canary-1b-v2" +text_history: + type: "simulstream.server.speech_processors.base_streamatt.FixedWordsTextHistory" + history_words: 10 +speech_chunk_size: 0.960 # seconds +detokenizer_type: "canary" +cross_attn_layer: -2 +cutoff_frame_num: 8 +num_beams: 5 +audio_history_max_duration: 160 # Maximum length for the audio buffer, in seconds +text_history_max_len: 128 +word_level_postprocess: True # Disable if character-level language diff --git a/simulstream/server/speech_processors/canary_streamatt.py b/simulstream/server/speech_processors/canary_streamatt.py new file mode 100755 index 0000000..cec237c --- /dev/null +++ b/simulstream/server/speech_processors/canary_streamatt.py @@ -0,0 +1,167 @@ +# Copyright 2025 FBK + +# 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 logging +import torch +import numpy as np + +from types import SimpleNamespace +from typing import List, Tuple + +from dataclasses import replace +import copy + +from simulstream.server.speech_processors import SAMPLE_RATE +from simulstream.server.speech_processors.base_streamatt import BaseStreamAtt, BOW_PREFIX + +from nemo.collections.asr.models import ASRModel +from nemo.collections.asr.parts.submodules.multitask_decoding import ( + MultiTaskDecodingConfig, +) +from nemo.collections.asr.models.aed_multitask_models import ( + MultiTaskTranscriptionConfig, +) + +logger = logging.getLogger(__name__) + +MEL_HOP_SAMPLES = 160 +CANARY_AUDIO_SUBSAMPLING = 8 + + +class CanaryStreamAtt(BaseStreamAtt): + """ + StreamAtt policy implementation for NVIDIA's Canary-v2 model + + Args: + config (SimpleNamespace): Configuration object. + Supported attributes: + - **pnc (str)**: ``"yes"`` for punctuation/capitalisation, ``"no"`` otherwise. + Defaults to ``"yes"``. + - **audio_history_max_duration (int)**: Maximum audio history in seconds. + Defaults to ``30``. + """ + + def __init__(self, config: SimpleNamespace): + super().__init__(config) + self.use_raw_audio_history = True + self.mel_hop_samples = MEL_HOP_SAMPLES + self.audio_subsampling_factor = CANARY_AUDIO_SUBSAMPLING + self._pnc: str = getattr(self.config, "pnc", "yes") + self._audio_history_max_duration: int = getattr(self.config, "audio_history_max_duration", 30) + + # Build the transcription config, which is reused for every transcribe() call. + self.transcription_cfg = MultiTaskTranscriptionConfig( + batch_size=1, + return_hypotheses=True, + enable_chunking=False, + verbose=False, + ) + + @property + def audio_max_len(self) -> int: + """Maximum audio history length in raw waveform samples.""" + return self._audio_history_max_duration * SAMPLE_RATE + + def set_source_language(self, language: str) -> None: + self.src_lang = language + + def set_target_language(self, language: str) -> None: + self.tgt_lang = language + + @classmethod + def load_model(cls, config: SimpleNamespace): + if not hasattr(cls, "model") or cls.model is None: + cls.model = ASRModel.from_pretrained(model_name=config.model_name) + + # Configure decoding strategy + multitask_decoding = MultiTaskDecodingConfig() + multitask_decoding.strategy = "beam" + multitask_decoding.return_xattn_scores = True + multitask_decoding.beam.beam_size = getattr(config, "num_beams", 5) + cls.model.change_decoding_strategy(multitask_decoding) + + cls.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + assert cls.model.cfg.preprocessor.sample_rate == SAMPLE_RATE + cls.model.to(cls.device) + + def _build_transcription_config(self): + """ + Return a ``MultiTaskTranscriptionConfig`` whose prompt encodes the current source/target + languages, task, PNC preference, and forced decoder prefix. + """ + + default_turns = self.model.prompt.get_default_dialog_slots() + default_slots = copy.deepcopy(default_turns[0]["slots"]) + default_slots["source_lang"] = self.src_lang + default_slots["target_lang"] = self.tgt_lang + default_slots["pnc"] = self._pnc + + turns = [ + { + "role": "user", "slots": default_slots + }, + { + "role": "user_prefix", + "slots": { + "prefix": self.model.tokenizer.tokens_to_text(self.text_history) + }, + }, + ] + + return replace(self.transcription_cfg, prompt={"turns": turns}) + + def _remove_eos_tokens(self, token_ids: List[int]) -> List[int]: + """Strip leading EOS tokens that the model may prepend when a forced prefix is used.""" + if not token_ids: + return token_ids + pos = 0 + while pos < len(token_ids) and token_ids[pos] == self.model.tokenizer.eos_id: + pos += 1 + return token_ids[pos:] + + def _preprocess(self, waveform: np.ndarray) -> np.ndarray: + """ + Append the incoming waveform chunk to the raw audio history and return it. + + Returns: + np.ndarray: Accumulated raw audio history. + """ + if self.audio_history is None: + self.audio_history = waveform.astype(np.float32) + else: + self.audio_history = np.concatenate( + [self.audio_history, waveform.astype(np.float32)]) + + return self.audio_history + + def _generate(self, speech: np.ndarray) -> Tuple[List[str], torch.Tensor]: + override_config = self._build_transcription_config() + + with torch.inference_mode(): + output = self.model.transcribe(audio=speech, override_config=override_config) + + hypothesis = output[0] + + token_ids = hypothesis.y_sequence.detach().cpu().tolist() + token_ids = self._remove_eos_tokens(token_ids) + tokens: List[str] = self.model.tokenizer.ids_to_tokens(token_ids) + + xatt_raw = hypothesis.xatt_scores[self.cross_attn_layer] + xatt = xatt_raw.mean(dim=0).cpu() + xatt = self.normalize_attn(xatt) + + return tokens, xatt + + def tokens_to_string(self, tokens: List[str]) -> str: + return self.model.tokenizer.tokens_to_text(tokens) \ No newline at end of file From 4279681f7f6bbddd989e25ae7708cb97c6a58281 Mon Sep 17 00:00:00 2001 From: azziko Date: Wed, 29 Apr 2026 07:31:28 +0000 Subject: [PATCH 02/13] Add audio history type flag to the base streamatt --- simulstream/server/speech_processors/base_streamatt.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/simulstream/server/speech_processors/base_streamatt.py b/simulstream/server/speech_processors/base_streamatt.py index fa7ccd4..b0899e3 100644 --- a/simulstream/server/speech_processors/base_streamatt.py +++ b/simulstream/server/speech_processors/base_streamatt.py @@ -60,6 +60,10 @@ class BaseStreamAtt(BaseSpeechProcessor): context for next predictions. - **audio_subsampling_factor (int)**: Subsampling factor of the model, if any. Defaults to 1. + - **mel_hop_samples (int)**: Number of raw waveform samples per mel frame. + Defaults to 1. + - **use_raw_audio_history (bool)**: Returns whether ``audio_history`` stores raw waveform samples + rather than processed frames. Defaults to False. - **text_history_max_len (int)**: The maximum length of the textual history after which the current content is cut. Defaults to 128. - **cross_attention_layer (int)**: Layer from which to extract the cross-attention from. @@ -77,6 +81,8 @@ def __init__(self, config: SimpleNamespace): text_history_cls = class_load(text_history_config.type) self.text_history_method = text_history_cls(text_history_config) self.audio_subsampling_factor = getattr(self.config, "audio_subsampling_factor", 1) + self.mel_hop_samples = getattr(self.config, "mel_hop_samples", 1) + self.use_raw_audio_history = getattr(self.config, "use_raw_audio_history", False) self.text_history_max_len = getattr(self.config, "text_history_max_len", 128) self.cross_attn_layer = getattr(self.config, "cross_attention_layer", 3) self.cutoff_frame_num = getattr(self.config, "cutoff_frame_num", 2) @@ -176,6 +182,10 @@ def _update_speech_history(self, discarded_text: int, cross_attn: torch.Tensor) # Multiply by the subsampling factor to recover the original number of frames frames_to_cut = earliest_attended_idx * self.audio_subsampling_factor + # If audio is stored as raw waveform, convert frames to cut into number of samples to cut + if self.use_raw_audio_history: + frames_to_cut = frames_to_cut * self.mel_hop_samples + # Cut the unattended audio features self.audio_history = self.audio_history[frames_to_cut:] From 53afb67eef51305a30b76bd7fb890603ba7d90af Mon Sep 17 00:00:00 2001 From: azziko Date: Wed, 29 Apr 2026 17:19:23 +0000 Subject: [PATCH 03/13] Add stylistic fixes addressing the linter --- simulstream/server/speech_processors/base_streamatt.py | 4 ++-- .../server/speech_processors/canary_streamatt.py | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/simulstream/server/speech_processors/base_streamatt.py b/simulstream/server/speech_processors/base_streamatt.py index b0899e3..ffd99b8 100644 --- a/simulstream/server/speech_processors/base_streamatt.py +++ b/simulstream/server/speech_processors/base_streamatt.py @@ -62,8 +62,8 @@ class BaseStreamAtt(BaseSpeechProcessor): Defaults to 1. - **mel_hop_samples (int)**: Number of raw waveform samples per mel frame. Defaults to 1. - - **use_raw_audio_history (bool)**: Returns whether ``audio_history`` stores raw waveform samples - rather than processed frames. Defaults to False. + - **use_raw_audio_history (bool)**: Returns whether ``audio_history`` stores raw + waveform samples rather than processed frames. Defaults to False. - **text_history_max_len (int)**: The maximum length of the textual history after which the current content is cut. Defaults to 128. - **cross_attention_layer (int)**: Layer from which to extract the cross-attention from. diff --git a/simulstream/server/speech_processors/canary_streamatt.py b/simulstream/server/speech_processors/canary_streamatt.py index cec237c..4f80565 100755 --- a/simulstream/server/speech_processors/canary_streamatt.py +++ b/simulstream/server/speech_processors/canary_streamatt.py @@ -23,7 +23,7 @@ import copy from simulstream.server.speech_processors import SAMPLE_RATE -from simulstream.server.speech_processors.base_streamatt import BaseStreamAtt, BOW_PREFIX +from simulstream.server.speech_processors.base_streamatt import BaseStreamAtt from nemo.collections.asr.models import ASRModel from nemo.collections.asr.parts.submodules.multitask_decoding import ( @@ -44,7 +44,7 @@ class CanaryStreamAtt(BaseStreamAtt): StreamAtt policy implementation for NVIDIA's Canary-v2 model Args: - config (SimpleNamespace): Configuration object. + config (SimpleNamespace): Configuration object. Supported attributes: - **pnc (str)**: ``"yes"`` for punctuation/capitalisation, ``"no"`` otherwise. Defaults to ``"yes"``. @@ -57,8 +57,7 @@ def __init__(self, config: SimpleNamespace): self.use_raw_audio_history = True self.mel_hop_samples = MEL_HOP_SAMPLES self.audio_subsampling_factor = CANARY_AUDIO_SUBSAMPLING - self._pnc: str = getattr(self.config, "pnc", "yes") - self._audio_history_max_duration: int = getattr(self.config, "audio_history_max_duration", 30) + self._audio_history_max_duration = getattr(self.config, "audio_history_max_duration", 30) # Build the transcription config, which is reused for every transcribe() call. self.transcription_cfg = MultiTaskTranscriptionConfig( @@ -105,7 +104,6 @@ def _build_transcription_config(self): default_slots = copy.deepcopy(default_turns[0]["slots"]) default_slots["source_lang"] = self.src_lang default_slots["target_lang"] = self.tgt_lang - default_slots["pnc"] = self._pnc turns = [ { @@ -164,4 +162,4 @@ def _generate(self, speech: np.ndarray) -> Tuple[List[str], torch.Tensor]: return tokens, xatt def tokens_to_string(self, tokens: List[str]) -> str: - return self.model.tokenizer.tokens_to_text(tokens) \ No newline at end of file + return self.model.tokenizer.tokens_to_text(tokens) From b9ec9ba15aa4409ccdbb5e9427653c181578c9ed Mon Sep 17 00:00:00 2001 From: azziko Date: Sat, 2 May 2026 13:56:08 +0000 Subject: [PATCH 04/13] Add minor fixes --- ...y_streamaat.yaml => canary_streamatt.yaml} | 1 + .../speech_processors/base_streamatt.py | 13 +++---- .../speech_processors/canary_streamatt.py | 37 +++++++++++++------ 3 files changed, 32 insertions(+), 19 deletions(-) rename config/{canary_streamaat.yaml => canary_streamatt.yaml} (85%) diff --git a/config/canary_streamaat.yaml b/config/canary_streamatt.yaml similarity index 85% rename from config/canary_streamaat.yaml rename to config/canary_streamatt.yaml index 861a4f7..a353714 100755 --- a/config/canary_streamaat.yaml +++ b/config/canary_streamatt.yaml @@ -9,5 +9,6 @@ cross_attn_layer: -2 cutoff_frame_num: 8 num_beams: 5 audio_history_max_duration: 160 # Maximum length for the audio buffer, in seconds +mel_hop_samples: 160 # Number of audio samples between adjacent mel frames text_history_max_len: 128 word_level_postprocess: True # Disable if character-level language diff --git a/simulstream/server/speech_processors/base_streamatt.py b/simulstream/server/speech_processors/base_streamatt.py index ffd99b8..1ec877c 100644 --- a/simulstream/server/speech_processors/base_streamatt.py +++ b/simulstream/server/speech_processors/base_streamatt.py @@ -81,8 +81,11 @@ def __init__(self, config: SimpleNamespace): text_history_cls = class_load(text_history_config.type) self.text_history_method = text_history_cls(text_history_config) self.audio_subsampling_factor = getattr(self.config, "audio_subsampling_factor", 1) - self.mel_hop_samples = getattr(self.config, "mel_hop_samples", 1) + self.mel_hop_samples = getattr(self.config, "mel_hop_samples", 160) self.use_raw_audio_history = getattr(self.config, "use_raw_audio_history", False) + self.frames_to_audio_history = self.audio_subsampling_factor + if self.use_raw_audio_history: + self.frames_to_audio_history *= self.mel_hop_samples self.text_history_max_len = getattr(self.config, "text_history_max_len", 128) self.cross_attn_layer = getattr(self.config, "cross_attention_layer", 3) self.cutoff_frame_num = getattr(self.config, "cutoff_frame_num", 2) @@ -179,12 +182,8 @@ def _update_speech_history(self, discarded_text: int, cross_attn: torch.Tensor) # Only one token: use the unique most attended frame earliest_attended_idx = most_attended_idxs[0] - # Multiply by the subsampling factor to recover the original number of frames - frames_to_cut = earliest_attended_idx * self.audio_subsampling_factor - - # If audio is stored as raw waveform, convert frames to cut into number of samples to cut - if self.use_raw_audio_history: - frames_to_cut = frames_to_cut * self.mel_hop_samples + # Multiply by the number of frames/samples corresponding to the audio history + frames_to_cut = earliest_attended_idx * self.frames_to_audio_history # Cut the unattended audio features self.audio_history = self.audio_history[frames_to_cut:] diff --git a/simulstream/server/speech_processors/canary_streamatt.py b/simulstream/server/speech_processors/canary_streamatt.py index 4f80565..2e85922 100755 --- a/simulstream/server/speech_processors/canary_streamatt.py +++ b/simulstream/server/speech_processors/canary_streamatt.py @@ -31,13 +31,11 @@ ) from nemo.collections.asr.models.aed_multitask_models import ( MultiTaskTranscriptionConfig, + parse_multitask_prompt, ) logger = logging.getLogger(__name__) -MEL_HOP_SAMPLES = 160 -CANARY_AUDIO_SUBSAMPLING = 8 - class CanaryStreamAtt(BaseStreamAtt): """ @@ -46,19 +44,28 @@ class CanaryStreamAtt(BaseStreamAtt): Args: config (SimpleNamespace): Configuration object. Supported attributes: - - **pnc (str)**: ``"yes"`` for punctuation/capitalisation, ``"no"`` otherwise. - Defaults to ``"yes"``. - **audio_history_max_duration (int)**: Maximum audio history in seconds. Defaults to ``30``. + - **num_beams (int)**: Number of beams to use for beam search decoding. + Defaults to ``5``. """ def __init__(self, config: SimpleNamespace): super().__init__(config) self.use_raw_audio_history = True - self.mel_hop_samples = MEL_HOP_SAMPLES - self.audio_subsampling_factor = CANARY_AUDIO_SUBSAMPLING + self.mel_hop_samples = getattr(self.config, "mel_hop_samples", 160) + self.audio_subsampling_factor = getattr(self.config, "audio_subsampling_factor", 8) self._audio_history_max_duration = getattr(self.config, "audio_history_max_duration", 30) + expected_mel_hop_samples = ( + self.model.cfg.preprocessor.window_stride * self.model.cfg.preprocessor.sample_rate + ) + + assert self.mel_hop_samples == expected_mel_hop_samples, ( + f"mel_hop_samples is set to {self.mel_hop_samples} in the config, but the loaded " + f"model's preprocessor uses {expected_mel_hop_samples} samples per mel frame" + ) + # Build the transcription config, which is reused for every transcribe() call. self.transcription_cfg = MultiTaskTranscriptionConfig( batch_size=1, @@ -117,7 +124,12 @@ def _build_transcription_config(self): }, ] - return replace(self.transcription_cfg, prompt={"turns": turns}) + cfg_copy = copy.deepcopy(self.transcription_cfg) + cfg_copy.prompt = turns + + logger.info(f"{self.model.tokenizer.tokens_to_text(self.text_history)=}") + + return cfg_copy def _remove_eos_tokens(self, token_ids: List[int]) -> List[int]: """Strip leading EOS tokens that the model may prepend when a forced prefix is used.""" @@ -135,11 +147,12 @@ def _preprocess(self, waveform: np.ndarray) -> np.ndarray: Returns: np.ndarray: Accumulated raw audio history. """ + waveform = waveform.astype(np.float32) if self.audio_history is None: - self.audio_history = waveform.astype(np.float32) + self.audio_history = waveform else: self.audio_history = np.concatenate( - [self.audio_history, waveform.astype(np.float32)]) + [self.audio_history, waveform]) return self.audio_history @@ -153,10 +166,10 @@ def _generate(self, speech: np.ndarray) -> Tuple[List[str], torch.Tensor]: token_ids = hypothesis.y_sequence.detach().cpu().tolist() token_ids = self._remove_eos_tokens(token_ids) - tokens: List[str] = self.model.tokenizer.ids_to_tokens(token_ids) + tokens = self.model.tokenizer.ids_to_tokens(token_ids) xatt_raw = hypothesis.xatt_scores[self.cross_attn_layer] - xatt = xatt_raw.mean(dim=0).cpu() + xatt = xatt_raw.mean(dim=0).cpu() # we average over heads xatt = self.normalize_attn(xatt) return tokens, xatt From 056ec4e9b249c79320c8b848560a17d3ed82063e Mon Sep 17 00:00:00 2001 From: azziko Date: Sat, 2 May 2026 13:56:58 +0000 Subject: [PATCH 05/13] Fix linter issues --- simulstream/server/speech_processors/canary_streamatt.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/simulstream/server/speech_processors/canary_streamatt.py b/simulstream/server/speech_processors/canary_streamatt.py index 2e85922..b289db1 100755 --- a/simulstream/server/speech_processors/canary_streamatt.py +++ b/simulstream/server/speech_processors/canary_streamatt.py @@ -19,7 +19,6 @@ from types import SimpleNamespace from typing import List, Tuple -from dataclasses import replace import copy from simulstream.server.speech_processors import SAMPLE_RATE @@ -31,7 +30,6 @@ ) from nemo.collections.asr.models.aed_multitask_models import ( MultiTaskTranscriptionConfig, - parse_multitask_prompt, ) logger = logging.getLogger(__name__) From 39f13805d28101fceeb884472ea9847c75dc223a Mon Sep 17 00:00:00 2001 From: azziko Date: Mon, 4 May 2026 14:07:07 +0000 Subject: [PATCH 06/13] Add minor fixes --- config/canary_streamatt.yaml | 2 ++ simulstream/server/speech_processors/base_streamatt.py | 2 +- simulstream/server/speech_processors/canary_streamatt.py | 5 ----- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/config/canary_streamatt.yaml b/config/canary_streamatt.yaml index a353714..65d0976 100755 --- a/config/canary_streamatt.yaml +++ b/config/canary_streamatt.yaml @@ -8,7 +8,9 @@ detokenizer_type: "canary" cross_attn_layer: -2 cutoff_frame_num: 8 num_beams: 5 +audio_subsampling_factor: 8 audio_history_max_duration: 160 # Maximum length for the audio buffer, in seconds mel_hop_samples: 160 # Number of audio samples between adjacent mel frames text_history_max_len: 128 word_level_postprocess: True # Disable if character-level language +use_raw_audio_history: True \ No newline at end of file diff --git a/simulstream/server/speech_processors/base_streamatt.py b/simulstream/server/speech_processors/base_streamatt.py index 1ec877c..9fb65da 100644 --- a/simulstream/server/speech_processors/base_streamatt.py +++ b/simulstream/server/speech_processors/base_streamatt.py @@ -61,7 +61,7 @@ class BaseStreamAtt(BaseSpeechProcessor): - **audio_subsampling_factor (int)**: Subsampling factor of the model, if any. Defaults to 1. - **mel_hop_samples (int)**: Number of raw waveform samples per mel frame. - Defaults to 1. + Defaults to 160, i.e. 10ms at 16kHz. - **use_raw_audio_history (bool)**: Returns whether ``audio_history`` stores raw waveform samples rather than processed frames. Defaults to False. - **text_history_max_len (int)**: The maximum length of the textual history after which diff --git a/simulstream/server/speech_processors/canary_streamatt.py b/simulstream/server/speech_processors/canary_streamatt.py index b289db1..bf0f387 100755 --- a/simulstream/server/speech_processors/canary_streamatt.py +++ b/simulstream/server/speech_processors/canary_streamatt.py @@ -50,9 +50,6 @@ class CanaryStreamAtt(BaseStreamAtt): def __init__(self, config: SimpleNamespace): super().__init__(config) - self.use_raw_audio_history = True - self.mel_hop_samples = getattr(self.config, "mel_hop_samples", 160) - self.audio_subsampling_factor = getattr(self.config, "audio_subsampling_factor", 8) self._audio_history_max_duration = getattr(self.config, "audio_history_max_duration", 30) expected_mel_hop_samples = ( @@ -125,8 +122,6 @@ def _build_transcription_config(self): cfg_copy = copy.deepcopy(self.transcription_cfg) cfg_copy.prompt = turns - logger.info(f"{self.model.tokenizer.tokens_to_text(self.text_history)=}") - return cfg_copy def _remove_eos_tokens(self, token_ids: List[int]) -> List[int]: From 3f9a6eb7fe6d60152aab6bd76241d78c2ccac054 Mon Sep 17 00:00:00 2001 From: azziko Date: Tue, 5 May 2026 20:13:15 +0000 Subject: [PATCH 07/13] Delete removing eos in the beginning --- .../server/speech_processors/canary_streamatt.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/simulstream/server/speech_processors/canary_streamatt.py b/simulstream/server/speech_processors/canary_streamatt.py index bf0f387..5363589 100755 --- a/simulstream/server/speech_processors/canary_streamatt.py +++ b/simulstream/server/speech_processors/canary_streamatt.py @@ -124,15 +124,6 @@ def _build_transcription_config(self): return cfg_copy - def _remove_eos_tokens(self, token_ids: List[int]) -> List[int]: - """Strip leading EOS tokens that the model may prepend when a forced prefix is used.""" - if not token_ids: - return token_ids - pos = 0 - while pos < len(token_ids) and token_ids[pos] == self.model.tokenizer.eos_id: - pos += 1 - return token_ids[pos:] - def _preprocess(self, waveform: np.ndarray) -> np.ndarray: """ Append the incoming waveform chunk to the raw audio history and return it. @@ -158,7 +149,6 @@ def _generate(self, speech: np.ndarray) -> Tuple[List[str], torch.Tensor]: hypothesis = output[0] token_ids = hypothesis.y_sequence.detach().cpu().tolist() - token_ids = self._remove_eos_tokens(token_ids) tokens = self.model.tokenizer.ids_to_tokens(token_ids) xatt_raw = hypothesis.xatt_scores[self.cross_attn_layer] From 6b23ddff31b773aaf3f29aaa2618a6741124d6e3 Mon Sep 17 00:00:00 2001 From: azziko Date: Tue, 5 May 2026 21:03:22 +0000 Subject: [PATCH 08/13] Add unit test for audio trimming in update history --- uts/speech_processors/test_streamatt.py | 38 ++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/uts/speech_processors/test_streamatt.py b/uts/speech_processors/test_streamatt.py index 180c408..a26bd0c 100644 --- a/uts/speech_processors/test_streamatt.py +++ b/uts/speech_processors/test_streamatt.py @@ -14,8 +14,15 @@ import unittest from types import SimpleNamespace +from unittest.mock import MagicMock +import torch +import numpy as np -from simulstream.server.speech_processors.base_streamatt import PunctuationTextHistory + +from simulstream.server.speech_processors.base_streamatt import ( + BaseStreamAtt, + PunctuationTextHistory, +) class TestPunctuationTextHistory(unittest.TestCase): @@ -60,5 +67,34 @@ def test_no_strong_punctuation(self): self.assertEqual(selected_history, ['回', '到', '纽', '约', '后', ',', '我']) +def _make_mock(text_history, audio_history, frames_to_audio_history, audio_max_len=100_000): + proc = MagicMock() + proc.text_history = text_history + proc.audio_history = audio_history + proc.frames_to_audio_history = frames_to_audio_history + proc.audio_max_len = audio_max_len + proc._cut_audio_exceeding_maxlen.side_effect = \ + lambda: BaseStreamAtt._cut_audio_exceeding_maxlen(proc) + return proc + + +def _cross_attn(n_text_tokens, n_audio_frames, earliest_attended_frame, discarded_text=0): + attn = torch.zeros(discarded_text + n_text_tokens, n_audio_frames) + for i in range(discarded_text, discarded_text + n_text_tokens): + attn[i, earliest_attended_frame] = 1.0 + return attn + + +class TestUpdateSpeechHistory(unittest.TestCase): + def test_trim_audio_history(self): + """ Test that audio history is trimmed correctly """ + audio = np.arange(40, dtype=np.float32) + proc = _make_mock(["▁hello"], audio.copy(), frames_to_audio_history=4) + attn = _cross_attn( + n_text_tokens=1, n_audio_frames=10, earliest_attended_frame=2, discarded_text=1) + BaseStreamAtt._update_speech_history(proc, discarded_text=1, cross_attn=attn) + np.testing.assert_array_equal(proc.audio_history, audio[8:]) + + if __name__ == "__main__": unittest.main() From 52803f49a2ff83b76a1f49d156e8352a432cf8a5 Mon Sep 17 00:00:00 2001 From: azziko Date: Tue, 5 May 2026 21:10:52 +0000 Subject: [PATCH 09/13] Change the canary dependency version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 45a6581..3579e9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ hf = [ canary = [ "Cython", - "nemo_toolkit[asr]==2.4.0", + "nemo_toolkit[asr]==2.8.0", ] vad = [ From cbc895e65df3eba23915d3bc726d30ca7e3241a0 Mon Sep 17 00:00:00 2001 From: Aziz Sharipov <101587881+azziko@users.noreply.github.com> Date: Wed, 6 May 2026 10:36:27 +0200 Subject: [PATCH 10/13] Update simulstream/server/speech_processors/canary_streamatt.py Co-authored-by: Marco Gaido --- simulstream/server/speech_processors/canary_streamatt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simulstream/server/speech_processors/canary_streamatt.py b/simulstream/server/speech_processors/canary_streamatt.py index 5363589..1a2f68f 100755 --- a/simulstream/server/speech_processors/canary_streamatt.py +++ b/simulstream/server/speech_processors/canary_streamatt.py @@ -37,7 +37,7 @@ class CanaryStreamAtt(BaseStreamAtt): """ - StreamAtt policy implementation for NVIDIA's Canary-v2 model + StreamAtt policy implementation for NVIDIA's Canary-v2 model. Args: config (SimpleNamespace): Configuration object. From 59bf6f3042bf68d9b2af56b0d09d565357768757 Mon Sep 17 00:00:00 2001 From: Aziz Sharipov <101587881+azziko@users.noreply.github.com> Date: Wed, 6 May 2026 10:37:25 +0200 Subject: [PATCH 11/13] Update uts/speech_processors/test_streamatt.py Co-authored-by: Marco Gaido --- uts/speech_processors/test_streamatt.py | 70 +++++++++++++++++-------- 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/uts/speech_processors/test_streamatt.py b/uts/speech_processors/test_streamatt.py index a26bd0c..5b46f4c 100644 --- a/uts/speech_processors/test_streamatt.py +++ b/uts/speech_processors/test_streamatt.py @@ -67,34 +67,62 @@ def test_no_strong_punctuation(self): self.assertEqual(selected_history, ['回', '到', '纽', '约', '后', ',', '我']) -def _make_mock(text_history, audio_history, frames_to_audio_history, audio_max_len=100_000): - proc = MagicMock() - proc.text_history = text_history - proc.audio_history = audio_history - proc.frames_to_audio_history = frames_to_audio_history - proc.audio_max_len = audio_max_len - proc._cut_audio_exceeding_maxlen.side_effect = \ - lambda: BaseStreamAtt._cut_audio_exceeding_maxlen(proc) - return proc +class FakeStreamAtt(BaseStreamAtt): + def _preprocess(self, waveform: np.float32) -> Union[Dict[str, torch.Tensor], torch.Tensor]: + raise NotImplementedError("_preprocess not implemented in FakeStreamAtt") -def _cross_attn(n_text_tokens, n_audio_frames, earliest_attended_frame, discarded_text=0): - attn = torch.zeros(discarded_text + n_text_tokens, n_audio_frames) - for i in range(discarded_text, discarded_text + n_text_tokens): - attn[i, earliest_attended_frame] = 1.0 - return attn + @classmethod + def load_model(cls, config: SimpleNamespace): + raise NotImplementedError("load_model not implemented in FakeStreamAtt") + + def set_source_language(self, language: str) -> None: + pass + + def set_target_language(self, language: str) -> None: + pass + + def tokens_to_string(self, tokens: List[str]) -> str: + return " ".join(tokens) + + def _generate(self, speech: torch.Tensor) -> Tuple[List[str], torch.Tensor]: + raise NotImplementedError("_generate not implemented in FakeStreamAtt") + + @property + def audio_max_len(self) -> float: + return 10000 class TestUpdateSpeechHistory(unittest.TestCase): - def test_trim_audio_history(self): - """ Test that audio history is trimmed correctly """ + def _run_update_speech_history(self, use_raw_audio_history): + config = SimpleNamespace( + use_raw_audio_history=use_raw_audio_history, + audio_subsampling_factor=2, + mel_hop_samples=2, + text_history=SimpleNamespace( + type="simulstream.server.speech_processors.base_streamatt.FixedWordsTextHistory", + ) + + ) audio = np.arange(40, dtype=np.float32) - proc = _make_mock(["▁hello"], audio.copy(), frames_to_audio_history=4) - attn = _cross_attn( - n_text_tokens=1, n_audio_frames=10, earliest_attended_frame=2, discarded_text=1) - BaseStreamAtt._update_speech_history(proc, discarded_text=1, cross_attn=attn) - np.testing.assert_array_equal(proc.audio_history, audio[8:]) + proc = FakeStreamAtt(config) + proc.text_history = ["▁hello"] + proc.audio_history = audio.copy() + + attn = torch.zeros(2, 10) + attn[1, 2] = 1.0 + + proc._update_speech_history(discarded_text=1, cross_attn=attn) + return proc.audio_history.tolist() + def test_update_speech_history_trims_audio_with_raw_audio(self): + audio_hist = self._run_update_speech_history(use_raw_audio_history=True) + # 2 audio token discarded, subsampling factor is 2, num mel hop is 2, so 2*2*2=8 samples removed + self.assertListEqual(audio_hist, list(np.arange(8, 40, dtype=np.float32))) + def test_update_speech_history_trims_audio(self): + audio_hist = self._run_update_speech_history(use_raw_audio_history=False) + # 2 audio token discarded, subsampling factor is 2, so 2*2=4 samples removed + self.assertListEqual(audio_hist, list(np.arange(4, 40, dtype=np.float32))) if __name__ == "__main__": unittest.main() From 076ed3711fe348c9e5bd9a0da6ffb0c4c6d3ef92 Mon Sep 17 00:00:00 2001 From: azziko Date: Wed, 6 May 2026 08:44:20 +0000 Subject: [PATCH 12/13] Fix linter --- uts/speech_processors/test_streamatt.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/uts/speech_processors/test_streamatt.py b/uts/speech_processors/test_streamatt.py index 5b46f4c..52cabf3 100644 --- a/uts/speech_processors/test_streamatt.py +++ b/uts/speech_processors/test_streamatt.py @@ -14,9 +14,9 @@ import unittest from types import SimpleNamespace -from unittest.mock import MagicMock import torch import numpy as np +from typing import Dict, List, Tuple, Union from simulstream.server.speech_processors.base_streamatt import ( @@ -117,12 +117,15 @@ def _run_update_speech_history(self, use_raw_audio_history): def test_update_speech_history_trims_audio_with_raw_audio(self): audio_hist = self._run_update_speech_history(use_raw_audio_history=True) - # 2 audio token discarded, subsampling factor is 2, num mel hop is 2, so 2*2*2=8 samples removed + # 2 audio token discarded, subsampling factor is 2, + # num mel hop is 2, so 2*2*2=8 samples removed self.assertListEqual(audio_hist, list(np.arange(8, 40, dtype=np.float32))) def test_update_speech_history_trims_audio(self): audio_hist = self._run_update_speech_history(use_raw_audio_history=False) - # 2 audio token discarded, subsampling factor is 2, so 2*2=4 samples removed + # 2 audio token discarded, subsampling factor is 2, so 2*2=4 samples removed self.assertListEqual(audio_hist, list(np.arange(4, 40, dtype=np.float32))) + + if __name__ == "__main__": unittest.main() From a1fea185f0192247fd6c3765e20e0e06ed5d7264 Mon Sep 17 00:00:00 2001 From: azziko Date: Thu, 7 May 2026 19:53:42 +0000 Subject: [PATCH 13/13] Add minor fixes --- simulstream/server/speech_processors/canary_streamatt.py | 3 --- uts/speech_processors/test_streamatt.py | 1 - 2 files changed, 4 deletions(-) diff --git a/simulstream/server/speech_processors/canary_streamatt.py b/simulstream/server/speech_processors/canary_streamatt.py index 1a2f68f..660bf00 100755 --- a/simulstream/server/speech_processors/canary_streamatt.py +++ b/simulstream/server/speech_processors/canary_streamatt.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License -import logging import torch import numpy as np @@ -32,8 +31,6 @@ MultiTaskTranscriptionConfig, ) -logger = logging.getLogger(__name__) - class CanaryStreamAtt(BaseStreamAtt): """ diff --git a/uts/speech_processors/test_streamatt.py b/uts/speech_processors/test_streamatt.py index 52cabf3..296b30f 100644 --- a/uts/speech_processors/test_streamatt.py +++ b/uts/speech_processors/test_streamatt.py @@ -18,7 +18,6 @@ import numpy as np from typing import Dict, List, Tuple, Union - from simulstream.server.speech_processors.base_streamatt import ( BaseStreamAtt, PunctuationTextHistory,