yacht commited on
Commit
33de2fc
·
verified ·
1 Parent(s): d930715

OpenThai2.0 ASR Streaming v1.0: weights, reference service, licence and NVIDIA notices (private staging; model card to follow)

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ openthai2.0-asr-streaming_bias-1.0.nemo filter=lfs diff=lfs merge=lfs -text
37
+ openthai2.0-asr-streaming_raw.nemo filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ OpenMDW License Agreement, version 1.1 (OpenMDW-1.1)
2
+
3
+ By exercising rights granted to you under this agreement, you accept and
4
+ agree to its terms.
5
+
6
+ As used in this agreement, "Model Materials" means the materials provided
7
+ to you under this agreement, consisting of: (1) one or more machine
8
+ learning models (including architecture and parameters); and (2) all
9
+ related artifacts (including associated data, documentation and software)
10
+ that are provided to you hereunder.
11
+
12
+ Subject to your compliance with this agreement, permission is hereby
13
+ granted, free of charge, to deal in the Model Materials without
14
+ restriction, including under all copyright, patent, database, and trade
15
+ secret rights included or embodied therein.
16
+
17
+ If you distribute any portion of the Model Materials, you shall retain in
18
+ your distribution (1) a copy of this agreement, and (2) all copyright
19
+ notices and other notices of origin included in the Model Materials that
20
+ are applicable to your distribution.
21
+
22
+ If you file, maintain, or voluntarily participate in a lawsuit against any
23
+ person or entity asserting that the Model Materials directly or indirectly
24
+ infringe any patent or copyright, then all rights and grants made to you
25
+ hereunder are terminated, unless that lawsuit was in response to a
26
+ corresponding lawsuit first brought against you.
27
+
28
+ This agreement does not impose any restrictions or obligations with
29
+ respect to any use, modification, or sharing of any outputs generated by
30
+ using the Model Materials.
31
+
32
+ THE MODEL MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
33
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
34
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NONINFRINGEMENT,
35
+ ACCURACY, OR THE ABSENCE OF LATENT OR OTHER DEFECTS OR ERRORS, WHETHER OR
36
+ NOT DISCOVERABLE, ALL TO THE GREATEST EXTENT PERMISSIBLE UNDER APPLICABLE
37
+ LAW.
38
+
39
+ YOU ARE SOLELY RESPONSIBLE FOR (1) CLEARING RIGHTS OF OTHER PERSONS THAT
40
+ MAY APPLY TO THE MODEL MATERIALS OR ANY USE THEREOF, INCLUDING WITHOUT
41
+ LIMITATION ANY PERSON'S COPYRIGHTS OR OTHER RIGHTS INCLUDED OR EMBODIED IN
42
+ THE MODEL MATERIALS; (2) OBTAINING ANY NECESSARY CONSENTS, PERMISSIONS OR
43
+ OTHER RIGHTS REQUIRED FOR ANY USE OF THE MODEL MATERIALS; OR (3)
44
+ PERFORMING ANY DUE DILIGENCE OR UNDERTAKING ANY OTHER INVESTIGATIONS INTO
45
+ THE MODEL MATERIALS OR ANYTHING INCORPORATED OR EMBODIED THEREIN.
46
+
47
+ IN NO EVENT SHALL THE PROVIDERS OF THE MODEL MATERIALS BE LIABLE FOR ANY
48
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT
49
+ OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE MODEL
50
+ MATERIALS, THE USE THEREOF OR OTHER DEALINGS THEREIN.
nvidia/NVIDIA_NEMOTRON_ASR_STREAMING_README.md ADDED
@@ -0,0 +1,763 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: other
3
+ license_name: openmdw-1.1
4
+ license_link: >-
5
+ https://openmdw.ai/license/1-1/
6
+ library_name: nemo
7
+ language:
8
+ - en
9
+ - es
10
+ - de
11
+ - fr
12
+ - it
13
+ - ar
14
+ - ja
15
+ - ko
16
+ - pt
17
+ - ru
18
+ - hi
19
+ - zh
20
+ - vi
21
+ - he
22
+ - nl
23
+ - cs
24
+ - da
25
+ - pl
26
+ - 'no'
27
+ - sv
28
+ - th
29
+ - tr
30
+ - bg
31
+ - el
32
+ - et
33
+ - fi
34
+ - hr
35
+ - hu
36
+ - lt
37
+ - lv
38
+ - ro
39
+ - sk
40
+ - uk
41
+ - mt
42
+ - sl
43
+ datasets:
44
+ - nvidia/Granary
45
+ - multilingual_librispeech
46
+ - fleurs
47
+ - mozilla-foundation/common_voice_8_0
48
+ - voxpopuli
49
+ - europarl
50
+ thumbnail: null
51
+ tags:
52
+ - transformers
53
+ - speech-recognition
54
+ - cache-aware ASR
55
+ - automatic-speech-recognition
56
+ - streaming-asr
57
+ - multilingual
58
+ - speech
59
+ - audio
60
+ - FastConformer
61
+ - RNNT
62
+ - Parakeet
63
+ - ASR
64
+ - pytorch
65
+ - NeMo
66
+ widget:
67
+ - example_title: Librispeech sample 1
68
+ src: https://cdn-media.huggingface.co/speech_samples/sample1.flac
69
+ - example_title: Librispeech sample 2
70
+ src: https://cdn-media.huggingface.co/speech_samples/sample2.flac
71
+ model-index:
72
+ - name: nemotron-asr-streaming-multilingual-0.6b
73
+ results:
74
+ - task:
75
+ name: Automatic Speech Recognition
76
+ type: automatic-speech-recognition
77
+ dataset:
78
+ name: FLEURS (English)
79
+ type: google/fleurs
80
+ config: en_us
81
+ split: test
82
+ metrics:
83
+ - name: WER (1.12s frame size, LangID)
84
+ type: wer
85
+ value: 7.91
86
+ - task:
87
+ name: Automatic Speech Recognition
88
+ type: automatic-speech-recognition
89
+ dataset:
90
+ name: FLEURS (Spanish)
91
+ type: google/fleurs
92
+ config: es_419
93
+ split: test
94
+ metrics:
95
+ - name: WER (1.12s frame size, LangID)
96
+ type: wer
97
+ value: 4.11
98
+ - task:
99
+ name: Automatic Speech Recognition
100
+ type: automatic-speech-recognition
101
+ dataset:
102
+ name: FLEURS (French)
103
+ type: google/fleurs
104
+ config: fr_fr
105
+ split: test
106
+ metrics:
107
+ - name: WER (1.12s frame size, LangID)
108
+ type: wer
109
+ value: 9.03
110
+ - task:
111
+ name: Automatic Speech Recognition
112
+ type: automatic-speech-recognition
113
+ dataset:
114
+ name: FLEURS (Italian)
115
+ type: google/fleurs
116
+ config: it_it
117
+ split: test
118
+ metrics:
119
+ - name: WER (1.12s frame size, LangID)
120
+ type: wer
121
+ value: 4.25
122
+ - task:
123
+ name: Automatic Speech Recognition
124
+ type: automatic-speech-recognition
125
+ dataset:
126
+ name: FLEURS (Portuguese)
127
+ type: google/fleurs
128
+ config: pt_br
129
+ split: test
130
+ metrics:
131
+ - name: WER (1.12s frame size, LangID)
132
+ type: wer
133
+ value: 5.48
134
+ - task:
135
+ name: Automatic Speech Recognition
136
+ type: automatic-speech-recognition
137
+ dataset:
138
+ name: FLEURS (German)
139
+ type: google/fleurs
140
+ config: de_de
141
+ split: test
142
+ metrics:
143
+ - name: WER (1.12s frame size, LangID)
144
+ type: wer
145
+ value: 8.31
146
+ - task:
147
+ name: Automatic Speech Recognition
148
+ type: automatic-speech-recognition
149
+ dataset:
150
+ name: FLEURS (Hindi)
151
+ type: google/fleurs
152
+ config: hi_in
153
+ split: test
154
+ metrics:
155
+ - name: WER (1.12s frame size, LangID)
156
+ type: wer
157
+ value: 6.81
158
+ - task:
159
+ name: Automatic Speech Recognition
160
+ type: automatic-speech-recognition
161
+ dataset:
162
+ name: FLEURS (Korean)
163
+ type: google/fleurs
164
+ config: ko_kr
165
+ split: test
166
+ metrics:
167
+ - name: WER (1.12s frame size, LangID)
168
+ type: wer
169
+ value: 7.12
170
+ metrics:
171
+ - wer
172
+ pipeline_tag: automatic-speech-recognition
173
+ ---
174
+
175
+ # Nemotron 3.5 ASR
176
+
177
+ <style>
178
+ h1, h2, h3, h4, h5, h6 {
179
+ color: #76b900; /* NVIDIA green */
180
+ font-weight: 700;
181
+ }
182
+
183
+ hr {
184
+ border: none;
185
+ border-top: 1px solid #e5e7eb;
186
+ margin: 2rem 0;
187
+ }
188
+
189
+ /* Improve list spacing */
190
+ ul, ol {
191
+ margin-top: 0.5rem;
192
+ margin-bottom: 0.5rem;
193
+ }
194
+
195
+ /* Badge alignment consistency */
196
+ img {
197
+ display: inline;
198
+ vertical-align: middle;
199
+ }
200
+ </style>
201
+
202
+ <p align="center">
203
+ <a href="#model-architecture">
204
+ <img src="https://img.shields.io/badge/Model_Arch-FastConformer--CacheAware--RNNT-76b900?style=flat#model-badge" alt="Model architecture"/>
205
+ </a>
206
+ &nbsp;
207
+ <a href="#model-architecture">
208
+ <img src="https://img.shields.io/badge/Params-600M-76b900?style=flat#model-badge" alt="Model size"/>
209
+ </a>
210
+ &nbsp;
211
+ <a href="#supported-languages">
212
+ <img src="https://img.shields.io/badge/Language-Multilingual-76b900?style=flat#model-badge" alt="Language"/>
213
+ </a>
214
+ <a href="https://developer.nvidia.com/nemotron" target="_blank" style="margin: 2px;">
215
+ <img alt="Homepage" src="https://img.shields.io/badge/🏠Nemotron Developer Page-Learn More Here!-536af5?color=76B900&logoColor=white" style="display: inline-block; vertical-align: middle;"/>
216
+ </a>
217
+ <a href="https://discord.gg/9xpKQtVvrk" target="_blank" style="margin: 2px;">
218
+ <img alt="Discord" src="https://img.shields.io/badge/Discord-NVIDIA%20AI%20Developer-7289da?logo=discord&logoColor=white&color=7289da" style="display: inline-block; vertical-align: middle;"/>
219
+ </a>
220
+ <a href="https://openmdw.ai/license/1-1/" style="margin: 2px;">
221
+ <img alt="License" src="https://img.shields.io/badge/License-OpenMDW--1.1-f5de53" style="display: inline-block; vertical-align: middle;"/>
222
+ </a>
223
+ </p>
224
+
225
+ <div align="center" style="margin-bottom: -20px;">
226
+ <img src="model_overview.png" alt="Nemotron 3.5 ASR overview: multilingual audio across 40 language-locales is transcribed by a cache-aware FastConformer-RNNT model with language-ID prompting into punctuated text with an automatic language tag" width="900"/>
227
+ </div>
228
+ <div align="center" style="margin-top: 0; margin-bottom: 0;">
229
+ <img src="throughput_vs_chunk.png" alt="Concurrent streams supported on a single H100: Nemotron ASR streaming vs Parakeet RNNT, across chunk sizes" width="900"/>
230
+ </div>
231
+
232
+
233
+ > [!Note]
234
+ > This model is the multilingual extension of [nvidia/nemotron-speech-streaming-en-0.6b](https://huggingface.co/nvidia/nemotron-speech-streaming-en-0.6b), adding language-ID prompt conditioning to support transcription across **40 language-locales** from a single model.
235
+
236
+ **Nemotron 3.5 ASR** is a multilingual, streaming Automatic Speech Recognition (ASR) model engineered to deliver high-quality multilingual transcription across both low-latency streaming and high-throughput batch workloads. Developed by NVIDIA, this 600M parameter model transcribes speech into text with native support for punctuation and capitalization, and offers runtime flexibility with configurable chunk sizes, including 80ms, 160ms, 320ms, 560ms, and 1120ms.
237
+
238
+ By leveraging a state-of-the-art **Cache-Aware FastConformer-RNNT** architecture, the model eliminates redundant overlapping computations common in traditional "buffered" streaming. This allows it to process only new audio chunks while reusing cached encoder context, significantly improving computational efficiency and minimizing end-to-end delay without sacrificing accuracy.
239
+
240
+ It was trained on a massive ASR dataset and is engineered to perform across diverse and challenging acoustic conditions.
241
+
242
+ This model is ready for commercial use.
243
+
244
+ ## Release Date
245
+
246
+ - Hugging Face [06/04/2026] via https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b
247
+
248
+ ## Why Choose Nemotron 3.5 ASR?
249
+
250
+ - 🌍 **Single Multilingual Model:** Transcribes 40 language-locales from one model through language-ID prompt conditioning, with optional automatic language detection.
251
+ - ⚡ **Native Streaming Architecture:** Cache-aware design enables efficient processing of continuous audio streams, designed and optimized for low-latency voice agent applications.
252
+ - 💰 **Improved Operational Efficiency:** Delivers superior throughput compared to traditional buffered streaming approaches. This allows for a higher number of parallel streams within the same GPU memory constraints, directly reducing operational costs for production environments.
253
+ - 🎛️ **Dynamic Runtime Flexibility:** Choose the optimal operating point on the latency-accuracy Pareto curve at inference time. No re-training is required to adjust for different use-case requirements.
254
+ - 📝 **Punctuation & Capitalization:** Built-in support for punctuation and capitalization in output text.
255
+
256
+ - 🔧 **Fine-tuning:** Check our [blog post](https://huggingface.co/blog/nvidia/fine-tuning-nemotron-35-asr) of **how to fine-tune Nemotron 3.5 ASR to improve these languages**, including before/after results.
257
+
258
+ ---
259
+
260
+ ## Supported Languages
261
+
262
+ The model supports **40 language-locales** in total, across three tiers:
263
+
264
+ - **Transcription-ready (19 locales):** highest-accuracy ASR, ready out of the box.
265
+ - **Broad-coverage (13 locales):** production ASR across an additional 13 locales.
266
+ - **Adaptation-ready (8 locales):** recognized by the tokenizer; fine-tune on in-domain data to unlock full transcription.
267
+
268
+ | Tier | Languages (locales) |
269
+ | :--- | :--- |
270
+ | **Transcription-ready (19 locales)** | English (en-US, en-GB), Spanish (es-US, es-ES), French (fr-FR, fr-CA), Italian (it-IT), Portuguese (pt-BR, pt-PT), Dutch (nl-NL), German (de-DE), Turkish (tr-TR), Russian (ru-RU), Arabic (ar-AR), Hindi (hi-IN), Japanese (ja-JP), Korean (ko-KR), Vietnamese (vi-VN), Ukrainian (uk-UA) |
271
+ | **Broad-coverage (13 locales)** | Polish (pl-PL), Swedish (sv-SE), Czech (cs-CZ), Norwegian Bokmål (nb-NO), Danish (da-DK), Bulgarian (bg-BG), Finnish (fi-FI), Croatian (hr-HR), Slovak (sk-SK), Mandarin (zh-CN), Hungarian (hu-HU), Romanian (ro-RO), Estonian (et-EE) |
272
+ | **Adaptation-ready (8 locales)** | Greek (el-GR), Lithuanian (lt-LT), Latvian (lv-LV), Maltese (mt-MT), Slovenian (sl-SI), Hebrew (he-IL), Thai (th-TH), Norwegian Nynorsk (nn-NO) |
273
+
274
+ > **Note:** Transcription-ready and broad-coverage locales (**32 total**) produce ASR transcription out of the box; adaptation-ready locales require fine-tuning on in-domain data to enable full transcription. The model supports uppercase and lowercase letters, punctuation, spaces, and apostrophes.
275
+
276
+ > **Note:** We would recommend [Nemotron ASR Streaming (English)](https://huggingface.co/nvidia/nemotron-speech-streaming-en-0.6b) model for English-only transcription use cases. For all other transcription ready locales, we recommend Nemotron 3.5 ASR to leverage its expanded multilingual capabilities.
277
+
278
+ > [!Tip]
279
+ > **Automatic language detection / language tagging:** When run with `target_lang=auto`, the model detects the spoken language and emits the corresponding **language code/tag** in the output following the terminal punctuation. This lets a single deployment transcribe mixed-language traffic and automatically label each utterance with its detected language — no separate language-ID component required.
280
+
281
+ ---
282
+
283
+ ## Model Architecture
284
+
285
+ **Architecture Type:** FastConformer-CacheAware-RNNT with Prompt
286
+
287
+ This model consists of a cache-aware streaming Parakeet (FastConformer) encoder with an RNN-T decoder and language-ID prompt conditioning. It is based on the Cache-Aware [\[1\]](#ref-1) FastConformer [\[2\]](#ref-2) architecture with 24 encoder layers and an RNNT (Recurrent Neural Network Transducer) decoder. The cache-aware streaming design enables efficient processing of audio in chunks while maintaining context from previous frames. Unlike buffered inference, this model maintains caches for all encoder self-attention and convolution layers. This enables reuse of hidden states at every streaming step, where cached activations eliminate redundant computations. As a result, there are no overlapping computations; each processed frame is strictly non-overlapping. This model leverages prompts to guide the transcription process, enabling language-specific transcription from a single ASR model through language ID conditioning.
288
+
289
+ <p align="center">
290
+ <img src="model_architecture.png" alt="Nemotron 3.5 ASR architecture: FastConformer encoder and language-ID encoding are concatenated, projected, and fed to the RNNT decoder" width="900"/>
291
+ </p>
292
+
293
+ The language-ID prompt is fused with the acoustic representation as follows:
294
+
295
+ - **FastConformer encoder** processes audio into an acoustic embedding of shape (D=1024, T).
296
+ - **Language Encoding** expands a 128-dim one-hot language vector across the time axis → (K=128, T), broadcasting the language identity to every frame.
297
+ - **Concatenation** along the feature axis → fused tensor (D + K, T).
298
+ - **Projection layer** maps the fused features to the RNNT decoder.
299
+
300
+ **Network Architecture:**
301
+ - Encoder: Cache-Aware FastConformer with 24 layers
302
+ - Decoder: RNNT (Recurrent Neural Network Transducer)
303
+ - Parameters: 600M
304
+
305
+ **This model was developed based on [nvidia/nemotron-speech-streaming-en-0.6b](https://huggingface.co/nvidia/nemotron-speech-streaming-en-0.6b).**
306
+
307
+ ---
308
+
309
+ ## Results at a Glance
310
+
311
+ ASR performance is measured using Word Error Rate (WER) on the **FLEURS** test sets. Accuracy stays strong across both modes and improves as the chunk size grows, while remaining competitive even at the lowest-latency 80ms setting. Full tables are in [Performance](#performance).
312
+
313
+ <p align="center">
314
+ <img src="fleurs_wer_vs_chunk_size.png" alt="FLEURS average WER vs streaming chunk size (LangID vs Auto-detect)" width="900"/>
315
+ </p>
316
+
317
+ <p align="center">
318
+ <img src="fleurs_langid_vs_auto.png" alt="FLEURS WER by language: LangID vs Auto-detect at 320ms chunk" width="900"/>
319
+ </p>
320
+
321
+ > **Note:** Japanese and Korean are measured using Character Error Rate (CER) rather than WER, as is standard for these languages.
322
+
323
+ ---
324
+
325
+ ## Throughput & Efficiency
326
+
327
+ Despite being **roughly half the size** (0.6B vs. 1.1B), Nemotron 3.5 ASR serves **far more concurrent streams at far lower latency** than the [Parakeet RNNT 1.1B multilingual model](https://build.nvidia.com/nvidia/parakeet-1_1b-rnnt-multilingual-asr), which runs on buffered streaming. The cache-aware streaming design avoids the redundant recomputation of buffered inference, so a single H100 can sustain dramatically higher concurrency at every chunk size — directly lowering the cost per stream in production. At the lowest-latency 80ms setting, Nemotron sustains **~17× more concurrent streams** (240 vs. 14); at the 1120ms setting it sustains **6× more** (2,400 vs. 400). The latency-vs-concurrency curves tell the same story: Nemotron (solid green) holds low final-token latency well past 1,000 parallel requests, while Parakeet RNNT 1.1B (dashed blue) saturates after only a few hundred.
328
+
329
+ <p align="center">
330
+ <img src="throughput_vs_chunk.png" alt="Concurrent streams supported on a single H100: Nemotron ASR streaming vs Parakeet RNNT, across chunk sizes" width="900"/>
331
+ </p>
332
+
333
+ <p align="center">
334
+ <img src="latency_vs_parallel.png" alt="Median final-token latency vs number of parallel requests on a single H100, Nemotron vs Parakeet RNNT across chunk sizes" width="900"/>
335
+ </p>
336
+
337
+ > Measured on a single NVIDIA H100. Throughput is the number of real-time streams sustainable in parallel; latency is the median final-token latency at a given level of concurrency.
338
+
339
+ ---
340
+
341
+ ## Explore more from NVIDIA
342
+
343
+ For documentation, deployment guides, enterprise-ready APIs, and the latest open models—including Nemotron and other cutting-edge speech, translation, and generative AI—visit the NVIDIA Developer Portal at [developer.nvidia.com](https://developer.nvidia.com/).
344
+ Join the community to access tools, support, and resources to accelerate your development with NVIDIA's NeMo, Speech NIM, and foundation models.
345
+
346
+ - What is [Nemotron](https://www.nvidia.com/en-us/ai-data-science/foundation-models/nemotron/)?
347
+ - NVIDIA Developer [Nemotron](https://developer.nvidia.com/nemotron)
348
+ - [NVIDIA Speech NIM](https://docs.nvidia.com/nim/speech/latest/about/index.html)
349
+ - [NeMo Documentation](https://docs.nvidia.com/nemo-framework/user-guide/latest/nemotoolkit/asr/models.html)
350
+
351
+ Also, check out the following NVIDIA speech models:
352
+ - Nemotron ASR Streaming (English) (Nemotron 3 ASR) - https://huggingface.co/nvidia/nemotron-speech-streaming-en-0.6b
353
+ - Multitalker Parakeet Streaming - https://huggingface.co/nvidia/multitalker-parakeet-streaming-0.6b-v1
354
+ - Parakeet Realtime EOU - https://huggingface.co/nvidia/parakeet_realtime_eou_120m-v1
355
+
356
+ ---
357
+
358
+ ## How to Use this Model
359
+
360
+ There are several ways to use this model. Choose the one that fits your needs.
361
+
362
+ ### Run locally with NeMo-Speech.cpp
363
+
364
+ [NeMo-Speech.cpp](https://github.com/NVIDIA/NeMo-Speech.cpp) provides a
365
+ lightweight native C++ runtime for local inference with
366
+ this model. After [installing the runtime](https://github.com/NVIDIA/NeMo-Speech.cpp#installation):
367
+
368
+ ```bash
369
+ hf download nvidia/nemotron-3.5-asr-streaming-0.6b \
370
+ nemotron-3.5-asr-streaming-0.6b.q8_0.gguf \
371
+ --local-dir models
372
+
373
+ nemo-speech transcribe audio.wav \
374
+ --model models/nemotron-3.5-asr-streaming-0.6b.q8_0.gguf \
375
+ --language en-US
376
+ ```
377
+
378
+ Use another supported locale or `--language auto` for automatic language
379
+ detection. See the [NeMo-Speech.cpp documentation](https://github.com/NVIDIA/NeMo-Speech.cpp)
380
+ for more details.
381
+
382
+ ### NVIDIA NeMo
383
+
384
+ To train, fine-tune or perform inference with this model, install [NVIDIA NeMo](https://github.com/NVIDIA/NeMo) [\[4\]](#ref-4) after installing Python 3.11 or later, Cython, and a recent PyTorch version.
385
+
386
+ ```bash
387
+ apt-get update && apt-get install -y libsndfile1 ffmpeg
388
+ pip install Cython packaging
389
+ pip install git+https://github.com/NVIDIA/NeMo.git@main#egg=nemo_toolkit[asr]
390
+ ```
391
+
392
+ #### Loading the Model
393
+
394
+ ```python
395
+ import nemo.collections.asr as nemo_asr
396
+ asr_model = nemo_asr.models.ASRModel.from_pretrained(model_name="nvidia/nemotron-3.5-asr-streaming-0.6b")
397
+ ```
398
+
399
+ #### Streaming Inference
400
+
401
+ You can use the cache-aware streaming inference script from NeMo - [NeMo/examples/asr/asr_cache_aware_streaming/speech_to_text_cache_aware_streaming_infer.py](https://github.com/NVIDIA-NeMo/NeMo/blob/main/examples/asr/asr_cache_aware_streaming/speech_to_text_cache_aware_streaming_infer.py)
402
+
403
+ This is a prompt-conditioned multilingual model: pass the target language with `target_lang` (e.g. `en-US`, `es-ES`, `de-DE`), or use `target_lang=auto` for automatic language detection.
404
+
405
+ ```bash
406
+ cd NeMo
407
+ python examples/asr/asr_cache_aware_streaming/speech_to_text_cache_aware_streaming_infer.py \
408
+ model_path=<model_path> \
409
+ dataset_manifest=<dataset_manifest> \
410
+ batch_size=<batch_size> \
411
+ target_lang=<lang_id> \ #language key (e.g. en-US) or "auto" for automatic language detection
412
+ att_context_size="[56,13]" \ #set the second value to the desired right context from {0,1,3,6,13}
413
+ strip_lang_tags=true \ #true: remove the detected language tag from the text; false: keep it in the output
414
+ output_path=<output_folder>
415
+ ```
416
+
417
+ **`strip_lang_tags`** controls how the detected language tag is handled in the output. The model appends a language tag (e.g. `<en-US>`) after the transcript's terminal punctuation:
418
+ - `strip_lang_tags=false` (keep): the tag is left in the output, so you can read the detected language directly from each utterance — useful for mixed-language traffic and language labeling.
419
+ - `strip_lang_tags=true` (remove): the tag is stripped, leaving only the clean transcript text — useful when you only need the spoken words.
420
+
421
+ #### Setting up Streaming Configuration
422
+
423
+ Latency is defined by the `att_context_size` param, where att_context_size = `{num_frames_left_context, num_frame_right_context}`, all measured in **80ms frames**:
424
+
425
+ * [56, 0]: Chunk size = 1 (1 × 80ms = 0.08s)
426
+ * [56, 1]: Chunk size = 2 (2 × 80ms = 0.16s)
427
+ * [56, 3]: Chunk size = 4 (4 × 80ms = 0.32s)
428
+ * [56, 6]: Chunk size = 7 (7 × 80ms = 0.56s)
429
+ * [56, 13]: Chunk size = 14 (14 × 80ms = 1.12s)
430
+
431
+ Here, chunk size = current frame + right context; each chunk is processed in non-overlapping fashion.
432
+
433
+ ### 🤗 Transformers usage
434
+
435
+ This checkpoint also runs with [🤗 Transformers](https://github.com/huggingface/transformers). The target language is passed through the processor's `language` argument: a locale such as `en-US`/`de-DE`, a bare code such as `de`, or `auto` for automatic language detection. In `auto` mode the model appends an `<xx-XX>` language tag after the transcript's terminal punctuation; it is a special token, so decoding with `skip_special_tokens=True` strips it (clean transcript) and `skip_special_tokens=False` keeps it for language labeling.
436
+
437
+ Nemotron3_5Asr is available in 🤗 Transformers starting from v5.13.0.
438
+
439
+ ```bash
440
+ pip install "transformers>=5.13.0"
441
+ ```
442
+
443
+ <details>
444
+ <summary>➡️ Pipeline</summary>
445
+
446
+ ```python
447
+ from transformers import pipeline
448
+
449
+ pipe = pipeline("automatic-speech-recognition", model="nvidia/nemotron-3.5-asr-streaming-0.6b")
450
+ out = pipe("https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/bcn_weather.mp3")
451
+ print(out)
452
+ ```
453
+
454
+ The pipeline uses the default language prompt (index 0, `en-US`). For explicit language conditioning or automatic detection, pass the processor's `language` argument (see the AutoModel example below).
455
+ </details>
456
+
457
+ <details>
458
+ <summary>➡️ Offline transcription</summary>
459
+
460
+ ```python
461
+ from transformers import AutoModelForRNNT, AutoProcessor
462
+ from transformers.audio_utils import load_audio
463
+
464
+ model_id = "nvidia/nemotron-3.5-asr-streaming-0.6b"
465
+ processor = AutoProcessor.from_pretrained(model_id)
466
+ model = AutoModelForRNNT.from_pretrained(model_id, device_map="auto")
467
+
468
+ audio = load_audio(
469
+ "https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/bcn_weather.mp3",
470
+ sampling_rate=processor.feature_extractor.sampling_rate,
471
+ )
472
+
473
+ # Condition on a known language ...
474
+ inputs = processor(audio, sampling_rate=processor.feature_extractor.sampling_rate, language="en-US")
475
+ inputs.to(model.device, dtype=model.dtype)
476
+ output = model.generate(**inputs, return_dict_in_generate=True)
477
+ print(processor.decode(output.sequences, skip_special_tokens=True))
478
+
479
+ # ... or let the model detect it and keep the emitted <xx-XX> language tag.
480
+ inputs = processor(audio, sampling_rate=processor.feature_extractor.sampling_rate, language="auto")
481
+ inputs.to(model.device, dtype=model.dtype)
482
+ output = model.generate(**inputs, return_dict_in_generate=True)
483
+ print(processor.decode(output.sequences, skip_special_tokens=False))
484
+ ```
485
+ </details>
486
+
487
+ <details>
488
+ <summary>➡️ Streaming transcription</summary>
489
+
490
+ ```python
491
+ from threading import Thread
492
+ from transformers import AutoModelForRNNT, AutoProcessor, TextIteratorStreamer
493
+ from transformers.audio_utils import load_audio
494
+
495
+ model_id = "nvidia/nemotron-3.5-asr-streaming-0.6b"
496
+ processor = AutoProcessor.from_pretrained(model_id)
497
+ model = AutoModelForRNNT.from_pretrained(model_id, device_map="auto")
498
+
499
+ processor.set_num_lookahead_tokens(6)
500
+ print(f"Streaming latency: {processor.streaming_latency_ms} ms")
501
+
502
+ # The language prompt rides along on every chunk; use a locale (e.g. "de-DE") or "auto".
503
+ language = "en-US"
504
+
505
+ sampling_rate = processor.feature_extractor.sampling_rate
506
+ audio = load_audio(
507
+ "https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/obama.mp3",
508
+ sampling_rate=sampling_rate,
509
+ )
510
+
511
+ first_chunk_inputs = processor(
512
+ audio[: processor.num_samples_first_audio_chunk],
513
+ sampling_rate=sampling_rate,
514
+ is_streaming=True,
515
+ is_first_audio_chunk=True,
516
+ language=language,
517
+ return_tensors="pt",
518
+ )
519
+ first_chunk_inputs = first_chunk_inputs.to(model.device, dtype=model.dtype)
520
+
521
+
522
+ def input_features_generator():
523
+ yield first_chunk_inputs.input_features[:, : processor.num_mel_frames_first_audio_chunk, :]
524
+
525
+ mel_frame_idx = processor.num_mel_frames_first_audio_chunk
526
+ hop_length = processor.feature_extractor.hop_length
527
+ n_fft = processor.feature_extractor.n_fft
528
+
529
+ start_idx = mel_frame_idx * hop_length - n_fft // 2
530
+ while (end_idx := start_idx + processor.num_samples_per_audio_chunk) < audio.shape[0]:
531
+ inputs = processor(
532
+ audio[start_idx:end_idx],
533
+ sampling_rate=sampling_rate,
534
+ is_streaming=True,
535
+ is_first_audio_chunk=False,
536
+ language=language,
537
+ return_tensors="pt",
538
+ )
539
+ inputs = inputs.to(model.device, dtype=model.dtype)
540
+ yield inputs.input_features
541
+
542
+ mel_frame_idx += processor.num_mel_frames_per_audio_chunk
543
+ start_idx = mel_frame_idx * hop_length - n_fft // 2
544
+
545
+
546
+ streamer = TextIteratorStreamer(processor.tokenizer, skip_special_tokens=True)
547
+ generate_kwargs = {
548
+ **first_chunk_inputs,
549
+ "input_features": input_features_generator(),
550
+ "streamer": streamer,
551
+ }
552
+ thread = Thread(target=model.generate, kwargs=generate_kwargs)
553
+ thread.start()
554
+
555
+ print("Model output (streaming):", end=" ", flush=True)
556
+ for text_chunk in streamer:
557
+ print(text_chunk, end="", flush=True)
558
+ thread.join()
559
+ ```
560
+ </details>
561
+
562
+ For more details about usage, please refer to the [Transformers documentation](https://huggingface.co/docs/transformers/en/model_doc/nemotron3_5_asr).
563
+
564
+ ### Input(s): <br>
565
+
566
+ **Input Type(s):** Audio, Lang ID <br>
567
+
568
+ **Input Format(s):** wav, string <br>
569
+
570
+ **Input Parameters:** One-Dimensional (1D) for audio and One-Dimensional (1D) for Lang ID <br>
571
+
572
+ **Other Properties Related to Input:** Maximum Length in seconds specific to GPU Memory, No Pre-Processing Needed, Mono channel is required.
573
+
574
+ By leveraging NVIDIA’s hardware (e.g. GPU cores) and software frameworks (e.g., CUDA libraries), the model achieves faster training and inference times compared to CPU-only solutions. <br>
575
+
576
+ ### Output
577
+
578
+ **Output Type(s):** Text String in Input Language <br>
579
+
580
+ **Output Format(s):** String <br>
581
+
582
+ **Output Parameters:** One-Dimensional (1D) <br>
583
+
584
+ **Other Properties Related to Output:** No Maximum Character Length, transcribe punctuation and capitalization.
585
+
586
+ By leveraging NVIDIA’s hardware (e.g. GPU cores) and software frameworks (e.g., CUDA libraries), the model achieves faster training and inference times compared to CPU-only solutions. <br>
587
+
588
+ ---
589
+
590
+ ## Software Integration
591
+
592
+ **Runtime Engine:** NeMo 26.06
593
+
594
+ **Supported Hardware Microarchitecture Compatibility:**
595
+ - NVIDIA Ampere
596
+ - NVIDIA Blackwell
597
+ - NVIDIA Hopper
598
+ - NVIDIA Jetson
599
+ - NVIDIA Lovelace
600
+ - NVIDIA Turing
601
+ - NVIDIA Volta
602
+
603
+ **Supported Operating System(s):**
604
+ * Linux <br>
605
+ * Linux 4 Tegra <br>
606
+
607
+ The integration of foundation and fine-tuned models into AI systems requires additional testing using use-case-specific data to ensure safe and effective deployment. Following the V-model methodology, iterative testing and validation at both unit and system levels are essential to mitigate risks, meet technical and functional requirements, and ensure compliance with safety and ethical standards before deployment.<vr>
608
+
609
+
610
+
611
+ ---
612
+
613
+
614
+ ## Model Version(s):
615
+ nemotron-3.5-asr-streaming-0.6b-v1 <br>
616
+
617
+ ## Training and Evaluation Datasets:
618
+
619
+ ### Training Datasets
620
+
621
+ It was trained on speech data across 40 language-locales. The training data is a dynamic blend of public and proprietary internal datasets normalized to have spoken forms in text with punctuation and capitalization, including:
622
+
623
+
624
+ - NVIDIA Riva multilingual ASR training set (Proprietary)
625
+ - NVIDIA Granary [\[3\]](#ref-3)
626
+ - Multilingual LibriSpeech (MLS)
627
+ - Mozilla Common Voice
628
+ - FLEURS
629
+ - VoxPopuli / Europarl-ASR
630
+
631
+ ** Data Modality: Audio <br>
632
+
633
+ ** Audio Training Data Size: 10,000 to 1 Million Hours <br>
634
+
635
+ ** Data Collection Method by dataset <br>
636
+ * Human <br>
637
+
638
+ ** Labeling Method by dataset <br>
639
+ * Human <br>
640
+ * Synthetic: Synthetic labels were generated from an ensemble of ASR models ([NVIDIA Canary](https://build.nvidia.com/nvidia/canary-1b-asr), [Parakeet Multilingual 1.1B RNNT](https://build.nvidia.com/nvidia/parakeet-1_1b-rnnt-multilingual-asr), [Parakeet CTC 1.1B](https://build.nvidia.com/nvidia/parakeet-ctc-1_1b-asr), [OpenAI Whisper](https://huggingface.co/openai/whisper-large-v3), and [FunASR](https://github.com/modelscope/FunASR)), with punctuation and capitalization (PnC) generated from [Qwen3-32B](https://huggingface.co/Qwen/Qwen3-32B).
641
+
642
+
643
+
644
+
645
+ ### Evaluation Datasets
646
+
647
+ The model was evaluated on multilingual ASR benchmarks:
648
+
649
+ - FLEURS
650
+ - Mozilla Common Voice (MCV)
651
+ - Multilingual LibriSpeech (MLS)
652
+ - NVIDIA internal multilingual evaluation sets
653
+
654
+ ** Data Collection Method by dataset <br>
655
+ * Human <br>
656
+
657
+ ** Labeling Method by dataset <br>
658
+ * Human <br>
659
+
660
+ ---
661
+
662
+ ## Performance
663
+
664
+ ASR performance is measured using the Word Error Rate (WER). The tables below report WER (%) on the **FLEURS** test sets across configurable streaming chunk sizes, in two modes:
665
+ - **Language Input (LangID):** the target language is provided to the model.
666
+ - **Auto-detect:** the model automatically detects the spoken language.
667
+
668
+ > **Note:** Japanese, Korean, and Mandarin are evaluated using Character Error Rate (CER) rather than WER, as is standard for these languages.
669
+ > **Note on text normalization:** WER/CER are computed after text normalization that aligns the reference and hypothesis (e.g., casing, punctuation, numerals, and formatting conventions). Normalization is not perfect across all 40 language-locales, and residual mismatches between normalized text can inflate the reported error rates — actual transcription quality may be somewhat better than the numbers suggest.
670
+
671
+ ### Transcription-ready (19 locales)
672
+
673
+ _Languages are ordered by accuracy (lowest WER first)._
674
+
675
+ <table>
676
+ <thead>
677
+ <tr><th rowspan="2" align="left">Language</th><th colspan="5" align="center" style="background-color:#76b900;color:#ffffff">Language Input (LangID)</th><th colspan="5" align="center" style="background-color:#6b7280;color:#ffffff;border-left:2px solid #cbd5e1;">Auto-detect</th></tr>
678
+ <tr><th align="center" style="background-color:#eef6e0">80ms</th><th align="center" style="background-color:#eef6e0">160ms</th><th align="center" style="background-color:#eef6e0">320ms</th><th align="center" style="background-color:#eef6e0">560ms</th><th align="center" style="background-color:#eef6e0">1.12s</th><th align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">80ms</th><th align="center" style="background-color:#f3f4f6;">160ms</th><th align="center" style="background-color:#f3f4f6;">320ms</th><th align="center" style="background-color:#f3f4f6;">560ms</th><th align="center" style="background-color:#f3f4f6;">1.12s</th></tr>
679
+ </thead>
680
+ <tbody>
681
+ <tr><td align="left">Spanish (es-US, es-ES)</td><td align="center" style="background-color:#eef6e0;">4.87</td><td align="center" style="background-color:#eef6e0;">4.64</td><td align="center" style="background-color:#eef6e0;">4.39</td><td align="center" style="background-color:#eef6e0;">4.26</td><td align="center" style="background-color:#eef6e0;">4.11</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">5.04</td><td align="center" style="background-color:#f3f4f6;">4.82</td><td align="center" style="background-color:#f3f4f6;">4.48</td><td align="center" style="background-color:#f3f4f6;">4.34</td><td align="center" style="background-color:#f3f4f6;">4.13</td></tr>
682
+ <tr><td align="left">Italian (it-IT)</td><td align="center" style="background-color:#eef6e0;">5.23</td><td align="center" style="background-color:#eef6e0;">4.85</td><td align="center" style="background-color:#eef6e0;">4.83</td><td align="center" style="background-color:#eef6e0;">4.41</td><td align="center" style="background-color:#eef6e0;">4.25</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">5.28</td><td align="center" style="background-color:#f3f4f6;">4.89</td><td align="center" style="background-color:#f3f4f6;">4.84</td><td align="center" style="background-color:#f3f4f6;">4.47</td><td align="center" style="background-color:#f3f4f6;">4.32</td></tr>
683
+ <tr><td align="left">Portuguese (pt-BR, pt-PT)</td><td align="center" style="background-color:#eef6e0;">6.29</td><td align="center" style="background-color:#eef6e0;">6.10</td><td align="center" style="background-color:#eef6e0;">5.81</td><td align="center" style="background-color:#eef6e0;">5.65</td><td align="center" style="background-color:#eef6e0;">5.48</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">6.41</td><td align="center" style="background-color:#f3f4f6;">6.19</td><td align="center" style="background-color:#f3f4f6;">5.82</td><td align="center" style="background-color:#f3f4f6;">5.57</td><td align="center" style="background-color:#f3f4f6;">5.47</td></tr>
684
+ <tr><td align="left">Hindi (hi-IN)</td><td align="center" style="background-color:#eef6e0;">8.13</td><td align="center" style="background-color:#eef6e0;">7.97</td><td align="center" style="background-color:#eef6e0;">7.41</td><td align="center" style="background-color:#eef6e0;">7.05</td><td align="center" style="background-color:#eef6e0;">6.81</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">11.47</td><td align="center" style="background-color:#f3f4f6;">10.83</td><td align="center" style="background-color:#f3f4f6;">9.88</td><td align="center" style="background-color:#f3f4f6;">9.26</td><td align="center" style="background-color:#f3f4f6;">8.23</td></tr>
685
+ <tr><td align="left">Korean (ko-KR)</td><td align="center" style="background-color:#eef6e0;">7.59</td><td align="center" style="background-color:#eef6e0;">7.70</td><td align="center" style="background-color:#eef6e0;">7.27</td><td align="center" style="background-color:#eef6e0;">7.18</td><td align="center" style="background-color:#eef6e0;">7.12</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">8.31</td><td align="center" style="background-color:#f3f4f6;">8.18</td><td align="center" style="background-color:#f3f4f6;">7.81</td><td align="center" style="background-color:#f3f4f6;">7.49</td><td align="center" style="background-color:#f3f4f6;">7.30</td></tr>
686
+ <tr><td align="left">English (en-US, en-GB)</td><td align="center" style="background-color:#eef6e0;">9.43</td><td align="center" style="background-color:#eef6e0;">8.88</td><td align="center" style="background-color:#eef6e0;">8.27</td><td align="center" style="background-color:#eef6e0;">7.99</td><td align="center" style="background-color:#eef6e0;">7.91</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">9.72</td><td align="center" style="background-color:#f3f4f6;">9.34</td><td align="center" style="background-color:#f3f4f6;">8.84</td><td align="center" style="background-color:#f3f4f6;">8.80</td><td align="center" style="background-color:#f3f4f6;">8.84</td></tr>
687
+ <tr><td align="left">German (de-DE)</td><td align="center" style="background-color:#eef6e0;">9.81</td><td align="center" style="background-color:#eef6e0;">9.21</td><td align="center" style="background-color:#eef6e0;">8.83</td><td align="center" style="background-color:#eef6e0;">8.42</td><td align="center" style="background-color:#eef6e0;">8.31</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">9.90</td><td align="center" style="background-color:#f3f4f6;">9.37</td><td align="center" style="background-color:#f3f4f6;">8.87</td><td align="center" style="background-color:#f3f4f6;">8.58</td><td align="center" style="background-color:#f3f4f6;">8.22</td></tr>
688
+ <tr><td align="left">French (fr-FR, fr-CA)</td><td align="center" style="background-color:#eef6e0;">10.97</td><td align="center" style="background-color:#eef6e0;">10.60</td><td align="center" style="background-color:#eef6e0;">9.79</td><td align="center" style="background-color:#eef6e0;">9.45</td><td align="center" style="background-color:#eef6e0;">9.03</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">11.03</td><td align="center" style="background-color:#f3f4f6;">10.60</td><td align="center" style="background-color:#f3f4f6;">9.84</td><td align="center" style="background-color:#f3f4f6;">9.46</td><td align="center" style="background-color:#f3f4f6;">9.02</td></tr>
689
+ <tr><td align="left">Russian (ru-RU)</td><td align="center" style="background-color:#eef6e0;">10.84</td><td align="center" style="background-color:#eef6e0;">10.73</td><td align="center" style="background-color:#eef6e0;">9.87</td><td align="center" style="background-color:#eef6e0;">9.60</td><td align="center" style="background-color:#eef6e0;">9.17</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">12.47</td><td align="center" style="background-color:#f3f4f6;">12.09</td><td align="center" style="background-color:#f3f4f6;">11.01</td><td align="center" style="background-color:#f3f4f6;">10.57</td><td align="center" style="background-color:#f3f4f6;">10.03</td></tr>
690
+ <tr><td align="left">Turkish (tr-TR)</td><td align="center" style="background-color:#eef6e0;">12.34</td><td align="center" style="background-color:#eef6e0;">12.33</td><td align="center" style="background-color:#eef6e0;">12.05</td><td align="center" style="background-color:#eef6e0;">11.34</td><td align="center" style="background-color:#eef6e0;">11.17</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">12.61</td><td align="center" style="background-color:#f3f4f6;">12.28</td><td align="center" style="background-color:#f3f4f6;">11.93</td><td align="center" style="background-color:#f3f4f6;">11.51</td><td align="center" style="background-color:#f3f4f6;">11.32</td></tr>
691
+ <tr><td align="left">Vietnamese (vi-VN)</td><td align="center" style="background-color:#eef6e0;">13.41</td><td align="center" style="background-color:#eef6e0;">12.87</td><td align="center" style="background-color:#eef6e0;">12.29</td><td align="center" style="background-color:#eef6e0;">11.78</td><td align="center" style="background-color:#eef6e0;">11.18</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">13.59</td><td align="center" style="background-color:#f3f4f6;">13.02</td><td align="center" style="background-color:#f3f4f6;">12.40</td><td align="center" style="background-color:#f3f4f6;">12.02</td><td align="center" style="background-color:#f3f4f6;">11.22</td></tr>
692
+ <tr><td align="left">Dutch (nl-NL)</td><td align="center" style="background-color:#eef6e0;">14.03</td><td align="center" style="background-color:#eef6e0;">13.43</td><td align="center" style="background-color:#eef6e0;">12.17</td><td align="center" style="background-color:#eef6e0;">11.97</td><td align="center" style="background-color:#eef6e0;">11.46</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">14.09</td><td align="center" style="background-color:#f3f4f6;">13.80</td><td align="center" style="background-color:#f3f4f6;">12.62</td><td align="center" style="background-color:#f3f4f6;">12.24</td><td align="center" style="background-color:#f3f4f6;">11.70</td></tr>
693
+ <tr><td align="left">Japanese (ja-JP)</td><td align="center" style="background-color:#eef6e0;">13.87</td><td align="center" style="background-color:#eef6e0;">12.90</td><td align="center" style="background-color:#eef6e0;">12.22</td><td align="center" style="background-color:#eef6e0;">11.91</td><td align="center" style="background-color:#eef6e0;">11.48</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">14.97</td><td align="center" style="background-color:#f3f4f6;">13.85</td><td align="center" style="background-color:#f3f4f6;">13.00</td><td align="center" style="background-color:#f3f4f6;">12.38</td><td align="center" style="background-color:#f3f4f6;">11.66</td></tr>
694
+ <tr><td align="left">Arabic (ar-AR)</td><td align="center" style="background-color:#eef6e0;">13.17</td><td align="center" style="background-color:#eef6e0;">12.65</td><td align="center" style="background-color:#eef6e0;">12.55</td><td align="center" style="background-color:#eef6e0;">12.13</td><td align="center" style="background-color:#eef6e0;">12.03</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">13.47</td><td align="center" style="background-color:#f3f4f6;">12.85</td><td align="center" style="background-color:#f3f4f6;">12.67</td><td align="center" style="background-color:#f3f4f6;">12.18</td><td align="center" style="background-color:#f3f4f6;">12.06</td></tr>
695
+ <tr><td align="left">Ukrainian (uk-UA)</td><td align="center" style="background-color:#eef6e0;">15.70</td><td align="center" style="background-color:#eef6e0;">15.21</td><td align="center" style="background-color:#eef6e0;">14.55</td><td align="center" style="background-color:#eef6e0;">13.67</td><td align="center" style="background-color:#eef6e0;">13.07</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">18.81</td><td align="center" style="background-color:#f3f4f6;">17.96</td><td align="center" style="background-color:#f3f4f6;">16.79</td><td align="center" style="background-color:#f3f4f6;">15.60</td><td align="center" style="background-color:#f3f4f6;">14.59</td></tr>
696
+ <tr><td align="left"><strong>Average</strong></td><td align="center" style="background-color:#eef6e0;"><strong>10.38</strong></td><td align="center" style="background-color:#eef6e0;"><strong>10.00</strong></td><td align="center" style="background-color:#eef6e0;"><strong>9.49</strong></td><td align="center" style="background-color:#eef6e0;"><strong>9.12</strong></td><td align="center" style="background-color:#eef6e0;"><strong>8.84</strong></td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;"><strong>11.14</strong></td><td align="center" style="background-color:#f3f4f6;"><strong>10.67</strong></td><td align="center" style="background-color:#f3f4f6;"><strong>10.05</strong></td><td align="center" style="background-color:#f3f4f6;"><strong>9.63</strong></td><td align="center" style="background-color:#f3f4f6;"><strong>9.21</strong></td></tr>
697
+ </tbody>
698
+ </table>
699
+
700
+ ### Broad-coverage (13 locales)
701
+
702
+ _Languages are ordered by accuracy (lowest WER first)._
703
+
704
+ <table>
705
+ <thead>
706
+ <tr><th rowspan="2" align="left">Language</th><th colspan="5" align="center" style="background-color:#76b900;color:#ffffff">Language Input (LangID)</th><th colspan="5" align="center" style="background-color:#6b7280;color:#ffffff;border-left:2px solid #cbd5e1;">Auto-detect</th></tr>
707
+ <tr><th align="center" style="background-color:#eef6e0">80ms</th><th align="center" style="background-color:#eef6e0">160ms</th><th align="center" style="background-color:#eef6e0">320ms</th><th align="center" style="background-color:#eef6e0">560ms</th><th align="center" style="background-color:#eef6e0">1.12s</th><th align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">80ms</th><th align="center" style="background-color:#f3f4f6;">160ms</th><th align="center" style="background-color:#f3f4f6;">320ms</th><th align="center" style="background-color:#f3f4f6;">560ms</th><th align="center" style="background-color:#f3f4f6;">1.12s</th></tr>
708
+ </thead>
709
+ <tbody>
710
+ <tr><td align="left">Polish (pl-PL)</td><td align="center" style="background-color:#eef6e0;">19.88</td><td align="center" style="background-color:#eef6e0;">18.92</td><td align="center" style="background-color:#eef6e0;">17.48</td><td align="center" style="background-color:#eef6e0;">16.61</td><td align="center" style="background-color:#eef6e0;">15.15</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">22.65</td><td align="center" style="background-color:#f3f4f6;">21.63</td><td align="center" style="background-color:#f3f4f6;">20.05</td><td align="center" style="background-color:#f3f4f6;">18.52</td><td align="center" style="background-color:#f3f4f6;">16.55</td></tr>
711
+ <tr><td align="left">Norwegian Bokmål (nb-NO)</td><td align="center" style="background-color:#eef6e0;">20.43</td><td align="center" style="background-color:#eef6e0;">20.07</td><td align="center" style="background-color:#eef6e0;">18.90</td><td align="center" style="background-color:#eef6e0;">18.44</td><td align="center" style="background-color:#eef6e0;">18.10</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">20.91</td><td align="center" style="background-color:#f3f4f6;">20.19</td><td align="center" style="background-color:#f3f4f6;">19.29</td><td align="center" style="background-color:#f3f4f6;">18.76</td><td align="center" style="background-color:#f3f4f6;">18.01</td></tr>
712
+ <tr><td align="left">Finnish (fi-FI)</td><td align="center" style="background-color:#eef6e0;">21.19</td><td align="center" style="background-color:#eef6e0;">20.57</td><td align="center" style="background-color:#eef6e0;">20.05</td><td align="center" style="background-color:#eef6e0;">18.94</td><td align="center" style="background-color:#eef6e0;">18.34</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">21.61</td><td align="center" style="background-color:#f3f4f6;">20.88</td><td align="center" style="background-color:#f3f4f6;">20.40</td><td align="center" style="background-color:#f3f4f6;">19.36</td><td align="center" style="background-color:#f3f4f6;">18.72</td></tr>
713
+ <tr><td align="left">Mandarin (zh-CN)</td><td align="center" style="background-color:#eef6e0;">20.56</td><td align="center" style="background-color:#eef6e0;">20.22</td><td align="center" style="background-color:#eef6e0;">20.03</td><td align="center" style="background-color:#eef6e0;">19.51</td><td align="center" style="background-color:#eef6e0;">19.28</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">22.45</td><td align="center" style="background-color:#f3f4f6;">21.07</td><td align="center" style="background-color:#f3f4f6;">20.59</td><td align="center" style="background-color:#f3f4f6;">20.40</td><td align="center" style="background-color:#f3f4f6;">19.87</td></tr>
714
+ <tr><td align="left">Czech (cs-CZ)</td><td align="center" style="background-color:#eef6e0;">24.18</td><td align="center" style="background-color:#eef6e0;">23.20</td><td align="center" style="background-color:#eef6e0;">22.41</td><td align="center" style="background-color:#eef6e0;">21.04</td><td align="center" style="background-color:#eef6e0;">20.41</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">25.81</td><td align="center" style="background-color:#f3f4f6;">25.12</td><td align="center" style="background-color:#f3f4f6;">23.68</td><td align="center" style="background-color:#f3f4f6;">22.55</td><td align="center" style="background-color:#f3f4f6;">21.45</td></tr>
715
+ <tr><td align="left">Bulgarian (bg-BG)</td><td align="center" style="background-color:#eef6e0;">24.50</td><td align="center" style="background-color:#eef6e0;">23.58</td><td align="center" style="background-color:#eef6e0;">22.80</td><td align="center" style="background-color:#eef6e0;">21.70</td><td align="center" style="background-color:#eef6e0;">20.53</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">28.28</td><td align="center" style="background-color:#f3f4f6;">27.22</td><td align="center" style="background-color:#f3f4f6;">25.54</td><td align="center" style="background-color:#f3f4f6;">24.05</td><td align="center" style="background-color:#f3f4f6;">21.84</td></tr>
716
+ <tr><td align="left">Slovak (sk-SK)</td><td align="center" style="background-color:#eef6e0;">25.08</td><td align="center" style="background-color:#eef6e0;">24.14</td><td align="center" style="background-color:#eef6e0;">23.73</td><td align="center" style="background-color:#eef6e0;">22.51</td><td align="center" style="background-color:#eef6e0;">21.28</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">27.59</td><td align="center" style="background-color:#f3f4f6;">26.06</td><td align="center" style="background-color:#f3f4f6;">25.61</td><td align="center" style="background-color:#f3f4f6;">24.15</td><td align="center" style="background-color:#f3f4f6;">22.68</td></tr>
717
+ <tr><td align="left">Swedish (sv-SE)</td><td align="center" style="background-color:#eef6e0;">25.61</td><td align="center" style="background-color:#eef6e0;">24.85</td><td align="center" style="background-color:#eef6e0;">23.63</td><td align="center" style="background-color:#eef6e0;">22.72</td><td align="center" style="background-color:#eef6e0;">22.17</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">26.28</td><td align="center" style="background-color:#f3f4f6;">25.56</td><td align="center" style="background-color:#f3f4f6;">24.18</td><td align="center" style="background-color:#f3f4f6;">23.57</td><td align="center" style="background-color:#f3f4f6;">22.53</td></tr>
718
+ <tr><td align="left">Croatian (hr-HR)</td><td align="center" style="background-color:#eef6e0;">27.92</td><td align="center" style="background-color:#eef6e0;">27.09</td><td align="center" style="background-color:#eef6e0;">25.79</td><td align="center" style="background-color:#eef6e0;">24.92</td><td align="center" style="background-color:#eef6e0;">23.97</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">32.13</td><td align="center" style="background-color:#f3f4f6;">31.20</td><td align="center" style="background-color:#f3f4f6;">29.65</td><td align="center" style="background-color:#f3f4f6;">28.95</td><td align="center" style="background-color:#f3f4f6;">27.46</td></tr>
719
+ <tr><td align="left">Romanian (ro-RO)</td><td align="center" style="background-color:#eef6e0;">31.52</td><td align="center" style="background-color:#eef6e0;">30.93</td><td align="center" style="background-color:#eef6e0;">29.04</td><td align="center" style="background-color:#eef6e0;">27.77</td><td align="center" style="background-color:#eef6e0;">25.90</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">34.22</td><td align="center" style="background-color:#f3f4f6;">33.26</td><td align="center" style="background-color:#f3f4f6;">30.97</td><td align="center" style="background-color:#f3f4f6;">29.84</td><td align="center" style="background-color:#f3f4f6;">26.88</td></tr>
720
+ <tr><td align="left">Estonian (et-EE)</td><td align="center" style="background-color:#eef6e0;">29.95</td><td align="center" style="background-color:#eef6e0;">29.66</td><td align="center" style="background-color:#eef6e0;">28.59</td><td align="center" style="background-color:#eef6e0;">27.37</td><td align="center" style="background-color:#eef6e0;">26.35</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">30.58</td><td align="center" style="background-color:#f3f4f6;">30.09</td><td align="center" style="background-color:#f3f4f6;">28.72</td><td align="center" style="background-color:#f3f4f6;">28.03</td><td align="center" style="background-color:#f3f4f6;">27.19</td></tr>
721
+ <tr><td align="left">Danish (da-DK)</td><td align="center" style="background-color:#eef6e0;">32.62</td><td align="center" style="background-color:#eef6e0;">31.51</td><td align="center" style="background-color:#eef6e0;">30.00</td><td align="center" style="background-color:#eef6e0;">28.92</td><td align="center" style="background-color:#eef6e0;">27.49</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">33.15</td><td align="center" style="background-color:#f3f4f6;">31.77</td><td align="center" style="background-color:#f3f4f6;">30.22</td><td align="center" style="background-color:#f3f4f6;">29.33</td><td align="center" style="background-color:#f3f4f6;">27.81</td></tr>
722
+ <tr><td align="left">Hungarian (hu-HU)</td><td align="center" style="background-color:#eef6e0;">32.70</td><td align="center" style="background-color:#eef6e0;">32.03</td><td align="center" style="background-color:#eef6e0;">30.92</td><td align="center" style="background-color:#eef6e0;">29.72</td><td align="center" style="background-color:#eef6e0;">28.68</td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;">33.40</td><td align="center" style="background-color:#f3f4f6;">32.39</td><td align="center" style="background-color:#f3f4f6;">31.49</td><td align="center" style="background-color:#f3f4f6;">30.20</td><td align="center" style="background-color:#f3f4f6;">29.18</td></tr>
723
+ <tr><td align="left"><strong>Average</strong></td><td align="center" style="background-color:#eef6e0;"><strong>25.86</strong></td><td align="center" style="background-color:#eef6e0;"><strong>25.14</strong></td><td align="center" style="background-color:#eef6e0;"><strong>24.11</strong></td><td align="center" style="background-color:#eef6e0;"><strong>23.09</strong></td><td align="center" style="background-color:#eef6e0;"><strong>22.13</strong></td><td align="center" style="background-color:#f3f4f6;border-left:2px solid #cbd5e1;"><strong>27.62</strong></td><td align="center" style="background-color:#f3f4f6;"><strong>26.65</strong></td><td align="center" style="background-color:#f3f4f6;"><strong>25.41</strong></td><td align="center" style="background-color:#f3f4f6;"><strong>24.44</strong></td><td align="center" style="background-color:#f3f4f6;"><strong>23.09</strong></td></tr>
724
+ </tbody>
725
+ </table>
726
+
727
+ ### Adaptation-ready languages (fine-tune to enable)
728
+
729
+ These **8 language-locales** are recognized by the tokenizer but are not tuned for production transcription out of the box: **Greek (el-GR), Hebrew (he-IL), Lithuanian (lt-LT), Slovenian (sl-SI), Latvian (lv-LV), Maltese (mt-MT), Thai (th-TH), and Norwegian Nynorsk (nn-NO)**. Fine-tuning on in-domain data is recommended to bring them to production quality.
730
+
731
+ ---
732
+
733
+ ## License/Terms of Use
734
+
735
+ Governing Terms: Use of the model is governed by the [OpenMDW-1.1](https://openmdw.ai/license/1-1/) license.
736
+
737
+ ## Deployment Geography
738
+
739
+ Global
740
+
741
+ ## Use Case
742
+
743
+ This model is for transcription of multilingual audio.
744
+
745
+ ## References
746
+
747
+ <a id="ref-1"></a>[1] [Stateful Conformer with Cache-based Inference for Streaming Automatic Speech Recognition](https://arxiv.org/abs/2312.17279)
748
+
749
+ <a id="ref-2"></a>[2] [Fast Conformer with Linearly Scalable Attention for Efficient Speech Recognition](https://arxiv.org/abs/2305.05084)
750
+
751
+ <a id="ref-3"></a>[3] [NVIDIA Granary](https://huggingface.co/datasets/nvidia/Granary)
752
+
753
+ <a id="ref-4"></a>[4] [NVIDIA NeMo Framework](https://github.com/NVIDIA/NeMo)
754
+
755
+ ---
756
+
757
+ ## Ethical Considerations
758
+
759
+ NVIDIA believes Trustworthy AI is a shared responsibility and we have established policies and practices to enable development for a wide array of AI applications. When downloaded or used in accordance with our terms of service, developers should work with their internal model team to ensure this model meets requirements for the relevant industry and use case and addresses unforeseen product misuse.
760
+
761
+ Please report model quality, risk, security vulnerabilities or NVIDIA AI Concerns [here](https://www.nvidia.com/en-us/support/submit-security-vulnerability/).
762
+
763
+ ---
nvidia/bias.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ Field | Response
2
+ :---------------------------------------------------------------------------------------------------|:---------------
3
+ What is the language balance of the model validation data? | en-US: 25.63%, es-ES: 10.50%, pt-PT: 5.26%, hi-IN: 5.10%, fr-FR: 4.56%, de-DE: 4.28%, pt-BR: 3.14%, mt-MT: 2.83%, hu-HU: 2.82%, ro-RO: 2.81%, bg-BG: 2.78%, el-GR: 2.77%, lt-LT: 2.26%, fi-FI: 2.26%, it-IT: 2.14%, zh-CN: 2.14%, lv-LV: 2.06%, ja-JP: 1.98%, sk-SK: 1.90%, ru-RU: 1.76%, ko-KR: 1.72%, et-EE: 1.67%, sl-SI: 1.59%, ar-AR: 1.57%, es-US: 1.43%, he-IL: 0.85%, vi-VN: 0.83%, fr-CA: 0.32%, nl-NL: 0.21%, tr-TR: 0.19%, en-GB: 0.14%, pl-PL: 0.14%, uk-UA: 0.11%, th-TH: 0.09%, hr-HR: 0.04%, cs-CZ: 0.03%, da-DK: 0.03%, nb-NO: 0.03%, sv-SE: 0.03%, nn-NO: 0.00%
4
+ What is the geographic origin language balance of the model validation data? | Europe: 55.01%, North America: 27.38%, Asia: 11.86%, South America: 3.14%, Middle East: 2.61%
5
+ What is the accent balance of the model validation data? | en-US: 25.63%, es-ES: 10.50%, pt-PT: 5.26%, hi-IN: 5.10%, fr-FR: 4.56%, de-DE: 4.28%, pt-BR: 3.14%, mt-MT: 2.83%, hu-HU: 2.82%, ro-RO: 2.81%, bg-BG: 2.78%, el-GR: 2.77%, lt-LT: 2.26%, fi-FI: 2.26%, it-IT: 2.14%, zh-CN: 2.14%, lv-LV: 2.06%, ja-JP: 1.98%, sk-SK: 1.90%, ru-RU: 1.76%, ko-KR: 1.72%, et-EE: 1.67%, sl-SI: 1.59%, ar-AR: 1.57%, es-US: 1.43%, he-IL: 0.85%, vi-VN: 0.83%, fr-CA: 0.32%, nl-NL: 0.21%, tr-TR: 0.19%, en-GB: 0.14%, pl-PL: 0.14%, uk-UA: 0.11%, th-TH: 0.09%, hr-HR: 0.04%, cs-CZ: 0.03%, da-DK: 0.03%, nb-NO: 0.03%, sv-SE: 0.03%, nn-NO: 0.00%
6
+ Participation considerations from adversely impacted groups ([protected classes](https://www.senate.ca.gov/protected-classes)) in model design and testing: | Age, Gender, Linguistic Background
7
+ Measures taken to mitigate against unwanted bias: | Used a custom dataset to evaluate model performance across genders, age groups, and linguistic backgrounds.
8
+
9
+
nvidia/explainability.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Field | Response
2
+ :------------------------------------------------------------------------------------------------------|:---------------------------------------------------------------------------------
3
+ Intended Task/Domain: | Speech Transcription
4
+ Model Type: | FastConformer-CacheAware-RNNT
5
+ Intended Users: | This model is intended for developers and data scientists building interactive call centers, virtual assistants, and language learning assistants.
6
+ Output: | Transcribed text with timestamps and confidence scores
7
+ Describe how the model works: | Model transcribes audio input into text for the input language
8
+ Name the adversely impacted groups this has been tested to deliver comparable outcomes regardless of: | Age, Gender, National Origin
9
+ Technical Limitations & Mitigation: | Transcripts may not be 100% accurate. Accuracy varies depending on the characteristics of the input audio, such as domain, use case, accent, noise, speech type, and speech context.
10
+ Verified to have met prescribed NVIDIA quality standards: | Yes
11
+ Performance Metrics: | Word Error Rate (WER), Silence Robustness (Characters/mins of silent audio), Latency (in milliseconds), Throughput (Total audio processed per unit of time)
12
+ Potential Known Risks: | Not recommended for word-for-word transcription as accuracy varies based on the characteristics of input audio (domain, use case, accent, noise, speech type, and context of speech)
13
+ Licensing: | Governing Terms: Use of the model is governed by the [OpenMDW-1.1](https://openmdw.ai/license/1-1/) license.
14
+
15
+
nvidia/privacy.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Field | Response
2
+ :----------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------------
3
+ Generatable or reverse engineerable personal data? | No
4
+ Personal data used to create this model? | Yes - Voice
5
+ Was consent obtained for any personal data used? | Yes
6
+ Is a mechanism in place to honor data subject right of access or deletion of personal data? | Yes
7
+ If personal data was collected for the development of the model, was it collected directly by NVIDIA? | Yes
8
+ If personal data was collected for the development of the model by NVIDIA, do you maintain or have access to disclosures made to data subjects? | Yes
9
+ If personal data was collected for the development of this AI model, was it minimized to only what was required? | Yes
10
+ Is there provenance for all datasets used in training? | Yes
11
+ Does data labeling (annotation, metadata) comply with privacy laws? | Yes
12
+ Is data compliant with data subject requests for data correction or removal, if such a request was made? | No, not possible with externally-sourced data.
13
+ Applicable Privacy Policy | https://www.nvidia.com/en-us/about-nvidia/privacy-policy/
14
+ How often is dataset reviewed? | Dataset is initially reviewed upon addition, and subsequent reviews are conducted as needed or upon request for changes.
15
+ Was data from user interactions with the AI model (e.g. user input and prompts) used to train the model? | No
16
+
17
+
nvidia/safety.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ Field | Response
2
+ :---------------------------------------------------|:----------------------------------
3
+ Model Application Field(s): | Speech Transcription
4
+ Describe the life critical impact (if present). | Not Applicable
5
+ Use Case Restrictions: | Abide by Governing Terms: Use of the model is governed by the [OpenMDW-1.1](https://openmdw.ai/license/1-1/) license.
6
+ Model and dataset restrictions: | The Principle of least privilege (PoLP) is applied limiting access for dataset generation and model development. Restrictions enforce dataset access during training, and dataset license constraints adhered to.
7
+
8
+
openthai2.0-asr-streaming_bias-1.0.nemo ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6094a83e9729f5bf1ddd45f459594bfef4ab3e7caa5bc58263b959b0a585f815
3
+ size 2562969600
openthai2.0-asr-streaming_raw.nemo ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:61f39dea77508e2d7656b126b8fba7d6ae42d788f0c6fe0d9f35f5fab583f5cb
3
+ size 2562969600
serve/README.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenThai2.0 ASR Streaming service: reference
2
+
3
+ Install, start and example calls: see the main [README](../README.md).
4
+
5
+ ## Endpoints
6
+
7
+ | endpoint | what it does |
8
+ |---|---|
9
+ | `POST /v1/transcribe` | multipart `file` + the fields below → JSON |
10
+ | `WS /v1/stream` | JSON config → PCM16 LE 16 kHz mono frames → text `end`; replies `partial`… then one `final` |
11
+ | `GET /v1/health` | loaded model and settings |
12
+
13
+ | field | values | default |
14
+ |---|---|---|
15
+ | `mode` | `standard` · `conversation` · `meeting` | `standard` |
16
+ | `lang` | `th-TH` · `th-mix` | `th-TH` |
17
+ | `latency_ms` | 80 · 320 · 560 · 1120 | 560 |
18
+ | `diarize` | `true` · `false` | on for `meeting` |
19
+
20
+ **Response fields:**
21
+ - `text` (raw, as scored) and `display_text` (for display).
22
+ - `duration_s`, `lang`, `latency_ms`, `mode`, `policy`, `delta`, `model`, `processing_s`.
23
+ - With speaker labels, also `segments` `[{speaker, start, end, text}]` and `speakers`.
24
+
25
+ **Errors:** 400 empty file · 415 unreadable audio · 422 bad option.
26
+
27
+ ## What each mode does
28
+
29
+ The blank bias δ controls how readily the model writes something: the more negative it is, the fewer words get dropped.
30
+
31
+ | mode | decode |
32
+ |---|---|
33
+ | `standard` | δ −1.0; clips under 2 s re-decoded at −2.5; empty output re-decoded at −4.0 |
34
+ | `conversation` | δ −2.0; empty output re-decoded at −4.0 |
35
+ | `meeting` | δ −1.0 + speaker labels from `nvidia/Nemotron-3-Diarization` (downloaded on first use) |
36
+
37
+ ## Environment variables
38
+
39
+ | var | default | meaning |
40
+ |---|---|---|
41
+ | `CHINDA_DEVICE` | `cpu` | `cuda` for GPU |
42
+ | `CHINDA_MODEL` | `../openthai2.0-asr-streaming_raw.nemo` | must be the raw file; `*_bias-*` files are refused (the bias would apply twice) |
43
+ | `CHINDA_PRELOAD` | unset | `1` = load at start-up; otherwise the first request waits about 20 s |
44
+ | `CHINDA_REPLICAS` | `1` | model copies, about 2.6 GB each; each copy runs one decode step at a time |
45
+ | `CHINDA_NUM_THREADS` | min(16, CPUs) | CPU threads |
46
+ | `CHINDA_DIAR_MODEL` | download | path to a local `Nemotron-3-Diarization.nemo` |
47
+ | `CHINDA_CUDA_GRAPHS` | `0` | leave at 0: `1` crashes on the request after speaker labels |
48
+
49
+ ## Good to know
50
+
51
+ - No authentication or rate limiting.
52
+ - In a live stream, speaker labels arrive with the `final` message only. Speakers are anonymous (`speaker_0`, …).
53
+ - Live text equals decoding the finished utterance on its own. Batch decoding (the benchmark) can differ by the last word on clips under 2 s.
54
+ - With `ffmpeg` on the PATH, the API also accepts mp3, m4a and ogg.
55
+ - The decode loop is adapted from NVIDIA NeMo's `speech_to_text_cache_aware_streaming_infer.py` (Apache-2.0).
serve/app.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenThai2.0 ASR Streaming: HTTP/WebSocket service (FastAPI) on top of serve/chinda_asr.py.
2
+
3
+ cd serve && python -m uvicorn app:app --host 127.0.0.1 --port 18081 (see README.md)
4
+
5
+ Environment:
6
+ CHINDA_MODEL raw (unbaked) .nemo (default ../openthai2.0-asr-streaming_raw.nemo)
7
+ CHINDA_DEVICE cpu (default) | cuda
8
+ CHINDA_NUM_THREADS torch CPU threads (default min(16, cpu_count))
9
+ CHINDA_REPLICAS model replicas (default 1; each ~2.6 GB)
10
+ CHINDA_PRELOAD 1 = load the model at startup instead of on the first request
11
+ CHINDA_DIAR_MODEL Nemotron-3-Diarization .nemo for speaker labels (default: downloaded from Hugging Face)
12
+
13
+ Endpoints:
14
+ POST /v1/transcribe multipart: file + form mode | policy, lang, latency_ms[, diarize]
15
+ WS /v1/stream first message JSON {mode | policy, lang, latency_ms[, diarize]}; then binary PCM16 LE 16 kHz
16
+ mono; text message 'end' -> {"type":"final", ...} (+ speaker segments when diarize)
17
+ GET /v1/health
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import json
23
+ import logging
24
+ import os
25
+ import time
26
+
27
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
28
+ from fastapi.concurrency import run_in_threadpool
29
+ from fastapi.responses import JSONResponse
30
+
31
+ from chinda_asr import LANGS, LATENCY_PRESETS, MODES, POLICIES, ChindaASR, load_audio
32
+ from chinda_diar import Diarizer, build_turns
33
+
34
+ logging.basicConfig(level=os.environ.get("CHINDA_LOGLEVEL", "INFO"))
35
+ log = logging.getLogger("chinda_app")
36
+
37
+ ASR = ChindaASR(replicas=int(os.environ.get("CHINDA_REPLICAS", "1"))) # device: CHINDA_DEVICE (default cpu)
38
+ DIAR = Diarizer() # speaker labels, loaded on first use
39
+ app = FastAPI(title="OpenThai2.0 ASR Streaming", version="1.0")
40
+
41
+ RESULT_KEYS = ("text", "duration_s", "lang", "latency_ms", "policy", "model", "processing_s")
42
+
43
+
44
+ @app.on_event("startup")
45
+ async def _startup() -> None:
46
+ if os.environ.get("CHINDA_PRELOAD") == "1":
47
+ await run_in_threadpool(ASR.load)
48
+
49
+
50
+ def _validate(lang: str, latency_ms: int, policy: str) -> None:
51
+ try:
52
+ ASR._check(lang, latency_ms, policy)
53
+ except ValueError as e:
54
+ raise HTTPException(status_code=422, detail=str(e)) from e
55
+
56
+
57
+ def _is_true(v) -> bool:
58
+ return str(v).strip().lower() in ("1", "true", "yes", "on")
59
+
60
+
61
+ @app.get("/v1/health")
62
+ async def health():
63
+ return {"status": "ok", **ASR.info()}
64
+
65
+
66
+ @app.post("/v1/transcribe")
67
+ async def transcribe(
68
+ file: UploadFile = File(...),
69
+ lang: str = Form("th-TH"),
70
+ latency_ms: int = Form(560),
71
+ policy: str = Form("shortclip"),
72
+ diarize: str = Form(""),
73
+ mode: str = Form(""),
74
+ ):
75
+ if mode: # standard | conversation | meeting -> that mode's dev-selected policy
76
+ if mode not in MODES:
77
+ raise HTTPException(status_code=422, detail=f"unknown mode {mode!r}; one of {sorted(MODES)}")
78
+ policy = MODES[mode]
79
+ _validate(lang, latency_ms, policy)
80
+ want_spk = _is_true(diarize) if diarize else mode == "meeting" # meeting mode labels speakers unless diarize=false
81
+ data = await file.read()
82
+ if not data:
83
+ raise HTTPException(status_code=400, detail="empty file")
84
+ try:
85
+ audio = await run_in_threadpool(load_audio, data)
86
+ except Exception as e: # noqa: BLE001
87
+ raise HTTPException(status_code=415, detail=f"could not decode audio: {e}") from e
88
+ t0 = time.time()
89
+ if want_spk:
90
+ from chinda_diar import transcribe_with_speakers
91
+ # speaker labels use ONE stream at the meeting-mode setting (policy standard); see serve/chinda_diar.py
92
+ res = await run_in_threadpool(transcribe_with_speakers, ASR, DIAR, audio, lang, latency_ms)
93
+ out = {k: res[k] for k in RESULT_KEYS}
94
+ out.update(segments=res["segments"], speakers=res["speakers"])
95
+ else:
96
+ res = await run_in_threadpool(ASR.transcribe, audio, lang, latency_ms, policy)
97
+ out = {k: res[k] for k in RESULT_KEYS}
98
+ out["processing_s"] = round(time.time() - t0, 3)
99
+ out["display_text"] = res["display_text"]
100
+ out["delta"] = res["delta"]
101
+ if mode:
102
+ out["mode"] = mode
103
+ return out
104
+
105
+
106
+ @app.websocket("/v1/stream")
107
+ async def stream(ws: WebSocket):
108
+ await ws.accept()
109
+ session = None
110
+ try:
111
+ first = await ws.receive_text()
112
+ try:
113
+ cfg = json.loads(first)
114
+ lang = cfg.get("lang", "th-TH")
115
+ latency_ms = int(cfg.get("latency_ms", 560))
116
+ policy = cfg.get("policy", "shortclip")
117
+ if cfg.get("mode"):
118
+ if cfg["mode"] not in MODES:
119
+ raise ValueError(f"unknown mode {cfg['mode']!r}; one of {sorted(MODES)}")
120
+ policy = MODES[cfg["mode"]]
121
+ ASR._check(lang, latency_ms, policy)
122
+ want_spk = _is_true(cfg["diarize"]) if "diarize" in cfg else cfg.get("mode") == "meeting"
123
+ except (ValueError, TypeError) as e:
124
+ await ws.send_json({"type": "error", "code": 422, "detail": str(e)})
125
+ await ws.close(code=1003)
126
+ return
127
+ session = await run_in_threadpool(ASR.stream, lang, latency_ms, policy)
128
+ await ws.send_json({"type": "ready", "lang": lang, "latency_ms": latency_ms, "policy": policy})
129
+ last = None
130
+ while True:
131
+ msg = await ws.receive()
132
+ if msg.get("type") == "websocket.disconnect":
133
+ return
134
+ if msg.get("bytes") is not None:
135
+ buf = msg["bytes"]
136
+ if len(buf) % 2:
137
+ await ws.send_json({"type": "error", "code": 400, "detail": "PCM16 frames must have an even byte count"})
138
+ continue
139
+ partial = await run_in_threadpool(session.accept, buf)
140
+ if partial != last:
141
+ await ws.send_json({"type": "partial", "text": partial})
142
+ last = partial
143
+ elif msg.get("text") is not None:
144
+ if msg["text"].strip() == "end":
145
+ res = await run_in_threadpool(session.finish)
146
+ final = {"type": "final", **{k: res[k] for k in RESULT_KEYS}, "display_text": res["display_text"],
147
+ "delta": res["delta"]}
148
+ if want_spk: # speaker labels for the whole stream, from the buffered audio (not per partial yet)
149
+ segs = await run_in_threadpool(DIAR.segments, session.audio)
150
+ final["segments"], _ = build_turns(session.tokens(), ASR.tokenizer, segs)
151
+ final["speakers"] = len({t["speaker"] for t in final["segments"]})
152
+ await ws.send_json(final)
153
+ await ws.close()
154
+ return
155
+ await ws.send_json({"type": "error", "code": 400, "detail": "text messages other than 'end' are not understood"})
156
+ except WebSocketDisconnect:
157
+ return
158
+ except Exception as e: # noqa: BLE001
159
+ log.exception("stream failed")
160
+ try:
161
+ await ws.send_json({"type": "error", "code": 500, "detail": str(e)})
162
+ await ws.close(code=1011)
163
+ except Exception: # noqa: BLE001
164
+ pass
165
+
166
+
167
+ if __name__ == "__main__":
168
+ import uvicorn
169
+
170
+ uvicorn.run(app, host=os.environ.get("CHINDA_HOST", "127.0.0.1"), port=int(os.environ.get("CHINDA_PORT", "18081")))
serve/chinda_asr.py ADDED
@@ -0,0 +1,817 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ChindaASR reference inference library.
2
+
3
+ An exact port of the benchmark decode (scripts/score_cer_ckpt.sh ->
4
+ NeMo examples/asr/asr_cache_aware_streaming/speech_to_text_cache_aware_streaming_infer.py):
5
+
6
+ model_path=<.nemo> batch_size=32 att_context_size=[56,6]
7
+ rnnt_decoding.greedy.max_symbols_per_step=2 target_lang=th-TH strip_lang_tags=true
8
+
9
+ plus the decode policies of scripts/shortclip_decode.sh (standard / shortclip / conversational).
10
+
11
+ Design (see README.md for the full rationale)
12
+ ---------------------------------------------
13
+ * ONE model instance per replica (default 1 replica), loaded lazily on first use from the UNBAKED
14
+ (raw) model file. The blank-logit bias delta is applied per call by writing
15
+ joint.joint_net[-1].bias[blank] = float(orig) + delta -- the exact statement
16
+ scripts/bake_blank_bias.py uses to bake *_bias-<delta>.nemo files.
17
+ serve/tests/verify_bias_files.py proved (on v8c) that baked files differ from the raw one in exactly
18
+ that one scalar and that float32(orig + delta) is bit-identical to each baked value, so this is
19
+ the same model as loading the baked file -- at 1/4 of the memory.
20
+ * The language prompt (th-TH=32 / th-mix=63), the att_context_size (latency preset) and the blank
21
+ bias are model-wide state in NeMo. Every decode step therefore runs under the replica's lock and
22
+ sets all three first. Streaming sessions keep their own caches/hypotheses and take the lock only
23
+ for the duration of one encoder step, so many sessions interleave safely on one model.
24
+ * The batch decode reuses NeMo's CacheAwareStreamingAudioBuffer + conformer_stream_step loop
25
+ verbatim (same chunking, same keep_all_outputs / drop_extra_pre_encoded logic).
26
+ * Live streaming computes log-mel features incrementally, only for frames whose 32 ms STFT window
27
+ (plus the pre-emphasis tap) is fully inside the audio received so far, and feeds NeMo's own buffer
28
+ a window of those frames. A step is taken only when it is provably NOT the final step, so every
29
+ encoder call sees the same tensor it would see in a whole-file bs=1 decode. At finish() the
30
+ committed frames are re-checked against a whole-utterance feature pass (up to
31
+ verify_stream_max_s); on any mismatch the utterance is re-decoded from scratch, so the final text
32
+ always equals transcribe() on the same audio.
33
+
34
+ Tested on CPU (FP32) and on an H100 GPU (TF32, like the benchmark); see DESIGN.md for the parity results.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import dataclasses
39
+ import io
40
+ import json
41
+ import logging
42
+ import os
43
+ import queue
44
+ import shutil
45
+ import subprocess
46
+ import tempfile
47
+ import threading
48
+ import time
49
+ from typing import Any, Iterable, Optional, Sequence
50
+
51
+ import numpy as np
52
+
53
+ log = logging.getLogger("chinda_asr")
54
+
55
+ SAMPLE_RATE = 16000
56
+ FEATURE_TOL = 1e-3 # max |log-mel difference| accepted between streamed and whole-utterance features
57
+ # (GPU rounding noise); larger => the stream is re-decoded from the buffered audio
58
+ HOP = 160 # samples per feature frame (preprocessor window_stride 0.01 s)
59
+ HALF_WIN = 256 # n_fft // 2: a center=True STFT frame t spans samples [t*HOP - 256, t*HOP + 256)
60
+ # Model file: $CHINDA_MODEL, else the raw file shipped next to this folder (Hugging Face repo layout:
61
+ # <repo>/openthai2.0-asr-streaming_raw.nemo + <repo>/serve/).
62
+ _PACKAGED_MODEL = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
63
+ "openthai2.0-asr-streaming_raw.nemo")
64
+ DEFAULT_MODEL_PATH = os.environ.get("CHINDA_MODEL") or _PACKAGED_MODEL
65
+ MODEL_NAME = os.environ.get("CHINDA_MODEL_NAME", "openthai2.0-asr-streaming-v1.0")
66
+
67
+ # latency (ms) -> encoder att_context_size [left, right]; right-context chunks of (right+1) x 80 ms
68
+ LATENCY_PRESETS: dict[int, tuple[int, int]] = {80: (56, 0), 320: (56, 3), 560: (56, 6), 1120: (56, 13)}
69
+ LANGS = ("th-TH", "th-mix") # th-TH = Thai mode (prompt slot 32), th-mix = mixed Thai-English (slot 63)
70
+
71
+
72
+ @dataclasses.dataclass(frozen=True)
73
+ class Policy:
74
+ """A decode policy = scripts/shortclip_decode.sh with (BASE_D, SHORT_D, RETRY_D, T)."""
75
+
76
+ name: str
77
+ base_delta: float
78
+ short_delta: Optional[float] = None # re-decode clips shorter than short_threshold_s at this delta
79
+ short_threshold_s: float = 0.0
80
+ retry_delta: Optional[float] = None # re-decode clips whose output is still empty at this delta
81
+
82
+ def describe(self) -> str:
83
+ s = f"delta {self.base_delta:+.1f}"
84
+ if self.short_delta is not None:
85
+ s += f"; clips < {self.short_threshold_s:g} s re-decoded at {self.short_delta:+.1f}"
86
+ if self.retry_delta is not None:
87
+ s += f"; empty output retried at {self.retry_delta:+.1f}"
88
+ return s
89
+
90
+
91
+ POLICIES: dict[str, Policy] = {
92
+ "standard": Policy("standard", -1.0),
93
+ "shortclip": Policy("shortclip", -1.0, short_delta=-2.5, short_threshold_s=2.0, retry_delta=-4.0),
94
+ "conversational": Policy("conversational", -2.0, retry_delta=-4.0),
95
+ }
96
+
97
+ # MODES. Each mode = the decode policy selected on that register's development set, then reported once on its test set:
98
+ # standard read speech, news, dictation, lectures dev val_v1ext_sub300 -> -1.0; short-clip fix (GS2-dev)
99
+ # conversation TV talk shows, YouTube / social media, podcasts, interviews dev GS2-dev -> -2.0, empty -> -4.0
100
+ # meeting multi-speaker rooms dev LOTUSDIS validation -> -1.0; the <2 s second pass HURTS there (+0.12), so none.
101
+ # Speaker labels (who said what) are the meeting feature: chinda_diar.py, on by default for mode=meeting.
102
+ MODES: dict[str, str] = {"standard": "shortclip", "conversation": "conversational", "meeting": "standard"}
103
+
104
+
105
+ def display_text(text: str) -> str:
106
+ """The tokenizer's nmt_nfkc normalizer decomposes SARA AM (U+0E33) into NIKHAHIT+SARA AA
107
+ (U+0E4D U+0E32), so raw output contains the two-codepoint form. Recompose it for display.
108
+ (The benchmark scorer NFKC-decomposes both sides, so this does not change any CER.)"""
109
+ return text.replace("ํา", "ำ")
110
+
111
+
112
+ # ----------------------------------------------------------------------------------------------
113
+ # audio loading
114
+ # ----------------------------------------------------------------------------------------------
115
+ def _to_mono_16k(data: np.ndarray, sr: int) -> np.ndarray:
116
+ data = np.asarray(data, dtype=np.float32)
117
+ if data.ndim == 2:
118
+ # mono stays bit-identical to soundfile's 1-D read; multi-channel is averaged (NeMo's
119
+ # get_samples would instead hand a (channels, samples) array to the model and fail)
120
+ data = data[:, 0] if data.shape[1] == 1 else data.mean(axis=1, dtype=np.float32)
121
+ if int(sr) != SAMPLE_RATE:
122
+ import librosa # same resampler (librosa default soxr_hq) as NeMo's get_samples
123
+
124
+ data = librosa.resample(data, orig_sr=int(sr), target_sr=SAMPLE_RATE)
125
+ return np.ascontiguousarray(data, dtype=np.float32)
126
+
127
+
128
+ def _ffmpeg_decode(src: Any) -> tuple[np.ndarray, int]:
129
+ """Decode anything ffmpeg understands; keep the native rate/channels (resampling is done by
130
+ librosa afterwards, exactly as for soundfile input)."""
131
+ if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
132
+ raise RuntimeError("audio format not readable by soundfile and ffmpeg/ffprobe are not installed")
133
+ tmp = None
134
+ try:
135
+ if isinstance(src, (bytes, bytearray)):
136
+ fd, tmp = tempfile.mkstemp(suffix=".audio", dir=os.environ.get("CHINDA_TMPDIR") or None)
137
+ with os.fdopen(fd, "wb") as fh:
138
+ fh.write(src)
139
+ path = tmp
140
+ else:
141
+ path = os.fspath(src)
142
+ probe = subprocess.run(
143
+ ["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries",
144
+ "stream=sample_rate,channels", "-of", "json", path],
145
+ capture_output=True, check=True,
146
+ )
147
+ st = json.loads(probe.stdout)["streams"][0]
148
+ sr, ch = int(st["sample_rate"]), int(st["channels"])
149
+ out = subprocess.run(
150
+ ["ffmpeg", "-nostdin", "-v", "error", "-i", path, "-map", "0:a:0", "-f", "f32le",
151
+ "-acodec", "pcm_f32le", "pipe:1"],
152
+ capture_output=True, check=True,
153
+ )
154
+ data = np.frombuffer(out.stdout, dtype="<f4").astype(np.float32)
155
+ return data.reshape(-1, ch), sr
156
+ finally:
157
+ if tmp is not None:
158
+ os.unlink(tmp)
159
+
160
+
161
+ def load_audio(src: Any) -> np.ndarray:
162
+ """Path, bytes or binary file-like -> float32 mono 16 kHz numpy array.
163
+
164
+ soundfile first (the same read NeMo's get_samples does, so 16 kHz mono WAV/FLAC input is
165
+ bit-identical to the benchmark), ffmpeg as a fallback for everything else (mp3, m4a, ogg, ...).
166
+ """
167
+ import soundfile as sf
168
+
169
+ raw = None
170
+ if isinstance(src, (bytes, bytearray, memoryview)):
171
+ raw = bytes(src)
172
+ elif hasattr(src, "read"):
173
+ raw = src.read()
174
+ try:
175
+ data, sr = sf.read(io.BytesIO(raw) if raw is not None else os.fspath(src), dtype="float32", always_2d=True)
176
+ except Exception as e: # noqa: BLE001 - any soundfile failure -> try ffmpeg
177
+ log.info("soundfile could not read input (%s); falling back to ffmpeg", e)
178
+ data, sr = _ffmpeg_decode(raw if raw is not None else src)
179
+ return _to_mono_16k(data, sr)
180
+
181
+
182
+ def pcm16_to_float32(buf: bytes) -> np.ndarray:
183
+ """PCM16 little-endian mono -> float32 in [-1, 1) (the same scaling soundfile uses)."""
184
+ return np.frombuffer(buf, dtype="<i2").astype(np.float32) / 32768.0
185
+
186
+
187
+ # ----------------------------------------------------------------------------------------------
188
+ # one model replica
189
+ # ----------------------------------------------------------------------------------------------
190
+ _BUFFER_CLS = None
191
+
192
+
193
+ def _buffer_cls():
194
+ """CacheAwareStreamingAudioBuffer that reuses a cached preprocessor instead of rebuilding it
195
+ (the parent deep-copies the whole model config on every construction). The cached preprocessor
196
+ is itself produced by the parent's extract_preprocessor(), so it is the identical module."""
197
+ global _BUFFER_CLS
198
+ if _BUFFER_CLS is None:
199
+ from nemo.collections.asr.parts.utils.streaming_utils import CacheAwareStreamingAudioBuffer
200
+
201
+ class _Buffer(CacheAwareStreamingAudioBuffer):
202
+ def __init__(self, model, preprocessor, normalize_type, streaming_cfg):
203
+ self._cached_pre = (preprocessor, normalize_type)
204
+ super().__init__(model=model, online_normalization=False, pad_and_drop_preencoded=False)
205
+ # pin the streaming config this buffer was built for (a later preset change on the shared
206
+ # model must not alter a live session's chunking)
207
+ self.streaming_cfg = streaming_cfg
208
+
209
+ def extract_preprocessor(self):
210
+ self.model_normalize_type = self._cached_pre[1]
211
+ return self._cached_pre[0]
212
+
213
+ _BUFFER_CLS = _Buffer
214
+ return _BUFFER_CLS
215
+
216
+
217
+ class _Replica:
218
+ """One loaded model. Not thread-safe by itself: callers hold self.lock around configure+use."""
219
+
220
+ def __init__(self, model_path: str, device: str):
221
+ import copy
222
+
223
+ import torch
224
+ from omegaconf import OmegaConf
225
+ from nemo.collections.asr.models import ASRModel
226
+ from nemo.collections.asr.parts.submodules.rnnt_decoding import RNNTDecodingConfig
227
+ from nemo.collections.asr.parts.utils.streaming_utils import CacheAwareStreamingAudioBuffer
228
+ from nemo.utils import model_utils
229
+
230
+ self.torch = torch
231
+ self.lock = threading.Lock()
232
+ self.device = torch.device(device)
233
+ t0 = time.time()
234
+ # --- exactly transcribe_utils.setup_model() ---
235
+ cfg = ASRModel.restore_from(restore_path=model_path, return_config=True)
236
+ cls = model_utils.import_class_by_path(cfg.target)
237
+ model = cls.restore_from(restore_path=model_path, map_location=self.device)
238
+ # --- exactly the infer script's decoding setup: RNNTDecodingConfig(fused_batch_size=-1)
239
+ # + rnnt_decoding.greedy.max_symbols_per_step=2, then strip_lang_tags=true ---
240
+ dec = OmegaConf.structured(RNNTDecodingConfig(fused_batch_size=-1))
241
+ dec.greedy.max_symbols_per_step = 2
242
+ dec.fused_batch_size = -1
243
+ # CUDA-graph greedy decoding OFF by default (2026-09-26): the captured graph keeps pointing at buffers of a
244
+ # finished session; the diarizer (serve/chinda_diar.py) calls torch.cuda.empty_cache(), which releases them, and
245
+ # the next session's graph replay crashed with "CUDA error: an illegal memory access" (reproduced: meeting 1 ->
246
+ # diarize -> meeting 2; meeting 2 alone is fine). CHINDA_CUDA_GRAPHS=1 re-enables it where no diarizer shares the
247
+ # process. Outputs are identical either way (same kernels); only speed differs.
248
+ dec.greedy.use_cuda_graph_decoder = os.environ.get("CHINDA_CUDA_GRAPHS", "0") == "1"
249
+ model.change_decoding_strategy(dec, verbose=False)
250
+ model.decoding.set_strip_lang_tags(True, lang_tag_pattern=None)
251
+ model = model.to(device=self.device, dtype=torch.float32)
252
+ model.eval()
253
+ self.model = model
254
+ self.load_s = time.time() - t0
255
+
256
+ if not hasattr(model, "set_inference_prompt"):
257
+ raise RuntimeError(f"{type(model).__name__} is not a prompt-conditioned model")
258
+ self.prompt_dict = dict(model.cfg.model_defaults.prompt_dictionary)
259
+ self.bias = model.joint.joint_net[-1].bias
260
+ self.blank_id = int(self.bias.shape[0]) - 1
261
+ if self.blank_id != int(model.decoding.blank_id):
262
+ raise RuntimeError(f"blank id mismatch: bias row {self.blank_id} vs decoding {model.decoding.blank_id}")
263
+ self.orig_blank_bias = float(self.bias[self.blank_id].item())
264
+ self.greedy_cfg = OmegaConf.to_container(model.cfg.decoding.greedy)
265
+
266
+ # streaming configs per preset (copied, so sessions can pin them)
267
+ self.streaming_cfgs = {}
268
+ for ms, att in LATENCY_PRESETS.items():
269
+ model.encoder.set_default_att_context_size(list(att))
270
+ self.streaming_cfgs[att] = copy.deepcopy(model.encoder.streaming_cfg)
271
+ # the cached preprocessor, built by NeMo's own extract_preprocessor (dither 0, pad_to 0)
272
+ probe = CacheAwareStreamingAudioBuffer(model=model, online_normalization=False, pad_and_drop_preencoded=False)
273
+ self.preprocessor = probe.preprocessor
274
+ self.normalize_type = probe.model_normalize_type
275
+ self._cur: tuple = (None, None, None)
276
+ # set a defined default state: th-TH, 560 ms, delta 0 (unbaked)
277
+ model.set_inference_prompt("th-TH") # logs "Inference prompt set to 'th-TH' (index 32)"
278
+ self.configure("th-TH", LATENCY_PRESETS[560], 0.0)
279
+
280
+ # -- model-wide state; call with self.lock held --------------------------------------------
281
+ def configure(self, lang: str, att: tuple[int, int], delta: float) -> None:
282
+ torch = self.torch
283
+ if lang != self._cur[0]:
284
+ if lang not in self.prompt_dict:
285
+ raise ValueError(f"unknown lang {lang!r}; model prompts include {sorted(self.prompt_dict)[:10]}...")
286
+ # identical effect to model.set_inference_prompt(lang) (which only sets this attribute + logs)
287
+ self.model._inference_prompt_index = self.prompt_dict[lang]
288
+ if att != self._cur[1]:
289
+ self.model.encoder.set_default_att_context_size(list(att))
290
+ if delta != self._cur[2]:
291
+ with torch.no_grad():
292
+ # == scripts/bake_blank_bias.py: b[blank] = before + a.delta
293
+ self.bias[self.blank_id] = self.orig_blank_bias + float(delta)
294
+ self._cur = (lang, att, delta)
295
+
296
+ def state(self) -> dict:
297
+ return {
298
+ "prompt_index": int(self.model._inference_prompt_index),
299
+ "att_context_size": list(self.model.encoder.att_context_size),
300
+ "blank_bias": float(self.bias[self.blank_id].item()),
301
+ }
302
+
303
+ def features(self, audio: np.ndarray):
304
+ """== CacheAwareStreamingAudioBuffer.preprocess_audio(audio) with the cached preprocessor."""
305
+ torch = self.torch
306
+ sig = torch.from_numpy(np.array(audio, dtype=np.float32, copy=True)).unsqueeze_(0).to(self.device)
307
+ ln = torch.Tensor([audio.shape[0]]).to(self.device)
308
+ feats, _ = self.preprocessor(input_signal=sig, length=ln)
309
+ return feats
310
+
311
+ def new_buffer(self, att: tuple[int, int]):
312
+ return _buffer_cls()(self.model, self.preprocessor, self.normalize_type, self.streaming_cfgs[att])
313
+
314
+ def stream_step(self, st: "_StepState", chunk, lengths, keep_all_outputs: bool, drop_extra: int):
315
+ """One conformer_stream_step exactly as perform_streaming() calls it. Call with lock held,
316
+ inside torch.inference_mode(), after configure()."""
317
+ model = self.model
318
+ if st.caches is None:
319
+ st.caches = model.encoder.get_initial_cache_state(batch_size=int(chunk.shape[0]))
320
+ c1, c2, c3 = st.caches
321
+ (st.pred_out, texts, c1, c2, c3, st.hyps) = model.conformer_stream_step(
322
+ processed_signal=chunk.to(self.torch.float32),
323
+ processed_signal_length=lengths,
324
+ cache_last_channel=c1,
325
+ cache_last_time=c2,
326
+ cache_last_channel_len=c3,
327
+ keep_all_outputs=keep_all_outputs,
328
+ previous_hypotheses=st.hyps,
329
+ previous_pred_out=st.pred_out,
330
+ drop_extra_pre_encoded=drop_extra,
331
+ return_transcription=True,
332
+ )
333
+ st.caches = (c1, c2, c3)
334
+ st.texts = texts
335
+ st.step_num += 1
336
+
337
+ def decode_batch(self, audios: Sequence[np.ndarray], lang: str, att: tuple[int, int], delta: float) -> list[str]:
338
+ """perform_streaming() on one batch. Returns the raw (unstripped) hypothesis texts."""
339
+ torch = self.torch
340
+ with self.lock:
341
+ self.configure(lang, att, delta)
342
+ cfg = self.streaming_cfgs[att]
343
+ with torch.inference_mode():
344
+ buf = self.new_buffer(att)
345
+ for a in audios:
346
+ buf.append_audio(a, stream_id=-1)
347
+ st = _StepState()
348
+ for step_num, (chunk, lengths) in enumerate(iter(buf)):
349
+ self.stream_step(
350
+ st, chunk, lengths,
351
+ keep_all_outputs=buf.is_buffer_empty(),
352
+ drop_extra=0 if step_num == 0 else cfg.drop_extra_pre_encoded,
353
+ )
354
+ if st.texts is None:
355
+ return [""] * len(audios)
356
+ return [h.text for h in st.texts] # == extract_transcriptions()
357
+
358
+
359
+ class _StepState:
360
+ __slots__ = ("caches", "hyps", "pred_out", "texts", "step_num")
361
+
362
+ def __init__(self):
363
+ self.caches = None
364
+ self.hyps = None
365
+ self.pred_out = None
366
+ self.texts = None
367
+ self.step_num = 0
368
+
369
+
370
+ # ----------------------------------------------------------------------------------------------
371
+ # public API
372
+ # ----------------------------------------------------------------------------------------------
373
+ class ChindaASR:
374
+ """Thread-safe ChindaASR decoder.
375
+
376
+ asr = ChindaASR() # nothing is loaded yet
377
+ asr.transcribe(audio) # loads on first use (~20 s on CPU)
378
+ asr.transcribe_batch([a1, a2, ...]) # batches of 32, like the benchmark
379
+ s = asr.stream(); s.accept(chunk); s.finish()
380
+ """
381
+
382
+ def __init__(
383
+ self,
384
+ model_path: str = DEFAULT_MODEL_PATH,
385
+ device: Optional[str] = None,
386
+ num_threads: Optional[int] = None,
387
+ replicas: int = 1,
388
+ verify_stream_max_s: float = 600.0,
389
+ ):
390
+ if "_bias-" in os.path.basename(model_path):
391
+ raise ValueError(
392
+ f"{model_path} already has a blank bias baked in; pass the raw (unbaked) model file, e.g. "
393
+ "openthai2.0-asr-streaming_raw.nemo -- the policies apply delta at decode time"
394
+ )
395
+ self.model_path = model_path
396
+ self.device = device or os.environ.get("CHINDA_DEVICE", "cpu") # "cuda" / "cuda:0" on a GPU host
397
+ self.num_threads = num_threads or int(os.environ.get("CHINDA_NUM_THREADS", "0")) or min(16, os.cpu_count() or 1)
398
+ self.n_replicas = max(1, int(replicas))
399
+ self.verify_stream_max_s = verify_stream_max_s
400
+ self._load_lock = threading.Lock()
401
+ self._pool: Optional[queue.Queue] = None
402
+ self._replicas: list[_Replica] = []
403
+
404
+ # -- loading --------------------------------------------------------------------------------
405
+ @property
406
+ def loaded(self) -> bool:
407
+ return self._pool is not None
408
+
409
+ def load(self) -> "ChindaASR":
410
+ if self._pool is not None:
411
+ return self
412
+ with self._load_lock:
413
+ if self._pool is None:
414
+ import torch
415
+
416
+ if self.device == "cpu":
417
+ torch.set_num_threads(self.num_threads)
418
+ torch.set_float32_matmul_precision("high") # as the infer script (no effect on CPU matmul)
419
+ reps = [_Replica(self.model_path, self.device) for _ in range(self.n_replicas)]
420
+ pool: queue.Queue = queue.Queue()
421
+ for r in reps:
422
+ pool.put(r)
423
+ self._replicas = reps
424
+ self._pool = pool
425
+ log.info("loaded %s x%d on %s in %.1fs", self.model_path, len(reps), self.device, reps[0].load_s)
426
+ return self
427
+
428
+ def _acquire(self) -> _Replica:
429
+ self.load()
430
+ return self._pool.get()
431
+
432
+ def _release(self, rep: _Replica) -> None:
433
+ self._pool.put(rep)
434
+
435
+ @property
436
+ def tokenizer(self):
437
+ """The model's SentencePiece tokenizer (identical in every replica)."""
438
+ self.load()
439
+ return self._replicas[0].model.tokenizer
440
+
441
+ def info(self) -> dict:
442
+ d = {
443
+ "model": MODEL_NAME,
444
+ "model_path": self.model_path,
445
+ "loaded": self.loaded,
446
+ "device": self.device,
447
+ "num_threads": self.num_threads,
448
+ "replicas": self.n_replicas,
449
+ "langs": list(LANGS),
450
+ "latency_ms": sorted(LATENCY_PRESETS),
451
+ "policies": {k: v.describe() for k, v in POLICIES.items()},
452
+ }
453
+ if self.loaded:
454
+ r = self._replicas[0]
455
+ d.update(
456
+ prompt_slots={k: r.prompt_dict.get(k) for k in LANGS},
457
+ blank_id=r.blank_id,
458
+ unbaked_blank_bias=r.orig_blank_bias,
459
+ greedy=dict(max_symbols_per_step=r.greedy_cfg.get("max_symbols_per_step"),
460
+ loop_labels=r.greedy_cfg.get("loop_labels")),
461
+ )
462
+ return d
463
+
464
+ # -- helpers --------------------------------------------------------------------------------
465
+ @staticmethod
466
+ def _check(lang: str, latency_ms: int, policy: str) -> tuple[tuple[int, int], Policy]:
467
+ if lang not in LANGS:
468
+ raise ValueError(f"lang must be one of {LANGS}, got {lang!r}")
469
+ if int(latency_ms) not in LATENCY_PRESETS:
470
+ raise ValueError(f"latency_ms must be one of {sorted(LATENCY_PRESETS)}, got {latency_ms!r}")
471
+ if policy not in POLICIES:
472
+ raise ValueError(f"policy must be one of {sorted(POLICIES)}, got {policy!r}")
473
+ return LATENCY_PRESETS[int(latency_ms)], POLICIES[policy]
474
+
475
+ @staticmethod
476
+ def _as_array(a: Any) -> np.ndarray:
477
+ if isinstance(a, np.ndarray):
478
+ if a.ndim != 1:
479
+ raise ValueError("audio array must be 1-D float32 mono 16 kHz (use load_audio() for files)")
480
+ return np.ascontiguousarray(a, dtype=np.float32)
481
+ return load_audio(a)
482
+
483
+ def decode_pass(
484
+ self, audios: Sequence[np.ndarray], lang: str = "th-TH", latency_ms: int = 560, delta: float = -1.0,
485
+ batch_size: int = 32,
486
+ ) -> list[str]:
487
+ """ONE decode pass at a given blank-bias delta: consecutive groups of batch_size, in order
488
+ (exactly the infer script's manifest loop). Returns raw, unstripped texts."""
489
+ att, _ = self._check(lang, latency_ms, "standard")
490
+ arrs = [self._as_array(a) for a in audios]
491
+ out = [""] * len(arrs)
492
+ live = [i for i, a in enumerate(arrs) if a.shape[0] > 0] # empty input -> ""
493
+ bs = max(1, int(batch_size))
494
+ for k in range(0, len(live), bs):
495
+ idx = live[k:k + bs]
496
+ rep = self._acquire()
497
+ try:
498
+ texts = rep.decode_batch([arrs[i] for i in idx], lang, att, float(delta))
499
+ finally:
500
+ self._release(rep)
501
+ for i, t in zip(idx, texts):
502
+ out[i] = t
503
+ return out
504
+
505
+ # -- file / batch transcription ------------------------------------------------------------
506
+ def transcribe(self, audio: Any, lang: str = "th-TH", latency_ms: int = 560, policy: str = "shortclip") -> dict:
507
+ """One utterance, decoded alone (batch size 1)."""
508
+ return self.transcribe_batch([audio], lang=lang, latency_ms=latency_ms, policy=policy, batch_size=1)[0]
509
+
510
+ def transcribe_batch(
511
+ self, audios: Sequence[Any], lang: str = "th-TH", latency_ms: int = 560, policy: str = "shortclip",
512
+ batch_size: int = 32,
513
+ ) -> list[dict]:
514
+ """Batched transcription with the same pass structure as scripts/shortclip_decode.sh:
515
+ pass 1 = all inputs at the policy's base delta; pass 2 = inputs shorter than the threshold,
516
+ re-decoded together (output replaces pass 1 even if empty); pass 3 = inputs whose output is
517
+ still empty, re-decoded together (output used only if non-empty). Each pass batches its
518
+ inputs in order, batch_size at a time."""
519
+ att, pol = self._check(lang, latency_ms, policy)
520
+ t0 = time.time()
521
+ arrs = [self._as_array(a) for a in audios]
522
+ n = len(arrs)
523
+ durs = [a.shape[0] / SAMPLE_RATE for a in arrs]
524
+ raw = self.decode_pass(arrs, lang, latency_ms, pol.base_delta, batch_size)
525
+ cur = [r.strip() for r in raw]
526
+ cur_raw = list(raw)
527
+ cur_delta = [pol.base_delta] * n
528
+ passes: list[list[dict]] = [[{"delta": pol.base_delta, "reason": "base", "text": c}] for c in cur]
529
+ if pol.short_delta is not None:
530
+ idx = [i for i in range(n) if durs[i] < pol.short_threshold_s]
531
+ if idx:
532
+ raw2 = self.decode_pass([arrs[i] for i in idx], lang, latency_ms, pol.short_delta, batch_size)
533
+ for i, r in zip(idx, raw2):
534
+ cur[i], cur_raw[i], cur_delta[i] = r.strip(), r, pol.short_delta
535
+ passes[i].append({"delta": pol.short_delta, "reason": f"duration<{pol.short_threshold_s:g}s", "text": r.strip()})
536
+ if pol.retry_delta is not None:
537
+ idx = [i for i in range(n) if not cur[i]]
538
+ if idx:
539
+ raw3 = self.decode_pass([arrs[i] for i in idx], lang, latency_ms, pol.retry_delta, batch_size)
540
+ for i, r in zip(idx, raw3):
541
+ passes[i].append({"delta": pol.retry_delta, "reason": "empty", "text": r.strip()})
542
+ if r.strip():
543
+ cur[i], cur_raw[i], cur_delta[i] = r.strip(), r, pol.retry_delta
544
+ dt = time.time() - t0
545
+ return [
546
+ {
547
+ "text": cur[i],
548
+ "display_text": display_text(cur[i]),
549
+ "raw_text": cur_raw[i],
550
+ "duration_s": round(durs[i], 3),
551
+ "lang": lang,
552
+ "latency_ms": int(latency_ms),
553
+ "policy": policy,
554
+ "delta": cur_delta[i],
555
+ "passes": passes[i],
556
+ "model": MODEL_NAME,
557
+ "processing_s": round(dt, 3), # wall time of the whole call (shared by the batch)
558
+ }
559
+ for i in range(n)
560
+ ]
561
+
562
+ # -- live streaming -------------------------------------------------------------------------
563
+ def stream(self, lang: str = "th-TH", latency_ms: int = 560, policy: str = "shortclip") -> "StreamSession":
564
+ self._check(lang, latency_ms, policy)
565
+ self.load()
566
+ return StreamSession(self, lang, int(latency_ms), policy)
567
+
568
+
569
+ class StreamSession:
570
+ """A live stream of ONE utterance (batch size 1).
571
+
572
+ accept(pcm_float32) -> current partial text (first pass, base delta of the policy)
573
+ finish() -> final result dict, identical to asr.transcribe(all_audio, ...)
574
+
575
+ Thread-safe per session (calls are serialized); many sessions may run concurrently.
576
+ """
577
+
578
+ def __init__(self, asr: ChindaASR, lang: str, latency_ms: int, policy: str):
579
+ self.asr = asr
580
+ self.lang = lang
581
+ self.latency_ms = latency_ms
582
+ self.policy_name = policy
583
+ self.att, self.policy = asr._check(lang, latency_ms, policy)
584
+ rep = asr._replicas[0]
585
+ self.cfg = rep.streaming_cfgs[self.att]
586
+ self._lock = threading.Lock()
587
+ self._audio = np.zeros(1 << 16, dtype=np.float32)
588
+ self._n = 0
589
+ self._committed = 0 # feature frames computed so far (exact, final values)
590
+ self._win = None # committed frames [self._win_base, self._committed) as a (1, F, T) tensor
591
+ self._win_base = 0
592
+ self._kept: Optional[list] = [] # all committed frames, kept for the finish() verification
593
+ self._buffer_idx = 0 # NeMo buffer_idx, absolute frames
594
+ self._st = _StepState()
595
+ self._partial = ""
596
+ self._done = False
597
+ self._t0 = time.time()
598
+ self.stats = {"steps": 0, "chunks_in": 0}
599
+
600
+ # -- audio + features -----------------------------------------------------------------------
601
+ def _append(self, x: np.ndarray) -> None:
602
+ need = self._n + x.shape[0]
603
+ if need > self._audio.shape[0]:
604
+ grown = np.zeros(max(need, 2 * self._audio.shape[0]), dtype=np.float32)
605
+ grown[: self._n] = self._audio[: self._n]
606
+ self._audio = grown
607
+ self._audio[self._n: need] = x
608
+ self._n = need
609
+
610
+ def _new_frames(self, upto: int, final: bool):
611
+ """Frames [self._committed, upto) computed on an aligned audio segment.
612
+ Segment start s is a multiple of HOP with >= 2 frames of margin, so the segment's own
613
+ pre-emphasis restart and zero left-padding touch no frame we keep; with final=True the
614
+ segment ends at the true end of audio, so the end padding matches the whole-file pass."""
615
+ rep = self.asr._replicas[0]
616
+ F = self._committed
617
+ s = 0 if F < 3 else (F - 2) * HOP
618
+ seg = self._audio[s: self._n]
619
+ feats = rep.features(seg) # (1, nfeat, frames) incl. the masked last frame
620
+ j0, j1 = F - s // HOP, upto - s // HOP
621
+ new = feats[:, :, j0:j1]
622
+ if new.shape[-1] != upto - F:
623
+ raise RuntimeError(f"feature bookkeeping error: got {new.shape[-1]} frames, want {upto - F}")
624
+ return new
625
+
626
+ def _commit(self, final: bool) -> None:
627
+ torch = self.asr._replicas[0].torch
628
+ if final:
629
+ upto = self._n // HOP + 1 if self._n > 0 else 0 # == frames of a whole-file pass
630
+ else:
631
+ upto = (self._n - HALF_WIN) // HOP + 1 if self._n >= HALF_WIN else 0 # window fully received
632
+ if upto <= self._committed:
633
+ return
634
+ with torch.inference_mode():
635
+ new = self._new_frames(upto, final)
636
+ self._win = new if self._win is None else torch.cat((self._win, new), dim=-1)
637
+ if self._kept is not None:
638
+ self._kept.append(new)
639
+ if self._n > self.asr.verify_stream_max_s * SAMPLE_RATE:
640
+ self._kept = None # too long to re-verify cheaply; stop keeping a copy
641
+ self._committed = upto
642
+
643
+ # -- encoder steps --------------------------------------------------------------------------
644
+ def _run_steps(self, final: bool) -> None:
645
+ """Run every chunk step whose inputs are final. A non-final call only takes steps that are
646
+ provably not the last one (keep_all_outputs=False, full-size chunk)."""
647
+ cfg = self.cfg
648
+ pick = lambda v, first: (v[0] if first else v[1]) if isinstance(v, list) else v # noqa: E731
649
+ torch = self.asr._replicas[0].torch
650
+ while True:
651
+ idx = self._buffer_idx
652
+ first = idx == 0
653
+ cs, ss = pick(cfg.chunk_size, first), pick(cfg.shift_size, first)
654
+ pre = pick(cfg.pre_encode_cache_size, first)
655
+ if not final and self._committed < max(idx + cs, idx + ss + 1):
656
+ return
657
+ if final and idx >= self._committed:
658
+ return
659
+ # a window of NeMo's own buffer: frames [base, committed), base = idx - pre_encode_cache
660
+ base = max(0, idx - pre)
661
+ if base < self._win_base:
662
+ raise RuntimeError("stream window trimmed too far")
663
+ win = self._win[:, :, base - self._win_base:]
664
+ rep = self.asr._acquire()
665
+ try:
666
+ with rep.lock:
667
+ rep.configure(self.lang, self.att, self.policy.base_delta)
668
+ with torch.inference_mode():
669
+ buf = rep.new_buffer(self.att)
670
+ buf.buffer = win
671
+ buf.streams_length = torch.tensor([self._committed - base], device=win.device)
672
+ buf.buffer_idx = idx - base
673
+ buf.step = self._st.step_num
674
+ it = iter(buf)
675
+ item = next(it, None)
676
+ if item is None: # NeMo drops a final chunk shorter than sampling_frames
677
+ self._buffer_idx = self._committed
678
+ return
679
+ chunk, lengths = item
680
+ keep_all = buf.is_buffer_empty()
681
+ if keep_all and not final:
682
+ raise RuntimeError("internal: a non-final call reached the last chunk")
683
+ rep.stream_step(
684
+ self._st, chunk, lengths, keep_all_outputs=keep_all,
685
+ drop_extra=0 if self._st.step_num == 0 else cfg.drop_extra_pre_encoded,
686
+ )
687
+ self._buffer_idx = base + buf.buffer_idx
688
+ finally:
689
+ self.asr._release(rep)
690
+ self.stats["steps"] += 1
691
+ self._partial = self._st.texts[0].text if self._st.texts else ""
692
+ # trim frames no future step can touch
693
+ keep_from = max(0, self._buffer_idx - pick(cfg.pre_encode_cache_size, False))
694
+ if keep_from > self._win_base:
695
+ self._win = self._win[:, :, keep_from - self._win_base:]
696
+ self._win_base = keep_from
697
+
698
+ # -- public ---------------------------------------------------------------------------------
699
+ def accept(self, pcm: Any) -> str:
700
+ """Feed float32 mono 16 kHz samples (np.ndarray) -- or raw PCM16 LE bytes. Returns the partial text."""
701
+ with self._lock:
702
+ if self._done:
703
+ raise RuntimeError("stream already finished")
704
+ x = pcm16_to_float32(pcm) if isinstance(pcm, (bytes, bytearray, memoryview)) else np.asarray(pcm, dtype=np.float32).reshape(-1)
705
+ self.stats["chunks_in"] += 1
706
+ if x.shape[0]:
707
+ self._append(x)
708
+ self._commit(final=False)
709
+ self._run_steps(final=False)
710
+ return self._partial
711
+
712
+ def tokens(self) -> list:
713
+ """[(token_id, time_s)] of the streamed hypothesis in emission order; time = RNNT frame index x 80 ms (97% of
714
+ recognised characters fall inside their true speaker turn, 99.9% within 0.25 s; measured 2026-09-26). Used by
715
+ chinda_diar for speaker attribution. Reflects the streamed first pass, not a policy's second-pass re-decode."""
716
+ with self._lock:
717
+ h = self._st.hyps[0] if self._st.hyps else None
718
+ if h is None or h.timestamp is None:
719
+ return []
720
+ ids = h.y_sequence.tolist() if hasattr(h.y_sequence, "tolist") else list(h.y_sequence)
721
+ ts = h.timestamp.tolist() if hasattr(h.timestamp, "tolist") else [int(t) for t in h.timestamp]
722
+ return [(int(i), float(t) * 0.08) for i, t in zip(ids, ts)]
723
+
724
+ @property
725
+ def partial(self) -> str:
726
+ return self._partial
727
+
728
+ @property
729
+ def audio(self) -> np.ndarray:
730
+ return self._audio[: self._n].copy()
731
+
732
+ def finish(self) -> dict:
733
+ """End of utterance: flush the last chunk(s), then the policy's second pass on the buffered audio."""
734
+ with self._lock:
735
+ if self._done:
736
+ raise RuntimeError("stream already finished")
737
+ self._done = True
738
+ audio = self._audio[: self._n].copy()
739
+ fallback = None
740
+ if self._n > 0:
741
+ self._commit(final=True)
742
+ self._run_steps(final=True)
743
+ # safety net: the frames we streamed must equal a whole-utterance feature pass. Bit-identical on CPU;
744
+ # on GPU the chunked and the whole-utterance feature pass round differently (float noise, measured
745
+ # 2026-09-26), so a bit-exact test re-decoded EVERY stream. A real bug (missing, shifted or
746
+ # misnormalised frames) moves log-mel values by O(1); tolerate only rounding noise.
747
+ if self._kept is not None:
748
+ torch = self.asr._replicas[0].torch
749
+ with torch.inference_mode():
750
+ full = self.asr._replicas[0].features(audio)
751
+ mine = torch.cat(self._kept, dim=-1)
752
+ if full.shape != mine.shape:
753
+ fallback = "feature_mismatch"
754
+ else:
755
+ self.stats["feature_maxdiff"] = float((full - mine).abs().max()) if full.numel() else 0.0
756
+ if self.stats["feature_maxdiff"] > FEATURE_TOL:
757
+ fallback = "feature_mismatch"
758
+ first_raw = self._partial if self._n > 0 else ""
759
+ if fallback:
760
+ log.warning("stream features differed from the whole-utterance pass; re-decoding (bs=1)")
761
+ first_raw = self.asr.decode_pass([audio], self.lang, self.latency_ms, self.policy.base_delta, 1)[0]
762
+ # second pass: identical to transcribe_batch() with a single input
763
+ pol = self.policy
764
+ cur, cur_raw, cur_delta = first_raw.strip(), first_raw, pol.base_delta
765
+ passes = [{"delta": pol.base_delta, "reason": "base", "text": cur}]
766
+ dur = self._n / SAMPLE_RATE
767
+ if pol.short_delta is not None and dur < pol.short_threshold_s and self._n > 0:
768
+ r = self.asr.decode_pass([audio], self.lang, self.latency_ms, pol.short_delta, 1)[0]
769
+ cur, cur_raw, cur_delta = r.strip(), r, pol.short_delta
770
+ passes.append({"delta": pol.short_delta, "reason": f"duration<{pol.short_threshold_s:g}s", "text": cur})
771
+ if pol.retry_delta is not None and not cur and self._n > 0:
772
+ r = self.asr.decode_pass([audio], self.lang, self.latency_ms, pol.retry_delta, 1)[0]
773
+ passes.append({"delta": pol.retry_delta, "reason": "empty", "text": r.strip()})
774
+ if r.strip():
775
+ cur, cur_raw, cur_delta = r.strip(), r, pol.retry_delta
776
+ return {
777
+ "text": cur,
778
+ "display_text": display_text(cur),
779
+ "raw_text": cur_raw,
780
+ "first_pass_text": first_raw.strip(),
781
+ "duration_s": round(dur, 3),
782
+ "lang": self.lang,
783
+ "latency_ms": self.latency_ms,
784
+ "policy": self.policy_name,
785
+ "delta": cur_delta,
786
+ "passes": passes,
787
+ "model": MODEL_NAME,
788
+ "stream_steps": self.stats["steps"],
789
+ "stream_fallback": fallback,
790
+ "stream_feature_maxdiff": self.stats.get("feature_maxdiff"),
791
+ "processing_s": round(time.time() - self._t0, 3),
792
+ }
793
+
794
+
795
+ # ----------------------------------------------------------------------------------------------
796
+ # CLI: CUDA_VISIBLE_DEVICES='' python serve/chinda_asr.py a.wav [b.wav ...] --policy shortclip
797
+ # ----------------------------------------------------------------------------------------------
798
+ def _main(argv: Optional[Iterable[str]] = None) -> None:
799
+ import argparse
800
+
801
+ ap = argparse.ArgumentParser(description="ChindaASR file transcription (reference decoder)")
802
+ ap.add_argument("files", nargs="+")
803
+ ap.add_argument("--lang", default="th-TH", choices=LANGS)
804
+ ap.add_argument("--latency-ms", type=int, default=560, choices=sorted(LATENCY_PRESETS))
805
+ ap.add_argument("--policy", default="shortclip", choices=sorted(POLICIES))
806
+ ap.add_argument("--batch-size", type=int, default=32)
807
+ ap.add_argument("--model", default=DEFAULT_MODEL_PATH)
808
+ ap.add_argument("--threads", type=int, default=None)
809
+ a = ap.parse_args(argv)
810
+ logging.basicConfig(level=logging.INFO)
811
+ asr = ChindaASR(a.model, num_threads=a.threads)
812
+ for f, r in zip(a.files, asr.transcribe_batch(a.files, a.lang, a.latency_ms, a.policy, a.batch_size)):
813
+ print(json.dumps({"file": f, **{k: r[k] for k in ("text", "duration_s", "delta")}}, ensure_ascii=False))
814
+
815
+
816
+ if __name__ == "__main__":
817
+ _main()
serve/chinda_diar.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Speaker labels ("who said what") for the ChindaASR reference service (2026-09-26).
2
+
3
+ Design, chosen by measurement on 11 Thai meetings: ONE ASR stream plus the speaker
4
+ segments of nvidia/Nemotron-3-Diarization (OpenMDW-1.1). Every recognised token goes to the speaker the diarizer marks
5
+ active at the token's time; a turn breaks on a speaker change or a pause longer than `pause_s`. On Thai meetings this beat
6
+ NVIDIA's one-masked-ASR-stream-per-speaker design (text errors 20.7 vs 27.9; text+speaker errors 17.7 vs 19.2) and costs
7
+ one ASR stream however many people talk. Token times are the RNNT hypothesis frame indices x 80 ms: 97% of recognised
8
+ characters fall inside their true turn and 99.9% within 0.25 s (measured on LOTUSDIS S010).
9
+
10
+ Files: the diarizer runs in its OFFLINE configuration over the whole recording (best speaker labels, DER 17.5 on the Thai
11
+ meetings). Live streams: speaker labels are attached at the END of the stream from the buffered audio; per-partial live
12
+ labels are not provided yet.
13
+
14
+ from chinda_asr import ChindaASR; from chinda_diar import Diarizer, transcribe_with_speakers
15
+ asr = ChindaASR(device="cuda"); diar = Diarizer(device="cuda")
16
+ res = transcribe_with_speakers(asr, diar, audio) # res["segments"] = [{speaker, start, end, text}, ...]
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import os
22
+ import re
23
+ import threading
24
+ from typing import Any, Optional
25
+
26
+ import numpy as np
27
+
28
+ log = logging.getLogger("chinda_diar")
29
+
30
+ # $CHINDA_DIAR_MODEL, else downloaded from Hugging Face on first use (nvidia/Nemotron-3-Diarization, OpenMDW-1.1).
31
+ DIAR_MODEL_PATH = os.environ.get("CHINDA_DIAR_MODEL") or ""
32
+ DIAR_HF_REPO, DIAR_HF_FILE = "nvidia/Nemotron-3-Diarization", "Nemotron-3-Diarization.nemo"
33
+ # offline configuration = the model card's 30.4 s-latency row (spkcache 264, fifo 40, chunk 340, right context 40, update 300)
34
+ OFFLINE = dict(chunk_len=340, chunk_right_context=40, fifo_len=40, spkcache_update_period=300, spkcache_len=264)
35
+ FRAME_S = 0.08
36
+ LANGTAG = re.compile(r"^<[a-z]{2,3}(-[A-Za-z]{2,4})?>$")
37
+
38
+
39
+ class Diarizer:
40
+ """Thread-safe, lazily loaded Nemotron-3-Diarization (needs NeMo Speech at the commit pinned in the README)."""
41
+
42
+ def __init__(self, device: Optional[str] = None, model_path: str = DIAR_MODEL_PATH):
43
+ self.device = device or os.environ.get("CHINDA_DEVICE", "cpu")
44
+ self.model_path = model_path
45
+ self._model = None
46
+ self._lock = threading.Lock()
47
+
48
+ def load(self) -> "Diarizer":
49
+ with self._lock:
50
+ if self._model is None:
51
+ from nemo.collections.asr.models import SortformerEncLabelModel
52
+
53
+ if not os.path.exists(self.model_path):
54
+ from huggingface_hub import hf_hub_download
55
+
56
+ self.model_path = hf_hub_download(DIAR_HF_REPO, DIAR_HF_FILE)
57
+ m = SortformerEncLabelModel.restore_from(self.model_path, map_location=self.device).eval()
58
+ for k, v in OFFLINE.items():
59
+ setattr(m.sortformer_modules, k, v)
60
+ self._model = m
61
+ log.info("diarizer loaded from %s on %s", self.model_path, self.device)
62
+ return self
63
+
64
+ def segments(self, audio: np.ndarray, sample_rate: int = 16000) -> list[tuple[float, float, str]]:
65
+ """[(start_s, end_s, 'speaker_N')] for one recording (float32 mono)."""
66
+ self.load()
67
+ if audio.shape[0] < int(0.5 * sample_rate): # too short to say anything about speakers
68
+ return [(0.0, audio.shape[0] / sample_rate, "speaker_0")] if audio.shape[0] else []
69
+ with self._lock:
70
+ out = self._model.diarize(audio=[np.asarray(audio, dtype=np.float32)], sample_rate=sample_rate, batch_size=1)
71
+ segs = []
72
+ for line in out[0]:
73
+ s, e, spk = line.split()
74
+ segs.append((float(s), float(e), spk))
75
+ return sorted(segs)
76
+
77
+
78
+ def speaker_at(t: float, segs: list[tuple[float, float, str]], prev: Optional[str]) -> Optional[str]:
79
+ """Speaker active at time t: the covering segment (the longest if several overlap), else the nearest one within
80
+ 1 s, else the previous token's speaker (identical to scripts/diar_thai.py, which produced the measurements)."""
81
+ cover = [s for s in segs if s[0] - 0.2 <= t <= s[1] + 0.2]
82
+ if cover:
83
+ return max(cover, key=lambda s: s[1] - s[0])[2]
84
+ near = min(segs, key=lambda s: min(abs(t - s[0]), abs(t - s[1])), default=None)
85
+ if near is not None and min(abs(t - near[0]), abs(t - near[1])) <= 1.0:
86
+ return near[2]
87
+ return prev
88
+
89
+
90
+ def build_turns(tokens: list[tuple[int, float]], tokenizer: Any, segs: list[tuple[float, float, str]],
91
+ pause_s: float = 1.0) -> tuple[list[dict], list[dict]]:
92
+ """tokens = [(token_id, time_s)] of ONE stream in emission order -> (turns, per-token records)."""
93
+ recs, prev = [], None
94
+ for i, t in tokens:
95
+ piece = tokenizer.ids_to_tokens([i])[0]
96
+ if i == 0 or LANGTAG.match(piece):
97
+ continue
98
+ prev = speaker_at(t, segs, prev) or "speaker_0"
99
+ recs.append({"speaker": prev, "t": round(t, 3), "id": int(i), "piece": piece})
100
+ turns, cur = [], None
101
+ for r in recs:
102
+ if cur is None or r["speaker"] != cur["speaker"] or r["t"] - cur["end"] > pause_s:
103
+ if cur is not None:
104
+ turns.append(cur)
105
+ cur = {"speaker": r["speaker"], "start": r["t"], "end": r["t"], "ids": []}
106
+ cur["ids"].append(r["id"])
107
+ cur["end"] = r["t"]
108
+ if cur is not None:
109
+ turns.append(cur)
110
+ out = []
111
+ for c in turns:
112
+ text = tokenizer.ids_to_text(c["ids"]).strip()
113
+ if text:
114
+ out.append({"speaker": c["speaker"], "start": round(c["start"], 2), "end": round(c["end"] + FRAME_S, 2), "text": text})
115
+ return out, recs
116
+
117
+
118
+ def transcribe_with_speakers(asr: Any, diar: Diarizer, audio: Any, lang: str = "th-TH", latency_ms: int = 560,
119
+ pause_s: float = 1.0, include_tokens: bool = False) -> dict:
120
+ """Who said what for one recording: one ChindaASR stream (policy 'standard', the meeting-mode setting) + offline
121
+ diarization. Returns the stream's final result plus `segments` (turns with speaker, start, end, text)."""
122
+ from chinda_asr import load_audio
123
+
124
+ x = load_audio(audio) if not isinstance(audio, np.ndarray) else np.asarray(audio, dtype=np.float32)
125
+ s = asr.stream(lang=lang, latency_ms=latency_ms, policy="standard")
126
+ step = 16000
127
+ for k in range(0, x.shape[0], step):
128
+ s.accept(x[k:k + step])
129
+ res = s.finish()
130
+ segs = diar.segments(x)
131
+ turns, recs = build_turns(s.tokens(), asr.tokenizer, segs, pause_s=pause_s)
132
+ res.update(segments=turns, speakers=len({t["speaker"] for t in turns}),
133
+ diarization=[{"start": a, "end": b, "speaker": c} for a, b, c in segs])
134
+ if include_tokens:
135
+ res["tokens"] = recs
136
+ return res